微前端技术原理
*本文尽量不涉及 qiankun/garfish 等框架 Api 的使用方法,没有使用过上述两种框架的同学也可以阅读。
微前端是一种多个团队通过独立发布功能的方式来共同构建现代化 web 应用的技术手段及方法策略。
微前端架构具备以下几个核心价值:
- 技术栈无关
主框架不限制接入应用的技术栈,微应用具备完全自主权
- 独立开发、独立部署
微应用仓库独立,前后端可独立开发,部署完成后主框架自动完成同步更新
- 增量升级
在面对各种复杂场景时,我们通常很难对一个已经存在的系统做全量的技术栈升级或重构,而微前端是一种非常好的实施渐进式重构的手段和策略
- 独立运行时
每个微应用之间状态隔离,运行时状态不共享
我们部门目前正好处于 Vue 和 React 技术栈切换的时期,面对历史存量大的 Vue 应用,我们很难短期内将这些应用迁移到 React 技术栈,因此渐进式的增量升级是很好的策略,而微前端能让我们更好地解决这个问题。
开始之前
为什么不用 iframe?
引用自 qiankun 作者的文章 Why Not Iframe。
iframe 最大的特性就是提供了浏览器原生的硬隔离方案,不论是样式隔离、js 隔离这类问题统统都能被完美解决。但他的最大问题也在于他的隔离性无法被突破,导致应用间上下文无法被共享,随之带来的开发体验、产品体验的问题。
- url 不同步。浏览器刷新 iframe url 状态丢失、后退前进按钮无法使用。
- UI 不同步,DOM 结构不共享。想象一下屏幕右下角 1/4 的 iframe 里来一个带遮罩层的弹框,同时我们要求这个弹框要浏览器居中显示,还要浏览器 resize 时自动居中..
- 全局上下文完全隔离,内存变量不共享。iframe 内外系统的通信、数据同步等需求,主应用的 cookie 要透传到根域名都不同的子应用中实现免登效果。
- 慢。每次子应用进入都是一次浏览器上下文重建、资源重新加载的过程。
其中有的问题比较好解决(问题1),有的问题我们可以睁一只眼闭一只眼(问题4),但有的问题我们则很难解决(问题3)甚至无法解决(问题2),而这些无法解决的问题恰恰又会给产品带来非常严重的体验问题, 最终导致我们舍弃了 iframe 方案。
基础原理分析
1.Html Entry
entry意为入口。无论是qiankun还是garfish都需要子应用在入口js中提供生命周期钩子,以供主应用在合适的时机调用。比如qiankun需要子应用至少导出 bootstrap、mount、unmount 三个生命周期钩子;garifsh需要子应用导出provider生命周期钩子。
通常有两种entry: JS Entry 和 Html Entry。
Js Entry 的缺点是:
- 子应用更新打包后的
js bundle名称会变化,主应用需要保证每次获取都是最新的js bundle。 - 子应用所有资源打包到一个文件中,会失去 css 提取、静态资源并行加载、首屏加载(体积巨大)等优化。
- 需要在子应用打包过程中,修改相应的配置以补全子应用 js 资源的路径。
因此需要使用 html entry,你只需要指定子应用的 html 入口即可,微前端框架在加载 html 字符串后,从中提取出 css、js 资源,运行子应用时,安装样式、执行脚本,运行脚本中提供的生命周期钩子。因此优点也很明显:
- 无需关心应用打包后的 js 名称变化的问题。
- 仍然可以享受 css提取、静态资源并行加载(内部使用
Promise.all并行发出请求资源)、首屏加载等优化。 - 请求资源时,自动补全资源路径。
解析 html 字符串的流程如下:
不难发现该流程中没有马上执行 script 标签内的脚本,这是因为 script 标签内的脚本需要等到 JS 沙箱创建完后才执行,JS 沙箱相关内容将在下一小节介绍。
如果从多个 script 脚本中找到子应用导出的生命周期钩子呢?(下面以 qiankun 的实现为例)
entry通常是多个scitpt中的最后一个,或者是包含entry属性的script标签。- 在执行 entry script 前记录一下 window 上的最后一个属性 a,执行后记录一下 window 上的最后一个属性 b,如果这两个属性不一样,那么属性 b 就是 entry script 导出的生命周期钩子。(详细解释请见 Example)
- 无论是
qiankun还是garfish,都要求子应用打包时的libraryTarget设置为umd。 - 对于
qiankun,要求子应用至少导出bootstrap、mount、unmount三个生命周期钩子,同时要求打包时设置library属性(假如设置为MyApp),那么打包后的代码就像这样:
(function webpackUniversalModuleDefinition(root, factory) {
if (typeof exports === 'object' && typeof module === 'object')
module.exports = factory();
else if (typeof define === 'function' && define.amd) define([], factory);
else if (typeof exports === 'object') exports['MyLibrary'] = factory();
else root['MyApp'] = factory();
})(typeof self !== 'undefined' ? self : this, function () {
return _entry_return_;
});
这个时候解构 window.MyApp 就能拿到三个生命周期钩子。
Example
假如现在有三个文件如下:
以上文件中:
-
入口文件是
index.js,引用关系是index.js->a.js->b.js,其中a.js和b.js都有修改window对象。 -
webpack.config.js做如下配置:
其中 libraryTarget 为 umd,library 为 MyApp。
打包后,执行 bundle.js 的执行结果为:window 上按顺序新增三个属性,a、b、mount,入口文件 index.js 的导出函数将是 window 上的最后一个属性。
原因分析
前提条件:多次循环遍历 window 上的属性,其顺序是一定的;2.新增属性会添加到 window 对象所有属性后面;3. UMD 会将入口文件的执行结果的导出内容挂载到 window 对象上。
上述代码打包后的内容将会像这样:
打包后的代码会做四个事情:
- 创建
webpackUniversalModuleDefinition函数,传入root和factory参数,root为window对象,factory函数待会介绍,这个函数执行完成后会往window上挂载一个MyApp对象 factory函数中第一部分是从入口文件(index.js)出发,收集到的所有经过编辑转换后的模块代码。- 第二部分实现了
module、module.exports、require,模块代码执行完后,export的内容会挂载到module.exports上。 - 返回
require入口文件后的结果,在我们的例子中,返回内容是一个对象,包含了mount方法,这样就能从window.MyApp.mount访问到mount钩子函数了,qiankun即是通过这种方法获取到用户导出的钩子函数。
在整个过程中,虽然 a.js 和 b.js 都会往 window 上挂载属性,但是 index.js 导出的内容是最后挂载到 window 对象上的,所以能通过 window 对象最后一个属性拿到入口文件导出的生命周期钩子函数。
JS 隔离
1.JS 沙箱
Js 沙箱做的事情可以用两句话概括:
- 为每一个子应用创建一个专属的 “window 对象” (不是真的 window 对象,下面会解释);
- 执行子应用时,将新建的 “window 对象” 作为子应用脚本的全局变量,子应用对全局变量的读写操作都作用到这个 “window 对象”中。
先介绍沙箱,沙箱通常有三种:
1. LegacySandbox(依赖 Proxy)
缺点:虽然子应用之间的状态是隔离的,但是父子应用都会修改同一个 window 对象,互相污染。
🌟 2.ProxySandbox (依赖 Proxy)
稳定后会取代 LegacySandbox。
3.SnapshotSandbox
无法复制加载中的内容
对于不支持 Proxy 的浏览器, SnapshotSandbox 是一种替代方案。
2.劫持一些全局方法
除了 JS 沙箱,还需要劫持一些全局方法,如 计时器、 window 事件监听、window.history 事件监听、动态向 Head/Body元素添加子元素方法(如 appendChild、insertBefore)。
这里重点介绍一下 计时器劫持 和 动态添加子元素方法的劫持:
1.计时器劫持
我们知道,setInterval() 调用后会返回一个非零数值,用来标识通过setInterval()创建的计时器,这个值可以用来作为clearInterval()的参数来清除对应的计时器 。
因此,原理很简单:
- 在子应用运行时调用
setInterval(),可以把返回的定时器标识收集在一个数组中(假设数组名叫intervals),在子应用失活时通过调用 **clearInterval()取消这些定时器; - 在子应用运行时调用
clearInterval(某个定时器标识),从intervals中删除该定时器标识。
-
动态添加子元素方法的劫持
主要劫持了:
HTMLHeadElement.prototype.appendChildHTMLHeadElement.prototype.removeChildHTMLBodyElement.prototype.appendChildHTMLBodyElement.prototype.removeChildHTMLHeadElement.prototype.insertBeforeDocument.prototype.createElement
以上劫持仅处理动态添加的 link、style、script 标签。
-
createElement
子应用调用 createElement,会触发 sandbox proxy 对象上的 get 拦截器,从而可以判断某个元素是否由子应用创建,如果是,这个元素才可以被 appendChild、insertBefore 的劫持处理。
-
appendChild、insertBefore
-
添加
link、style标签
会做 css 隔离相关处理(下一小节介绍)。如果是 link 标签,会使用 fetch 获取到样式表内容字符串,外包一层 style 标签,插入进子应用的 DOM 中。动态添加的样式标签会被收集起来 (假设收集到一个名为 dynamicStyleSheetElements 的数组中)。
如果一个 style 标签是被 styled-components 或 emotion 创建的,那么这个 style 标签,很可能没有内容,但是会在 style.sheet.cssRules 上存有 css 规则。我们称这种样式元素为 styled-component liked style 标签。
假如子应用第一次挂载时创建了一个 styled-component liked style 标签,这个 style 标签会被存进 dynamicStyleSheetElements 数组中。在子应用卸载、又再次挂载后,这个 style 标签从缓存中被取出,并重新挂载到子应用中,此时你会发现,style 标签的样式竟然不生效了。这是因为样式元素从文档中删除后,浏览器会自动清除样式元素表。
所以,就需要在子应用卸载的时候,将 styled-component liked style 标签的 cssRules 存起来,在再次挂载时候,重新将 cssRules 写入到 style 标签中,这样样式才能重新生效。
-
添加 script 标签
- 对于外部 script 脚本,先使用 fetch 获取到脚本内容字符串。
- 得到脚本后,指定 js 沙箱的 proxy 对象为全局对象,执行脚本内容,同时触发 load 和 error 两个事件。
- 将 script 标签以注释的形式添加到子应用容器中。
如何指定 js 沙箱的 proxy对象 为全局对象呢?
- 将 js 沙箱创建的 proxy 对象放到 window.proxy 上
- 执行以下代码
// scriptText:脚本内容
// sourceUrl: 脚本资源链接(内联脚本没有)
eval(`;(function(window, self, globalThis) {
;${scriptText}\n${sourceUrl}}
).bind(window.proxy)(window.proxy, window.proxy, window.proxy);`)
-
removeChild
判断如果是子应用创建的link、style、script 标签,就从子应用容器中移除它们。
-
CSS 隔离
-
基本的隔离方法
上一小节中介绍了,子应用创建的样式标签会添加到子应用容器下,那么在子应用卸载的时候,样式表也能跟着一起被卸载,从而避免子应用之间的样式污染。
-
Shadow DOM 样式隔离
给子应用容器节点挂载一个 shadow DOM,已实现父子应用、多个子应用之间的样式隔离。
Shadow DOM 允许将隐藏的 DOM 树附加到常规的 DOM 树中——它以 shadow root 节点为起始根节点,在这个根节点的下方,可以是任意元素,和普通的 DOM 元素一样。—— MDN
if (appElement.attachShadow) {
shadow = appElement.attachShadow({ mode: 'open' });
} else {
// createShadowRoot was proposed in initial spec, which has then been deprecated
shadow = (appElement as any).createShadowRoot();
}
注:如果使用 React 16 或更低版本,不建议使用 shadow DOM 样式隔离, 详见 。
-
Scoped 样式隔离
一句话理解,就是给子应用的所有样式规则添加一个 scope(类似 Vue 中的 )。
这里以 qiankun 的实现来讲解原理:
- 创建子应用容器节点后,通过
document.querySelectorAll('style')找到所有style元素 - 对于普通样式规则
通过 for 循环遍历 style.sheet.cssRules,转换 css 样式。
// 假如子应用名字叫 child
// 转换前
.app-main {
font-size: 14px;
}
// 转换后
div[data-qiankun="child"] .app-main {
font-size: 14px;
}
- 对于@media、@supports
// 转换前
@media screen and (max-width: 300px) {
div {
font-size: 14px
}
}
// 转换后
@media screen and (max-width: 300px) {
div[data-qiankun="child"] div {
font-size: 14px
}
}
- 对于每个 style 元素,注册一个 MutationObserver 监听元素的变化,变化的时候对元素内容也做上述转换
const mutator = new MutationObserver((mutations) => {
for (let i = 0; i < mutations.length; i += 1) {
const mutation = mutations[i];
if (mutation.type === 'childList') {
// 转换操作
}
}
});
mutator.observe(styleNode, { childList: true });
注:对于通过动态 appendChild 到 head 或 body 元素 / 动态 insertBefore 到 head 元素的样式节点,也会做上述转换。
-
监听路由变化切换子应用
- 无论是
qiankun还是garfish,都要子应用注册的时候,提供子应用激活规则 (路由字符串 或 函数)。因此,监听hashchange和popstate事件,在事件回调函数中,根据注册的子应用激活规则,卸载/激活子应用。 - 以
Vue-Router的history模式为例,在切换路由时,通常会做三件重要事情:执行一连串的 hook 函数、更新url、router-view更新,其中更新 url,就是通过pushState/replaceState的形式实现的。因此重写并增强history.pushState和history.replaceState方法,在执行它们的时候,可以拿到执行前、执行后的 url,对比是否有变化,如果有,根据注册的子应用激活规则,卸载/激活子应用。
- 无论是
-
子应用通信
应用之间的通信能通过发布订阅模实现,无论是 qiankun 还是 garfish 的通信 API,本质原理都类似 EventEmitter。
-
子应用预加载
在第一小节介绍 Html Entry 的时候,我们知道解析 html 包含了几个步骤:通过 fetch 获取 html 字符串、将外部样式表 url 放入 styles 数组中、将内联和外部 js 放入 scritps 数组中。
因此,预加载的时候可以通过遍历需要预加载的应用对应的 styles 和 scripts 数组,在 requestIdelCallback 中使用 fetch 获取其中的外部资源,并将它们缓存下来。这样在下次获取这些资源时,就能从缓存中直接拿到。
值得一提的是,这里所说的“缓存”是指存在代码中的全局变量中,所以,即使将 Chrome Devtools 中 Network 的 Disable cache 选项打开,只要资源预加载成功、页面没有重新刷新,也是会走“缓存”。
加入我们
我们来自字节跳动飞书商业应用研发部(Lark Business Applications),目前我们在北京、深圳、上海、武汉、杭州、成都、广州、三亚都设立了办公区域。我们关注的产品领域主要在企业经验管理软件上,包括飞书 OKR、飞书绩效、飞书招聘、飞书人事等 HCM 领域系统,也包括飞书审批、OA、法务、财务、采购、差旅与报销等系统。欢迎各位加入我们。
扫码发现职位&投递简历
官网投递