我最早是在 2016 年接触到微前端的,当时社区里以介绍概念居多,在实践方案,尤其是在业务落地方面应用的比较少。后来,随着方案逐渐成熟,社区里关于微前端的讨论越来越多。

今天,我们先从概念、关键技术原理层面来对微前端进行详细说明。后续会有专门的文章来介绍微前端的实践经验。

什么是微前端

微前端的概念来源于微服务,其整体的架构思路是将前端应用分解成一些更小、更简单的能够独立开发、测试、部署的应用,之后将这些应用组成整体,在用户看来仍然是内聚的单个产品,用户体验是一致的。

从概念上看,微前端架构由主应用和子应用两个部分组成,子应用负责具体的业务实现,主应用负责子应用的加载和卸载,即生命周期管理。

从概念延伸开来,我们不难发现,使用微前端,可以获得如下收益:

  • 子应用独立开发、部署,技术栈无关
    拆分以后,子应用拥有独立的代码仓库、独立的开发部署流程,甚至可以自由的使用任何技术栈进行开发。由此,我们可以在组织架构层面形成不同的团队来负责不同的业务模块,各个团队之间相对独立自主,互不干扰。
  • 增量升级,多技术体系共存
    对于很多大型的组织,他们的产品通常都经历了长期的迭代,功能复杂,同时技术栈通常也比较老旧。使用微前端以后,借助于独立的子应用,可以获得增量升级的能力。既可以实现新功能使用新的技术栈,同时与老技术栈共存。又可以对老功能进行逐步迭代升级,小步快跑。
  • 产品层面的自由组合
    借助于微前端,我们可以对各个子应用自由的进行上下线。换句话说,我们可以根据产品需要,自由的将不同的子应用组合成新的产品。

技术分析

在微前端架构下,有主应用和子应用两个基本角色。子应用负责具体的业务逻辑,主应用负责调度子应用。考虑到主应用的特殊性功能,为了保证整个框架的可用性,通常主应用不负责任何业务逻辑。

路由与子应用加载

由于主应用负责调度子应用,因此主应用需要具备路由管理和资源加载能力。所谓路由管理,就是主应用中需要维护一个路由表,当页面路由发生变化的时候,主应用可以知道当前需要启动哪个子应用。这个路由表可以是动态的,也可以是静态的。

知道启动哪个子应用之后,主应用就需要加载子应用的资源。通常有两种资源加载方式:

  • JS Entry。
    通常将子应用的所有资源打包成一个入口文件,在 single-spa 的很多样例中就使用了这种方式。
  • HTML Entry。
    子应用构建输出的是一个 HTML 文件,主应用通过加载这个 HTML 文件完成子应用的加载。

相比较而言,JS Entry 的方案限制更多一些,比如要求将图片、样式等所有资源打包成一个 JS Bundle,构建的包太大,也无法利用浏览器的并行加载能力。同时,子应用还需要与主应用约定好要挂载的节点,主应用要提前初始化好,或者子应用自行创建,避免挂载失败或者冲突。

HTML Entry 很好的避免了 JS Entry 的问题。本质上,HTML 文件充当的是应用静态资源表的角色。主应用加载了 HTML 以后,浏览器会自行下载子应用的各种资源。同时,由于构建产物是 HTML,子应用具备与独立应用开发时一致的开发体验。当然,HTML Entry 也存在缺点,比如要多一次请求,先加载了 HTML 才能知道加载哪些资源。

在加载完子应用的资源以后,主应用就可以启动子应用,完成页面渲染了。那么该如何启动子应用呢?主应用需要与子应用之前制定一个接口规范,比如在 single-spa 中就指定了 bootstrapmountunmountunload 四个方法。子应用暴露这四个方法给主应用,主应用通过这四个方法来管理子应用的声明周期。

隔离

解决了路由和子应用加载的问题,理论上说我们已经实现了微前端的核心能力。但是,在实际的工程实践中,我们还需要解决很多的细节问题。其中最大的一部分就是如何做好子应用间的隔离。比如如何避免子应用间的样式冲突。

抛开现有的微前端方案,假如让我们从头开始实现一套微前端架构,将独立开发部署的各个子应用组合起来。相信大多数同学都会首先想到 iframe。其实我们就可以通过 iframe 来理解微前端架构中的种种技术细节。

iframe 自带的样式、环境隔离机制使得它具备天然的沙箱能力,但是 iframe 也有很多天然的缺陷,比如事件无法冒泡到顶层,路由跳转无法与主应用同步,与主应用通信复杂繁琐等。

我们可以参考 iframe 的设计思想,来设计如何对子应用进行隔离。一个传统的 iframe 具备四层能力:文档的加载能力、HTML 的渲染能力、独立执行 JavaScript 的能力、隔离样式的能力。

文档的加载能力和 HTML 的渲染能力在前面主应用加载子应用资源的时候,我们已经做了说明。

我们现在来说说如何实现独立的 JavaScript 运行环境和样式隔离。

沙箱(sandbox)

通常,子应用在运行期间会有一些污染性的副作用产生,比如全局变量、全局事件、定时器、网络请求、localStorage、全局 Style 样式、全局 DOM 元素等。为了保证应用能够稳定的运行且互不影响,需要提供安全的运行环境,能够有效地隔离、收集、清除应用在运行期间所产生的副作用,也就是沙箱的设计目标。

有两种沙箱的设计思路。一种是快照模式,另一种是虚拟机(virtual machine)模式。

快照模式

所谓快照模式,就是将启动子应用之前,对当前环境打一个快照,子应用退出之后,再重新加载这个快照来恢复环境。

在实现层面,我们可以针对每一种副作用设计一个 save 方法保存当前状态,在设计一个 load 方法来加载保存的状态。

框照模式的缺陷是对操作的顺序要求非常严格,当页面有多个子应用的时候,快照沙箱就会有多个实例存在,此时不同顺序的 saveload 会产生问题。

VM(虚拟机)模式

虚拟机想必大家都听说过,是一种计算机系统的仿真器,通过软件模拟具有完整硬件系统功能的、运行在一个完全隔离环境中的完整计算机系统。使用虚拟机就跟使用真实的计算机一样。

NodeJS 中也提供了 VM 模块,不过不同于传统的 VM,它并不具备虚拟机那么强的隔离性,并没有模拟完整的硬件系统,仅仅将指定代码放置了特定的上下文中编译并执行,无法用来执行不可信来源的代码。

下面的代码展示了 NodeJS 的 VM 模块的基本用法:

const vm = require('vm');

const x = 1;

const context = { x: 2 };
vm.createContext(context); // 将 context 对象上下文化

const code = 'x += 40; var y = 17;';
// `x` and `y` 在上下文中是全局变量
// 初始状态下, x 的值为 2,因为 context.x 得值是 2
vm.runInContext(code, context);

console.log(context.x); // 42
console.log(context.y); // 17

console.log(x); // 1; y 为未定义

参考 NodeJS 中 VM 模块的设计,以及 JavaScript 词法作用域的特性,可以设计出 VM 沙箱,不过与传统的 VM 差异也同样存在,它并不能执行不可信的代码,因为它的隔离能力仅限于将其运行在一个指定的上下文环境中。

let code = `(function(document, window){ /* 代码逻辑 */ })`

(new Function('document', 'window', code)(fakeDocument, fakeWindow))

针对前面提到的子应用运行产生的全局变量、全局事件等种种副作用,我们可以针对性的做处理,提供新的执行上下文。比如,用新的 window 对象用来隔离全局变量,用新的 document 来收集创建的 dom 对象,style 样式,script 标签等。全局事件、localStorage 等都可以一一进行处理。

下面借助于 Proxy,我们可以轻松的对当前的执行上下文进行劫持,创建新的执行上下文。下面的代码展示了如何劫持 window 对象。

const varBox = {};
const fakeWindow = new Proxy(window, {
  get(target, key) {
    return varBox[key] || window[key];
  },
  set(target, key, value) {
    varBox[key] = value;
    return true;
  }
});

const code = `(function(window) {
  window.a = '111';
  console.log(window.a);
})`;

const fn = new Function('window', code);
fn(fakeWindow);

VM 模式的沙箱,可以有效的解决子应用之间、主子应用之间各种副作用的有效隔离问题。qiankun 的沙箱模式就是 VM 模式。

样式隔离

虽然说,VM 模式的沙箱可以收集子应用运行过程中产生的样式,然后在子应用卸载的时候去除样式,但是考虑到子应用的 dom 结构最终还是要并入到主应用的 dom 树中去,VM 沙箱无法避免主应用的样式干扰到子应用的样式的问题。

这时候,我们就需要借助于一些其他手段,比如在主子应用中都使用 css modules 来减少样式冲突。

Shadow Dom

如果不考虑兼容性,Shadow Dom 是子应用样式隔离的一个绝佳选择。

我们把子应用放到 Shadow Dom 中,可以原生实现子应用间的样式隔离。但是 Shadow Dom 本身也有诸多限制,很多依赖库还不支持 Shadow Dom。比如埋点检测,事件处理等。

我们这里仅是将 Shadow Dom 作为补充技术方案来进行说明。

需要注意的问题

技术领域有句话叫“没有永远的银弹”。本文开头我们介绍了使用微前端可以获得的很多收益,现在我们来讲讲微前端带来的问题。

  • 整个产品的复杂度从代码转移到了基础设施
    我们需要有一套应用注册、管理的系统,并要和现有的应用发布流程对接。同时还要围绕微前端方案构建一整套的基础工具,比如开发调试工具,埋点监控系统等。
  • 增加了学习和理解成本
    子应用或多或少要了解一些微前端方案的技术原理,才能带来更好的开发和产品体验。

常见面试知识点、技术解决方案、教程,都可以扫码关注公众号“众里千寻”获取,或者来这里 https://everfind.github.io

03-05 15:37