模块联邦
背景
随着SPA前端应用日渐流行, 前端应用开始变得庞大臃肿, 前端工程化方案也在日趋成熟, 模块化就是其中无法绕过的一部分. 除了传统意义上的组件级别的划分与复用, 另一个最近常常被提起的概念便是微前端.
这里的微前端指的是, 将一部分相对独立的业务组件解耦出来, 作为独立的业务组件/子应用, 独立迭代, 在多个主应用之间复用. 这里的粒度可以说是相当自由, 既可以大到一个独立的复杂的路由页面, 也可以小到一个完整的功能模块, 最小甚至可以是多个基础组件组合而成的业务组件. 一个生动的例子就是美团的APP和小程序, 其中的”外卖“, “美食“, “骑车“和”酒店“等模块就是一个个独立的子应用.
拆分子应用的核心理念和主要目的, 是将子应用的迭代过程与主应用解耦. 随着主应用变得庞大臃肿, 其发版流程也会变得冗长, 打包编译, 自动化测试甚至热更新等耗时会明显增加, 庞大的代码仓库也会给开发人员带来心智负担, 甚至可能因为发版失误造成主应用崩溃/核心模块不可用.
而拆分子应用之后, 子应用不仅可以独立代码仓库, 保持代码的单一功能原则, 还能独立设置自动化测试, 甚至部分方案还允许子应用基于完全不同技术栈. 这样, 主子应用可以交由不同人甚至不同团队进行开发, 主子应用开发者无需关心对方的代码实现, 只需保证自己应用功能正常.
最理想的情况下, 微前端应用是这样的.
- 子应用是独立的. 子应用可以与主应用位于不同的代码仓库, 拥有独立的发布流程, 可以在不改动主应用代码的情况下完成发版, 联调阶段也能轻易指定不同的子应用版本.
- 主子应用的划分应该是无感的非侵入的, 主应用可以像普通组件一样使用子应用, 子应用开发也不会受到其他限制.
- 划分主子应用带来的性能开销是可控的. 部分场景下, 一个主应用可能会承载数十个子应用, 或是某些子应用会存在多个实例, 甚至可能出现子应用嵌套子应用的情况, 因此, 微前端架构带来的额外性能开销应该尽可能小, 不然就会出现内存占用高, 浏览器卡顿等现象.
- 全局变量与样式隔离. 由于子应用的开发和发布都可以是独立的, 因此微前端框架最好拥有一定的样式隔离和变量隔离的能力, 避免由于开发环境与线上环境的差异, 或是多个主应用之间的差异, 带来样式污染, 变量污染等问题造成线上事故.
但哪怕在最理想的情况下, 划分子应用也不能解决所有的问题. 除了划分子应用不当带来的发布和维护成本增加的问题, 划分子应用本身还会带来
- 功能代码复用困难
- 开发, 联调, 定位问题链路变长
方案对比
可以看到, 虽然Iframe方案天然支持独立发布和隔离机制, 但相对的, 开发限制也相当大, 哪怕是最简单的屏幕中心弹窗, 也得花好一番功夫实现, 更别提高昂的性能开销和传参与回调的各种不便了.
另一个经常被选择的方案是NPM引用. 这也是最原生的引用方式, 但它的弊端在于没有隔离机制, 发版也需要主应用更新依赖. 当子应用变多, 参与开发的人员变多时, 频繁的依赖文件冲突也会带来潜在的危险, 更别提子应用根本没法独立发布.
CDN引用NPM包可以看作是本地引用NPM包的进阶版本, 解决了子应用无法独立发布问题, 但同样没有引入隔离机制, 还带来了新的问题: 基于 semver
语义化版本号和路由重定向的版本控制难以精细化控制, 无法支撑所有使用场景, 容易错发版本造成线上事故. 举一个简单例子, 当发布beta包时错发成patch版本就会导致线上流量被导向错误版本, 撤包的过程也很复杂, 更别提这种方式难以在不改动主项目的情况下引用beta包.
基于js沙箱的微前端框架提供了另外一条思路. 通过Proxy或with的方法可以改变全局变量的指向, 通过拦截浏览器函数的方法可以做到样式隔离与dom隔离. 使用js脚本控制各个子应用的生命周期, 可以更加精细化对主子引用进行控制, 但无法否认的是, 这种技术也才刚刚成熟, 泛用性和稳定性还有待考验, 更别提内存占用大等缺点了.
模块联邦
模块联邦是webpack5提供的微前端的解决方案, 每个应用既可以作为容器使用其他应用提供的模块, 同时也能为其他容器提供远程模块. 原生的模块联邦可以在编译时指定固定的url作为远程模块, 运行时(应用启动时)再向该url请求和加载相应模块.
光从上面的能力来看, 模块联邦似乎和CDN相比没有压倒性优势, 但当安装了 external-remotes-plugin 插件后就完全不一样了. 这个插件允许你在编译时不再是指定一个固定的URL, 而是指定一个window上的变量名, 只要在应用启动前, 这个变量名被赋值了最终的URL地址, 它就能加载这个远程模块. 换句话说, 只需要配置在HTML中注入的变量, 或是引用一段外联 <script>
脚本, 就可以精细化控制加载的远程模块的版本了.
当你并非使用公共unpkg, 对CDN资源解析结果有较强的控制能力时, 你可能会觉得模块联邦听起来和使用CDN引用NPM包别无两样, 但他们最大的区别在于远程地址的确定时机. CDN引用的本质是编译时, 在打包编译应用的时候, html里的CDN地址就已经固定下来了, 后续只能在CDN解析时, 利用路由重定向的方法控制加载版本. 而模块联邦可以做到运行时, 在应用初始化的时候通过配置下发让应用直接加载确定的版本. 并且, 由于地址是通过配置下发, 可以直接指向带哈希的文件, 明确加载的应用文件.
而且模块联邦还提供了另一个杀手级能力: 模块共享. 主子引用编译时都能指定依赖包作为共享模块(如React), 这样, 当主应用提供了相应共享模块时, 子应用加载时便不会加载共享模块了, 而当主应用没有提供共享模块时, 子应用加载时便会一并加载共享模块, 以保证其能在任何情况下正常工作, 而且不会有模块名的全局变量污染问题了.
模块联邦的缺点
模块联邦并不是完美的. 正如上面所说, 目前来看模块联邦只是NPM形式的拓展, 它依然有着它的缺陷.
例如, 模块联邦并没有样式隔离机制, 这意味着, 当主子应用很有可能会互相造成样式污染.
模块联邦最佳实践
说了那么多模块联邦的好与坏, 那么有没有一个最佳实践参考呢?
正如上面提到的, 在我们的项目中, 模块联邦是与 external-remotes-plugin
一同使用的.
- 在webpack配置文件中, 指定了每个模块对应一个
window
上的一个全局变量名, 这些变量名会以特殊前缀开头, 避免被其他变量或意外影响. - 在配置中心, 维护了每个模块对应的最新的remoteEntry CDN地址, 这个文件名包含了打包的hash值, 方便一眼看出加载的版本是否符合预期.
- 在主应用BFF层, 返回主应用HTML时, 会从配置中心读取模块联邦配置, 并注入到html中, 这样remoteEntry就伴随着html一同下发, 主应用启动时就能加载所有远程模块
- 发布子模块更新或回滚时, 只需要发布CDN后在配置中心修改模块对应的CDN地址, 页面刷新之后就能指向最新的子模块版本.
- remoteEntry和所有子应用的文件, 由于文件名中包含当此打包的hash值, 所以统一设置有效期很长的强缓存头, 避免刷新后重新加载或是发起协商缓存.
- 联调时, 假如配置中心不支持联调环境独立配置, 还可以使用代理劫持remoteEntry. 通过匹配CDN地址中的模块名, 将remoteEntry劫持到联调版本的remoteEntry地址, 达到访问特定子模块版本的目的.