30分钟快速掌握微前端 qiankun 的所有核心技术
# 30分钟快速掌握微前端 qiankun 的所有核心技术
# 一. 引言
# 1. 微前端是什么?
已经了解微前端的朋友可自行跳过本节,简单介绍下微前端,微前端是将前端更加细分化的一种技术方案,类似与后端微服务,下图所示 3 个可独立构建测试部署并可增量升级的不同技术栈应用,可以集成在一个基座应用中一起展示。
微前端是一种多个团队通过独立发布功能的方式来共同构建现代化 web 应用的技术手段及方法策略。
微前端架构具备以下几个核心价值:
技术栈无关
主框架不限制接入应用的技术栈,微应用具备完全自主权独立开发、独立部署
微应用仓库独立,前后端可独立开发,部署完成后主框架自动完成同步更新增量升级
在面对各种复杂场景时,我们通常很难对一个已经存在的系统做全量的技术栈升级或重构,而微前端是一种非常好的实施渐进式重构的手段和策略独立运行时
每个微应用之间状态隔离,运行时状态不共享
# 2. 微前端解决了什么问题?
微前端架构旨在解决单体应用在一个相对长的时间跨度下,由于参与的人员、团队的增多、变迁、从一个普通应用演变成一个巨石应用(Frontend Monolith)后,随之而来的应用不可维护的问题。这类问题在企业级 Web 应用中尤其常见。
# 3. 如何实现微前端?
实现微前端需要解决的技术问题有:
- 应用接入
- 应用入口
- 应用隔离
- 样式隔离
- 应用通信
- 应用路由
# 4. 为什么选择 qiankun?
- 在利用 Single SPA 或其它微应用框架构建前端系统中遇到的一些问题,如样式隔离、JS沙箱、资源预加载、JS 副作用处理等等这些你需要的能力全部内置到了
qiankun
里面。 - 到目前为止,已经大概有 2000+ 的应用,使用
qiankun
来接入自己的微前端体系。qiankun
在蚂蚁内外受过了大量线上系统的考验,所以它是一个值得信赖的生产可用的解决方案。
短短一年时间,qiankun
已然成为最热门的微前端框架之一,虽然源码一直在更新,但是它的核心技术始终是那么几个:JS 沙箱、CSS 样式隔离、应用 HTML 入口接入、应用通信、应用路由等,
接下来将通过演示 demo
的方式详细说明几种技术的设计与实现。
# 二. JS 沙箱隔离的设计与实现
# 1. JS 沙箱简介
JS 沙箱 简单点说就是,主应用有一套全局环境 window
,子应用有一套私有的全局环境 fakeWindow
,子应用所有操作都只在新的全局上下文中生效,这样的子应用就好比被一个个箱子装起来与主应用隔离,
因此主应用加载应用便不会造成JS 变量的相互污染、JS 副作用、CSS 样式被覆盖等,每个子应用的全局上下文都是独立的。
# 2. 快照沙箱 - snapshotSandbox
快照沙箱就是在应用沙箱挂载和卸载的时候记录快照,在应用切换的时候依据快照恢复环境。
- demo 演示
- 实现代码
// 子应用A
mountSnapshotSandbox();
window.a = 123;
console.log("快照沙箱挂载后的a:", window.a); // 123
unmountSnapshotSandbox();
console.log("快照沙箱卸载后的a:", window.a); // undefined
mountSnapshotSandbox();
console.log("快照沙箱再次挂载后的a:", window.a); // 123
2
3
4
5
6
7
8
// snapshotSandbox.ts
// 遍历对象key并将key传给回调函数执行
function iter(obj: object, callbackFn: (prop: any) => void) {
for (const prop in obj) {
if (obj.hasOwnProperty(prop)) {
callbackFn(prop);
}
}
}
// 挂载快照沙箱
mountSnapshotSandbox()
{
// 记录当前快照
this.windowSnapshot = {} as Window;
iter(window, (prop) => {
this.windowSnapshot[prop] = window[prop];
});
// 恢复之前的变更
Object.keys(this.modifyPropsMap).forEach((p: any) => {
window[p] = this.modifyPropsMap[p];
});
}
// 卸载快照沙箱
unmountSnapshotSandbox()
{
// 记录当前快照上改动的属性
this.modifyPropsMap = {};
iter(window, (prop) => {
if (window[prop] !== this.windowSnapshot[prop]) {
// 记录变更,恢复环境
this.modifyPropsMap[prop] = window[prop];
window[prop] = this.windowSnapshot[prop];
}
});
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
- 优点
- 兼容几乎所有浏览器
- 缺点
- 无法同时有多个运行时快照沙箱,否则在 window 上修改的记录会混乱,一个页面只能运行一个单实例微应用
# 3. 代理沙箱 - proxySandbox
当有多个实例的时候,比如有 A
、B
两个应用,A
应用就活在 A
应用的沙箱里面,B
应用就活在 B
应用的沙箱里面,A
和 B
无法互相干扰,这样的沙箱就是代理沙箱,
这个沙箱的实现思路其实也是通过 ES6
的 proxy (opens new window),通过代理特性实现的。
Proxy
对象用于创建一个对象的代理,从而实现基本操作的拦截和自定义(如属性查找、赋值、枚举、函数调用等)。 简单来说就是,可以在对目标对象设置一层拦截。无论对目标对象进行什么操作,都要经过这层拦截。
- Proxy vs Object.defineProperty
Object.defineProperty
也能实现基本操作的拦截和自定义,那为什么用Proxy
呢?因为Proxy
能解决以下问题:
- 删除或者增加对象属性无法监听到。
- 数组的变化无法监听到(
vue2
正是使用的Object.defineProperty
劫持属性,watch
中无法检测数组改变的元凶找到了)
demo 演示
- 简单版本
- 实际场景版本
- 实现代码
- 简单版本
const proxyA = new CreateProxySandbox({});
const proxyB = new CreateProxySandbox({});
proxyA.mountProxySandbox();
proxyB.mountProxySandbox();
(function (window) {
window.a = "this is a";
console.log("代理沙箱 a:", window.a); // undefined
})(proxyA.proxy);
(function (window) {
window.b = "this is b";
console.log("代理沙箱 b:", window.b); // undefined
})(proxyB.proxy);
proxyA.unmountProxySandbox();
proxyB.unmountProxySandbox();
(function (window) {
console.log("代理沙箱 a:", window.a); // undefined
})(proxyA.proxy);
(function (window) {
console.log("代理沙箱 b:", window.b); // undefined
})(proxyB.proxy);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
- 真实场景版本
<!DOCTYPE html>
<html lang="en">
<body data-qiankun-A>
<h5>代理沙箱:</h5>
<button onclick="mountA()">代理沙箱模式挂载a应用</button>
<button onclick="unmountA()">代理沙箱模式卸载a应用</button>
<button onclick="mountB()">代理沙箱模式挂载b应用</button>
<button onclick="unmountB()">代理沙箱模式卸载b应用</button>
<script src="proxySandbox.js"></script>
<script src="index.js"></script>
</body>
</html>
2
3
4
5
6
7
8
9
10
11
12
13
a
应用js,在 a
应用挂在期间加载的所有 js
都会运行在 a
应用的沙箱(proxyB.proxy
)中
// a.js
window.a = "this is a";
console.log("代理沙箱1 a:", window.a);
2
3
b
应用js,在 b
应用挂载期间加载所有的 js
都会运行在 b
应用的沙箱(proxyB.proxy
)中
// b.js
window.b = "this is b";
console.log("代理沙箱 b:", window.b);
2
3
const proxyA = new CreateProxySandbox({});
const proxyB = new CreateProxySandbox({});
function mountA() {
proxyA.mountProxySandbox();
fetch("./a.js")
.then((response) => response.text())
.then((scriptText) => {
const sourceUrl = `//# sourceURL=a.js\n`;
window.proxy = proxyA.proxy;
eval(
`;(function(window, self){with(window){;${scriptText}\n${sourceUrl}}}).bind(window.proxy)(window.proxy, window.proxy);`
);
});
}
function unmountA() {
proxyA.unmountProxySandbox();
fetch("./a.js")
.then((response) => response.text())
.then((scriptText) => {
const sourceUrl = `//# sourceURL=a.js\n`;
eval(
`;(function(window, self){with(window){;${scriptText}\n${sourceUrl}}}).bind(window.proxy)(window.proxy, window.proxy);`
);
});
}
function mountB() {
proxyB.mountProxySandbox();
fetch("./b.js")
.then((response) => response.text())
.then((scriptText) => {
const sourceUrl = `//# sourceURL=b.js\n`;
window.proxy = proxyB.proxy;
eval(
`;(function(window, self){with(window){;${scriptText}\n${sourceUrl}}}).bind(window.proxy)(window.proxy, window.proxy);`
);
});
}
function unmountB() {
proxyB.unmountProxySandbox();
fetch("./b.js")
.then((response) => response.text())
.then((scriptText) => {
const sourceUrl = `//# sourceURL=b.js\n`;
eval(
`;(function(window, self){with(window){;${scriptText}\n${sourceUrl}}}).bind(window.proxy)(window.proxy, window.proxy);`
);
});
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
代理沙箱代码
// proxySandbox.ts
function CreateProxySandbox(fakeWindow = {}) {
const _this = this;
_this.proxy = new Proxy(fakeWindow, {
set(target, p, value) {
if (_this.sandboxRunning) {
target[p] = value;
}
return true;
},
get(target, p) {
if (_this.sandboxRunning) {
return target[p];
}
return undefined;
},
});
_this.mountProxySandbox = () => {
_this.sandboxRunning = true;
};
_this.unmountProxySandbox = () => {
_this.sandboxRunning = false;
};
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
- 优点
- 可同时运行多个沙箱
- 不会污染 window 环境
- 缺点
- 不兼容 ie
- 在全局作用域上通过
var
或function
声明的变量和函数无法被代理沙箱劫持,因为代理对象Proxy
只能识别在该对象上存在的属性,通过var
或function
声明的变量开辟了新的地址,自然无法被Proxy
劫持,比如
const proxy1 = new CreateProxySandbox({});
proxy1.mountProxySandbox();
(function (window) {
mountProxySandbox();
var a = "this is proxySandbox1";
function b() {}
console.log("代理沙箱1挂载后的a, b:", window.a, window.b); // undefined undefined
})(proxy1.proxy);
proxy1.unmountProxySandbox();
(function (window) {
console.log("代理沙箱1卸载后的a, b:", window.a, window.b); // undefined undefined
})(proxy1.proxy);
2
3
4
5
6
7
8
9
10
11
12
13
一种解决方案是不用 var
和 function
声明全局变量和全局函数,比如
var a = 1; // 失效
a = 1; // 有效
window.a = 1; // 有效
function b() {} // 失效
b = () => {}; // 有效
window.b = () => {}; // 有效
2
3
4
5
6
7
# 三. CSS 隔离的设计与实现
# 1. CSS 隔离简介
页面中有多个微应用时,要确保 A
应用的样式不会影响 B
应用的样式,就需要对应用的样式采取隔离。
# 2. 动态样式表 - Dynamic Stylesheet
# 3. 工程化手段 - BEM、CSS Modules、CSS in JS
通过一系列约束和编译时生成不同类名、JS 中处理 CSS 生成不同类名来解决隔离问题
# 4. Shadow DOM
Shadow DOM
允许将隐藏的 DOM
树附加到常规的 DOM
树种 —— 它以 shadow root
节点为起始根节点,在这个根节点的下方,可以是任意元素,和普通的 DOM
元素一样,隐藏的 DOM
样式和其余 DOM
是完全隔离的,类似于 iframe
的样式隔离效果。
移动端框架
Ionic
的组件样式隔离就是采用的Shadow DOM
方案,保证相同组件的样式不会冲突。
- demo 演示
- 代码实现
<!DOCTYPE html>
<html lang="en">
<body data-qiankun-A>
<h5>样式隔离:</h5>
<p class="title">一行文字</p>
<script src="scopedCSS.js"></script>
<script src="index.js"></script>
</body>
</html>
2
3
4
5
6
7
8
9
10
// index.js
var bodyNode = document.getElementsByTagName("body")[0];
openShadow(bodyNode);
2
3
// scopedCss.js
function openShadow(domNode) {
var shadow = domNode.attachShadow({ mode: "open" });
shadow.innerHTML = domNode.innerHTML;
domNode.innerHTML = "";
}
2
3
4
5
6
优点
1.完全隔离 CSS 样式缺点
2.在使用一些弹窗组件的时候(弹窗很多情况下都是默认添加到了 document.body )这个时候它就跳过了阴影边界,跑到了主应用里面,样式就丢了
# 5. 运行时转换样式 - runtime css transformer
动态运行时地去改变 CSS
,比如 A 应用的一个样式 p.title
,转换后会变成 div[data-qiankun-A] p.title,div[data-qiankun-A]
是微应用最外层的容器节点,故保证 A
应用的样式只有在 div[data-qiankun-A]
下生效。
- demo 演示
- 代码实现
<!-- index.html -->
<html lang="en">
<head>
<style>
p.title {
font-size: 20px;
}
</style>
</head>
<body data-qiankun-A>
<p class="title">一行文字</p>
<script src="scopedCSS.js"></script>
<script>
var styleNode = document.getElementsByTagName("style")[0];
scopeCss(styleNode, "body[data-qiankun-A]");
</script>
</body>
</html>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// scopedCSS.js
function scopeCss(styleNode, prefix) {
const css = ruleStyle(styleNode.sheet.cssRules[0], prefix);
styleNode.textContent = css;
}
function ruleStyle(rule, prefix) {
const rootSelectorRE = /((?:[^\w\-.#]|^)(body|html|:root))/gm;
let { cssText } = rule;
// 绑定选择器, a,span,p,div { ... }
cssText = cssText.replace(/^[\s\S]+{/, (selectors) =>
selectors.replace(/(^|,\n?)([^,]+)/g, (item, p, s) => {
// 绑定 div,body,span { ... }
if (rootSelectorRE.test(item)) {
return item.replace(rootSelectorRE, (m) => {
// 不要丢失有效字符 如 body,html or *:not(:root)
const whitePrevChars = [",", "("];
if (m && whitePrevChars.includes(m[0])) {
return `${m[0]}${prefix}`;
}
// 用前缀替换根选择器
return prefix;
});
}
return `${p}${prefix} ${s.replace(/^ */, "")}`;
})
);
return cssText;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
优点
1.支持大部分样式隔离需求
2.解决了Shadow DOM
方案导致的丢失根节点问题缺点
1.运行时重新加载样式,会有一定性能损耗
# 四. 清除 js 副作用的设计与实现
# 1. 清除 js 副作用简介
子应用在 沙箱
中使用 window.addEventListener、setInterval
这些 需异步监听的全局api
时,要确保子应用在移出时也要移出对应的监听事件,否则会对其他应用造成副作用。
# 2. 实现清除 js 操作副作用
- demo 演示
- 代码实现
<!DOCTYPE html>
<html lang="en">
<body>
<h5>清除window副作用:</h5>
<button onclick="mountSandbox()">挂载沙箱并开启副作用</button>
<button onclick="unmountSandbox(true)">卸载沙箱并关闭副作用</button>
<button onclick="unmountSandbox()">普通卸载沙箱</button>
<script src="proxySandbox.js"></script>
<script src="patchSideEffects.js"></script>
<script src="index.js"></script>
</body>
</html>
2
3
4
5
6
7
8
9
10
11
12
13
let mountingFreer;
const proxy2 = new CreateProxySandbox({});
function mountSandbox() {
proxy2.mountProxySandbox();
// 在沙箱环境中执行的代码
(function (window, self) {
with (window) {
// 记录副作用
mountingFreer = patchSideEffects(window);
window.a = "this is proxySandbox2";
console.log("代理沙箱2挂载后的a:", window.a); // undefined
// 设置屏幕变化监听
window.addEventListener("resize", () => {
console.log("resize");
});
// 定时输出字符串
setInterval(() => {
console.log("Interval");
}, 500);
}
}.bind(proxy2.proxy)(proxy2.proxy, proxy2.proxy));
}
/**
* @param isPatch 是否关闭副作用
*/
function unmountSandbox(isPatch = false) {
proxy2.mountProxySandbox();
console.log("代理沙箱2卸载后的a:", window.a); // undefined
if (isPatch) {
mountingFreer();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// patchSideEffects.js
const rawAddEventListener = window.addEventListener;
const rawRemoveEventListener = window.removeEventListener;
const rawWindowInterval = window.setInterval;
const rawWindowClearInterval = window.clearInterval;
function patch(global) {
const listenerMap = new Map();
let intervals = [];
global.addEventListener = (type, listener, options) => {
const listeners = listenerMap.get(type) || [];
listenerMap.set(type, [...listeners, listener]);
return rawAddEventListener.call(window, type, listener, options);
};
global.removeEventListener = (type, listener, options) => {
const storedTypeListeners = listenerMap.get(type);
if (
storedTypeListeners &&
storedTypeListeners.length &&
storedTypeListeners.indexOf(listener) !== -1
) {
storedTypeListeners.splice(storedTypeListeners.indexOf(listener), 1);
}
return rawRemoveEventListener.call(window, type, listener, options);
};
global.clearInterval = (intervalId) => {
intervals = intervals.filter((id) => id !== intervalId);
return rawWindowClearInterval(intervalId);
};
global.setInterval = (handler, timeout, ...args) => {
const intervalId = rawWindowInterval(handler, timeout, ...args);
intervals = [...intervals, intervalId];
return intervalId;
};
return function free() {
listenerMap.forEach((listeners, type) =>
[...listeners].forEach((listener) =>
global.removeEventListener(type, listener)
)
);
global.addEventListener = rawAddEventListener;
global.removeEventListener = rawRemoveEventListener;
intervals.forEach((id) => global.clearInterval(id));
global.setInterval = rawWindowInterval;
global.clearInterval = rawWindowClearInterval;
};
}
function patchSideEffects(global) {
return patch(global);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58