微前端作为用户界面的一部分,通常由许多组件组成,并使用类似于React、Vue和Angular等框架来渲染组件。每个微前端可以由不同的团队进行管理,并可以自主选择框架。虽然在迁移或测试时可以添加额外的框架,出于实用性考虑,建议只使用一种框架。
每个微前端都拥有独立的git仓库、package.json和构建工具配置。因此,每个微前端都拥有独立的构建进程和独立的部署/CI。这通常意味着,每个仓库能快速构建。
主框架的定位则仅仅是:导航路由 + 资源加载框架。
路由系统
正常访问一个子应用的页面时,浏览器地址 https://app.alipay.com/subApp/123/detail, 当刷新时 主应用的路由系统已经激活,但子应用的资源可能还没有完全加载完毕,从而导致路由注册表里发现没有能匹配子应用 /subApp/123/detail
的规则, 可能出现 404。
我们需要设计这样一套路由机制:
主框架配置子应用的路由为 subApp: { url: '/subApp/**', entry: './subApp.js' }
,则当浏览器的地址为 /subApp/abc
时,框架需要先加载 entry 资源,待 entry 资源加载完毕,确保子应用的路由系统注册进主框架之后后,再去由子应用的路由系统接管 url change 事件,同时在子应用路由切出时,主框架需要触发相应的 destroy 事件,子应用在监听到该事件时,调用自己的卸载方法卸载应用,如 React 场景下 destroy = () => ReactDOM.unmountAtNode(container)
。
JS Entry vs HTML Entry
在确定了运行时载入的方案后,另一个需要决策的点是,我们需要子应用提供什么形式的资源作为渲染入口?
JS Entry 的方式通常是子应用将资源打成一个 entry script,但这个方案的限制也颇多,如要求子应用的所有资源打包到一个 js bundle 里,包括 css、图片等资源。除了打出来的包可能体积庞大之外的问题之外,资源的并行加载等特性也无法利用上
HTML Entry 则更加灵活,直接将子应用打出来 HTML 作为入口,主框架可以通过 fetch html 的方式获取子应用的静态资源,同时将 HTML document 作为子节点塞到主框架的容器中。这样不仅可以极大的减少主应用的接入成本,子应用的开发方式及打包方式基本上也不需要调整,而且可以天然的解决子应用之间样式隔离的问题(后面提到)。想象一下这样一个场景
如果是 JS Entry 方案,主框架需要在子应用加载之前构建好相应的容器节点(比如这里的 “#root” 节点),不然子应用加载时会因为找不到 container 报错。但问题在于,主应用并不能保证子应用使用的容器节点为某一特定标记元素。而 HTML Entry 的方案则天然能解决这一问题,保留子应用完整的环境上下文,从而确保子应用有良好的开发体验
HTML Entry 方案下,主框架注册子应用的方式则变成:
framework.registerApp('subApp1', { entry: '//abc.alipay.com/index.html'})
样式隔离
由于微前端场景下,不同技术栈的子应用会被集成到同一个运行时中,所以我们必须在框架层确保各个子应用之间不会出现样式互相干扰的问题。
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 也会被复写
JS 隔离
解决了样式隔离的问题后,有一个更关键的问题我们还没有解决:如何确保各个子应用之间的全局变量不会互相干扰,从而保证每个子应用之间的软隔离?
针对 JS 隔离的问题,我们独创了一个运行时的 JS 沙箱。简单画了个架构图:
即在应用的 bootstrap 及 mount 两个生命周期开始之前分别给全局状态打下快照,然后当应用切出/卸载时,将状态回滚至 bootstrap 开始之前的阶段,确保应用对全局状态的污染全部清零。而当应用二次进入时则再恢复至 mount 前的状态的,从而确保应用在 remount 时拥有跟第一次 mount 时一致的全局上下文。
当然沙箱里做的事情还远不止这些,其他的还包括一些对全局事件监听的劫持等,以确保应用在切出之后,对全局事件的监听能得到完整的卸载,同时也会在 remount 时重新监听这些全局事件,从而模拟出与应用独立运行时一致的沙箱环境。
传统iframe
在入口框架中用iframe来显示子模块的页面,切换子模块时,iframe也跟着切换成对应子模块页面的url。
虽然iframe是比较容易实现的,但通常也会有一些问题:
- 显示区域受限制,比如子项目中显示弹窗蒙层时,蒙层只会覆盖iframe区域,无法覆盖整个页面,内容也无法真正居中。
- 页面浏览记录无法自动被记录,刷新页面后iframe又自动回到首页。
- 全局上下文完全隔离,变量不共享,页面间通信比较麻烦,比如子项目与主题框架、子项目之间通信等,只能采用postMessage方式。
- 速度较慢,每次进入子应用时都要重建整个上下文。
代表的框架 single-spa
微前端类型
- single-spa applications:为一组特定路由渲染组件的微前端。
- single-spa parcels: 不受路由控制,渲染组件的微前端。
- utility modules: 非渲染组件,用于暴露共享javascript逻辑的微前端。
微前端通信
import {thing} from 'other-microfrontend'
是微前端间通信的首选方式。详细文档
性能
相比于原生应用,微前端性能更佳。这是由于懒加载 和其他相关的优化。微前端为我们提供一种迁移方式,从而解决我们原生项目中隐藏的问题。出于性能考虑,强烈建议框架(如:React, Vue, or Angular等)级别的实例仅引用一次,具体做法参考。
拆分应用
大多数的微服务体系都鼓励独立的代码仓库、构建和部署。虽然 single-spa不能解决如何托管、构建或部署 代码的问题,但是这些问题与许多single-spa用户相关,因此这里讨论了一些策略。
Monorepo | NPM包 | 动态加载模块 | |
---|---|---|---|
搭建难度 | 简单 | 中等 | 困难 |
代码是否独立 | No | No | ✅ |
分开构建 | No | ✅ | ✅ |
分别部署 | No | ✅ | ✅ |
例子 | simple-webpack-examplesingle-spa-examples | single-spa-login-example-with-npm-packages | SystemJS example |
缺点 | 灵活性和自由度不足,项目越来越大时,打包速度越来越慢 | 当single-spa应用发生更改时,根应用程序应该重新安装、重新构建和重新部署 |
动态加载模块
创建一个父应用,允许子应用单独部署。为了实现这一点,创建一个manifest文件,当子应用部署更新时,它控制子应用的“上线”版本及加载的JavaScript文件。
改变每个子应用加载的JavaScript文件有很多的方法: