一、引言
微前端是什么?
曾经理解微前端的敌人可自行跳过本节,简略介绍下微前端,微前端是将前端更加 细分化 的一种技术计划,相似与后端微服务,下图所示 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);
}
未完待续
下期会接着从利用 接入的设计与实现 、 通信的设计与实现 、 利用路由监听的设计与实现 持续探秘微前端技术,敬请期待,如果感觉本文内容对您有帮忙,请 点个赞 反对,你们的反对就是偶更新滴能源!
参考资料:
微前端连载 6/7:微前端框架 – qiankun 大法好
qiankun 官网文档