一个十五年前的微前端架构系统
目前有一个延续了十五年的系统,前端是 Silverlight 实现的,作为一个拥有上千页面的中台系统,它的基础架构提供了如下能力:
-
页面层级独立编译、部署、懒加载
-
全局状态共享
-
允许提取公共组件、公共依赖复用
这个基于 Silverlight 架构实现了多个团队独立开发、部署各自维护的页面,最后在运行时集成到系统中的功能。这套架构放到今天,也称得上是微前端架构了,而且几乎不存在全局变量、样式污染的问题:
-
C# 天生模块化,全局变量没法泄露到 namespace 外面去
-
Silverlight 需要中心化地注册全局样式,业务模块很难对全局主题样式魔改
时过境迁,Silverlight 也终于成为了历史的眼泪,这套系统因为无法在 IE 以外的浏览器上运行而不得不被我们用现代前端技术重写(指 Vue.js 2)。不过很可惜,新的系统架构是由一个用 7 天时间从零开始入门 Vue.js 的开发人员边看教程边搭建的,由于代码(指代码质量与架构)糊的很差,所以随着迁移的页面越来越多,诸多问题暴露了出来。但是我相信第一次用 JS 来开发微前端架构的人都或早或晚会遇到这些问题,于是便将这些暴露出的问题记录下来,希望能对这些纠结微前端技术选型的人有所帮助。
微前端的基本结构
在我所遇到的需求中,我们需要将一个或多个页面作为独立的微前端模块,它们可以被独立的开发、打包、部署,也是被加载到浏览器中执行的最小单元,可以看作是前端的“微服务”。
除了这些服务于各自业务目标的前端微服务之外,还需要有一个入口模块,它将根据用户的权限以及偏好设置加载不同的微前端模块到用户的浏览器中,并通过路由组件将页面展示出来。这个入口模块可以看作是微前端架构的“网关”。
至此,一个微前端架构的基本结构已经展示出来了。微前端模块服务于业务功能,入口模块专注于组装以及展示业务模块。这两个基本组成部分的职责还算是比较简单明了的,但是在实现功能的过程中却有很多地方需要慎重决定。
一个标准的运行时
由于浏览器的品牌、版本众多,对 ECMAScript 的特性支持千奇百怪,所以我们往往需要借助 Babel 以及 core-js
在过时的浏览器上给缺失的功能打补丁。由于每个微前端模块都是独立打包,为了避免重复进行 Polyfill,所以可以选择在入口文件一次性导入所有的 Polyfill:
import "core-js/stable";
import "regenerator-runtime/runtime";
这样做的话,虽然入口模块的体积可能会很大,但是由于不知道其他业务模块项目中到底会使用哪些特性,只能让入口模块为整个微前端提供一个标准的执行环境,其他独立打包的业务模块仅需要使用 @babel/preset-env
把新的语法转换成目标浏览器兼容的语法即可。
{
loader: 'babel-loader',
options: {
presets: [
['@babel/preset-env', { useBuiltIns: 'entry', corejs: '3' }],
],
},
},
共享各模块之间的公共依赖
为了能尽可能的避免通用的第三方依赖(如 Vue 全家桶、Lodash、dayjs、axios 等)被业务模块反复的打包,如何共享依赖是必须要处理的。在我们微前端架构早期实现中,我们通过 Webpack Dll Plugin 来实现对依赖的共享,不过最开始我们并没有意识到 Dll Plugin 存在着一个非常棘手的问题 —— 默认情况下,Dll 的每次更新都可能伴随着 manifest 文件的变动,这导致引用 Dll 的业务模块也必须跟着一起重新编译。解决这个问题的方法也很简单,通过 HashedModuleIdsPlugin 来根据模块名称生成固定的模块 ID。但是如果依赖本身升级了,例如 foo/bar.js
路径变为了 foo/esm/bar.mjs
,这样 DllPlugin 生成出来的 Manifest 也还是会发生变化,最终我们的所有业务模块还是得一起重新打包 :(。
所以我们最终还是换了用 Externals 的方式来打包公共依赖,具体的做法可以参考探索webpack4与webpack5多项目公共代码复用架构。同样的,对于实践过程中提取出来的公共组件也可以利用相同的方式来进行跨项目的共享。
在一开始我们是使用 DllPlugin 进行公共依赖抽取的,在发现了上面提到的 DllPlugin 可能存在的问题后,我们切换到了 Externals 的方案。但由于更新线上全部的业务模块涉及到不同城市的三个团队,所以我们整了在运行时重定向 DllPlugin 引用到 Externals 上的东西,这样为所有的业务模块升级就预留了更多的时间。 |
除了上面提到的显式共享的依赖之外,还有一类是由打包工具产生的隐式依赖,例如 @babel/runtime
、regeneratorRuntime
。Babel 在生成的代码中用到了许多自带的工具函数(全部的工具函数压缩混淆后大概 63K 左右),通常这些小函数会被内联到 chunk 的开头而不会在各个模块间共享。Babel 提供了一个插件来帮助复用这些工具函数——@babel/plugin-transform-runtime。这个插件会把 Babel 生成的代码中的内联工具函数转换为对 @babel/runtime
的引用。最开始我也想着用 Externals 来处理这个问题,但是 @babel/runtime
将每个工具函数都导出成了单独的模块,这样我们的 Externals 的暴露逻辑就会变得很复杂,甚至可能需要专门的维护一个工具函数列表。再者,这些工具函数中,体积最大的就是 regeneratorRuntime
,而这个库是可以把自己注册成全局变量的:
import 'regeneratorRuntime/runtime';
最重要的是,如果针对现代浏览器使用差异化加载策略的话,这些工具函数的最终占用体积会小很多。所以我们最终没有处理这部分依赖的共享,仅在全局注册一个 regeneratorRuntime
。
业务模块的加载方式
由于我们的整体架构不考虑对 Vue 以外的前端库的兼容,所以就直接使用 VueRouter 来实现业务模块的按需加载了。在入口模块启动的时候,通过后端接口查询当前用户可访问的页面列表,再根据这些页面列表查询到对应的业务模块 bundle 文件部署地址,最后为这些业务模块生成路由信息并添加到 Router 中。最开始看来好像没啥问题,但是随着项目演进,经常就出现需要在业务模块中嵌套加载其他业务模块的需求,类比到后端,就像是微服务之间需要相互调用。不过由于我们项目开发人员大多是业余前端,最开始为了实现这样的需求,要么是在立项前就把业务模块写到一起,要么就是通过 iframe 嵌套 :(。
虽然我们的微前端架构开发者调研过 qiankun 这样非常成熟的微前端解决方案,但是并没有 get 到其中关于业务模块加载的内在目标。
在我看来,入口模块不仅仅是前端微服务的网关,同样也需要承担服务发现的职责:
const userHomeModule = entryModule.resolveModule('user-home');
获取到其他前端微服务的引用之后,我们还需要能够调用它们:
entryModule.mountModule(userHomeModule);
这样前端微服务之间相互调用的链路就打通了。当然,上面的代码只不过是伪代码,对于我们选择的 Vue 来说,可能的实现方式是这样的:
// use vue router to resolve other business module
const { resolved } = this.$router.resolveRoute('/user/home');
const comp = resolved.matched[0].component;
const vnode = this.$createElement(comp)
// then mount this vnode
在我们目前遇到的场景中,如果一开始我们微前端框架提供了类似的功能那么前端模块的拆分可以更加的细粒度,而且还可以避免 N 重 iframe 嵌套。但是你不能指望一个才学了 7 天 Vue.js 的业余前端对这些中高级用法有所了解。
入口模块的“创意工坊”
WIP