大鹿
Jiann
Do not go gentle into that good night. 🏕️

微前端

需求分析

什么是微前端 微前端提供了一种技术:可以将多个独立的Web应用聚合到一起,提供统一的访问入口。一个微前端应用给用户的感观就是一个完整的应用,但是在技术角度上是由一个个独立的应用组合通过某种方式组合而成的。 目前的微前端框架一般都具有以下 三个 特点:

  • 技术栈无关:主框架不限制接入应用的技术栈,子应用具备完全自主权。
  • 独立性强:独立开发、独立部署,子应用仓库独立。
  • 状态隔离:运行时每个子应用之间状态隔离。

微前端实现形式

1. 服务端集成

微前端的第一种实现思路是服务端集成,即通过 Nginx 配置反向代理来实现不同路径映射到不同应用(如下图所示),这样可以实现项目的独立开发和部署。 image.png 但同时这种做法也会丢失SPA的体验,每一次命中路由都会重新请求资源,不能局部更新当前页面。

2. 运行时集成

为什么不用 iframe,这几乎是所有微前端方案第一个会被 challenge 的问题。但是大部分微前端方案又不约而同放弃了 iframe 方案,自然是有原因的,并不是为了 "炫技" 或者刻意追求 "特立独行"。 如果不考虑体验问题,iframe 几乎是最完美的微前端解决方案了。 iframe 最大的特性就是提供了浏览器原生的硬隔离方案,不论是样式隔离、js 隔离这类问题统统都能被完美解决。但他的最大问题也在于他的隔离性无法被突破,导致应用间上下文无法被共享,随之带来的开发体验、产品体验的问题。

  1. url 不同步。浏览器刷新 iframe url 状态丢失、后退前进按钮无法使用。
  2. UI 不同步,DOM 结构不共享。想象一下屏幕右下角 1/4 的 iframe 里来一个带遮罩层的弹框,同时我们要求这个弹框要浏览器居中显示,还要浏览器 resize 时自动居中..
  3. 全局上下文完全隔离,内存变量不共享。iframe 内外系统的通信、数据同步等需求,主应用的 cookie 要透传到根域名都不同的子应用中实现免登效果。
  4. 慢。每次子应用进入都是一次浏览器上下文重建、资源重新加载的过程。 其中有的问题比较好解决(问题1),有的问题我们可以睁一只眼闭一只眼(问题4),但有的问题我们则很难解决(问题3)甚至无法解决(问题2),而这些无法解决的问题恰恰又会给产品带来非常严重的体验问题, 最终导致我们舍弃了 iframe 方案。

3. 单应用集成

single-spa是一个用于 前端微服务化 的JavaScript前端解决方案。single-spa的核心就是定义了一套 协议 。协议包含主应用的配置信息和子应用的生命周期,通过这套协议,主应用可以方便的知道在什么情况下激活哪个子应用。

微前端需要解决的技术问题

路由系统及 Future State

页面加载或者刷新时,需要主应用控制加载对应的子应用及页面,要实现这样一套机制,我们可以自己去劫持 url change 事件从而实现自己的路由系统,也可以基于社区已有的 ui router library,尤其是 react-router 在 v4 之后实现了 Dynamic Routing 能力,我们只需要复写一部分路由发现的逻辑即可。这里我们推荐直接选择社区比较完善的相关实践 single-spa。

App Entry

解决了路由问题后,主框架与子应用集成的方式,也会成为一个需要重点关注的技术决策。 微前端架构模式下,子应用打包的方式,基本分为两种: image.png 两者的优缺点也很明显: image.png 很显然,要实现真正的技术栈无关跟独立部署两个核心目标,大部分场景下我们需要使用运行时加载子应用这种方案。

JS Entry vs HTML Entry

在确定了运行时载入的方案后,另一个需要决策的点是,我们需要子应用提供什么形式的资源作为渲染入口? JS Entry 的方式通常是子应用将资源打成一个 entry script,比如 single-spa 的 example 中的方式。但这个方案的限制也颇多,如要求子应用的所有资源打包到一个 js bundle 里,包括 css、图片等资源。除了打出来的包可能体积庞大之外的问题之外,资源的并行加载等特性也无法利用上。 HTML Entry 则更加灵活,直接将子应用打出来 HTML 作为入口,主框架可以通过 fetch html 的方式获取子应用的静态资源,同时将 HTML document 作为子节点塞到主框架的容器中。这样不仅可以极大的减少主应用的接入成本,子应用的开发方式及打包方式基本上也不需要调整,而且可以天然的解决子应用之间样式隔离的问题(后面提到)。想象一下这样一个场景: <!-- 子应用 index.html --> <script src**="//unpkg/antd.min.js"></script> <body> <main id="root"></main> </body> // 子应用入口 ReactDOM.render(<App/>, document.getElementById('root')) 如果是 JS Entry 方案,主框架需要在子应用加载之前构建好相应的容器节点(比如这里的 "#root" 节点),不然子应用加载时会因为找不到 container 报错。但问题在于,主应用并不能保证子应用使用的容器节点为某一特定标记元素。而 HTML Entry 的方案则天然能解决这一问题,保留子应用完整的环境上下文,从而确保子应用有良好的开发体验。 HTML Entry 方案下,主框架注册子应用的方式则变成: framework.registerApp('subApp1', { entry:** '//abc.alipay.com/index.html'}) 本质上这里 HTML 充当的是应用静态资源表的角色,在某些场景下,我们也可以将 HTML Entry 的方案优化成 Config Entry,从而减少一次请求,如: framework.registerApp('subApp1', { html**:** '', scripts**:** ['//abc.alipay.com/index.js'], css**:** ['//abc.alipay.com/index.css']}) 总结一下: image.png

模块导入

微前端架构下,我们需要获取到子应用暴露出的一些钩子引用,如 bootstrap、mount、unmout 等(参考 single-spa),从而能对接入应用有一个完整的生命周期控制。而由于子应用通常又有集成部署、独立部署两种模式同时支持的需求,使得我们只能选择 umd 这种兼容性的模块格式打包我们的子应用。如何在浏览器运行时获取远程脚本中导出的模块引用也是一个需要解决的问题。 通常我们第一反应的解法,也是最简单的解法就是与子应用与主框架之间约定好一个全局变量,把导出的钩子引用挂载到这个全局变量上,然后主应用从这里面取生命周期函数。 这个方案很好用,但是最大的问题是,主应用与子应用之间存在一种强约定的打包协议。那我们是否能找出一种松耦合的解决方案呢? 很简单,我们只需要走 umd 包格式中的 global export 方式获取子应用的导出即可,大体的思路是通过给 window 变量打标记,记住每次最后添加的全局变量,这个变量一般就是应用 export 后挂载到 global 上的变量。实现方式可以参考 systemjs global import,这里不再赘述。

应用隔离

微前端架构方案中有两个非常关键的问题,有没有解决这两个问题将直接标志你的方案是否真的生产可用。比较遗憾的是此前社区在这个问题上的处理都会不约而同选择”绕道“的方式,比如通过主子应用之间的一些默认约定去规避冲突。而今天我们会尝试从纯技术角度,更智能的解决应用之间可能冲突的问题。

样式隔离

由于微前端场景下,不同技术栈的子应用会被集成到同一个运行时中,所以我们必须在框架层确保各个子应用之间不会出现样式互相干扰的问题。

Shadow DOM?

针对 "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 沙箱。简单画了个架构图:

image.png

即在应用的 bootstrap 及 mount 两个生命周期开始之前分别给全局状态打下快照,然后当应用切出/卸载时,将状态回滚至 bootstrap 开始之前的阶段,确保应用对全局状态的污染全部清零。而当应用二次进入时则再恢复至 mount 前的状态的,从而确保应用在 remount 时拥有跟第一次 mount 时一致的全局上下文。 当然沙箱里做的事情还远不止这些,其他的还包括一些对全局事件监听的劫持等,以确保应用在切出之后,对全局事件的监听能得到完整的卸载,同时也会在 remount 时重新监听这些全局事件,从而模拟出与应用独立运行时一致的沙箱环境。

全局状态管理与通信

需要定义通信规范

主子应用路由调度

将主子路由的控制解耦分离

  1. 通过iframe创建独立的window和document对象
  2. 通过js沙箱的形式将创建的window传递进去
  3. 主路由控制粗粒度的url,细粒度路由传递给沙箱的window
  4. 内部路由变更需同步到主路由

https://juejin.cn/post/6847902217945481224

相关框架

single-spa + systemjs 腾讯omi 基于webcomponent qiankun 基于single-spa,JS SandboxStyle Isolation Nginx 转发,header组件公用

其他

// 异步拉去代码片段
getJsStr().then(script => {
  const execScript = `with (sandbox) {;${script}\n}`;
  const code = new Function('sandbox', execScript).bind(this.sandbox);
  code(this.sandbox);
})
  • 异步拉取js,获取到代码片段
  • with (sandbox) {;${script}\n} 通过with语法,替换代码内部所有全局变量,a=1 => sandbox.a=1;window.a=1 => sandbox.window.a = 1
  • const code = new Function('sandbox', execScript).bind(this.sandbox); 执行后 this.a=1 => sandbox.a=1cons
  • code(this.sandbox); 传递外部window到函数内

Links

微前端在美团外卖的实践 如何在JavaScript中实现业务模块化的沙盒模型? 实施微前端的六种方式 通过对window代理实现全局变量沙盒 可能是你见过最完善的微前端解决方案 !!非常全,包含js沙盒,css隔离