像素

qiankun 微前端方案原理

子应用加载 - Html Entry

在使用 single-spa 时, 通常情况下,会将子应用的所有静态资源 - js、css、img 等打包成一个 js bundle,然后在 loadAppFunc 中通过加载执行这个 js bundle 的方式,获取子应用提供的生命周期方法,然后执行子应用的 mount 方法来加载子应用。这种方式称为 Js Entry。 有这样几点问题:

  • 不同的子应用打包出来的 js bundle 的名称可能会不一样,而且子应用更新也会导致 js bundle 的名称随时会变化,这就使得我们在定义 loadAppFunc 时,必须能动态获取子应用 js bundle 名称(子应用 js bundle 名称得保存起来以便 loadAppFunc 来获取)。
  • 所有的静态资源打包到一起,css 提取、资源并行加载、首屏加载等优化也就没有了。
  • 为了使得子应用的按需加载功能生效,我们需要在子应用打包过程中,修改相应的配置以补全子应用 js 资源的路径。

不同于 single-spa,qiankun 中采用了另一种方式 - Html Entry.

qiankun 是基于原生 fetch 来实现 loadAppFunc 的。简单来说,就是加载子应用时,qiankun 会根据子应用 entry 配置项指定的 url,通过 fetch 方法来获取子应用对应的 html 内容字符串,然后解析 html 内容,收集子应用的样式、js 脚本,安装样式并执行 js 脚本来获取子应用的生命周期方法,然后执行子应用的 mount 方法。

流程图: 1.jpg 这是图片

JS 隔离

qiankun 通过 sandbox 的形式,为每个微应用提供隔离的运行环境,保证子应用的 js 代码在执行时使用的全局变量都是独属于当前子应用的。 原理:

  • 为每一个子应用创建一个唯一的类 window 对象
  • 手动执行子应用的 js 脚本,将类 window 对象作为全局变量,对全局变量的读写都作用在类 window 对象上;
  • html entry 阶段解析出来的所有 js 脚本字符串 在执行时会先使用一个 IIFE - 立即执行函数包裹,然后通过 eval 方法手动触发

在 qiankun 中提供了三种 sandBox ProxySandbox, SnapshotSandbox, SingularProxySandbox.

ProxySandbox: 代理沙盒

qiankun 先构建一个空对象 - fakeWindow 作为一个假的 window 对象,然后在 fakeWindow 的基础上通过原生的 Proxy 创建一个 proxy 对象,这个 proxy 最后会作为子应用 js 代码执行时的全局变量。有了这个 proxy,我们就可以很方便的劫持 js 代码中对全局变量的读写操作。

当子应用中需要添加(修改)全局变量时,直接在 fakeWindow 中添加(修改);当子应用需要从全局变量中读取某个属性(方法)时,先从 fakeWindow 中获取,如果 fakeWindow 中没有,再从原生 window 中获取

class ProxySandbox {
    // ...
    name: string;  // 沙盒的名称
    proxy: WindowProxy; // 沙盒对应的 proxy 对象
    sandboxRunning: boolean; // 判断沙盒是否激活

    // 沙盒的激活方法,当子应用挂载时,要先通过 active 方法将沙盒激活
    active() {
        // ...
        this.sandboxRunning = true;
    }

    // 沙盒的失活方法。当子应用卸载以后,要执行 inactive 方法将沙盒失活
    inactive() {
        // ...
        this.sandboxRunning = false;
    }

    constructor(name) {
        // 以子应用的名称作为沙盒的名称
        this.name = name;
        const self = this;
        // 获取原生的 window 对象
        const rawWindow = window;
        // 假的 window 对象
        const fakeWindow = {};
        // 在这里,qiankun 之所以要使用 proxy,主要是想拦截 fakeWindow 的读写等操作
        // 比如,子应用中要使用 setTimeout 方法,fakeWindow 中并没有,就需要从 rawWindow 获取
        this.proxy = new Proxy(fakeWindow, {
            set(target, key, value) {
                if (self.sandboxRunning) { // 沙盒已经激活
                    // ...
                    // 子应用新增/修改的全局变量都保存到对应的fakeWindow
                    target[key] = value;
                }
            },
            get(target, key) {
                // ...
                // 读取属性时,先从 fakeWindow 中获取,如果没有,就从 rawWindow 中获取
                return key in target ? target[key] : rawWindow[key];
            },
            ...
        });


    }
}

SnapshotSandbox 快照沙盒

对于不支持 proxy 的浏览器,qiankun 采用快照的形式,将原生 window 上的属性、方法全部拷贝了一份到 fakeWindow,以便子应用在读取全局变量时,可以在 fakeWindow 中全部获取到。

class SnapshotSandbox {
  // ...
  name: string; // 子应用的名称
  proxy: WindowProxy; // 沙盒对应的 proxy 对象
  sandboxRunning: boolean; // 判断沙盒是否激活
  private windowSnapshot!: Window; // window 对象的快照
  private modifyPropsMap: Record<any, any> = {}; // 收集修改的 window 属性

  constructor(name: string) {
    this.name = name;
    this.proxy = window;
    this.type = SandBoxType.Snapshot; // 快照类型
  }

  // 沙盒的激活方法
  active() {
    // 记录当前快照
    this.windowSnapshot = {} as Window;
    // 遍历 window 对象的属性,把 window 对象可枚举的属性添加到 windowSnapshot 中
    iter(window, (prop) => {
      this.windowSnapshot[prop] = window[prop];
    });

    // 恢复之前的变更
    Object.keys(this.modifyPropsMap).forEach((p: any) => {
      window[p] = this.modifyPropsMap[p];
    });

    this.sandboxRunning = true;
  }
  // 沙盒的失活方法
  inactive() {
    this.modifyPropsMap = {};

    iter(window, (prop) => {
      if (window[prop] !== this.windowSnapshot[prop]) {
        // 记录变更,恢复环境
        this.modifyPropsMap[prop] = window[prop];
        window[prop] = this.windowSnapshot[prop];
      }
    });

    // ...

    this.sandboxRunning = false;
  }
}

SingularProxySandbox 单例沙盒

qiankun 在启用单例模式(父应用只有一个子应用挂载)时,会自动创建。SingularProxySandbox 也是基于 proxy 实现的。但是和 ProxySandbox 不同,SingularProxySandbox 是在原生 window 对象上直接修改属性的,这会导致父子应用之间全局变量的互相影响。目前,不管是单子应用还是多子应用,qiankun 默认都使用 ProxySandbox。SingularProxySandbox 只有我们我们在 start 方法中显示配置 { sandbox: {loose: true }} 才会使用。

新版本的 qiankun 中,SingularProxySandbox 会逐步废弃, 改为使用 ProxySandbox。

css 隔离

qiankun 中 css 隔离有两种:严格样式隔离和 scoped 样式隔离。

严格样式隔离

在 start 中 通过 strictStyleIsolation: true 开启严格样式隔离

import { start } from "qiankun";

start({
  sandbox: {
    strictStyleIsolation: true,
  },
});

严格样式隔离,是基于 Web Component 的 shadow Dom 实现的。通过 shadow Dom, 我们可以将一个隐藏的、独立的 dom 附加到一个另一个 dom 元素上,保证元素的私有化,不用担心与文档的其他部分发生冲突。

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;

scoped 样式隔离

在 start 中 experimentalStyleIsolation:true 开启 scoped 样式隔离

import { start } from "qiankun";
start({
  sandbox: {
    experimentalStyleIsolation: true,
  },
});

html entry 解析以后的 html 模板字符串,在添加到 container 指定的节点之前,会先包裹一层 div,并且为这个 div 节点添加 data-qiankun 属性,属性值为子应用的 name 属性;然后遍历 html 模板字符串中所有的 style 节点,依次为内部样式表中的样式添加 div["data-qiankun=xxx"] 前缀。qiankun 中子应用的 name 属性值是唯一的,这样通过属性选择器的限制,就可实现样式隔离。

实际的项目中,我们常常会遇到动态添加 style 的情形,比如没有进行 css 提取、react 应用使用 styled-components 等。通常,这些动态添加的 style 会通过 document.head.appendChild 的方式添加到 head 节点中。此时,如果 qiankun 启用严格样式隔离或者 scoped 样式隔离,css 隔离也是不会失效的 qiankun 对 document.head.appendChild 方法进行了劫持操作,具体如下:

// 原生的 appendChild 方法
const rawHeadAppendChild = document.head.appendChild;
// 重写原生方法
document.head.appendChild = function (newChild) {
  if (newChild.tagName === "STYLE") {
    // 对 style 节点做处理
  }
  // 找到子应用对应的 html 片段的根 dom 节点
  const mountDOM = "...";
  // 通过原生的 appendChild 将动态 style 添加到子应用对应的 html 片段中
  rawHeadAppendChild.call(mountDOM, newChild);
};

当子应用调用 document.head.appendChild 动态添加 style 时,会被 qiankun 劫持,然后将 style 添加到子应用对应的 html 片段中。此时如果 qiankun 配置了严格样式隔离,新增的 style 是添加到 shadow dom 中的,css 隔离自然生效;如果 qiankun 配置了 scoped 样式隔离,在将 style 添加到子应用对应的 html 片段之前,会先获取到样式内容,然后为样式内容添加 div["data-qiankun=xxx"] 前缀,css 隔离也生效。

子应用卸载副作用清理

  • 修改全局变量引发的副作用。由于 qiankun 使用了 sandbox 机制,每个子应用工作过程中都有各自独立的全局变量,不会修改 window,因此不会出现修改全局变量 window 的副作用,也就不用处理了
  • 动态添加 dom 节点引发的副作用。由于 qiankun 劫持了 document.head.appendChild、document.body.appendChild、 document.head.insertBefore, 动态添加的 dom 节点都是自动添加到子应用对应的 html 片段中。当子应用卸载时,子应用对应的 html 片段会自动移除,动态添加的 dom 节点自然也就一起移除了
  • setInterval 引发的副作用,qiankun 是通过劫持原生的 setInterval 方法来解决的
const rawWindowInterval = window.setInterval;
const rawWindowClearInterval = window.clearInterval;

function patch(global: Window) {
  // 收集子应用定义的定时器
  let intervals: number[] = [];
  // 重写原生的 clearInterval
  global.clearInterval = (intervalId: number) => {
    intervals = intervals.filter((id) => id !== intervalId);
    return rawWindowClearInterval(intervalId);
  };
  // 重写原生的 setInterval
  global.setInterval = (
    handler: Function,
    timeout?: number,
    ...args: any[]
  ) => {
    const intervalId = rawWindowInterval(handler, timeout, ...args);
    intervals = [...intervals, intervalId];
    return intervalId;
  };
  // free 函数在子应用卸载时调用
  return function free() {
    intervals.forEach((id) => global.clearInterval(id));
    global.setInterval = rawWindowInterval;
    global.clearInterval = rawWindowClearInterval;

    return noop;
  };
}

通过劫持 setInterval,子应用生成的定时器都会被收集,当子应用卸载时,收集的定时器会自动被 qiankun 清除掉,和 setInterval 一样, window.addEventListener 引发的副作用,qiankun 也是通过劫持原生的 window.addEventListener、window.removeEventListener 来处理的。

微应用重新挂载状态恢复

微应用重新挂载时需要恢复,修改过的全局变量、子应用动态添加的 style。

修改过的全局变量,通过 qiankun 中 js 隔离的沙箱自动恢复。

每个子应用都有一个独属于自己的 fakeWindow,这个 fakeWindow 会一直伴随子应用存在(子应用卸载时也存在)。子应用所有对全局变量的修改,实际上都发生在 fakeWindow 上,当子应用重新挂载时,全局变量自动恢复。

子应用动态添加的 style 恢复:

  1. qiankun 会劫持 document.head.appendChild 方法。当子应用第一次挂载时,遇到动态添加 style 的操作,会被劫持,新增的 style 会被缓存起来。这个缓存会一直伴随子应用存在。
  2. 微应用重新挂载,将之前缓存的 style 添加到对应的 html 片段中。

qiankun 工作过程

这是图片