Skip to content

微前端

1.什么是微前端

微前端是指存在于浏览器中的微服务,其借鉴了微服务的架构理念,将微服务的概念扩展到了前端。

如果对微服务的概念比较陌生的话,可以简单的理解为微前端就是将一个大型的前端应用拆分成多个模块,每个微前端模块可以由不同的团队进行管理,并可以自主选择框架,并且有自己的仓库,可以独立部署上线

一般呢,微前端多应用于企业中的中后台项目中,因为企业内部的中后台项目存活时间都比较长,动辄三五年或者更多,最后演变成一个巨石应用的概率往往高于其他类型的 web 应用。这就带来了技术栈落后编译部署慢两个问题。

image-20230924213707701

我们以常见的电商平台举例,某家已经存活了 10 年的电商平台的后台管理系统由几个模块构成,包括商品管理、库存管理、物流管理等模块,但是因为历史原因,这个项目一开始用 jquery 编写的,因为本着能跑就行的原则,这 10 年期间业务上一直没有太大改动所以就延续了下来,直到今天还是用的 jquery 维护的。

有一天,刚入职的小明被叫到办公室,老板和他说我们公司要开展新的业务,要开展生鲜配送服务,并且答应他说这个业务做大后公司就可以上市,就升你为公司的 CTO,小明听了十分开心,想着自己马上就可以升职加薪迎娶白富美了,于是马上就打开某聘开始找简历招人了,但是跟人聊的时候一说要用 jquery 去写一个大型的后台系统的时候,很多人都放弃了,还遭到了候选人的讽刺:“都 2022 年了怎么还有人用 jquery”。小明这个时候才明白,大清已经亡了,jquery 早就已经没人用了。想到这,小明陷入了沉思,自己升职加薪的梦难到要破灭了吗。。。

这个时候,某聘上的一个候选人打来了电话,说他有一个好的方案可以不用 jquery 在原来的项目上堆积代码,而是将新的项目单独拿出来去写,并且实现独立部署,然后小明马上约了这个候选人当面聊聊。候选人到了公司跟小明聊了聊后,这时候小明才第一次听到微前端的这个概念,看着眼前的候选人,小明的眼眶都湿润了,于是当场就和他签了合同,给了每个月 2 千 8 的高薪,并且答应等业务做大以后就给他取个漂亮嫂子。听到这,候选人的眼眶也湿润了。。。

好了,故事讲完了,那么接下来我们来看看,微前端到底是怎么实现小明想要的功能呢?

  • 之前的项目架构

    image-20230924213720991
  • 引入微前端后

    image-20230924213732014

2.微前端的好处

1.团队自治

在公司里面,一般团队都是按照业务去划分的,在没有微前端的时候,如果几个团队维护一个项目肯定会遇到一些冲突,比如合并代码的冲突,上线时间的冲突等。应用了微前端之后,就可以将项目根据业务模块拆分成几个小的模块,每个模块都由不同的团队去维护,单独开发,单独部署上线,这样团队直接就能实现自治,减少甚至不会出现和其他团队冲突的情况

2.兼容老项目

如果公司项目中存在的古老的 jquery 或者其他巨石项目的话,但是又不想用旧的技术栈去维护,选择使用微前端的方式去拆分项目是一个很好的选择。

3.跨技术栈

根据我们上面的例子,如果我们的微前端系统中需要新增一个业务模块时,只需要单独的新建一个项目,至于项目采用什么技术栈,完全可以由团队自己去定义,即使和其他模块用的不同的技术栈也不会有任何的问题

总结
image-20230924213745682

3.现有的微前端方案

1.iframe

iframe 大家都很熟悉,通过 iframe 实现的话就是每个子应用通过 iframe 标签来嵌入到父应用中,iframe 具有天然的隔离属性,各个子应用之间以及子应用和父应用之间都可以做到互不影响。

但是 iframe 也有很多缺点:

  1. url 不同步,如果刷新页面,iframe 中的页面的路由会丢失(刷新会回到首页)。
  2. 全局上下文完全隔离,内存变量不共享(两个应用之间的 window 对象是隔离的)。
  3. UI 不同步,比如 iframe 中的页面如果有带遮罩层的弹窗组件,则遮罩就不能覆盖整个浏览器,只能在 iframe 中生效。
  4. 慢。每次子应用进入都是一次浏览器上下文重建、资源重新加载的过程。

一句话总结:会产生应用隔离,谁也不关谁的事(如果只是嵌入其他应用的一个页面可以使用)

2.single-spa

官网:https://zh-hans.single-spa.js.org/docs/getting-started-overview

single-spa 是最早的微前端框架,可以兼容很多技术栈。

single-spa 首先在基座中注册所有子应用的路由,当 URL 改变时就会去进行匹配,匹配到哪个子应用就会去加载对应的那个子应用。

相对于 iframe 的实现方案,single-spa 中基座和各个子应用之间共享着一个全局上下文(可能会相互影响),并且不存在 URL 不同步和 UI 不同步的情况,但是 single-spa 也有以下的缺点:

  1. 没有实现 js 隔离和 css 隔离
  2. 需要修改大量的配置,包括基座和子应用的,不能开箱即用
3.qiankun

qiankun 是阿里开源的一个微前端的框架,在阿里内部已经经过一批线上应用的充分检验及打磨了,所以可以放心使用。qiankun 有什么优势呢?

  • 基于 single-spa 封装的,提供了更加开箱即用的 API
  • 技术栈无关,任意技术栈的应用均可使用/接入,不论是 React/Vue/Angular/JQuery 还是其他等框架。
  • HTML Entry 的方式接入,像使用 iframe 一样简单
  • 实现了 single-spa 不具备的样式隔离和 js 隔离
  • 资源预加载,在浏览器空闲时间预加载未打开的微应用资源,加速微应用打开速度。

4.基于 qiankun 的微前端实战

这次课程我们选择 qiankun 进行实战开发,项目目录如下:

less
├── micro-base     // 基座(react,什么架构都可以)
├── sub-react       // react子应用,create-react-app创建的react应用,使用webpack打包
├── sub-vue  // vue子应用,vite创建的子应用
└── sub-umi    // umi脚手架创建的子应用
  • 基座(主应用):主要负责集成所有的子应用,提供一个入口能够访问你所需要的子应用的展示,尽量不写复杂的业务逻辑
  • 子应用:根据不同业务划分的模块,每个子应用都打包成umd模块的形式供基座(主应用)来加载

1.基座

基座用的是create-react-app脚手架加上antd组件库搭建的项目,也可以选择 vue 或者其他框架,一般来说,基座只提供加载子应用的容器,尽量不写复杂的业务逻辑。

基座一般就是菜单目录结构!!!

基座改造
  1. 安装 qiankun

注意:只需要在基座中安装乾坤即可!

bash
// 安装qiankun
npm i qiankun // 或者 yarn add qiankun
  1. 修改入口文件
javascript
// 在src/index.tsx中增加如下代码
import { start, registerMicroApps } from "qiankun";

// 1. 要加载的子应用列表
const apps = [
  {
    name: "sub-react", // 子应用的名称
    entry: "//localhost:8080", // 默认会加载这个路径下的html,解析里面的js,注意端口号的匹配
    activeRule: "/sub-react", // 匹配的路由
    container: "#sub-app", // 加载的容器
  },
  {
    name: "sub-vue", // 子应用的名称
    entry: "//localhost:3002", // 默认会加载这个路径下的html,解析里面的js
    activeRule: "/sub-vue", // 匹配的路由
    container: "#sub-app", // 加载的容器
  },
  {
    name: "sub-umi", // 子应用的名称
    entry: "//localhost:3003", // 默认会加载这个路径下的html,解析里面的js
    activeRule: "/sub-umi", // 匹配的路由
    container: "#sub-app", // 加载的容器
  },
];

// 2. 注册子应用
registerMicroApps(apps, {
  beforeLoad: [async (app) => console.log("before load", app.name)],
  beforeMount: [async (app) => console.log("before mount", app.name)],
  afterMount: [async (app) => console.log("after mount", app.name)],
});

start(); // 3. 启动微服务

当微应用信息注册完之后,一旦浏览器的 url 发生变化,便会自动触发 qiankun 的匹配逻辑。 所有 activeRule 规则匹配上的微应用就会被插入到指定的 container 中,同时依次调用微应用暴露出的生命周期钩子。

  • registerMicroApps(apps, lifeCycles?)

    注册所有子应用,qiankun 会根据 activeRule 去匹配对应的子应用并加载

  • start(options?)

    启动 qiankun,可以进行预加载和沙箱设置

至此基座就改造完成,如果是老项目或者其他框架的项目想改成微前端的方式也是类似。

2.react 子应用(webpack 项目)

使用create-react-app脚手架创建,webpack进行配置,为了不 eject 所有的 webpack 配置,我们选择用react-app-rewired工具来改造 webpack 配置(这是专门用来修改 react 的 webpack 配置的常用插件)。

  1. 改造子应用的入口文件

qiankun 加载子应用就是通过它们在入口文件中暴露的生命周期!

jsx
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom'
let root: any

// 将render方法用函数包裹,供后续主应用与独立运行调用
function render(props: any) {
  const { container } = props
  const dom = container ? container.querySelector('#root') : document.getElementById('root')
  /**
   注意:
   querySelector返回的是第一个匹配的对象,只返回一个!
   querySelectorAll返回的是类数组对象,可以用Array.from转换为数组!
  */
  root = createRoot(dom)
  root.render(
    <BrowserRouter basename='/sub-react'>
      <App/>
    </BrowserRouter>
  )
}

// 判断是否在qiankun环境下,非qiankun环境下独立运行(开发环境一般就是独立的)
if (!(window as any).__POWERED_BY_QIANKUN__) {
  render({}); //如果不是在乾坤环境下就直接调用render方法
}

// 各个生命周期
// bootstrap 只会在微应用初始化的时候调用一次,下次微应用重新进入时会直接调用 mount 钩子,不会再重复触发 bootstrap。(相当于是应用的初始化mounted)
export async function bootstrap() {
  console.log('react app bootstraped');
}

// 应用每次进入都会调用 mount 方法,通常我们在这里触发应用的渲染方法(相当于就是页面初始化的mounted方法)
export async function mount(props: any) {
  console.log(props)
  props.onGlobalStateChange((state, prev) => {
    // state: 变更后的状态; prev 变更前的状态
    console.log(state, prev);
    // 将这个state存储到我们子应用store
  });
  props.setGlobalState({ count: 2 });
  render(props); //乾坤环境下会在mount中调用render方法
}

// 应用每次 切出/卸载 会调用的方法,通常在这里我们会卸载微应用的应用实例
export async function unmount(props: any) {
  root.unmount();
}

注意:basename 相当于是默认的路由前缀,和 qiankun-base 中的匹配的路由对应!

image-20230925093920793

image-20230925093743483

  1. 新增 public-path.js
js
if (window.__POWERED_BY_QIANKUN__) {
  // 动态设置 webpack publicPath,防止资源加载出错
  // eslint-disable-next-line no-undef
  __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__; //让乾坤帮我们处理,静态资源加载需要
}

需要在入口文件中引入:

image-20230925094053918

  1. 修改 webpack 配置文件
bash
npm i react-app-rewired

注意:一定是放在根文件夹下,而不是 src 文件夹下!!!

javascript
// 在根目录下新增config-overrides.js文件并新增如下配置
const { name } = require("./package");

module.exports = {
  webpack: (config) => {
    config.output.library = `${name}-[name]`;
    config.output.libraryTarget = "umd"; //打包成umd文件,方便qiankun读取我们入口文件的三个生命周期!
    config.output.chunkLoadingGlobal = `webpackJsonp_${name}`;
    return config;
  },
};

端口号:

image-20230925095150997

3.vue 子应用(vite 项目)

创建子应用

bash
# 创建子应用,选择vue3+vite
npm create vite@latest

改造子应用

  1. 安装vite-plugin-qiankun依赖包
bash
npm i vite-plugin-qiankun # yarn add vite-plugin-qiankun
  1. 修改 vite.config.js
javascript
import qiankun from "vite-plugin-qiankun";

export default defineConfig({
  base: "/sub-vue", // 应用前缀,和基座中配置的activeRule一致
  server: {
    port: 3002, //端口号
    cors: true,
    origin: "localhost:3002",
  },
  plugins: [
    vue(),
    qiankun("sub-vue", {
      // 配置qiankun插件
      useDevMode: true,
    }),
  ],
});
  1. 修改 main.ts
javascript
import { createApp } from "vue";
import "./style.css";
import App from "./App.vue";
import {
  renderWithQiankun,
  qiankunWindow,
} from "vite-plugin-qiankun/dist/helper";

let app: any;
if (!qiankunWindow.__POWERED_BY_QIANKUN__) {
  createApp(App).use(router).mount("#app"); //挂载App组件到根容器上面,注意需要use路由!
} else {
  renderWithQiankun({
    // 子应用挂载(每一次切换到本子应用的时候都会触发!!)
    mount(props) {
      app = createApp(App);
      app.use(router).mount(props.container.querySelector("#app"));
    },
    // 只有子应用第一次加载会触发
    bootstrap() {
      console.log("vue app bootstrap");
    },
    // 更新(多了一个更新函数,在本子应用里面更新数据的时候)
    update() {
      console.log("vue app update");
    },
    // 卸载
    unmount() {
      console.log("vue app unmount");
      app?.unmount(); //卸载app
    },
  });
}

4.umi 子应用(webpack 项目)

我们使用最新的 umi4 去创建子应用,创建好后只需要简单的配置就可以跑起来,qiankun 对于 umi 的兼容很好!

umi 和 qiankun 的开发团队是一个!

  1. 安装插件
bash
npm i @umijs/plugins
  1. 配置.umirc.ts
javascript
export default {
  base: "/sub-umi", //配置默认路径
  npmClient: "npm",
  plugins: ["@umijs/plugins/dist/qiankun"],
  qiankun: {
    slave: {},
  },
};

完成上面两步就可以在基座中看到 umi 子应用的加载了。

如果想在 qiankun 的生命周期中做些处理(umi 项目也可以不写生命周期,更加简单),需要修改下入口文件 app.ts

js
export const qiankun = {
  async mount(props: any) {
    console.log(props);
  },
  async bootstrap() {
    console.log("umi app bootstraped");
  },
  async afterMount(props: any) {
    console.log("umi app afterMount", props);
  },
};

5.小结

到这里,我们已经完成了应用的加载,已经覆盖了 react 和 vue 两大框架,并且选择了不同的脚手架还有打包工具,同理,angular 和 jquery 的项目大家感兴趣可以自己尝试下。

5.补充

1.样式隔离

qiankun 默认实现了各个子应用之间的样式隔离,但是基座和子应用之间的样式隔离没有实现(比如基座的 antd 会影响子应用的),所以基座和子应用之前的样式还会有冲突和覆盖的情况

解决方法:

  • 每个应用的样式使用固定的格式

    image-20230925102453379

  • 通过css-module的方式给每个应用自动加上前缀

  • 注意:在基座中改 antd 还是会影响到子应用,所以注意把子应用的样式优先级调高一些更好!

2.子应用间的跳转(或者微应用跳主应用页面)
  • 主应用和微应用都是 hash 模式,主应用根据 hash 来判断微应用,则不用考虑这个问题:因为 hash 值部分即#后面的都可以直接修改的(这样就可以直接换子应用了)。

  • history模式下微应用之间的跳转,或者微应用跳主应用页面,直接使用微应用的路由实例是不行的,原因是微应用的路由实例跳转都基于路由的 base(默认会在前面加上 base 路径,也没办法去掉 base 路径,无法跳脱本应用)。

    有两种办法可以跳转:

    1. window 对象的 history.pushState()

      这样就可以覆盖前面的 base 前缀了!

      注意:直接这样的话,还无法把 base 应用的菜单实现路由对应的高亮!

      image-20230925102956157

      history.pushState 方法:

      history.pushState() 是 HTML5 History API 中的一个方法,用于在浏览历史中创建一个新的状态,并且可以修改当前的 URL,但不会刷新页面。

      其语法如下:

      js
      history.pushState(state, title, url);
      • state:表示一个状态对象,该对象包含了当前历史状态的信息。可以是任意类型的数据,常用于保存页面状态相关的数据,但这个数据会随着历史记录一起保存。如果不需要传递数据,可以传入 null

      • title:表示新状态的标题,但通常现代浏览器忽略该参数,因为安全和隐私方面的考虑。

      • url:表示新状态的 URL,可以是相对 URL(./) 或绝对 URL(/)。如果是相对 URL,它会被解释为相对于当前 URL 的路径。

        绝对的 url 是可以覆盖掉前面的 base 前缀的!

      如何实现高亮:在基座中复写并监听history.pushState()方法并做相应的跳转逻辑

      注意:面包屑等也可以采取这种方案!来实现子应用的路由跳转使得根应用实现高亮!

      js
      const currentPath = window.location.pathname; //pathname是啥?例如,如果当前页面的完整 URL 是 http://example.com/products, 则 currentPath 将为 /products,也就是域名后面的路径
      
      const [selectedPath, setSelectedPath] = useState(
      	routes.find(item => currentPath.includes(item.key))?.key || '' //selectedKeys决定了高亮项
      );
      
      // 1.重写函数
      const _wr = function (type: string) {
        const orig = (window as any).history[type] //type是'pushState',orig就是pushState函数
        return function () { //这里返回重写的pushState函数
          const rv = orig.apply(this, arguments) //执行原来的pushState函数
      
          //主要是下面三行:创建分发了一个空事件(分发之后就会触发绑定的事件的回调,也就是bindHistory方法)
          const e: any = new Event(type)
          e.arguments = arguments
          window.dispatchEvent(e)
      
          return rv //返回pushState函数的返回值
        }
      }
      window.history.pushState = _wr('pushState') //重写window上面的pushState方法,因为window是共享的,这样在子应用里面调用的时候,走的就是重写的这个
      
      // 3.在这个函数中做跳转后的逻辑:这里是设置高亮的逻辑
      const bindHistory = () => {
        const currentPath = window.location.pathname;
        setSelectedPath(
        	routes.find(item => currentPath.includes(item.key))?.key || '' //设置高亮路径,看当前路径是否包含关键key,作为selectedPath
        )
      }
      
      // 2.监听、绑定事件
      window.addEventListener('pushState', bindHistory)

      这里重写的作用?

      js
      const e: any = new Event(type);
      e.arguments = arguments;
      window.dispatchEvent(e);

      这三行代码什么意思?

      这代码主要实现了通过 JavaScript 创建一个自定义事件,并分发(dispatch)这个事件到 window 对象上。让我们逐行解释这三行代码的作用:

      1. const e: any = new Event(type):
        • 创建一个自定义事件对象 e,类型为 Eventtype 是事件类型,比如 'customEvent'
        • new Event(type) 创建了一个不包含额外信息的简单事件。
      2. e.arguments = arguments:
        • 给事件对象 e 添加了一个自定义属性 arguments,并将当前函数的参数赋值给它。注意,arguments 是函数内置对象,它包含了所有函数参数。
      3. window.dispatchEvent(e):
        • 将创建好的事件对象 e 分发到 window 对象上,触发相应的事件处理函数。

      整体来说,这段代码实现了通过创建自定义事件,并携带当前函数的参数,然后将该事件分发到 window 对象上。通常用于在应用程序中实现自定义事件的传递,以便在不同模块或组件间进行通信。

      重写后的 pushState 方法在执行 pushState 时会触发自定义事件函数,并携带当前函数的参数,。重写方法可以用于监听页面状态的变化,实现自定义的状态管理或通知机制。 从而触发下面的逻辑:

      js
      // 监听、绑定事件
      window.addEventListener("pushState", bindHistory);
    2. 将主应用的路由实例通过 props 传给微应用,微应用使用这个路由实例跳转(从而跳脱本应用)。

      可以自行尝试一下!

3.公共依赖加载

场景:如果主应用和子应用都使用了相同的库或者包(antd, axios 等),就可以用externals的方式来引入,减少加载重复包导致资源浪费,就是一个项目使用后另一个项目不必再重复加载。

方式:

  • 主应用:将所有公共依赖配置webpackexternals,并且在index.html使用外链引入这些公共依赖

  • 子应用:和主应用一样配置webpackexternals,并且在index.html使用外链引入这些公共依赖,注意,还需要给子应用的公共依赖加上 ignore 属性(这是自定义的属性,非标准属性),qiankun 在解析时如果发现igonre属性就会自动忽略

以 axios 为例:

在 umi 子应用的 umirc.ts 文件里面:umi 可以这样配置外链插件的加载(其他项目直接写在 index.html 里面就可以,也就是 script 标签里面)

js
headScripts: [
	{ src: 'https://unpkg.com/axios@1.1.2/dist/axios.min.js', ignore: true },
],

在 base 主应用里面:

js
// 修改config-overrides.js
const { override, addWebpackExternals } = require("customize-cra");

module.exports = override(
  addWebpackExternals({
    axios: "axios",
  })
);
html
<!-- 注意:这里的公共依赖的版本必须和子应用的保持一致,加上属性ignore为true -->
<script
  ignore="true"
  src="https://unpkg.com/axios@1.1.2/dist/axios.min.js"></script>
4.全局状态管理

一般来说,各个子应用是通过业务来划分的,不同业务线应该降低耦合度,尽量去避免通信,但是如果涉及到一些公共的状态或者操作,qiankun 也是支持的

qinkun 提供了一个全局的GlobalState来共享数据,基座初始化之后,子应用可以监听到这个数据的变化,也能提交这个数据。

基座:index.ts 中

js
// 基座初始化
import { initGlobalState } from "qiankun";

const state = { count: 1 }; //公共数据

const actions = initGlobalState(state);
// 状态被修改的监听
actions.onGlobalStateChange((state, prev) => {
  // state: 变更后的状态; prev 变更前的状态
  console.log(state, prev);
});
actions.setGlobalState(state);

子应用:

js
// 应用每次进入都会调用 mount 方法,通常我们在这里触发应用的渲染方法
// 子项目监听和修改
export function mount(props) {
  console.log(props);
  props.onGlobalStateChange((state, prev) => {
    // state: 变更后的状态; prev 变更前的状态
    console.log(state, prev);
    // 将这个state存储到我们子应用store
  });
  props.setGlobalState({ count: 2 });
}