一、引言
微前端是什么?
曾经理解微前端的敌人可自行跳过本节,简略介绍下微前端,微前端是将前端更加细分化的一种技术计划,相似与后端微服务,下图所示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.jswindow.a = 'this is a';console.log('代理沙箱1 a:', window.a);
b
利用js,,在 b
利用挂载期间加载的所有 js
都会运行在 b
利用的沙箱(proxyB.proxy
)中
// b.jswindow.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.tsfunction 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.jsvar bodyNode = document.getElementsByTagName('body')[0];openShadow(bodyNode);
// scopedCss.jsfunction 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.jsfunction 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.jsconst 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官网文档