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 方法。
流程图:
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 恢复:
- qiankun 会劫持 document.head.appendChild 方法。当子应用第一次挂载时,遇到动态添加 style 的操作,会被劫持,新增的 style 会被缓存起来。这个缓存会一直伴随子应用存在。
- 微应用重新挂载,将之前缓存的 style 添加到对应的 html 片段中。