qiankun构成与原理

功能介绍&模块拆解

在聊原理之前先了解下qiankun提供的能力,一句话介绍qiankun功能:
能根据路由自动调度子应用并实现沙箱(主子、子子应用之间的JS和CSS)隔离。

举个例子:在主应用里注册两个子应用A&B,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { registerMicroApps } from 'qiankun';

registerMicroApps([
{
name: 'A',
entry: 'https://baidu.com',
container: '#yourContainer',
activeRule: '/baidu',
},
{
name: 'B',
entry: 'https://google.com',
container: '#yourContainer2',
activeRule: '/google',
},
]);

在pathname是/baidu_时加载和运行子应用A,如果路由pushState到了/google_时会unmount子应用A再加载和运行子应用B。

从上面的例子可以看出qiankun提供的能力可以划分为3大部分:

  • single-spa: 绑定路由的子应用调度,监听路由变化根据当前路由去调度对应的子应用
  • import-html-entry: 加载和运行子应用的HTML entry
  • sanbox:子应用的隔离,又分为JS的隔离和CSS的隔离
image

下面分别拆解来分析原理。

Single-spa

一句话介绍single-spa:根据路由变化做子应用调度(子应用生命周期管理)

image

以单个子应用的生命周期来看流程如下:

image

注册子应用

1
2
3
4
5
6
singleSpa.registerApplication({ // 注册一个子应用,注册其他子应用同理
name: 'taobao', // 子应用名,需要唯一
app: () => System.import('taobao'), // 如何拿到子应用的生命周期,这里demo用的System,qiankun实际上不是基于System
activeWhen: '/appName', // url 匹配规则,表示啥时候开始走这个子应用的生命周期
customProps: {} // 自定义 props,从子应用的 bootstrap, mount, unmount 回调可以拿到
})

子应用调度

子应用在自己的入口 js导出了生命周期函数钩子,那在切换路由时子应用A的unmount会执行,子应用B的mount会执行。针对React体系的子应用通常在各生命周期中做如下事情:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* bootstrap 只会在微应用初始化的时候调用一次,下次微应用重新进入时会直接调用 mount 钩子,不会再重复触发 bootstrap。
* 通常我们可以在这里做一些全局变量的初始化,比如不会在 unmount 阶段被销毁的应用级别的缓存等。
*/
export async function bootstrap() {
console.log('react app bootstraped');
}

/**
* 应用每次进入都会调用 mount 方法,通常我们在这里触发应用的渲染方法
*/
export async function mount(props) {
ReactDOM.render(<App />, props.container ? props.container.querySelector('#root') : document.getElementById('root'));
}

/**
* 应用每次 切出/卸载 会调用的方法,通常在这里我们会卸载微应用的应用实例
*/
export async function unmount(props) {
ReactDOM.unmountComponentAtNode(
props.container ? props.container.querySelector('#root') : document.getElementById('root'),
);
}

Html-entry-loader

一句话介绍html-entry-loader:把HTML当作子应用的manifest,加载和执行其中的JS拿到JS导出的模块,加载拿到其中的CSS

image

特别说明:

  • 外链的JS、CSS会被fetch下来以方便执行或提取
  • 默认不带沙箱隔离
1
2
3
4
5
6
7
importHTML('https://xxx.com/subApp.html')
.then(res => {
res.execScripts(proxy = window /*传入沙箱,默认全局window*/).then(exports => {
console.log(exports); // 导出的JS模块,子应用生命周期从这里拿
});
res.getExternalStyleSheets() // Promise<string[]> 获取导出的CSS内容
});

为啥选择HTML作为子应用manifest的描述载体

主应用需要拿到运行一个子应用所需要的信息,包括:JS、CSS、mountId。
除能用HTML作为载体之外,还有一种方式是通过一个JSON来描述,类似这样:

1
2
3
4
5
6
7
{
"version": "1.3.1",
"js": ["main.js", "common.js"],
"css": ["main.css"],
"publicPath": "https://cdn.cn/appXXX",
"mountId": "root"
}

这两种方式各有千秋:

优点 缺点
完全兼容原来就是以HTML方式输出的网页更灵活,支持HTML中内敛的JS、CSS等复用已有的公知的HTML规范作为协议,而不是新创造一种协议 存在信息冗余,传输体积大于JSON解析HTML耗时大于解析JSON
协议更简单,传输和解析更快 操作了一种新协议、规范,需要接入方按此新规范去改造适配,推广成本上升不够灵活

选择HTML最主要的原因是 “复用已有的公知的HTML规范作为协议,而不是新创造一种协议”,因为一个子应用可能需要在多个站点投放、或者需要独立运行。

如何提取HTML中的JS、CSS

通过正则表达式提取,源码里枚举了各种可能的情况下的正则提取式:

1
2
3
4
5
6
7
8
9
10
11
12
const ALL_SCRIPT_REGEX = /(<script[\s\S]*?>)[\s\S]*?<\/script>/gi;
const SCRIPT_TAG_REGEX = /<(script)\s+((?!type=('|")text\/ng-template\3).)*?>.*?<\/\1>/is;
const SCRIPT_SRC_REGEX = /.*\ssrc=('|")?([^>'"\s]+)/;
const SCRIPT_TYPE_REGEX = /.*\stype=('|")?([^>'"\s]+)/;
const SCRIPT_ENTRY_REGEX = /.*\sentry\s*.*/;
const SCRIPT_ASYNC_REGEX = /.*\sasync\s*.*/;
const SCRIPT_NO_MODULE_REGEX = /.*\snomodule\s*.*/;
const SCRIPT_MODULE_REGEX = /.*\stype=('|")?module('|")?\s*.*/;
const LINK_TAG_REGEX = /<(link)\s+.*?>/isg;
const STYLE_TAG_REGEX = /<style[^>]*>[\s\S]*?<\/style>/gi;
const STYLE_TYPE_REGEX = /\s+rel=('|")?stylesheet\1.*/;
const STYLE_HREF_REGEX = /.*\shref=('|")?([^>'"\s]+)/;

JS、CSS提取逻辑完整源码链接

如何执行子应用

1
2
3
4
5
6
7
8
9
10
11
12
function getExecutableScript(scriptSrc, scriptText, proxy, strictGlobal) {
const sourceUrl = isInlineCode(scriptSrc) ? '' : `//# sourceURL=${scriptSrc}\n`;

// 通过这种方式获取全局 window,因为 script 也是在全局作用域下运行的,所以我们通过 window.proxy 绑定时也必须确保绑定到全局 window 上
// 否则在嵌套场景下, window.proxy 设置的是内层应用的 window,而代码其实是在全局作用域运行的,会导致闭包里的 window.proxy 取的是最外层的微应用的 proxy
const globalWindow = (0, eval)('window');
globalWindow.proxy = proxy;
// TODO 通过 strictGlobal 方式切换 with 闭包,待 with 方式坑趟平后再合并
return strictGlobal
? `;(function(window, self, globalThis){with(window){;${scriptText}\n${sourceUrl}}}).bind(window.proxy)(window.proxy, window.proxy, window.proxy);`
: `;(function(window, self, globalThis){;${scriptText}\n${sourceUrl}}).bind(window.proxy)(window.proxy, window.proxy, window.proxy);`;
}

with语句

with的初衷是为了避免冗余的对象调用:

1
2
3
4
5
6
7
8
9
foo.bar.baz.x = 1;
foo.bar.baz.y = 2;
foo.bar.baz.z = 3;

with(foo.bar.baz){
x = 1;
y = 2;
z = 3;
}

使用with语句有很多问题,详情见

如何执行子应用的JS

1
2
3
4
5
6
7
8
9
10
11
12
13
const evalCache = {};

export function evalCode(scriptSrc, code) {
const key = scriptSrc;
if (!evalCache[key]) {
const functionWrappedCode = `window.__TEMP_EVAL_FUNC__ = function(){${code}}`;
(0, eval)(functionWrappedCode);
evalCache[key] = window.__TEMP_EVAL_FUNC__;
delete window.__TEMP_EVAL_FUNC__;
}
const evalFunc = evalCache[key];
evalFunc.call(window);
}
  • 用eval去执行以字符串形式保存的子应用JS
  • 借助window.TEMP_EVAL_FUNC记下子应用JS的执行结果,并缓存到evalCache中避免下次重复eval

Sanbox

JS隔离

JS隔离是qiankun最核心最复杂的部分。JS隔离需要实现的目标是:

  • 隔离是指对window修改进行隔离,封装污染window
  • 避免子应用A污染主应用的window
  • 避免子应用A污染子应用B的window

问:沙箱会做避免主应用对子应用A的window污染么? 答:不会,启动一个子应用时,子应用的window继承自主应用

三种JS沙箱实现: SnapshotSandbox、LegacySandbox 、ProxySandbox

不同的JS沙箱实现 原理简介 优点 缺点 开启方法
ProxySandbox 基于Proxy API实现 隔离性和性能较好 浏览器兼容性问题,依赖无法polyfill的Proxy API sanbox = true
SnapshotSandbox 基于diff算法实现 性能低,只支持单例子应用隔离作用有限 浏览器兼容性好,支持IE11 用于不支持 Proxy 的低版本浏览器降级使用
LegacySandbox 基于Proxy API实现,现已废弃不推荐使用 中间产物 中间产物 singular = true
  • qiankun会优先使用ProxySandbox,对于不兼容Proxy的浏览器会降级到SnapshotSandbox
  • ProxySandbox支持同时有多个子应用沙箱运行,SnapshotSandbox无法保证同时有多个子应用时的隔离
  • LegacySandbox时历史中间产物,现在已经没有存在的价值,所以废弃不推荐使用

ProxySandbox核心思想

拦截对window上字段的读&写,每个子应用一个沙箱(一个fakewindow),子应用对window读&写实际是对fakewindow的读写。

  • 一个map去存储子应用对window的修改记录,对window的写都会记录在内
  • get时优先去map中读,找不到就去外层真实的window上读
image
如何拦截对window的读写
1
2
3
4
5
6
7
const fakewindow = new ProxySandbox(); // 给子应用分配的代理window变量

((window) => {
with(window){
子应用代码
}
})(fakewindow);

子应用代码中对window的读写,实际上变成了对subAppProxy的读写。

SnapshotSandbox核心思想

把主应用的 window 对象做浅拷贝,将 window 的键值对存成一个 Hash Map。之后无论微应用对 window 做任何改动,当要在恢复环境时,把这个 Hash Map 又应用到 window 上就可以了。
微应用 mount 时:

  1. 先把上一次记录的变更 modifyPropsMap 应用到微应用的全局 window,没有则跳过
  2. 浅复制主应用的 window key-value 快照 = mainWindowKV,用于下次恢复全局环境

微应用 unmount 时

  1. 将当前微应用 window 的 key-value = microWindowKV 和 mainWindowKV 进行 Diff,Diff 出来的结果就是 modifyPropsMap
  2. 将上次快照 mainWindowKV 拷贝到主应用的 window 上,以此恢复环境

JS沙箱逃逸

你的JS里有诸如 document.body.appendChild(scriptElement) 这样的代码,会动态往DOM里面插入JS,如果不处理这些JS会在主应用的 window 上执行可能污染真正的window。
为此,沙箱还会拦截appendChild方法,凡是子应用中appendChild进去的JS都会被fetch下来去沙箱里面执行。
image

https://github.com/umijs/qiankun/blob/master/src/sandbox/patchers/dynamicAppend/common.ts#L396

CSS隔离

qiankun提供以下三种隔离样式的方式

CSS隔离实现方式 原理简介 优点 缺点 开启方法
CSS生命周期管理 子应用之间切换时,是会自动做子应用CSS的加载和卸载的,防止子应用A的CSS代入到子应用B中 无额外性能开销兼容性好 只能做子应用之间切换时的隔离,无法做主子、并发子的隔离 内置逻辑全程开启无法关闭
Scopted Style 给子应用套一层特殊选择器的div修改子应用CSS选择器前缀 能做到主子、并发子的隔离 提升CSS选择器复杂性,降低页面性能 experimentalStyleIsolation
Shadow DOM 用Shadow DOM包裹 能做到主子、并发子的隔离 浏览器兼容性问题,依赖无法polyfill的Shadow DOM API子应用需要做一些适配 strictStyleIsolation