“Yeah It’s on. ”
正文
Techniques, strategies and recipes for building a modern web app with multiple teams using different JavaScript frameworks. — Micro Frontends
我认为微前端的核心价值在于 “技术栈无关”,这才是它诞生的理由,或者说这才是能说服我采用微前端方案的理由。
微前端架构具备以下几个核心价值:
- 技术栈无关 主框架不限制接入应用的技术栈,子应用具备完全自主权
- 独立开发、独立部署 子应用仓库独立,前后端可独立开发,部署完成后主框架自动完成同步更新
- 独立运行时 每个子应用之间状态隔离,运行时状态不共享
中后台应用由于其应用生命周期长(动辄 3+ 年)等特点,最后演变成一个巨石应用的概率往往高于其他类型的 web 应用。而从技术实现角度,微前端架构解决方案大概分为两类场景:
- 单实例:即同一时刻,只有一个子应用被展示,子应用具备一个完整的应用生命周期。通常基于 url 的变化来做子应用的切换。
- 多实例:同一时刻可展示多个子应用。通常使用 Web Components 方案来做子应用封装,子应用更像是一个业务组件而不是应用。
问题
样式隔离
由于微前端场景下,不同技术栈的子应用会被集成到同一个运行时中,所以我们必须在框架层确保各个子应用之间不会出现样式互相干扰的问题。
针对 “Isolated Styles” 这个问题,如果不考虑浏览器兼容性,通常第一个浮现到我们脑海里的方案会是 Web Components。基于 Web Components 的 Shadow DOM 能力,我们可以将每个子应用包裹到一个 Shadow DOM 中,保证其运行时的样式的绝对隔离。
但 Shadow DOM 方案在工程实践中会碰到一个常见问题,比如我们这样去构建了一个在 Shadow DOM 里渲染的子应用:
const shadow = document.querySelector('#hostElement').attachShadow({mode: 'open'});
shadow.innerHTML = '<sub-app>Here is some new text</sub-app><link rel="stylesheet" href="//unpkg.com/antd/antd.min.css">';
由于子应用的样式作用域仅在 shadow 元素下,那么一旦子应用中出现运行时越界跑到外面构建 DOM 的场景,必定会导致构建出来的 DOM 无法应用子应用的样式的情况。
比如 sub-app 里调用了 antd modal 组件,由于 modal 是动态挂载到 document.body 的,而由于 Shadow DOM 的特性 antd 的样式只会在 shadow 这个作用域下生效,结果就是弹出框无法应用到 antd 的样式。解决的办法是把 antd 样式上浮一层,丢到主文档里,但这么做意味着子应用的样式直接泄露到主文档了。gg…
CSS Module? BEM?
社区通常的实践是通过约定 css 前缀的方式来避免样式冲突,即各个子应用使用特定的前缀来命名 class,或者直接基于 css module 方案写样式。对于一个全新的项目,这样当然是可行,但是通常微前端架构更多的目标是解决存量/遗产 应用的接入问题。很显然遗产应用通常是很难有动力做大幅改造的。
最主要的是,约定的方式有一个无法解决的问题,假如子应用中使用了三方的组件库,三方库在写入了大量的全局样式的同时又不支持定制化前缀?比如 a 应用引入了 antd 2.x,而 b 应用引入了 antd 3.x,两个版本的 antd 都写入了全局的 .menu class
,但又彼此不兼容怎么办?
Dynamic Stylesheet !
解决方案其实很简单,我们只需要在应用切出/卸载后,同时卸载掉其样式表即可,原理是浏览器会对所有的样式表的插入、移除做整个 CSSOM 的重构,从而达到 插入、卸载 样式的目的。这样即能保证,在一个时间点里,只有一个应用的样式表是生效的。
上文提到的 HTML Entry 方案则天生具备样式隔离的特性,因为应用卸载后会直接移除去 HTML 结构,从而自动移除了其样式表。
比如 HTML Entry 模式下,子应用加载完成的后的 DOM 结构可能长这样:
<html>
<body>
<main id="subApp">
// 子应用完整的 html 结构
<link rel="stylesheet" href="//alipay.com/subapp.css">
<div id="root">....</div>
</main>
</body>
</html>
当子应用被替换或卸载时,subApp
节点的 innerHTML 也会被复写,//alipay.com/subapp.css
也就自然被移除样式也随之卸载了。
JS 隔离
解决了样式隔离的问题后,有一个更关键的问题我们还没有解决:如何确保各个子应用之间的全局变量不会互相干扰,从而保证每个子应用之间的软隔离?
这个问题比样式隔离的问题更棘手,社区的普遍玩法是给一些全局副作用加各种前缀从而避免冲突。但其实我们都明白,这种通过团队间的”口头“约定的方式往往低效且易碎,所有依赖人为约束的方案都很难避免由于人的疏忽导致的线上 bug。那么我们是否有可能打造出一个好用的且完全无约束的 JS 隔离方案呢?
针对 JS 隔离的问题,我们独创了一个运行时的 JS 沙箱。
即在应用的 bootstrap 及 mount 两个生命周期开始之前分别给全局状态打下快照,然后当应用切出/卸载时,将状态回滚至 bootstrap 开始之前的阶段,确保应用对全局状态的污染全部清零。而当应用二次进入时则再恢复至 mount 前的状态的,从而确保应用在 remount 时拥有跟第一次 mount 时一致的全局上下文。
当然沙箱里做的事情还远不止这些,其他的还包括一些对全局事件监听的劫持等,以确保应用在切出之后,对全局事件的监听能得到完整的卸载,同时也会在 remount 时重新监听这些全局事件,从而模拟出与应用独立运行时一致的沙箱环境。
补充
iframe 架构
iframe 对于这种跨团队的中后台巨无霸项目依然有着天然的优势,理由如下:
- 框架无关:iframe 只加载部署完应用链接;
- 独立沙箱:iframe 拥有浏览器的独立沙箱,子应用和主应用完全不用考虑 js & css 污染问题;
- 开发简单:仅仅一个 iframe 标签;
iframe 方案的一些弊端:
- 通信方式简单,简单的 postmessage API 并不能满足业务的需求;
- 样式割裂,iframe 会导致诸如 Dialog 这样子的全局蒙层仅在 ifame 区块内展示,使我们系统更像是“拼凑”出来的;
- 性能瓶颈,路由的切换会导致 iframe 内子应用的重新加载,性能堪忧;
- 跨域问题,chrome80 的 samesite 策略会导致 iframe 方案的跨域 cookie 无法带给后端;
从single-spa到qiankun
https://mp.weixin.qq.com/s/ylkY9HWTQKPlbDb2M33YWQ
相比于single-spa
,qiankun
他解决了JS沙盒环境,不需要我们自己去进行处理。在single-spa
的开发过程中,我们需要自己手动的去写调用子应用JS的方法,而qiankun
不需要,乾坤只需要你传入响应的apps的配置即可,会帮助我们去加载。
qiankun:
- 基于 single-spa 封装,提供了更加开箱即用的 API。
- 技术栈无关,任意技术栈的应用均可 使用/接入,不论是 React/Vue/Angular/JQuery 还是其他等框架。
- HTML Entry 接入方式,让你接入微应用像使用 iframe 一样简单。
- 样式隔离,确保微应用之间样式互相不干扰。
- JS 沙箱,确保微应用之间 全局变量/事件 不冲突。
- 资源预加载,在浏览器空闲时间预加载未打开的微应用资源,加速微应用打开速度。