qiankun 源码解析🐳

热知识~,乾坤是基于 single-spa 实现的微前端框架,而 single-spa 用到 SystemJS 作为主要的模块加载工具

在进一步剖析乾坤源码之前,我们先来了解下 SystemJS 和 single-spa 的工作原理

SystemJS 和 single-spa

  • SystemJS: 允许在浏览器环境中动态加载微应用的模块,处理模块的导入导出
  • single-spa: 提供了微前端的核心架构和生命周期管理,确保各个微应用能够独立运行和协作。 通过路由劫持机制实现子应用的动态加载,并利用 SystemJS 作为模块加载器来管理各个子应用的导入与导出。为了确保子应用能够与主应用无缝集成,子应用需要遵循特定的接入协议,即暴露固定的生命周期方法:bootstrapmountunmount

SystemJS

是一个可运行于浏览器端的模块加载器,让我们可以在浏览器中使用 ES6 import/export 语法

我们可以通过 systemjs-importmap 指定依赖库的地址,也可以在 script标签里 System.import('./index.js') 直接导入某个模块,具体语法可以参考下面代码

注意 🙌,模块导入是一个异步过程,返回的是一个 Promise 对象,可以配合 then 来使用

html 复制代码
<body>
  <h3>主应用,也叫基座,用来加载子应用的 webpack importMap</h3>
  <div id="root"></div>
  <!-- 可以在浏览器使用 ES6 的 import/export 语法, 通过 systemjs-importmap 指定依赖库的地址 -->
  <script type="systemjs-importmap">
    {
        "imports": {
          "react-dom": "https://cdn.bootcdn.net/ajax/libs/react-dom/18.2.0/umd/react-dom.development.js",
          "react": "https://cdn.bootcdn.net/ajax/libs/react/18.2.0/umd/react.development.js"
        }
    }
  </script>
  <script src="https://cdn.bootcdn.net/ajax/libs/systemjs/6.10.1/system.min.js"></script>
  <script>
    // 直接加载子应用, 导入打包后的包来进行加载, 采用是 system规范
    System.import('./index.js')
  </script>
</body>

SystemJS 和 single-spa 没有任何关系,只是它的 in-browser import/export 和 single-spa 倡导的 in-browser run time 相符合,所以 single-spa 将其作为主要的导入导出工具 (并不是必须的!!!)

甚至在一些现代浏览器中,我们可以借助 importmap 实现 _import axios from 'axios'_ 导入功能💯

typescript 复制代码
<script type="importmap">
    {
       "imports": {
          "axios": "https://cdn.jsdelivr.net/npm/axios@0.20.0/dist/axios.min.js"
       }
    }
</script>
<script type="module">
  import axios from 'axios'
</script>

但在低版本浏览器中,我们就需要借助于一些 "Polyfill"来实现 import/export 了。SystemJS 就是解决这个问题的

我们也可以用 Webpack 动态引入,甚至可能比 SystemJS 更好用💯

typescript 复制代码
import(/* webpackChunkName: "index" */ './index.js').then(moduleA => {
  moduleA.doSomething();
});

single-spa

single-spa 通过路由劫持实现应用的加载(采用SystemJS),提供应用间公共组件加载以及公共业务逻辑处理。子应用需要遵循特定的接入协议,即暴露固定的生命周期钩子(bootstrap、mount、unmount)💯

无沙箱机制,需要实现自己的JS沙箱以及CSS沙箱

index.html(主应用)

负责声明资源路径

javascript 复制代码
  <script type="systemjs-importmap">
    {
      "imports": {
        "@burc/root-config": "//localhost:9000/burc-root-config.js",
        "@burc/react":"//localhost:3000/react.js",
        "@burc/vue":"//localhost:4000/js/app.js"
      }
    }
  </script>

main.js(主应用)

负责注册子应用和启动主应用 Application

javascript 复制代码
import {
  registerApplication,
  start
} from "single-spa";

registerApplication({
  name: "@burc/react", // 不重名即可
  app: () =>
    System.import('@burc/react'),
  activeWhen: (location) => location.pathname.startsWith('/react'),
});
registerApplication({
  name: "@burc/vue", // 不重名即可
  app: () =>
    System.import('@burc/vue'),
  activeWhen: (location) => location.pathname.startsWith('/vue'),
});

start({
  urlRerouteOnly: true,
});

sing-spa 只做了两件事: 一是提供生命周期概念,负责调度子应用的生命周期;二是劫持 url 变化事件,url 变化时匹配对应子应用,执行生命周期流程

Root Config: 指主应用的 index.html + main.js。HTML 负责声明资源路径,JS 负责注册子应用和启动主应用

Application: 子应用要暴露 bootstrap,mount,unmount 三个生命周期(接入协议)

registerMicroApps(注册子应用)

用于注册子应用的基础配置信息。当浏览器 url 发生变化时,会自动检查每一个微应用注册的 activeRule 规则,符合规则的应用将会被自动激活

详细参数可查看乾坤官网 - registerMicroApps(apps, lifeCycles?), 其基本语法如下:

javascript 复制代码
import { registerMicroApps, start } from 'qiankun';

registerMicroApps(
  [
    {
      name: 'reactApp', // 微应用的名称,微应用之间必须确保唯一
      entry: '//localhost:40000', // 微应用的入口
      activeRule: '/react', // 微应用的激活规则,当路径以 /react 为前缀时启动
      container: '#container', // 微应用的容器节点的选择器或者 Element 实例
      loader, // loading 状态发生变化时会调用的方法
      props: { userInfo:{ name: 'burc', password: 'xxxxxx'} }, // 主应用需要传递给微应用的数据
    },
    {
      name: 'vueApp',
      entry: '//localhost:20000', // 默认react启动的入口是10000端口
      activeRule: '/vue', // 当路径是 /react的时候启动
      container: '#container', // 应用挂载的位置
      loader,
      props: { userInfo:{ name: 'burc', password: 'xxxxxx'}},
    },
  ],
  {
    beforeLoad() { },
    beforeMount() { },
    afterMount() { },
    beforeUnmount() { },
    afterUnmount() { },
  },
)

start()

registerMicroApps 注册子应用,乾坤源码对应 qiankun/blob/master/src/apis.ts

name: 微应用之间必须确保唯一,标识,用于区分不同的微应用

javascript 复制代码
import { registerApplication, start as startSingleSpa } from 'single-spa';

export function registerMicroApps<T extends ObjectType>(
  apps: Array<RegistrableApp<T>>, // 本次要注册的应用
  lifeCycles?: FrameworkLifeCycles<T>, // 自己编写的生命周期
) {
  // 拿到没有被注册过的应用,name 属性就是用来区分不同的应用的
  const unregisteredApps = apps.filter((app) => !microApps.some((registeredApp) => registeredApp.name === app.name));
  // 最新要注册的应用
  microApps = [...microApps, ...unregisteredApps];
  // 循环注册未注册的应用
  unregisteredApps.forEach((app) => {
    // appConfig 应用的配置
    const { name, activeRule, loader = noop, props, ...appConfig } = app;
    // 注册应用的逻辑采用的是 single-spa 的 registerApplication (路由劫持也是在 single-spa 内部实现)
    registerApplication({
      name,
      app: async () => {
        loader(true);
        await frameworkStartedDefer.promise; // 等待调用 start 方法,frameworkStartedDefer.resolve()
        // loadApp方法返回的是一个函数 (loadApp())(),  沙箱的处理
        const { mount, ...otherMicroAppConfigs } = (
          await loadApp({ name, props, ...appConfig }, frameworkConfiguration, lifeCycles)
        )();

        return {
          // 返回的是应用的接入协议
          mount: [async () => loader(true), ...toArray(mount), async () => loader(false)],
          ...otherMicroAppConfigs,
        };
      },
      activeWhen: activeRule,
      customProps: props,
    });
    // 1. 目前不会执行app方法,会等待路径匹配后执行app方法
    // 2. 执行app方法时,也会等待调用start方法, await frameworkStartedDefer.promise
  });
}

注册应用的底层逻辑采用的是 single-spa 的 registerApplication 方法

监听路由变化,匹配对应的子应用 (single-spa内部执行) ,路径匹配成功后执行 app 方法,然后等待调用下文的 start 方法,再去加载子应用 loadApp()

下文的 start 方法:

typescript 复制代码
import { start as startSingleSpa } from 'single-spa';

export function start(opts: FrameworkConfiguration = {}) {
  ...
  frameworkStartedDefer.resolve(); // 调用成功的promise
}

loadApp(加载子应用)

乾坤源码对应 qiankun/blob/master/src/loader.ts

  1. 通过 importEntry 加载解析子应用的入口HTML文件,获取 template模版 以及 js脚本执行器 execScripts 等
  2. 对 template 模板进行处理,<qiankun-head-head> 替换 <head>,添加 data-namedata-version 等属性
  3. 创建 css沙箱,实现 影子dom沙箱 和 作用域css沙箱
  4. 创建 js沙箱,这里存在兼容性的降级操作,多应用代理沙箱 -> 单应用代理沙箱 -> 快照沙箱
  5. 在 js沙箱环境中执行脚本执行器 execScripts(),这里用 js沙箱的代理对象 代替了 全局对象window

importEntry(加载HTML)

通过 importEntry 加载解析子应用的入口HTML文件,获取解析后的html文件、并且拿到js脚本的执行器、和额外的js脚本

typescript 复制代码
import { importEntry } from 'import-html-entry'

// 获取解析后的html文件、并且拿到js脚本的执行器、和额外的js脚本
const { template, execScripts, assetPublicPath, getExternalScripts } = await importEntry(entry, importEntryOpts);

乾坤相比于 single-spa 有两大特色,一个是实现了 JS 和 CSS沙箱机制;

另一个就是使用 import-html-entry 实现了 HTML entry,而 single-spa 只能是 JS entry 的形式来加载子应用

  • JS Entry。 通常将子应用的所有资源打包成一个入口文件,在 single-spa 的很多样例中就使用了这种方式
  • HTML Entry。 子应用构建输出的是一个 HTML 文件,主应用通过加载这个 HTML 文件完成子应用的加载
import-html-entry到底干了些什么?

[import-html-entry](https://link.juejin.cn?target=https%3A%2F%2Fwww.npmjs.com%2Fpackage%2Fimport-html-entry "https://www.npmjs.com/package/import-html-entry"),它可以从指定的 URL 加载解析 HTML 文件,返回值如下: * **template:** 是注释掉了 js脚本,并将外部css样式转化为内部css样式之后的 html * **assetPublicPath:** 静态资源的公共路径 * **execScripts:** *Promise\<\>* ,执行js脚本的函数(包括内部脚本和外部脚本) * \*\*getExternalScripts:\*\**Promise\<\>* Scripts URL from template,返回 html 文件的所有js脚本 * \*\*getExternalStyleSheets:\*\**Promise\<\>* StyleSheets URL from template,返回 html 文件的外部css样式表 ![image.png](https://oss.xyyzone.com/jishuzhan/article/1952662139159752705/cca917c6fb850f6fa40257f93aa0b0c3.webp) **template模版,是注释掉了 js脚本,并将外部css样式转化为内部css样式之后的 html** ![image.png](https://oss.xyyzone.com/jishuzhan/article/1952662139159752705/b1004d49425f8a76a9ba369d9153fc74.webp) **getExternalScripts,*Promise\<\>*,返回 html 文件的所有js脚本** ![image.png](https://oss.xyyzone.com/jishuzhan/article/1952662139159752705/8c977137a92558184ac7be21d3befe55.webp) **getExternalStyleSheets,*Promise\<\>*,返回 html 文件的外部css样式表** ![image.png](https://oss.xyyzone.com/jishuzhan/article/1952662139159752705/8b7b432d4e4f4c2c23fef4971185a34d.webp) #### getDefaultTplWrapper(处理template) 对 template 模板进行处理,`` 替换 ``,添加 `data-name`、`data-version` 等属性 ```typescript // 对 template 模板进行处理 const appContent = getDefaultTplWrapper(appInstanceId, sandbox)(template); export function getDefaultTplWrapper(name: string, sandboxOpts: FrameworkConfiguration['sandbox']) { return (tpl: string) => { let tplWithSimulatedHead: string; if (tpl.indexOf('') !== -1) { // We need to mock a head placeholder as native head element will be erased by browser in micro app tplWithSimulatedHead = tpl .replace('', `<${qiankunHeadTagName}>`) .replace('', ``); } else { // Some template might not be a standard html document, thus we need to add a simulated head tag for them tplWithSimulatedHead = `<${qiankunHeadTagName}>${tpl}`; } return `

${tplWithSimulatedHead}
`; }; } ``` 结果如下: ![image.png](https://oss.xyyzone.com/jishuzhan/article/1952662139159752705/6e0f4fd35326041123adf9bfa11ddc3c.webp) #### createElement(css沙箱) 创建一个 css沙箱,实现 影子dom沙箱 和 作用域css沙箱 * 影子dom沙箱,就是给微应用的容器包裹上一个 [shadow dom](https://link.juejin.cn?target=https%3A%2F%2Fdeveloper.mozilla.org%2Fzh-CN%2Fdocs%2FWeb%2FWeb_Components%2FUsing_shadow_DOM "https://developer.mozilla.org/zh-CN/docs/Web/Web_Components/Using_shadow_DOM") 节点 * 作用域css沙箱,就是拿到所有的 `