qiankun 源码解析🐳

459 阅读13分钟

热知识~,乾坤是基于 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 来使用

<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'_ 导入功能💯

<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 更好用💯

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

single-spa

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

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

index.html(主应用)

负责声明资源路径

  <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

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?), 其基本语法如下:

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: 微应用之间必须确保唯一,标识,用于区分不同的微应用

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 方法:

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脚本

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,它可以从指定的 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

template模版,是注释掉了 js脚本,并将外部css样式转化为内部css样式之后的 html

image.png

getExternalScripts,Promise<>,返回 html 文件的所有js脚本

image.png

getExternalStyleSheets,Promise<>,返回 html 文件的外部css样式表

image.png

getDefaultTplWrapper(处理template)

对 template 模板进行处理,<qiankun-head-head> 替换 <head>,添加 data-namedata-version 等属性

// 对 template 模板进行处理
const appContent = getDefaultTplWrapper(appInstanceId, sandbox)(template);

export function getDefaultTplWrapper(name: string, sandboxOpts: FrameworkConfiguration['sandbox']) {
  return (tpl: string) => {
    let tplWithSimulatedHead: string;

    if (tpl.indexOf('<head>') !== -1) {
      // We need to mock a head placeholder as native head element will be erased by browser in micro app
      tplWithSimulatedHead = tpl
        .replace('<head>', `<${qiankunHeadTagName}>`)
        .replace('</head>', `</${qiankunHeadTagName}>`);
    } else {
      // Some template might not be a standard html document, thus we need to add a simulated head tag for them
      tplWithSimulatedHead = `<${qiankunHeadTagName}></${qiankunHeadTagName}>${tpl}`;
    }

    return `<div id="${getWrapperId(
      name,
    )}" data-name="${name}" data-version="${version}" data-sandbox-cfg=${JSON.stringify(
      sandboxOpts,
    )}>${tplWithSimulatedHead}</div>`;
  };
}

结果如下:

image.png

createElement(css沙箱)

创建一个 css沙箱,实现 影子dom沙箱 和 作用域css沙箱

  • 影子dom沙箱,就是给微应用的容器包裹上一个 shadow dom 节点
  • 作用域css沙箱,就是拿到所有的 <style>标签,对里面的 css 增加css前缀。**对于<link>外链 而言,已经提前通过 import-html-entry 将<link>链 转换成了 内嵌style

这两种 css 沙箱是如何实现的?可以搭配柏成之前写的 qiankun 的 CSS 沙箱隔离机制,配合食用~

function createElement(
  appContent: string,
  strictStyleIsolation: boolean,
  scopedCSS: boolean,
  appInstanceId: string,
): HTMLElement {
  const containerElement = document.createElement('div');
  containerElement.innerHTML = appContent;
  // appContent always wrapped with a singular div
  // 对于严格样式隔离 就是增加影子dom
  const appElement = containerElement.firstChild as HTMLElement;
  if (strictStyleIsolation) {
    if (!supportShadowDOM) {
      console.warn(
        '[qiankun]: As current browser not support shadow dom, your strictStyleIsolation configuration will be ignored!',
      );
    } else {
      const { innerHTML } = appElement;
      appElement.innerHTML = '';
      let shadow: ShadowRoot;

      if (appElement.attachShadow) {
        shadow = appElement.attachShadow({ mode: 'open' });
      } else {
        // createShadowRoot was proposed in initial spec, which has then been deprecated
        shadow = (appElement as any).createShadowRoot();
      }
      shadow.innerHTML = innerHTML;
    }
  }
  // 作用域css,就是拿到所有的style标签,对里面的css 增加css前缀
  //(对于link外链而言,已经提前通过 import-html-entry 将 link外链 转换成 内嵌style)
  if (scopedCSS) {
    const attr = appElement.getAttribute(css.QiankunCSSRewriteAttr);
    if (!attr) {
      appElement.setAttribute(css.QiankunCSSRewriteAttr, appInstanceId);
    }

    const styleNodes = appElement.querySelectorAll('style') || [];
    forEach(styleNodes, (stylesheetElement: HTMLStyleElement) => {
      css.process(appElement!, stylesheetElement, appInstanceId);
    });
  }

  return appElement;
}

createSandboxContainer(js沙箱)

创建一个 js沙箱,然后在沙箱环境中执行脚本执行器 execScripts(),注意!这个用沙箱的代理对象 替换了 全局window

const useLooseSandbox = typeof sandbox === 'object' && !!sandbox.loose; // 快照沙箱
// enable speedy mode by default
const speedySandbox = typeof sandbox === 'object' ? sandbox.speedy !== false : true; //proxy

let sandboxContainer;
if (sandbox) {
  // 创建一个沙箱
  sandboxContainer = createSandboxContainer(
    appInstanceId,
    // FIXME should use a strict sandbox logic while remount, see https://github.com/umijs/qiankun/issues/518
    initialAppWrapperGetter,
    scopedCSS,
    useLooseSandbox,
    excludeAssetFilter,
    global,
    speedySandbox,
  );
  // 用沙箱的代理对象作为接下来使用的全局对象
  global = sandboxContainer.instance.proxy as typeof window;
  mountSandbox = sandboxContainer.mount;
  unmountSandbox = sandboxContainer.unmount;
}

// 根据指定的沙箱环境执行脚本
const scriptExports: any = await execScripts(global, sandbox && !useLooseSandbox, {
  scopedGlobalVariables: speedySandbox ? cachedGlobals : [],
});

这里存在兼容性的降级操作,多应用代理沙箱 -> 单应用代理沙箱 -> 快照沙箱

这三种 js 沙箱是如何实现的?可以移步柏成之前写的 qiankun 的 JS 沙箱隔离机制,或者 乾坤源码

export function createSandboxContainer(
  appName: string,
  elementGetter: () => HTMLElement | ShadowRoot,
  scopedCSS: boolean,
  useLooseSandbox?: boolean,
  excludeAssetFilter?: (url: string) => boolean,
  globalContext?: typeof window,
  speedySandBox?: boolean,
) {
  let sandbox: SandBox;
  if (window.Proxy) {
    sandbox = useLooseSandbox
      ? new LegacySandbox(appName, globalContext) // 单应用代理沙箱
      : new ProxySandbox(appName, globalContext, { speedy: !!speedySandBox }); // 多应用代理沙箱
  } else {
    sandbox = new SnapshotSandbox(appName); // 快照沙箱
  }

  return {
    instance: sandbox,
  }
}

start(启动)

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

start API 详细参数可查看乾坤官网 - start(opts?)

  1. 如果支持预加载,则开始调用预加载的策略 doPrefetchStrategy
  2. 对 js沙箱来做降级处理,老旧浏览器不支持proxy autoDowngradeForLowVersionBrowser
  3. 启动 single-spa 的 start 方法,然后 single-spa 开始监听路由变化,并根据当前路由加载和挂载相应的微应用
import { start as startSingleSpa } from 'single-spa';

export function start(opts: FrameworkConfiguration = {}) {
  // 在start参数中,增加了 prefetch(预加载)、singular(单例模式)、(sandbox)沙箱
  frameworkConfiguration = { prefetch: true, singular: true, sandbox: true, ...opts };
  const {
    prefetch, 
    sandbox,
    singular,
    urlRerouteOnly = defaultUrlRerouteOnly,
    ...importEntryOpts
  } = frameworkConfiguration;
  // 如果支持预加载,则开始调用预加载的策略
  if (prefetch) {
    doPrefetchStrategy(microApps, prefetch, importEntryOpts);
  }
  // 对 js沙箱来做降级处理,老旧浏览器不支持proxy
  frameworkConfiguration = autoDowngradeForLowVersionBrowser(frameworkConfiguration);

  startSingleSpa({ urlRerouteOnly }); // 就是 single-spa 的 start方法
  started = true;

  frameworkStartedDefer.resolve(); // 调用成功的promise
}

doPrefetchStrategy(预加载)

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

start API 详细参数可查看乾坤官网 - start(opts?)

  • prefetch - boolean | 'all' | string[] | (( apps: RegistrableApp[] ) => { criticalAppNames: string[]; minorAppsName: string[] }) - 可选,是否开启预加载,默认为 true
  • 配置为 true 则会在第一个微应用 mount 完成后开始预加载其他微应用的静态资源
  • 配置为 'all' 则主应用 start 后即开始预加载所有微应用静态资源
export function doPrefetchStrategy(
  apps: AppMetadata[],
  prefetchStrategy: PrefetchStrategy,
  importEntryOpts?: ImportEntryOpts,
) {
  switch (prefetchStrategy) {
    case true:
      // 等待第一个应用加载完毕后 加载其他应用
      prefetchAfterFirstMounted(apps, importEntryOpts);
      break;

    case 'all':
      prefetchImmediately(apps, importEntryOpts);
      break;

    default:
      break;
  }
}

prefetchAfterFirstMounted

在第一个微应用 mount 完成后开始预加载其他微应用的静态资源

当第一个微应用 mount 完成后,single-spa中 内部会派发 dispatchEvent('single-spa:first-mount'),我们可以使用 window.addEventListener('single-spa:first-mount') 监听此事件

然后遍历所有未加载的子应用 app,调用 prefetch 依次去加载静态资源

function prefetchAfterFirstMounted(apps: AppMetadata[], opts?: ImportEntryOpts): void {
  // single-spa中 默认内部 dispatchEvent('single-spa:first-mount')
  window.addEventListener('single-spa:first-mount', function listener() {
    // 获取到所有未加载的app
    const notLoadedApps = apps.filter((app) => getAppStatus(app.name) === NOT_LOADED);

    if (process.env.NODE_ENV === 'development') {
      const mountedApps = getMountedApps();
      console.log(`[qiankun] prefetch starting after ${mountedApps} mounted...`, notLoadedApps);
    }

    // 遍历所有未加载的app依次去加载
    notLoadedApps.forEach(({ entry }) => prefetch(entry, opts));
    // 加载完毕后移除监听
    window.removeEventListener('single-spa:first-mount', listener);
  });
}

prefetchImmediately

主应用 start 后,即开始预加载所有微应用静态资源

遍历所有的子应用 app,调用 prefetch 依次去预加载静态资源

export function prefetchImmediately(apps: AppMetadata[], opts?: ImportEntryOpts): void {
  if (process.env.NODE_ENV === 'development') {
    console.log('[qiankun] prefetch starting for apps...', apps);
  }

  apps.forEach(({ entry }) => prefetch(entry, opts));
}

prefetch(预加载事件)

通用的预加载方法,预先加载子应用的静态资源,以减少实际访问时的加载时间

预加载用到了一个浏览器 API - requestIdleCallback,在此方法中插入一个函数,这个函数将在浏览器主线程空闲时期被调用,不会阻塞关键渲染路径的任务

预加载使用了 import-html-entry 来加载解析子应用的入口 HTML 文件,这里拿到了子应用的 全部js脚本(直接在 script 中编写的内部js、通过 script 引入的外部js)和 外部css样式(通过 link 标签引入的样式表)

对于工程化项目来讲,这里我可以理解为是预加载了子应用的 全部js脚本全部css样式(仅排除了入口html文件中直接在 style 标签中编写的样式)

import { importEntry } from 'import-html-entry';

function prefetch(entry: Entry, opts?: ImportEntryOpts): void {
  if (!navigator.onLine || isSlowNetwork) {
    // 如果慢网的情况或者无网的情况 结束
    return;
  }

  requestIdleCallback(async () => {
    // 预加载HTML入口文件,用 import-html-entry 替代了 systemjs
    const { getExternalScripts, getExternalStyleSheets } = await importEntry(entry, opts);
    requestIdleCallback(getExternalStyleSheets); // 仅获取外部的样式表
    requestIdleCallback(getExternalScripts); // 获取内部和外部的js脚本
  });
}

autoDowngradeForLowVersionBrowser(js沙箱自动降级)

LegacySandbox(单应用代理沙箱)和 ProxySandbox(多应用代理沙箱)是基于 proxy 实现的,具体是如何实现的?可参考 qiankun 的 JS 沙箱隔离机制

部分老旧浏览器不支持 proxy,则自动降级为 SnapshotSandbox(快照沙箱),这里只是修改了frameworkConfiguration配置项,增加了loose: true快照沙箱标识

js沙箱是如何被创建的?可以看上一章 loadApp 中的 createSandboxContainer 方法,乾坤源码对应 qiankun/blob/master/src/loader.ts(loadApp 中的 createSandboxContainer方法)

const autoDowngradeForLowVersionBrowser = (configuration: FrameworkConfiguration): FrameworkConfiguration => {
  const { sandbox, singular } = configuration;
  if (sandbox) {
    if (!window.Proxy) {
      // 不支持proxy 采用的是快照沙箱
      console.warn('[qiankun] Miss window.Proxy, proxySandbox will degenerate into snapshotSandbox');

      // 快照沙箱不支持多例模式
      if (singular === false) {
        console.warn(
          '[qiankun] Setting singular as false may cause unexpected behavior while your browser not support window.Proxy',
        );
      }

      return { ...configuration, sandbox: typeof sandbox === 'object' ? { ...sandbox, loose: true } : { loose: true } };
    }
  }

  return configuration;
};

参考文档