模块联邦

模块联邦

背景

随着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 一同使用的.

  1. 在webpack配置文件中, 指定了每个模块对应一个 window 上的一个全局变量名, 这些变量名会以特殊前缀开头, 避免被其他变量或意外影响.
  2. 在配置中心, 维护了每个模块对应的最新的remoteEntry CDN地址, 这个文件名包含了打包的hash值, 方便一眼看出加载的版本是否符合预期.
  3. 在主应用BFF层, 返回主应用HTML时, 会从配置中心读取模块联邦配置, 并注入到html中, 这样remoteEntry就伴随着html一同下发, 主应用启动时就能加载所有远程模块
  4. 发布子模块更新或回滚时, 只需要发布CDN后在配置中心修改模块对应的CDN地址, 页面刷新之后就能指向最新的子模块版本.
  5. remoteEntry和所有子应用的文件, 由于文件名中包含当此打包的hash值, 所以统一设置有效期很长的强缓存头, 避免刷新后重新加载或是发起协商缓存.
  6. 联调时, 假如配置中心不支持联调环境独立配置, 还可以使用代理劫持remoteEntry. 通过匹配CDN地址中的模块名, 将remoteEntry劫持到联调版本的remoteEntry地址, 达到访问特定子模块版本的目的.