微前端是近几年流行起来的概念,在工作中,我实践应用了微前端技术解决了一些项目中问题。这里计划写几篇文章,总结一下微前端相关的基础理论和实践。
什么是微前端
微前端是一种类似于微服务的架构,它将微服务的理念应用于 Web 开发中,即将 Web 应用由单一的单体应用转变为多个小型前端应用聚合为一的应用。各个前端应用独立运行,可以不同的团队独立开发,并具有同时构建和发布的能力。
这种架构可以提供很大的优势,比如简单、解耦的代码库、自治团队、独立发布和增量升级。开发过程大大加快,规模扩大,效率提高。
本质上来说,微前端提供了一种技术,可以将多个独立的 Web 应用聚合到一起,提供统一的访问入口 。我们既可以将大型的 Web 应用拆分为多个小型应用,又可以将多个小型应用聚合为一个应用。
技术价值
-
技术栈无关,主框架不限制接入应用的技术栈,子应用具备完全自主权
-
独立开发、独立部署 子应用仓库独立,前后端可独立开发,部署完成后主框架自动完成同步更新
-
独立运行时,每个子应用之间状态隔离,运行时状态不共享
解决问题
-
新旧系统的合并,共同维护
-
“巨石应用”分解:功能复杂的 Web 应用,拆分为简单的应用
-
渐进式技术栈升级:分模块升级一个系统的技术栈
微前端架构
微前端的一般架构模型如下
-
宿主(主)应用:负责维护子应用的生命周期:挂载、路由、卸载,隔离不同的应用,防止不同的子应用之间互相影响,提供应用间通信能力等。
-
子应用:独立的 Web 应用, 一般无特殊要求
技术选型
Why Not Iframe
优点
-
iframe 提供了浏览器原生隔离方案,能解决 JS 隔离、样式隔离等问题
-
可以在子系统完全不修改的情况下嵌入进来
缺点
-
页面加载问题:影响主页面加载,阻塞
onload
事件,本身加载也很慢,页面缓存过多会导致电脑卡顿。(无法解决) -
布局问题:iframe 必须给一个指定的高度,否则会塌陷。解决办法:子系统实时计算高度并通过 postMessage 发送给主页面,主页面动态设置高度,修改子系统或者代理插入脚本。有些情况会出现多个滚动条,用户体验不佳
-
UI 不同步,DOM 结构不共享,如弹窗及遮罩层问题:只能在 iframe 范围内垂直水平居中,没法在整个页面垂直水平居中
-
浏览器前进/后退问题:iframe 和主页面共用一个浏览历史,iframe 会影响页面的前进后退,大部分时候正常,iframe 多次重定向则会导致浏览器的前进后退功能无法正常使用,不是全部页面都会出现,基本可以忽略。但是 iframe 页面刷新会重置(比如说从列表页跳转到详情页,然后刷新,会返回到列表页),因为浏览器的地址栏没有变化
-
iframe 内外系统的通信、数据同步等需求,主应用的 cookie 要透传到根域名都不同的子应用中实现免登效果
-
iframe 的页面跳转到其他页面出问题,比如两个 iframe 之间相互跳转,直接跳转会只在 iframe 范围内跳转,所以必须通过主页面来进行跳转。不过 iframe 跳转的情况很少
-
不同源的系统之间的通讯需要通过 postMessage,存在一定的安全性
-
Safari 下 iframe 无法设置 localStorage(待确定)
Single SPA
优点
-
用户体验好,内容的改变不需要重新加载整个页面,避免了不必要的跳转和重复渲染
-
共享 DOM 结构,UI 状态
缺点
-
JS 和 CSS 隔离问题,容易造成全局污染,尤其是 vue 的全局组件,全局钩子
-
需要子系统配合修改 。但是不影响子系统独立开发部署,路由部分对子系统有一些改动,但是不影响功能。
JS 隔离
模块化的应用使得我们不用担心一般变量的冲突问题,所以 JS 隔离的重点 主要在于window
,document
等全局变量隔离
qiankun
在应用的 bootstrap 及 mount 两个生命周期开始之前分别给全局状态打下快照,然后当应用切出/卸载时,将状态回滚至 bootstrap 开始之前的阶段,确保应用对全局状态的污染全部清零
当应用二次进入时则再恢复至 mount 前的状态的,从而确保应用在 remount 时拥有跟第一次 mount 时一致的全局上下文
mfy
使用 iframe 生成 window 对象,通过 Proxy 将所有操作映射到 iframe 的 window 对象上。
使用 New Function 执行 JS 代码
export async function runScriptInSandbox(sandbox) {
const { window, document, location, history } = sandbox
const varNames = Object.keys(injectGlobalVars)
const resolver = new Function(`
return function(window, document, location, history${varNames.length ? ', ' + varNames.join(', ') : ''}) {
${scriptCode}
}
`)
const varList = varNames.map(name => injectGlobalVars[name])
return resolver()(window, document, location, history, ...varList)
}
样式隔离
-
控制 CSS 的命名空间:改造成本较高
- BEM,即 Block-Element-Modifier,是一种 CSS 命名方法论,每一个块(block)名应该有一个命名空间(前缀)
- CSS-in-JS,Vue 的
scoped style
,会生成随机的id/class
属性,避免样式污染
-
Shadow DOM:隔离性较好,浏览器原生支持
- 问题 :样式仅作用于 Shadow host 元素下,会导致全局样式不起作用,如 Antd 的 Modal 组件是挂载在
document.body
的,样式就会失效
- 问题 :样式仅作用于 Shadow host 元素下,会导致全局样式不起作用,如 Antd 的 Modal 组件是挂载在
-
Dynamic Stylesheet:在应用切出/卸载后,同时卸载掉其样式表
- 问题 :子应用和主应用存在样式冲突的风险
<html> <body> <main id="subApp"> // 子应用完整的 html 结构 <link rel="stylesheet" href="//alipay.com/subapp.css"> <div id="root">....</div> </main> </body> </html>
- 问题 :子应用和主应用存在样式冲突的风险
-
工具链改造:生成的 CSS 自动添加前缀,如
app-tcb-layout
- 问题:改造成本大
总得来说,目前并不存在一种完美的解决方案,在样式隔离上需要根据我们自己的需求做取舍,选择合适的方案。
路由
在微前端中,主应用需要管理子应用的路由切换,通常是通过劫持路由事件的方式来管理子应用的路由
// 监听 state 的变化
window.addEventListener('popstate', () => reactive('popstate'))
window.addEventListener('pushState', () => reactive('pushState'))
window.addEventListener('replaceState', () => reactive('replaceState'))
window.addEventListener('hashchange', () => reactive('hashchange'))
// hash 变化监听
window.addEventListener("hashchange", fn);
window.addEventListener("popstate", fn);
// 劫持 history api 操作
window.history.pushState = fn
window.history.replaceState = fn
App Entry
主应用如果加载子应用也是一个需要仔细考虑的问题,目前主要有两种方式:JS 加载和 HTML 加载。
JS Entry
加载单个 JS 文件作为入口,渲染子应用
优点
- 方便加载解析,可以直接通过
script
标签加载,无跨域问题
缺点
-
子应用的各类资源需要一起打包为一个
bundle
,资源加载效率、缓存利用率变低 -
主应用需要预留容器节点,以供子应用挂载使用
<!-- 子应用 index.html -->
<script src="//unpkg/antd.min.js"></script>
<body>
<main id="root"></main>
</body>
// 子应用入口
ReactDOM.render(<App/>, document.getElementById('root'))
HTML Entry
加载 HTML 文件作为入口,解析 HTML 文件,加载 JS,CSS 资源渲染应用。使用 DOMParser
解析 HTML
优点
-
减少应用的改造成本,容易接入
-
可以保留子应用完整的环境上下文,与独立开发时体验一致
-
支持并发加载资源,应用加载速度快
缺点
-
需要加载、解析 HTML 内容,存在跨域问题,实现较困难
-
子应用完全独立,公共依赖提取较困难
应用通信
微前端通常不会限制应用采用的框架,如何在不同的应用,框架之间进行通信是一个需要仔细考量的决定。
下面列出了一些常见的跨应用通信方法
1. 自定义事件
通过事件进行通信应该是最简单、通用的方案了
// 监听事件
window.addEventListener('message', (event) => {
// 处理事件
});
// 触发事件
window.dispatchEvent(new CustomEvent('message', { detail: input.value }))
2. 发布-订阅
通过发布-订阅模式实现通信
import { Observable } from 'windowed-observable';
const observable = new Observable('konoha');
observable.subscribe((ninja) => {
console.log(ninja)
})
observable.publish('Uchiha Shisui');
3. Web Workers
通过 Web Workers 进行事件通信
import Worky from 'worky'
const worker = new Worky("some-worker.js");
worker.on("eventName", function (some, data) {
// 处理
});
worker.emit("someEvent", and, some, data);
4. 共享状态
主应用创建 state store,共享给子应用使用,适用于主、子应用技术栈相同的场景。
最后
从某种程度上来说,微前端也是一种架构思想的理论化总结,通过解耦、拆分将大型应用分解为多个小型应用,降低应用的维护难度。
微前端为构建大型 Web 应用带来一种新的解决方案,虽然它还不够完美,但是已经比较成熟,可以应用于生产环境中。