一、引言
微前端是什么?
已经了解微前端的朋友可自行跳过本节,简单介绍下微前端,微前端是将前端更加细分化的一种技术方案,类似与后端微服务,下图所示 3 个可独立构建测试部署并可增量升级的不同技术栈应用,可以集成在一个基座应用中一起展示。
微前端是一种多个团队通过独立发布功能的方式来共同构建现代化 web 应用的技术手段及方法策略。
微前端架构具备以下几个核心价值:
在面对各种复杂场景时,我们通常很难对一个已经存在的系统做全量的技术栈升级或重构,而微前端是一种非常好的实施渐进式重构的手段和策略
- 独立运行时
每个微应用之间状态隔离,运行时状态不共享
演示一个微前端项目,其中菜单、地图都是微应用,菜单是 vue 项目,地图是 h5 项目,地图可独立运行,集成到基座中时原本入口的 html
会转换成 div
,html
里的 css
会被转换成 style
,js
会转换成字符串并通过 eval
函数直接执行。
微前端解决了什么问题?
微前端架构旨在解决单体应用在一个相对长的时间跨度下,由于参与的人员、团队的增多、变迁,从一个普通应用演变成一个巨石应用(Frontend Monolith)后,随之而来的应用不可维护的问题。这类问题在企业级 Web 应用中尤其常见。
如何实现微前端?
实现微前端需要解决的技术问题有:
- 应用接入
- 应用入口
- 应用隔离
- 样式隔离
- 应用通信
- 应用路由
为什么选择 qiankun?
- 在利用 Single SPA 或其它微应用框架构建微前端系统中遇到的一些问题,如样式隔离、JS 沙箱、资源预加载、JS 副作用处理等等这些你需要的能力全部内置到了
qiankun
里面
- 到目前为止,已经大概有 200+ 的应用,使用
qiankun
来接入自己的微前端体系。qiankun
在蚂蚁内外受过了大量线上系统的考验,所以它是一个值得信赖的生产可用的解决方案。
短短一年时间,qiankun 已然成为最热门的微前端框架之一,虽然源码一直在更新,但是他的核心技术始终是那么几个:JS 沙箱、CSS 样式隔离、应用 HTML 入口接入、应用通信、应用路由等,接下来将通过演示demo
的方式详细说明几种技术的设计与实现。
二、JS 沙箱隔离的设计与实现
2.1 JS 沙箱简介
JS 沙箱简单点说就是,主应用有一套全局环境window
,子应用有一套私有的全局环境fakeWindow
,子应用所有操作都只在新的全局上下文中生效,这样的子应用好比被一个个箱子装起来与主应用隔离,因此主应用加载子应用便不会造成JS 变量的相互污染、JS 副作用、CSS 样式被覆盖等,每个子应用的全局上下文都是独立的。
2.2 快照沙箱 - snapshotSandbox
快照沙箱就是在应用沙箱挂载和卸载的时候记录快照,在应用切换的时候依据快照恢复环境。
demo 演示
实现代码
1 2 3 4 5 6 7 8
| mountSnapshotSandbox(); window.a = 123; console.log("快照沙箱挂载后的a:", window.a); unmountSnapshotSandbox(); console.log("快照沙箱卸载后的a:", window.a); mountSnapshotSandbox(); console.log("快照沙箱再次挂载后的a:", window.a);
|
1 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
|
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]; } }); }
|
- 优点
- 缺点
- 无法同时有多个运行时快照沙箱,否则在 window 上修改的记录会混乱,一个页面只能运行一个单实例微应用
2.3 代理沙箱 - proxySandbox
当有多个实例的时候,比如有A
、B
两个应用,A
应用就活在 A
应用的沙箱里面,B
应用就活在 B
应用的沙箱里面,A
和 B
无法互相干扰,这样的沙箱就是代理沙箱,这个沙箱的实现思路其实也是通过 ES6
的 proxy,通过代理特性实现的。
Proxy
对象用于创建一个对象的代理,从而实现基本操作的拦截和自定义(如属性查找、赋值、枚举、函数调用等)。
简单来说就是,可以在对目标对象设置一层拦截。无论对目标对象进行什么操作,都要经过这层拦截
- Proxy vs Object.defineProperty
Object.defineProperty
也能实现基本操作的拦截和自定义,那为什么用 Proxy
呢?因为 Proxy
能解决以下问题:
- 删除或者增加对象属性无法监听到
- 数组的变化无法监听到(
vue2
正是使用的 Object.defineProperty
劫持属性,watch
中无法检测数组改变的元凶找到了)
实际场景版本
- 简单版本
1 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
| const proxyA = new CreateProxySandbox({}); const proxyB = new CreateProxySandbox({});
proxyA.mountProxySandbox(); proxyB.mountProxySandbox();
(function (window) { window.a = "this is a"; console.log("代理沙箱 a:", window.a); })(proxyA.proxy);
(function (window) { window.b = "this is b"; console.log("代理沙箱 b:", window.b); })(proxyB.proxy);
proxyA.unmountProxySandbox(); proxyB.unmountProxySandbox();
(function (window) { console.log("代理沙箱 a:", window.a); })(proxyA.proxy);
(function (window) { console.log("代理沙箱 b:", window.b); })(proxyB.proxy);
|
- 真实场景版本
1 2 3 4 5 6 7 8 9 10 11 12 13
| <!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>
|
a
应用 js,在 a
应用挂载期间加载的所有 js
都会运行在 a
应用的沙箱(proxyA.proxy
)中
1 2 3
| window.a = "this is a"; console.log("代理沙箱1 a:", window.a);
|
b
应用 js,,在 b
应用挂载期间加载的所有 js
都会运行在 b
应用的沙箱(proxyB.proxy
)中
1 2 3
| window.b = "this is b"; console.log("代理沙箱 b:", window.b);
|
1 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
| 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);` ); }); }
|
代理沙箱代码
1 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
| 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; }; }
|
- 可同时运行多个沙箱
- 不会污染 window 环境
- 不兼容 ie
- 在全局作用域上通过
var
或 function
声明的变量和函数无法被代理沙箱劫持,因为代理对象 Proxy
只能识别在该对象上存在的属性,通过 var
或 function
声明声明的变量是开辟了新的地址,自然无法被 Proxy
劫持,比如
1 2 3 4 5 6 7 8 9 10 11 12 13
| 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); })(proxy1.proxy);
proxy1.unmountProxySandbox(); (function (window) { console.log("代理沙箱1卸载后的a, b:", window.a, window.b); })(proxy1.proxy);
|
一种解决方案是不用 var 和 function 声明全局变量和全局函数,比如
1 2 3 4 5 6 7
| var a = 1; a = 1; window.a = 1;
function b() {} b = () => {}; window.b = () => {};
|
三、CSS 隔离的设计与实现
3.1 CSS 隔离简介
页面中有多个微应用时,要确保 A
应用的样式 不会影响 B
应用的样式,就需要对应用的样式采取隔离。
3.2 动态样式表 - Dynamic Stylesheet
3.3 工程化手段 - BEM、CSS Modules、CSS in JS
通过一系列约束和编译时生成不同类名、JS 中处理 CSS 生成不同类名来解决隔离问题
3.4 Shadow DOM
Shadow DOM
允许将隐藏的 DOM
树附加到常规的 DOM
树中——它以 shadow root
节点为起始根节点,在这个根节点的下方,可以是任意元素,和普通的 DOM
元素一样,隐藏的 DOM
样式和其余 DOM
是完全隔离的,类似于 iframe
的样式隔离效果。
移动端框架 Ionic
的组件样式隔离就是采用的 Shadow DOM
方案,保证相同组件的样式不会冲突。
demo 演示
代码实现
1 2 3 4 5 6 7 8 9 10
| <!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>
|
1 2 3
| var bodyNode = document.getElementsByTagName("body")[0]; openShadow(bodyNode);
|
1 2 3 4 5 6
| function openShadow(domNode) { var shadow = domNode.attachShadow({ mode: "open" }); shadow.innerHTML = domNode.innerHTML; domNode.innerHTML = ""; }
|
- 完全隔离 CSS 样式
- 在使用一些弹窗组件的时候(弹窗很多情况下都是默认添加到了 document.body )这个时候它就跳过了阴影边界,跑到了主应用里面,样式就丢了
动态运行时地去改变 CSS
,比如 A
应用的一个样式 p.title
,转换后会变成div[data-qiankun-A] p.title
,div[data-qiankun-A]
是微应用最外层的容器节点,故保证 A
应用的样式只有在 div[data-qiankun-A]
下生效。
- demo 演示
- 代码实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| <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>
|
1 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
| 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;
cssText = cssText.replace(/^[\s\S]+{/, (selectors) => selectors.replace(/(^|,\n?)([^,]+)/g, (item, p, s) => { if (rootSelectorRE.test(item)) { return item.replace(rootSelectorRE, (m) => { const whitePrevChars = [",", "("];
if (m && whitePrevChars.includes(m[0])) { return `${m[0]}${prefix}`; }
return prefix; }); }
return `${p}${prefix} ${s.replace(/^ */, "")}`; }) );
return cssText; }
|
- 支持大部分样式隔离需求
- 解决了
Shadow DOM
方案导致的丢失根节点问题
- 运行时重新加载样式,会有一定性能损耗
四、清除 js 副作用的设计与实现
4.1 清除 js 副作用简介
子应用在沙箱
中使用 window.addEventListener
、setInterval
这些 需异步监听的全局api
时,要确保子应用在移除时也要移除对应的监听事件,否则会对其他应用造成副作用。
4.2 实现清除 js 操作副作用
demo 演示
代码实现
1 2 3 4 5 6 7 8 9 10 11 12 13
| <!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>
|
1 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
| 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);
window.addEventListener("resize", () => { console.log("resize"); });
setInterval(() => { console.log("Interval"); }, 500); } }.bind(proxy2.proxy)(proxy2.proxy, proxy2.proxy)); }
function unmountSandbox(isPatch = false) { proxy2.mountProxySandbox(); console.log("代理沙箱2卸载后的a:", window.a); if (isPatch) { mountingFreer(); } }
|
1 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
| 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); }
|
未完待续
下期会接着从应用接入的设计与实现、通信的设计与实现、应用路由监听的设计与实现继续探秘微前端技术,敬请期待,如果觉得本文内容对您有帮助,请点个赞支持,你们的支持就是偶更新滴动力!
参考资料:
微前端连载 6/7:微前端框架 - qiankun 大法好
qiankun 官方文档