一、引言
微前端是什么?
已经了解微前端的朋友可自行跳过本节,简单介绍下微前端,微前端是将前端更加细分化的一种技术方案,类似与后端微服务,下图所示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演示
- 实现代码
// 子应用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
// 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];
}
});
}
优点
- 兼容几乎所有浏览器
缺点
- 无法同时有多个运行时快照沙箱,否则在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
中无法检测数组改变的元凶找到了)
- 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);
- 真实场景版本
<!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
)中
// a.js
window.a = 'this is a';
console.log('代理沙箱1 a:', window.a);
b
应用js,,在 b
应用挂载期间加载的所有 js
都会运行在 b
应用的沙箱(proxyB.proxy
)中
// b.js
window.b = 'this is b';
console.log('代理沙箱 b:', window.b);
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);`)
});
}
代理沙箱代码
// 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;
}
}
- 优点
- 可同时运行多个沙箱
- 不会污染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)
一种解决方案是不用var和function声明全局变量和全局函数,比如
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演示
- 代码实现
<!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>
// index.js
var bodyNode = document.getElementsByTagName('body')[0];
openShadow(bodyNode);
// scopedCss.js
function openShadow(domNode) {
var shadow = domNode.attachShadow({ mode: 'open' });
shadow.innerHTML = domNode.innerHTML;
domNode.innerHTML = "";
}
- 优点
- 完全隔离CSS样式
- 缺点
- 在使用一些弹窗组件的时候(弹窗很多情况下都是默认添加到了 document.body )这个时候它就跳过了阴影边界,跑到了主应用里面,样式就丢了
3.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>
// 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;
}
- 优点
- 支持大部分样式隔离需求
- 解决了
Shadow DOM
方案导致的丢失根节点问题
- 缺点
- 运行时重新加载样式,会有一定性能损耗
四、清除js副作用的设计与实现
4.1 清除js副作用简介
子应用在沙箱
中使用 window.addEventListener
、setInterval
这些 需异步监听的全局api
时,要确保子应用在移除时也要移除对应的监听事件,否则会对其他应用造成副作用。
4.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>
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();
}
}
// 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);
}
未完待续
下期会接着从应用接入的设计与实现、通信的设计与实现、应用路由监听的设计与实现继续探秘微前端技术,敬请期待,如果觉得本文内容对您有帮助,请点个赞支持,你们的支持就是偶更新滴动力!
参考资料:
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。