浅谈微前端
本文结合工作经历谈谈个人对微前端的理解及感受
最近时间比较宽裕,终于“闲”了下来写写博客,所以这一两周可能会比较高产。本文的写作灵感来源于一次很偶然的「发现」——偶阅《微前端的核心价值 (opens new window)》有感。
# 为什么需要做微前端
相信上面的文章同样会给你带来一些思考。在此我摘取其中一段:
我们去统计一下业界关于“微前端“发过声的公司,会发现 adopt 微前端的公司,基本上都是做 ToB 软件服务的,没有哪家 ToC 公司会有微前端的诉求(有也是内部的中后台系统),为什么会这样?很简单,因为很少有 ToC 软件活得过 3 年以上的。而对于 ToB 应用而言,3 - 5 年太常见了好吗!去看看阿里云最早的那些产品的控制台,去看看那些电信软件、银行软件,哪个不是 10+ 年的寿命?企业软件的升级有多痛这个我就不多说了。所以大部分企业应用都会有一个核心的诉求,就是如何确保我的遗产代码能平滑的迁移,以及如何确保我在若干年后还能用上时下热门的技术栈?
基于上面的表述,我认为微前端想要解决的两个问题是:
- 迁移:遗产代码 / 项目如何能平稳过渡而不被时代淘汰,且能用上时下热门技术栈赋予新生命
- 整合:多个系统或应用可以被整合为一个新的系统,且原有系统保持独立性
对于企业软件来说,整合是很常见的。以我工作所在的公司为例,我们本身有好几个独立的 Web 端产品,分别提供数据的采集、清洗、可视化等各个专一场景下丰富的能力,但实际仍会有将各个产品中的部分模块做整合,生成新产品的需求。在没有更好的微前端架构时,iframe 是最简单的方案。
但是 iframe 会带来很多问题,这些问题在实际项目中真实地体验过:
- URL 不同步,即 BFCache 失效:浏览器刷新页面后,原有的状态丢失。
- 上下文完全隔离,DOM 结构不能共享:iframe 会构建出独立的、完整的 document,iframe 子页面的遮罩层不能透穿至父页面文档。
- 性能低:每一个 iframe 的加载都是 document 重建和资源重载的过程,渲染性能低;且 iframe 的渲染使用独立的进程,内存不共享。所以,如果 iframe 嵌的是同源站点,几乎完全相同的资源被多次重复加载。
# 不用 iframe 如何做
这里参考 Phodal 《微前端的那些事儿 (opens new window)》一书中总结的实施微前端的六种方式:
- 使用 HTTP 服务器的路由来重定向多个应用
- 在不同的框架之上设计通讯、加载机制,诸如 Mooa 和 Single-SPA
- 通过组合多个独立应用、组件来构建一个单体应用
- iframe。使用 iframe 及自定义消息传递机制
- 使用纯 Web Component 构建应用
- 结合 Web Component 构建
以及他总结的快速选型指南图:
如果不需要考虑兼容 IE 的新项目,完全可以使用纯 Web Component 实现,因为 Web Component 提供了很好的沙箱环境,组件之间除了共享一样的全局上下文环境,CSS 与 DOM 都是独立的,不会互相影响。这种独立性让前端页面的发布、更新就可以控制到单个 Web Component 粒度,并且可以保证引用该组件的所有应用 / 页面,都能同步更新。
粒度变细和同步更新为什么这么重要?我以我的工作经历来举例。 假设有存放通用模块 shared1、shared2 的私仓 repo1,有引用 shared1 的项目 repo2 及引用 shared2 的项目 repo3,其中 repo2、repo3 都是通过 git package 的形式引用 repo1:
"dependencies": {
"repo1": "git+https://your-git-host.com/repo1#branch",
}
2
3
这样做确实可以让通用模块的代码复用,但是 build 完之后,shared1、shared2 都被打包进 repo2、repo3 各自的 dist chunk 里,模块之间的依赖关系靠 webpackJsonp
及其内部的 __webpack_require_
来维护,dist 与 repo1 中的代码已经没有任何关联关系。当某一天 shared1 或者 shared2 或者它们两者都有更新,repo2、repo3 都必须拉取 repo1 的代码重新打包发布。换言之,shared1、shared2 并不能独立发布。设想一下如果有十多个项目都引用了这个模块,而各个项目又分别是多个团队维护的,通用模块一更新所有人跟着发版,这样的工程结构你还能接受吗?
Web Component 固然是好,但毕竟是新鲜事物,基于 Web Component 的框架非常有限,所以全量 Web Component 是比较激进的,尤其是在国内还要兼容各种 IE 的大环境下,还要舍弃项目原有的技术栈,成本太高。如果能在构建系统上兼容这个问题,那么 Web Component 也不是那么必须,起码在目前来说,修改构建系统是一个更平稳更稳健的方案。
Webpack 5 就是在这种环境下诞生了。
# Module Federation
Webpack 5 一项重大更新——Module Federation,正是在构建工具层面解决了这个问题。你可以参考阅读这篇文章 (opens new window) 来了解这个 feature。现在假设你对该功能已经有所了解,我将该文下的例子摘下重要部分进行讲解。
const { ModuleFederationPlugin } = require('webpack').container
module.exports = {
// ..
output: {
publicPath: 'http://localhost:3001/',
},
plugins: [
new ModuleFederationPlugin({
name: 'app1',
library: { type: 'var', name: 'app1' },
filename: 'remoteEntry.js',
exposes: {
'./Header': './src/components/Header',
},
shared: ['react', 'react-dom'],
})
],
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
以上配置表明,当前项目向外暴露一个名为 removeEntry
的入口文件,通过该文件向外暴露变量 app1
作为访问当前项目各模块的命名空间,其中暴露的组件是 Header 组件。
注意,在早期的 Webpack 5 Beta 版本中,exposes: { Header: './src/components/Header' }
是正确的配置,但后续有一 PR (opens new window) 明确要求 exposes 对象的 key 需要以 ./
开头。否则你可能会遇到形如
TypeError: fn is not a function while loading "./Header" from webpack/container/reference/...
的报错
需要引入 Header 组件的项目也要做相应的配置:
const { ModuleFederationPlugin } = require('webpack').container
module.exports = {
// ...
output: {
publicPath: 'http://localhost:3002/',
},
plugins: [
new ModuleFederationPlugin({
name: 'app2',
library: { type: 'var', name: 'app2' },
remotes: {
app1: 'app1',
},
shared: ['react', 'react-dom'],
})
],
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
与 app1 不同,app2 的 ModuleFederationPlugin
需要声明 remotes
,即声明使用哪些远程模块及对应的命名空间。上述配置表明当前项目需要引用命名空间为 app1 的远程入口。若 app2 没有需要向外暴露的模块或组件,则不必声明 exposes。
这时在 app2 的 index.html
中引入 app1 所提供的 remoteEntry
:
<html>
<head>
<script src="http://localhost:3001/remoteEntry.js"></script>
</head>
<body>
<div id="root"></div>
</body>
</html>
2
3
4
5
6
7
8
app2/src/App.js
中的 Header
就可以被引入进来:
import React from 'react'
const Header = React.lazy(() => import('app1/Header'))
export default () => (
<div style={{margin: '20px'}}>
<React.Suspense fallback='Loading header'>
<Header>Hello this is App 2</Header>
</React.Suspense>
</div>
)
2
3
4
5
6
7
8
9
10
引入工作是 app1 向外暴露的 remoteEntry
所提供的,这是为什么 app2 的 index.html
需要引入这个文件。
这篇文章提供的是一个本地的例子,实际开发中我们几乎不会同时关注两个 app 及对应的仓库代码,但它演示的这种能力给我们提供了新的思路,可以用来解决我前面所遇到的发布粒度过大、更新不同步的问题。
我们将 repo1 使用 Webpack 5 打包后,发布至私有的 CDN 中,其余引用 shared1、share2... sharedN 模块的项目均以 ModuleFederation 提供的方式引入 repo1,如:
- 引入 repo1 暴露的 remoteEntry(一般只有几 KB)
<script src="https://your-cdn-url.com/repo1/remoteEntry.js">
- 以命名空间形式引入模块
const { lazy } from 'react'
const shared1 = lazy(() => import('repo1/shared1'))
2
如果 repo1 中某一(些)shared 模块更新,只需要 repo1 重新 build 并重新发布,其余项目引入的 repo1/remoteEntry 及具体被引入的 shared
也会随之同步,repo2、repo3 不必再拉取代码重新发布。