微前端技术原理

8,301 阅读12分钟

微前端技术原理

*本文尽量不涉及 qiankun/garfish 等框架 Api 的使用方法,没有使用过上述两种框架的同学也可以阅读。

微前端是一种多个团队通过独立发布功能的方式来共同构建现代化 web 应用的技术手段及方法策略。

微前端架构具备以下几个核心价值:

  • 技术栈无关

主框架不限制接入应用的技术栈,微应用具备完全自主权

  • 独立开发、独立部署

微应用仓库独立,前后端可独立开发,部署完成后主框架自动完成同步更新

  • 增量升级

在面对各种复杂场景时,我们通常很难对一个已经存在的系统做全量的技术栈升级或重构,而微前端是一种非常好的实施渐进式重构的手段和策略

  • 独立运行时

每个微应用之间状态隔离,运行时状态不共享

我们部门目前正好处于 Vue 和 React 技术栈切换的时期,面对历史存量大的 Vue 应用,我们很难短期内将这些应用迁移到 React 技术栈,因此渐进式的增量升级是很好的策略,而微前端能让我们更好地解决这个问题。

开始之前

为什么不用 iframe?

引用自 qiankun 作者的文章 Why Not Iframe

iframe 最大的特性就是提供了浏览器原生的硬隔离方案,不论是样式隔离、js 隔离这类问题统统都能被完美解决。但他的最大问题也在于他的隔离性无法被突破,导致应用间上下文无法被共享,随之带来的开发体验、产品体验的问题。

  1. url 不同步。浏览器刷新 iframe url 状态丢失、后退前进按钮无法使用。
  2. UI 不同步,DOM 结构不共享。想象一下屏幕右下角 1/4 的 iframe 里来一个带遮罩层的弹框,同时我们要求这个弹框要浏览器居中显示,还要浏览器 resize 时自动居中..
  3. 全局上下文完全隔离,内存变量不共享。iframe 内外系统的通信、数据同步等需求,主应用的 cookie 要透传到根域名都不同的子应用中实现免登效果。
  4. 慢。每次子应用进入都是一次浏览器上下文重建、资源重新加载的过程。

其中有的问题比较好解决(问题1),有的问题我们可以睁一只眼闭一只眼(问题4),但有的问题我们则很难解决(问题3)甚至无法解决(问题2),而这些无法解决的问题恰恰又会给产品带来非常严重的体验问题, 最终导致我们舍弃了 iframe 方案。

基础原理分析

1.Html Entry

entry意为入口。无论是qiankun还是garfish都需要子应用在入口js中提供生命周期钩子,以供主应用在合适的时机调用。比如qiankun需要子应用至少导出 bootstrapmountunmount 三个生命周期钩子;garifsh需要子应用导出provider生命周期钩子。

通常有两种entry: JS Entry 和 Html Entry。

Js Entry 的缺点是:

  • 子应用更新打包后的 js bundle 名称会变化,主应用需要保证每次获取都是最新的 js bundle
  • 子应用所有资源打包到一个文件中,会失去 css 提取、静态资源并行加载、首屏加载(体积巨大)等优化。
  • 需要在子应用打包过程中,修改相应的配置以补全子应用 js 资源的路径。

因此需要使用 html entry,你只需要指定子应用的 html 入口即可,微前端框架在加载 html 字符串后,从中提取出 cssjs 资源,运行子应用时,安装样式、执行脚本,运行脚本中提供的生命周期钩子。因此优点也很明显:

  • 无需关心应用打包后的 js 名称变化的问题。
  • 仍然可以享受 css提取、静态资源并行加载(内部使用 Promise.all 并行发出请求资源)、首屏加载等优化。
  • 请求资源时,自动补全资源路径。

解析 html 字符串的流程如下:

screenshot-20220519-113811.png

不难发现该流程中没有马上执行 script 标签内的脚本,这是因为 script 标签内的脚本需要等到 JS 沙箱创建完后才执行,JS 沙箱相关内容将在下一小节介绍。

如果从多个 script 脚本中找到子应用导出的生命周期钩子呢?(下面以 qiankun 的实现为例)

  1. entry 通常是多个 scitpt 中的最后一个,或者是包含 entry 属性的 script 标签。
  2. 在执行 entry script 前记录一下 window 上的最后一个属性 a,执行后记录一下 window 上的最后一个属性 b,如果这两个属性不一样,那么属性 b 就是 entry script 导出的生命周期钩子。(详细解释请见 Example

  1. 无论是qiankun还是garfish,都要求子应用打包时的 libraryTarget 设置为 umd
  2. 对于 qiankun ,要求子应用至少导出 bootstrapmountunmount 三个生命周期钩子,同时要求打包时设置 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

假如现在有三个文件如下:

以上文件中:

  1. 入口文件是 index.js,引用关系是 index.js -> a.js -> b.js,其中 a.jsb.js 都有修改 window 对象。

  2. webpack.config.js 做如下配置:

其中 libraryTargetumdlibraryMyApp

打包后,执行 bundle.js 的执行结果为:window 上按顺序新增三个属性,abmount,入口文件 index.js 的导出函数将是 window 上的最后一个属性。

原因分析

前提条件:多次循环遍历 window 上的属性,其顺序是一定的;2.新增属性会添加到 window 对象所有属性后面;3. UMD 会将入口文件的执行结果的导出内容挂载到 window 对象上

上述代码打包后的内容将会像这样:

打包后的代码会做四个事情:

  1. 创建 webpackUniversalModuleDefinition 函数,传入 rootfactory 参数,rootwindow 对象,factory 函数待会介绍,这个函数执行完成后会往 window 上挂载一个 MyApp 对象
  2. factory 函数中第一部分是从入口文件(index.js)出发,收集到的所有经过编辑转换后的模块代码。
  3. 第二部分实现了 modulemodule.exportsrequire,模块代码执行完后,export 的内容会挂载到 module.exports 上。
  4. 返回 require 入口文件后的结果,在我们的例子中,返回内容是一个对象,包含了 mount 方法,这样就能从 window.MyApp.mount 访问到 mount 钩子函数了,qiankun 即是通过这种方法获取到用户导出的钩子函数。

在整个过程中,虽然 a.jsb.js 都会往 window 上挂载属性,但是 index.js 导出的内容是最后挂载到 window 对象上的,所以能通过 window 对象最后一个属性拿到入口文件导出的生命周期钩子函数。

JS 隔离

1.JS 沙箱

Js 沙箱做的事情可以用两句话概括:

  1. 为每一个子应用创建一个专属的 “window 对象” (不是真的 window 对象,下面会解释);
  2. 执行子应用时,将新建的 “window 对象” 作为子应用脚本的全局变量,子应用对全局变量的读写操作都作用到这个 “window 对象”中。

先介绍沙箱,沙箱通常有三种:

1. LegacySandbox(依赖 Proxy)

screenshot-20220519-114527.png

缺点:虽然子应用之间的状态是隔离的,但是父子应用都会修改同一个 window 对象,互相污染。

🌟 2.ProxySandbox (依赖 Proxy)

稳定后会取代 LegacySandbox

3.SnapshotSandbox

无法复制加载中的内容

对于不支持 Proxy 的浏览器, SnapshotSandbox 是一种替代方案。

2.劫持一些全局方法

除了 JS 沙箱,还需要劫持一些全局方法,如 计时器、 window 事件监听、window.history 事件监听、动态向 Head/Body元素添加子元素方法(如 appendChild、insertBefore)。

这里重点介绍一下 计时器劫持 和 动态添加子元素方法的劫持:

1.计时器劫持

我们知道,setInterval() 调用后会返回一个非零数值,用来标识通过setInterval()创建的计时器,这个值可以用来作为clearInterval()的参数来清除对应的计时器 。

因此,原理很简单:

  1. 在子应用运行时调用 setInterval(),可以把返回的定时器标识收集在一个数组中(假设数组名叫 intervals),在子应用失活时通过调用 **clearInterval() 取消这些定时器;
  2. 在子应用运行时调用 clearInterval(某个定时器标识),从intervals中删除该定时器标识。

  1. 动态添加子元素方法的劫持

主要劫持了:

  • HTMLHeadElement.prototype.appendChild
  • HTMLHeadElement.prototype.removeChild
  • HTMLBodyElement.prototype.appendChild
  • HTMLBodyElement.prototype.removeChild
  • HTMLHeadElement.prototype.insertBefore
  • Document.prototype.createElement

以上劫持仅处理动态添加的 linkstylescript 标签。

  1. createElement

子应用调用 createElement,会触发 sandbox proxy 对象上的 get 拦截器,从而可以判断某个元素是否由子应用创建,如果是,这个元素才可以被 appendChildinsertBefore 的劫持处理。

  1. appendChildinsertBefore
  • 添加 linkstyle 标签

会做 css 隔离相关处理(下一小节介绍)。如果是 link 标签,会使用 fetch 获取到样式表内容字符串,外包一层 style 标签,插入进子应用的 DOM 中。动态添加的样式标签会被收集起来 (假设收集到一个名为 dynamicStyleSheetElements 的数组中)。

如果一个 style 标签是被 styled-componentsemotion 创建的,那么这个 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 标签
  1. 对于外部 script 脚本,先使用 fetch 获取到脚本内容字符串。
  2. 得到脚本后,指定 js 沙箱的 proxy 对象为全局对象,执行脚本内容,同时触发 load 和 error 两个事件。
  3. 将 script 标签以注释的形式添加到子应用容器中。

如何指定 js 沙箱的 proxy对象 为全局对象呢?

  1. 将 js 沙箱创建的 proxy 对象放到 window.proxy 上
  2. 执行以下代码
// scriptText:脚本内容

// sourceUrl: 脚本资源链接(内联脚本没有)

eval(`;(function(window, self, globalThis) {

  ;${scriptText}\n${sourceUrl}}

).bind(window.proxy)(window.proxy, window.proxy, window.proxy);`)
  1. removeChild

判断如果是子应用创建的linkstylescript 标签,就从子应用容器中移除它们。

  1. CSS 隔离

  1. 基本的隔离方法

上一小节中介绍了,子应用创建的样式标签会添加到子应用容器下,那么在子应用卸载的时候,样式表也能跟着一起被卸载,从而避免子应用之间的样式污染。

  1. 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 样式隔离, 详见

  1. Scoped 样式隔离

一句话理解,就是给子应用的所有样式规则添加一个 scope(类似 Vue 中的 )。

这里以 qiankun 的实现来讲解原理:

  1. 创建子应用容器节点后,通过 document.querySelectorAll('style') 找到所有 style 元素
  2. 对于普通样式规则

通过 for 循环遍历 style.sheet.cssRules,转换 css 样式。

// 假如子应用名字叫 child
// 转换前
.app-main {
    font-size: 14px;
}
// 转换后
div[data-qiankun="child"] .app-main {
    font-size: 14px;
}
  1. 对于@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
    }
}
  1. 对于每个 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 元素的样式节点,也会做上述转换。

  1. 监听路由变化切换子应用

    1. 无论是qiankun 还是 garfish ,都要子应用注册的时候,提供子应用激活规则 (路由字符串 或 函数)。因此,监听 hashchangepopstate 事件,在事件回调函数中,根据注册的子应用激活规则,卸载/激活子应用。
    2. Vue-Routerhistory 模式为例,在切换路由时,通常会做三件重要事情:执行一连串的 hook 函数、更新url、router-view 更新,其中更新 url,就是通过 pushState/replaceState 的形式实现的。因此重写并增强 history.pushStatehistory.replaceState 方法,在执行它们的时候,可以拿到执行前、执行后的 url,对比是否有变化,如果有,根据注册的子应用激活规则,卸载/激活子应用。
  1. 子应用通信

应用之间的通信能通过发布订阅模实现,无论是 qiankun 还是 garfish 的通信 API,本质原理都类似 EventEmitter

  1. 子应用预加载

在第一小节介绍 Html Entry 的时候,我们知道解析 html 包含了几个步骤:通过 fetch 获取 html 字符串、将外部样式表 url 放入 styles 数组中、将内联和外部 js 放入 scritps 数组中。

因此,预加载的时候可以通过遍历需要预加载的应用对应的 styles 和 scripts 数组,在 requestIdelCallback 中使用 fetch 获取其中的外部资源,并将它们缓存下来。这样在下次获取这些资源时,就能从缓存中直接拿到。

值得一提的是,这里所说的“缓存”是指存在代码中的全局变量中,所以,即使将 Chrome Devtools 中 Network 的 Disable cache 选项打开,只要资源预加载成功、页面没有重新刷新,也是会走“缓存”。

加入我们

我们来自字节跳动飞书商业应用研发部(Lark Business Applications),目前我们在北京、深圳、上海、武汉、杭州、成都、广州、三亚都设立了办公区域。我们关注的产品领域主要在企业经验管理软件上,包括飞书 OKR、飞书绩效、飞书招聘、飞书人事等 HCM 领域系统,也包括飞书审批、OA、法务、财务、采购、差旅与报销等系统。欢迎各位加入我们。

扫码发现职位&投递简历

官网投递

job.toutiao.com/s/FyL7DRg