微前端相关问题的解决思路
价值
微前端架构具备以下几个核心价值
- 技术栈无关:主框架不限制接入应用的技术栈,子应用具备完全自主权
- 独立开发、独立部署:子应用仓库独立,前后端可独立开发,部署完成后主框架自动完成同步更新
- 独立运行时:每个子应用之间状态隔离,运行时状态不共享
微前端架构旨在解决单体应用在一个相对长的时间跨度下,由于参与的人员、团队的增多、变迁,从一个普通应用演变成一个巨石应用后,随之而来的应用不可维护的问题,这类问题在企业级 web 应用中尤其常见
针对中后台应用的解决方案
从技术实现角度,微前端架构解决方案大概分为两类场景
- 单实例:即同一时刻,只有一个子应用被展示,子应用具备一个完整的应用生命周期,通常基于 url 的变化来做子应用的切换
- 多实例:同一时刻可以展示多个子应用,通常使用 Web Components 方案来做子应用封装,子应用更像是一个业务组件而不是应用
以下将以单实例场景下来介绍微前端(基于 single-spa),因为这个场景更贴近大部分中后台应用
实践中的问题
主框架的拼接层作为主框架的核心成员,充当调度者的角色,由它来决定在不同的条件下激活不同的子应用,它的定位仅仅是:导航路由和资源加载框架
要实现这样一套架构,需要解决以下几个技术问题
路由系统及 Future State
问题
当浏览器重新刷新时,主框架资源会被重新加载,同时异步 load 子应用的静态资源,由于此时主应用的路由系统已经激活,但子应用的资源可能还没有完全加载完毕,从而导致路由注册表里发现没有能匹配子应用的规则,这是会导致跳到 404 或者路由报错
该问题在所有 lazy load 方式加载子应用的方案中都会碰到,早些年 angularjs 社区把这个问题统一称为 Future State
解决
设计一套路由机制
- 主框架配置子应用的路由为
subApp: { url: '/subApp/**', entry: './subApp.js' } - 当浏览器的地址为
/subApp/abc时,框架需要先加载 entry 资源 - 待 entry 资源加载完毕,确保子应用的路由系统注册进主框架之后,再由子应用的路由系统接管 url change 事件
- 同时在子应用路由切出时,主框架需要触发响应的 destroy 事件,子应用在监听到该事件时,调用自己的卸载方法卸载应用
App Entry
主框架与子应用集成的方式
| 方案 | 特点 | 优点 | 缺点 |
|---|---|---|---|
| 构建时 | 子应用通过 Package Registry(或者 npm package、git tags)的方式,与主应用一起打包发布 | 主应用、子应用之间可以做打包优化,如依赖共享等 | 子应用与主应用之间产品工具链耦合,工具链也是技术栈的一部分 子应用每次发布依赖主应用重新打包发布 |
| 运行时 | 子应用自己构建打包,主应用运行时动态加载子应用资源 | 主应用与子应用完全解耦,子应用与技术栈无关 | 会多出一些运行时的复杂度和 overhead |
要实现真正的技术栈无关和独立部署两个核心目标,大部分情况下需要使用运行时加载子应用这种方案
JS Entry 和 HTML Entry
上面确定了集成方案,此处要选择子应用应该提供什么形式的资源作为渲染入口
| 资源形式 | 优点 | 缺点 |
|---|---|---|
| HTML Entry | 子应用开发、发布完全独立 子应用具备与独立应用开发时一致的开发体验 | 多一次请求,子应用资源解析消耗转移到运行时 主子应用不处于同一个构建环境,无法利用 bundler 的一些构建期的优化能力,如公共依赖抽取等 |
| JS Entry | 主子应用使用同一个 bundler,可以方便做构建时优化 | 子应用的发布需要主应用重新打包 主应用需要为每个子应用预留一个容器节点,且该节点 id 需与子应用的容器 id 保持一致 子应用各类资源需要一起打成一个 bundle,资源加载效率变低 |
模块导入
问题
微前端架构下,我们需要获取到子应用暴露出的一些钩子引用,如 bootstrap、mount、unmout 等(参考 single-spa),从而能对接入应用有一个完整的生命周期控制。而由于子应用通常又有集成部署、独立部署两种模式同时支持的需求,使得我们只能选择 umd 这种兼容性的模块格式打包我们的子应用。如何在浏览器运行时获取远程脚本中导出的模块引用也是一个需要解决的问题。
解决
只需要走 umd 包格式中的 global export 方式获取子应用的导出即可,大体的思路是通过给 window 变量打标记,记住每次最后添加的全局变量,这个变量一般就是应用 export 后挂载到 global 上的变量,实现方式可以参考systemjs global import
应用隔离
微前端架构方案中有两个非常关键的问题,有没有解决这两个问题将直接标志该方案是否真的生产可用
- 样式隔离
- js 隔离
样式隔离
在微前端场景下,不同技术栈的子应用会被集成到同一个运行时中,所以我们必须在框架层确保各个子应用之间不会出现样式互相干扰的问题
Shadow DOM 隔离性太强,如 modal 组件,是动态挂载到 document.body 的,而 Shadow DOM 的特性导致组件的样式只会在 shadow 这个作用域下生效,结果就是弹出框无法应用样式,解决办法是把样式上浮一层,丢到主应用,但是这样着意味着子应用的样式直接泄露到主应用了
CSS Module 或者 BEM 约定 css 前缀的方式来避免样式冲突,或者直接基于 css module 方案写样式,对于全新的项目是可行的,但是微前端架构更多的目标是解决遗产应用,遗产应用通常是很难有动力做大幅改造的
最主要的是,当两个子应用都引入了第三方组件库,但该库不支持定制化前缀?或者两个子应用都引入了同一个组件库的不同版本,都写入了全局的样式,但彼此不兼容怎么办?
Dynamic Stylesheet 该方案很简单,只需要在应用切出/卸载后,同时卸载掉其样式表即可,原理是浏览器会对所有的样式表的插入、移除做整个 CSSOM 的重构,从而达到插入、卸载样式的目的,这样就能保证在同一个时间点里,只有一个应用的样式表是生效的
HTML Entry 方案天生具有样式隔离的特性,因为应用卸载后会直接移除 HTML 结构,从而自动移除了其样式表,在该方案下,子应用加载完成后的 DOM 结构可能长这样
<html> <body> <main id="subApp"> <!-- 子应用完整的 html 结构 --> <link ref="stylesheet" href="subapp.css" /> <div id="root">...</div> </main> </body> </html>当子应用被替换或卸载时,subApp 节点的 innerHTML 也会被复写,subapp.css 也就自然被移除了
js 隔离
如何确保各个子应用之间的全局变量不会互相干扰,一般来说是劫持 window
通常有两种思路
- 沙箱快照,原理是对 window 进行多次遍历,达到激活和失活的效果
- proxy
各个框架的实现
- qankun:在应用的 bootstrap 和 mount 两个生命周期开始之前分别给全局状态打下快照,然后当应用切出/卸载时,将状态回滚值 bootstrap 开始之前的阶段,确保应用对全局状态的污染全部清 0,第二次进入时再恢复至 mount 之前的状态,从而确保 remount 时拥有跟第一次 mount 时一致的全局上下文
- berial:使用
Mutation Observer侦测 document 的变化,如果变化的 target 不是当前沙箱,则对其进行劫持,然后塞回沙箱
框架对比
qiankun 与 berial
berial 使用了原生 Shadow DOM,不依赖 single-spa
webpack5 module federation
webpack5 的新特性,中文名叫做“模块联邦”,该特性可以实现多个不同技术栈共存,而且不需要任何框架,但目前还没支持沙箱隔离