微前端架构详解 - 乾坤
引言
后有「微服务」,前有「微前端」。微前端是一个近几年才火起来的名词,伴随着近年来前端技术的超速发展和 “js 必将一统天下” 的口号,JavaScript 现在已经无所不能了。
10 年前,网页前端还是以 html + css + js 为主,被程序员们称为最简单的编程语言,最易上手的一个端,甚至部分前端还需要兼职美工。现在,前端领域已经玩出了各种花样,上能写服务端中间件,下能写移动端 App。连最开始的网页前端,也从 jQuery 这样的工具库转化到响应式框架和组件化的时代了。
组件化已经基本可以满足大中小型应用的需求了,但面对一些超大体量的应用时,传统组件化架构就会产生诸多问题。这时,为了解决这些问题,微前端架构诞生了。
一、微前端架构
1.1 微前端是什么
在微前端的定义上,定义有很多。微前端官网(没错,概念也有官网)的定义是:
Techniques, strategies and recipes for building a modern web app with multiple teams that can ship features independently.
英语好的自行理解,理解不了的看我的解读:
用于解决多团队开发一个超大型应用的技术。
那么微前端应用的架构又是什么样的呢?
如上图所示,
- 「Shared Stitching Layer」代表微前端基座,用于控制当前应该展示哪个页面、加载和移除页面,就像页面的「中控室」。
- 中间的「Ads Team」、「Products Team」、「Users Team」分别代表不同团队所开发的不同的前端项目,这些项目往往是直接给用户使用的。
- 下面的 「API Service」代表不同项目的后端,为前端项目提供服务。
基于这样的架构,研发管理上就可以做到不同团队只关注自己的应用功能,不必关心技术上对其他应用的影响,从而提高效率,如下图:
你可能会问:
不就是多团队开发然后用个壳子揉到一起吗,这真的能提高很多效率吗?
还真能,这就要从微前端的核心理念说起了。
1.2 微前端的核心理念
核心思想
微前端的核心思想是让应用之间技术栈无关,足够的无关,才叫「微前端」。
基于这个核心思想,我将微前端的理念总结为 4 个点,基于这 4 个点的技术发展将直接影响微前端架构的成熟度。
理念
高隔离性
隔离性是微前端最重要的特性之一,良好的隔离性可以让子应用与子应用之间、子应用与基座之间互不干扰,子应用只运行在自己的沙箱里。甚至应用各自可以用不同的技术栈。例如基座用 Vuejs,A 应用使用 React,B 应用使用原生 html + jQuery。低耦合度
微前端要求各个子应用之间耦合度很低,往往框架的「隔离性」做得越好,子应用间通信的限制就越多,通信成本就越高。如果应用之间耦合度高,则会产生频繁的通信操作,从而增加使用成本。所以微前端架构强制要求应用之间低耦合。如果应用因业务原因耦合度不得不变高,则应从业务拆分的角度考虑是否将两个子应用整合成为一个。高扩展性
微前端框架是一个非常灵活的框架,允许框架内的子应用进行任意形式的扩展,不限制基座和子应用依赖,基座和子应用可以基于业务和技术上来扩展功能,例如可以把基座扩展成一个云平台架子,或者改造成一个应用编排器。低侵入性
从微前端框架层来说,无论是对基座还是子应用,都需要保证尽可能低的侵入。能只侵入依赖的就不侵入代码,能只侵入 10 行代码的坚决不侵入 20 行。过高的侵入性会增加使用成本,降低扩展性和协作效率。侵入性和扩展性共同决定了微前端框架的易用性。
价值
如果用一句话来形容微前端的价值,我称之为:
因「无关」而「效率」
微前端解决应用在研发、维护、升级、变迁时因耦合而造成的维护性降低的问题,在超大型前端应用中尤为明显。
试想一下,如果一个巨型的 React(15) 单页应用,需要升级到 React(16) 的版本,那必定会迎来一波腥风血雨,做过此类不兼容升级工作的小伙伴一定知道心里的苦。
但如果是这个应用是微前端架构,完全可以保持基座依然是 React(15),先将 A 子应用升级到 React(16),既可以降低升级风险,还可以平稳过度。同理,这个机制在新技术实践的时候也适用,例如如果想试用 Vue3,就可以让 B 子应用使用 Vue3 开发,和 React 基座完成契合。甚至如果有一个 5 年前的项目 C 应用,突然想接入到我们系统里,按照传统的方式我们需要对这个 5 年前的项目做一次痛苦的改造,但按照微前端的模式他只需要在做一点接入相关的开发,即可与基座和 A、B 子应用融合。
1.3 形如 iframe 却非 iframe
写到这里,如果你对前端有了解,你可能会问:
你说的这些我明白,但 iframe 不是可以解决你说的这些问题吗?
那是当然,从 iframe 的机制来看,简直是为微前端而生的 html 原生组件,但回头一想,那些研究微前端的人总不能是傻子吧,如果 iframe 真的这么好用,还研究这么多干什么。
iframe 在使用上的确很方便,但功能限制缺有很多,有的微前端架构也是用 iframe 实现的,但都会面对如下问题:
- URL 状态的问题。iframe 的 url 状态会在刷新时丢失,同时后退、前进按钮都无法控制 iframe
- UI 完全隔离。iframe 内的 UI 只能在 iframe 内,这在需要弹框,或者层叠的时候会特别丑。比如一个 iframe 内部弹窗,遮罩智能盖住 iframe,无法影响到外面,导致看起来恨不协调
- 上下文隔离。iframe 内外通信及其不方便,一般通过 url、cookie 等方案来通信
- 性能消耗大。每一个 iframe 都可以看成是一个小的浏览器窗口,应用每次进入相当于打开多个窗口。一般会比单页应用更慢。
这些问题有的可以曲线解决,有的没法解决,这些没法解决的问题会直接影响交互体验,有时这些影响是不被接受的。这促使人们开始寻找新的道路,有没有一种方案可以「形如 iframe 却非 iframe」呢?
从目前的发展趋势来看,“让子应用能够像 iframe 一样简单接入,消除 iframe 的那些缺点” 是微前端架构不断在攻坚的目标之一。
1.4 评估微前端框架
上文说的都是微前端架构的理论,在实际场景中,往往没有一个微前端框架是处处完美的,那么如果评估一个微前端框架的成熟度呢?
我推荐从 3 个维度来评价,分别是:
- 运行隔离
- 应用侵入
- 使用体验
「运行隔离」代表着微前端框架基座、子应用之间是否会相互影响,能够做到在需要影响的时候影响(例如模态框),不需要影响的时候完全隔离(例如样式污染,上下文污染)。
「应用侵入」代表着框架对基座和子应用的侵入性,对应用尽可能低的侵入,才能让应用有更大的扩展空间和更低的接入成本。
「使用体验」代表框架在开始开发到上线使用整个流程里的使用体验,包含代码开发、热更新调试、版本控制、持续集成、性能优化等,一个成熟的微前端框架,在使用体验上一定做的不差。
二、微前端之巅 - 乾坤
说了这些微前端理念,那么在当下的技术生态中,哪个微前端框架最成熟呢?那一定是「乾坤」了,本文除了介绍微前端的概念之外,还会深入「乾坤」的实现原理来剖析,「乾坤」是如何一步一步优化和精进,从而成为当前的「微前端之巅」的。
2.1 Why 乾坤
乾坤(qiankun)的命名由来:
In Chinese traditional culture qian means heaven and kun stands for earth, so qiankun is the universe.
看到乾坤的作者用英语描述乾坤的含义时,竟然有一点点想笑。
为什么说 qiankun 是微前端之巅呢?
市面上的微前端框架除了 qiankun 之外,应该就属 single-spa
最流行了,single-spa
是一个扩展性极高的微前端框架,他仅仅只实现了微前端基座与子应用的一些约定部分,例如生命周期,加载方式等等。其余的东西都需要自己开发,对于一个普通用户来说 single-spa
是无法直接上手使用,需要进行二次开发。
而乾坤是正是基于 single-spa
二次开发的,在 single-spa
的基础上优化了使用体验并扩展了功能,使得乾坤成为了一个开箱即用,功能强大的微前端框架。可以说乾坤是站在 single-spa
的肩膀上成为微前端之巅的。
目前国内大多数微前端架构的技术方案都是采用的乾坤。
2.2 乾坤的核心实现
本小结会详细介绍「乾坤」是如何连接基座与子应用的,以及「乾坤」的具体实现和技术原理是怎么样的。
注:下文 qiankun 和 「乾坤」均指这个框架。qiankun 是他的英文名。「基座」代表微前端中的容器 - 主应用,容器内显示的称为「子应用」
2.2.1 应用接入原理
如果把基座看成是一个架子,子应用是架子里内容的一部分。基座和子应用都是单独部署的两个地址,那么第一个问题:
基座是如何加载子应用的?
在主流微前端框架中,加载的方式一般分为两种:
- JS Entry - 子应用打包时将所有资源(html、css、js)全部打包成一个 js 文件。基座通过加载这个 js 加载子应用。
- HTML Entry - 子应用按照原有的模式打包,基座通过加载应用的入口文件(index.html),然后再加载入口文件中的页面资源,最终汇总成 html、css、js 资源再加载到页面。
两种方式各有优劣,从体验的角度出发,HTML Entry 的体验更好,且侵入性更低,所以 qiankun 选择用 HTML Entry 的方式来加载子应用。
那么 qiankun 又是怎么实现 HTML Entry 的呢?
在 qiankun 的源码中,作者将这一部分抽离成了单独的 npm 库:
1 | "import-html-entry": "^1.9.0" |
这个库主要做了这些事情:
- 加载 entry html (index.html) 的内容到内存。
- 将 entry html 中的 css、js、link 等标签下的内容获取出来(包含外部的和内联的),整理成网页所需的 js、css 列表。并将无用标签去掉(例如注释、ignore 等)。
- 加载所有外链 js 脚本,并将这些外链 js 和内联 js 一起整理为 script list。
- 加载所有外链 css 文件,并将其以内联(
<style/>
)的方式插入到 entry html 中。 - 将处理后的 entry html 和待执行的 script list 返回给调用方(基座)。
在 import-html-entry
库处理完之后,基座在需要的加载子应用时候将这个 html 放到对应的 DOM 容器节点,并执行 script list,即完成子应用的加载。
同时为了满足丰富的实际场景,qiankun
还提供了预加载和按需加载两种策略,以供不同的场景使用,开启预加载之后,基座会在浏览器空闲时(requestIdleCallback)加载其余子应用,反之,只会在需要显示子应用的时候加载。
到这里,你可能会有第二个问题:
上文只是说如何将应用加载到页面上,并没有说怎么建立关系,基座和子应用之间是如何建立关系的呢?
首先在 qiankun
框架中,约定子应用必须导出 bootstrap、mount、unmount
三个声明周期函数,且必须以 umd
的格式导出模块。以 Vue
子应用的代码举例如下:
main.ts
1 | export async function bootstrap() { |
vue.config.js
1 | configureWebpack: { |
在上文最后,我们在加载子应用时有提到“执行 script list” 的步骤,这个步骤在执行完毕之后,就会检测该模块是否导出这三个生命周期函数,如果导出了,则认为这是一个子应用。
这三个生命周期函数分别的含义是:
- bootstrap 应用被装载。在应用首次初始化完成后触发,上文有提到 “执行 script list”,script list 执行完成后,即开始执行 bootstrap 函数。注意,此时应用只是被初始化,并不代表马上将要被显示。
- mount 应用被挂载。在应用需要显示时触发,此时,基座已经为子应用分配了 DOM 节点,然后调用 mount 函数。子应用在 mount 函数中可以根据基座分配的 DOM 节点,将子应用的内容渲染进去。
- unmount 应用被卸载。在子应用被关闭时触发,子应用需要在此函数中做一些清理操作。
需要注意的是,bootstrap 函数只会执行一次,而 mount 和 unmount 会根据基座对子应用的控制可能会执行多次。
qiankun 在加载子应用时,会为 window 注入标志性变量 window.__POWERED_BY_QIANKUN__
,子应用通过此标志来决定在基座中的加载逻辑和独立运行的加载逻辑,从而实现子应用可以在基座内外均可独立运行。
2.2.2 隔离的原理
子应用和基座的隔离主要有两点:
- 样式隔离
- js 隔离
样式隔离
要想做到子应用和基座之间的样式不会相互干扰,首先要做的就是样式隔离。
qiankun 提供了 3 种模式来实现不同效果的样式隔离:
- 动态载入 CSS(默认) - 代码中的配置为
sandbox = true
,这种模式的做法是直接将子应用的样式(2.2.1 中的 css 列表)全部直接加载到子应用挂载的 DOM 节点内,这样在卸载子应用的时候,移除该 DOM 节点,就可以自动去掉子应用使用的 css。但这种模式可能会导致子应用内的样式影响到基座。(例如子应用内和基座对同一个 id 的 DOM 元素配置了样式) - Shadow DOM 样式隔离 - 代码中的配置为
sandbox.strictStyleIsolation = true
,这种模式是使用浏览器原生的 Shadow DOM(mode = open) 实现,从而达到 Shadow Root 下的 css 无法影响到外部。参考链接:Shadow DOM - Scoped CSS 样式隔离 - 代码中的配置为
sandbox.experimentalStyleIsolation = true
。这种模式通过为 css 选择器添加[data-...]
限制,从而实现样式的隔离,这种模式可以做到应用内的样式不会影响到外部。
咋眼一看,你一定会觉得,第 2,3 种方案更好,能够做到完全隔离,第一种会影响基座,但为什么 qiankun 默认的是第 1 种方案呢?
当然还是因为体验的原因,我们在 UI 开发的过程中,会使用到类似「模态框」、「引导框」这样的组件,这样的组件往往需要直接挂载到 body 下,对网页整体加上蒙版什么的,如果我们使用第 2,3 种方案,那么子应用就只能在子应用内部玩,对子应用外的样式全部无效,弹框之类的也会变得很丑,还可能有 bug。如下图:
第 2,3 种方案:
第 1 种方案:
可见,没有遮罩的弹框显得索然无味。
所以综合权衡之下,qiankun 默认使用方案 1,通过人为约束子应用样式和基座区分开,也可以做到既满足弹框遮罩的场景,又实现基座和子应用样式互不影响。
js 隔离
js 隔离的核心是在基座和子应用中使用不同的上下文 (global env),从而达成基座和子应用之间 js 运行互不影响。
简单来说,就是给子应用单独的
window
,避免对基座的window
造成污染。
qiankun 在 js 隔离上,同样提供了 3 种方案,分别是:
LegacySandbox
- 传统 js 沙箱,目前已弃用,需要配置sandbox.loose = true
开启。此沙箱使用Proxy
代理子应用对 window 的操作,将子应用对 window 的操作同步到全局 window 上,造成侵入。但同时会将期间对 window 的新增、删除、修改操作记录到沙箱变量中,在子应用关闭时销毁,再根据记录将 window 还原到初始状态。ProxySandbox
- 代理 js 沙箱,非 IE 浏览器默认使用此沙箱。和LegacySandbox
同样基于Proxy
代理子应用对 window 的操作,和LegacySandbox
不同的是,ProxySandbox
会创建一个虚拟的 window 对象提供给子应用使用,哪怕是在运行时,子应用也不会侵入对 window,实现完全的隔离。SnapshotSandbox
- 快照 js 沙箱,IE 浏览器默认使用此沙箱。因为 IE 不支持Proxy
。此沙箱的原理是在子应用启动时,创建基座 window 的快照,存到一个变量中,子应用的 window 操作实质上是对这个变量操作。SnapshotSandbox
同样会将子应用运行期间的修改存储至modifyPropsMap
中,以便在子应用创建和销毁时还原。
注:样式隔离、JS 隔离都在会子应用 mount 前,bootstrap 时处理。
当然除了这些基本的隔离处理之外,qiankun 还提供了对 window 的各种监听和定时器的 Hook,保证子应用完整的销毁。
综合来说,qiankun 的 js 隔离方式比较完善,足够满足在子应用内的隔离需求了。
2.2.3 通信、路由的原理
相比于上文子应用隔离的原理而言,通信和路由更加偏向于应用,qiankun 在这两方面的设计基于微前端理念中的「低耦合度」。技术实现则是直接基于 single-spa
的基础,做了一点简单的扩展。
通信的原理
在通信部分,qiankun 提供了全局的 state 供子应用和基座使用。同时提供了 2 个函数供子应用操作使用,分别是:
- onGlobalStateChange:
(callback: OnGlobalStateChangeCallback, fireImmediately?: boolean) => void
在当前应用监听全局状态,有变更触发 callback,fireImmediately = true 立即触发 callback - setGlobalState:
(state: Record<string, any>) => boolean
按一级属性设置全局状态,子应用中只能修改已存在的一级属性
注:
setGlobalState
子应用仅能对全局 state 已存在的一级属性做修改,不能对 state 新增或删除属性。onGlobalStateChange
监听数据变化同样只针对于 state 已存在的一级属性。
这样设计的目的是想把全局 state 的掌控权交给基座主应用,避免子应用乱操作。
如果以上数据的通信不够用,也可以使用 window.addEventListener
直接进行事件通信。
路由的原理
qiankun 提供单实例(单个子应用)和多实例(多个子应用同时显示)模式。这里我们只讨论单实例模式,多实例模式目前还处于实验性阶段,多实例路由目前无法使用。
单实例模式下,qiankun 支持子应用使用 hash
和 history
两种路由模式,如果使用 history
需要设置 base
。例如:
1 | const router = new VueRouter({ |
qiankun 在 registerMicroApps
中获取应用激活规则和入口地址,在规则触发时就会加载该子应用,子应用加载完成后,应用内路由的权利就完全交给子应用了。
如果子应用资源使用的相对路径加载,那么子应用需要在被加载的第一时间指定 webpack_public_path
然后再初始化。代码如下:
1 | if (window.__POWERED_BY_QIANKUN__) { |
这一步,qiankun 将子应用在基座中配置的入口地址传递给子应用,子应用指定 webpack_public_path
后即可正确加载页面资源。
三、总结 & 思考
本文的写作过程是非常花费精力的,作者从一开始对微前端不怎么了解,通过大量查阅资料、阅读源码配合一些实际操作经验,结合自身对微前端的理解,最终输出了本篇文章。
在这一路上,我发现了 qiankun 的一路迭代不光是依靠一个又一个的技术人书写精巧的代码堆砌而成,更重要的是 qiankun 的使命和设计思路的构建,在阅读乾坤技术圆桌的文章时,qiankun 的作者 kuitos
、阿里内部开发者、社区贡献者对 qiankun 的使命和核心进行了非常深度的思考,多次精彩的脑暴和思维碰撞,这些思考指引着 qiankun 的发展,让 qiankun 的未来脱离传统「微前端」思想的束缚。
放眼整个框架圈,不光是 qiankun,许多知名开源框架都有这个过程。由此可以去想,我们在做技术组件,在做一些抽象的底层代码时,是否仔细去想过它的核心价值是什么。缺乏核心价值指导的组件是缺少灵魂的,它可以解决当下的问题,但也许无法面向未来。