动态 import 和模块联邦:webpack 5 时代的代码拆分实践
动态 import() 用了两年多,路由级懒加载几乎是中后台项目的标配,const UserList = () => import('./views/UserList.vue') 这种写法团队里随手就能写。但最近评估 webpack 5 升级的时候,发现光靠 import() 拆包解决的只是"同一个应用内部怎么按需加载",还有一类问题它压根没打算管:多个独立部署、独立仓库的应用,怎么在运行时共享同一份组件或者工具库,而不是各自打包一份、各自发一次版。
这正是 webpack 5 主推的 Module Federation(模块联邦)想解决的事。团队目前只是拿它在一个内部工具项目上做小范围试点,还没到生产大规模落地的程度,但这个特性背后的设计思路值得认真过一遍。
先把动态 import 的拆分粒度理清楚
在聊 Module Federation 之前,有必要先把 import() 能做到的拆分粒度说清楚,因为很多团队升级到 webpack 5 之后第一反应是"这些东西是不是也该变"。
路由级拆分是最基础、也是收益最明显的一层:
1// router/index.js 2const routes = [ 3 { 4 path: '/order', 5 component: () => import(/* webpackChunkName: "order" */ '@/views/Order.vue') 6 }, 7 { 8 path: '/report', 9 component: () => import(/* webpackChunkName: "report" */ '@/views/Report.vue') 10 } 11]
这一层的收益是首屏体积,代价是路由切换时有一次网络往返。组件级拆分再往下一层,把单个页面里"不是每次都需要"的重量级组件也摘出去,比如富文本编辑器、图表库、导出 Excel 的功能模块:
1// 页面里默认不加载,点了"导出"按钮才去拉这块代码 2async function handleExport() { 3 const { exportToExcel } = await import( 4 /* webpackChunkName: "export-excel" */ '@/utils/export-excel.js' 5 ) 6 exportToExcel(tableData.value) 7}
这种按交互触发的拆分,比按路由拆分更细,但也更容易踩一个坑:如果拆得过碎,首次触发那个交互时用户会感觉到明显的加载延迟,尤其是网络条件一般的时候。团队里现在的做法是给这类"点击才加载"的功能配一个轻量的 loading 态,或者在浏览器空闲时提前 import() 预热——用 requestIdleCallback 在页面加载完成后, 趁用户还没点到那个按钮之前,先把 chunk 拉下来缓存好:
1// 页面挂载后,空闲时预热一下大概率会被用到的 chunk 2if ('requestIdleCallback' in window) { 3 requestIdleCallback(() => { 4 import(/* webpackChunkName: "export-excel" */ '@/utils/export-excel.js') 5 }) 6}
import() 返回的 Promise 结果会被浏览器和 webpack 的模块缓存记住,真正点击导出的时候如果预热已经完成,这次调用几乎是立即 resolve,用户感觉不到二次请求。
魔法注释里的 webpackPrefetch 和 webpackPreload 也是这个思路的现成方案,区别在于触发时机和优先级:
1// prefetch:浏览器空闲时低优先级拉取,用于"大概率之后会用到"的模块 2import(/* webpackChunkName: "settings", webpackPrefetch: true */ '@/views/Settings.vue') 3 4// preload:和父 chunk 一起并行加载,优先级更高,用于"当前页面马上要用"的模块 5import(/* webpackChunkName: "chart", webpackPreload: true */ '@/components/BigChart.vue')
prefetch 生成的是 <link rel="prefetch">,交给浏览器自己找空闲时机;preload 生成 <link rel="preload">,会和当前页面的资源抢带宽,用多了反而拖慢首屏,只适合确定马上用得上的场景。这两个注释我们在实际项目里加得很克制,滥用等于把浏览器的调度权抢回来自己瞎指挥。
splitChunks:拆分背后还有一层自动分组
路由级和组件级拆分处理的是"哪些代码要延迟加载",但拆出来的这些 chunk 之间还有一层问题容易被忽略:公共依赖要不要重复打包。假设订单页面和报表页面都用到了同一个日期处理库,如果什么都不配置,webpack 默认会把这个库分别打进 order.js 和 report.js 两个 chunk 里,用户不管先访问哪个页面都要重新下载一遍。
splitChunks 就是处理这层的配置,webpack 5 沿用了 4 的思路但默认值调得更合理:
1// webpack.config.js 2module.exports = { 3 optimization: { 4 splitChunks: { 5 chunks: 'all', 6 cacheGroups: { 7 vendor: { 8 test: /[\\/]node_modules[\\/]/, 9 name: 'vendors', 10 priority: -10 11 }, 12 common: { 13 minChunks: 2, 14 name: 'common', 15 priority: -20, 16 reuseExistingChunk: true 17 } 18 } 19 } 20 } 21}
vendor 这组把所有来自 node_modules 的代码单独打成一个 chunk,业务代码变动频繁、第三方依赖变动很少,分开之后浏览器可以长期缓存 vendors.js,只有业务代码那部分需要在每次发布后重新下载。common 这组处理的是业务代码内部的公共部分——minChunks: 2 表示只要有两个及以上的入口都用到了同一段代码,就把它提取成公共 chunk,避免前面说的日期库被重复打两份。
这里有个实际的取舍:vendor 打得太粗,会把很少变化的核心库(Vue、Element Plus)和相对常改的小依赖混在一起,一旦有个小依赖升级版本,整个 vendors.js 的缓存就失效了,用户又要重新下载一整包。团队里后来把最稳定的几个大库单独分组,避免被拖累:
1cacheGroups: { 2 vueCore: { 3 test: /[\\/]node_modules[\\/](vue|vue-router|vuex)[\\/]/, 4 name: 'vue-core', 5 priority: 10 6 }, 7 vendor: { 8 test: /[\\/]node_modules[\\/]/, 9 name: 'vendors', 10 priority: -10 11 } 12}
priority 数值大的分组会优先匹配,所以 vue-core 这组要写在前面并给更高优先级,否则会被更宽泛的 vendor 规则先抢走。这套分组策略本质上是拿"缓存命中率"换"请求数量",chunk 分得越细、缓存粒度越精确,但同时页面首次加载要发起的请求数也越多,HTTP/2 之下这个代价没有 HTTP/1.1 时代那么重,但也不是没有成本,不能无限往细里拆。
异步组件加载状态:不只是拆分完就完事
动态 import() 拆完之后还有个容易被简化处理的环节:加载过程中的用户体验。defineAsyncComponent 直接返回一个 Promise 的话,网络慢的时候用户看到的就是一片空白,直到组件资源下载完成才突然出现,体验上是一次没有任何过渡的跳变。Vue 3 的 defineAsyncComponent 支持配置加载态和失败态:
1import { defineAsyncComponent } from 'vue' 2import LoadingSpinner from '@/components/LoadingSpinner.vue' 3import LoadErrorFallback from '@/components/LoadErrorFallback.vue' 4 5const BigChart = defineAsyncComponent({ 6 loader: () => import('@/components/BigChart.vue'), 7 loadingComponent: LoadingSpinner, 8 errorComponent: LoadErrorFallback, 9 delay: 200, 10 timeout: 8000 11})
delay 这个参数容易被忽略但很关键——如果网络够快,资源在 200 毫秒内就下载完了,根本不会闪现 loading 组件,避免了"加载状态一闪而过反而显得卡顿"的观感;只有真正超过这个阈值还没加载完,才会显示 loading。timeout 则是给最坏情况兜底,网络异常或者对方服务器没响应,超过这个时间就直接展示 errorComponent,附带一个重试按钮,而不是让用户面对一个永远转圈的加载态。
路由级别的拆分同理,Vue Router 4 允许在跳转前处理加载失败的情况,比如 chunk 因为发布后哈希值变化导致 404(用户停留在旧版本页面很久没刷新,路由跳转时去拉的还是旧的文件名):
1router.onError((error, to) => { 2 if (/Loading chunk \d+ failed/.test(error.message)) { 3 // chunk 加载失败,大概率是发布后资源哈希变了,引导用户刷新 4 window.location.href = to.fullPath 5 } 6})
这个报错在真实项目里出现的频率比想象中高,尤其是发布频繁的团队——用户开着页面挂了很久没有操作,趁着这段时间上线了一次新版本,旧的 HTML 里记着的 chunk 文件名在服务器上已经不存在了,回来一点击导航就报错。加一个兜底刷新,比让用户对着白屏茫然要好得多。
拆分拆到头之后剩下的问题
路由级、组件级、预热策略都做到位之后,团队里还是碰到一类新问题:中后台里有几个业务模块(比如权限管理、消息中心)被三四个独立的子系统同时用到,这几个子系统是不同的 git 仓库、不同的发布节奏、甚至不完全是同一拨人在维护。以前的做法无非两种,各有各的别扭。
第一种是发成 npm 包。业务组件抽成一个内部包,各项目 npm install 进来。这个方案的问题是发版链路太重——权限管理组件改一个小样式,要发包、各消费方升级依赖、重新构建、重新发布,一套流程走完往往是一两天以后的事,紧急修复根本等不起。而且各个子系统即便都锁了版本号,实际打包出来的产物里,这份组件代码是各自重复一份的,没有办法在运行时共享。
第二种是 iframe 嵌入。把权限管理页面整个做成一个独立应用,其它系统用 <iframe> 嵌进来。这个方案发布是独立了,改了权限模块只用发它自己,别的系统不用动。但代价也很直接:iframe 天然的样式隔离和通信成本,字体、弹层、全局样式很难和宿主页面统一,跨 iframe 传值要么用 postMessage,要么共享 cookie/localStorage 打通登录态,做深层交互(比如宿主页面的某个操作要联动 iframe 里的状态)体验总是差一截,路由前进后退这类浏览器行为也经常和宿主应用的路由打架。
Module Federation 想解决的正是这个中间地带:既要运行时的独立部署(改了权限模块不用重新构建所有消费方),又要接近原生的组件级集成(不是嵌一整个页面,而是像普通组件一样直接用,可以正常传 props、走同一个路由体系、共享同一份全局样式)。
Module Federation 大致怎么运作
核心概念是"暴露"和"消费"。一个应用可以把自己的某些模块通过 webpack.config.js 里的 ModuleFederationPlugin 暴露出去,另一个应用在运行时按需去拉这些模块,而不是在构建阶段就打包进自己的产物里。
暴露方(我们内部管它叫 provider,权限管理这个子系统)的配置大致是这样:
1// permission-app/webpack.config.js 2const { ModuleFederationPlugin } = require('webpack').container 3 4module.exports = { 5 // ... 6 plugins: [ 7 new ModuleFederationPlugin({ 8 name: 'permissionApp', 9 filename: 'remoteEntry.js', 10 exposes: { 11 './PermissionPanel': './src/components/PermissionPanel.vue', 12 './usePermissionCheck': './src/hooks/usePermissionCheck.js' 13 }, 14 shared: { 15 vue: { singleton: true, requiredVersion: '^3.0.0' }, 16 'element-plus': { singleton: true } 17 } 18 }) 19 ] 20}
消费方(比如订单系统)在自己的配置里声明这个远程模块的地址,用起来就跟本地组件差不多:
1// order-app/webpack.config.js 2new ModuleFederationPlugin({ 3 name: 'orderApp', 4 remotes: { 5 permissionApp: 'permissionApp@https://permission.internal.com/remoteEntry.js' 6 }, 7 shared: { 8 vue: { singleton: true, requiredVersion: '^3.0.0' } 9 } 10})
1// order-app 里某个页面直接动态引入远程组件 2const PermissionPanel = defineAsyncComponent(() => 3 import('permissionApp/PermissionPanel') 4)
浏览器实际发生的事情是:订单系统构建的时候完全不知道 permissionApp/PermissionPanel 里面是什么代码,构建产物也不包含它;只有运行到这一行、真正渲染这个组件的时候,浏览器才会去请求 permission.internal.com/remoteEntry.js,这个文件相当于权限管理应用暴露出来的一张"模块清单加载器",再按需去拉具体的组件代码。权限管理团队发布新版本,只要把这个远程入口文件更新到位,订单系统这边不用重新构建、不用重新发布,下次用户刷新页面就自动用上新版本。
shared 这个配置项是另一个需要理解的关键点。如果订单系统和权限管理各自都打包了一份 Vue,同一个页面里跑两份 Vue 实例,轻则体积翻倍,重则出现响应式系统认不出对方创建的组件这类诡异问题。shared 声明的意思是"这个依赖优先复用宿主环境已经加载的版本,不要重复加载",singleton: true 更进一步保证全局只有一份实例存在。这一块的版本协调是我们试点过程中踩得最多的地方——权限管理那边升级了 element-plus 的小版本,订单系统这边没跟着升,shared 配置里 requiredVersion 对不上,运行时会打出版本不兼容的警告,甚至直接各自加载一份互不共享,这时候"运行时共享模块"的初衷就落空了,退化成了两份代码各自为战。
双向的 host 和 remote:一个应用可以同时是两种角色
前面的例子里权限管理是暴露方、订单系统是消费方,看起来像是主从关系,但 Module Federation 的设计里这两个角色并不是互斥的——同一个应用完全可以既 exposes 自己的模块给别人用,又 remotes 别人的模块进来用。这一点评估初期容易被忽略,团队里第一版方案画的架构图默认订单系统只会是消费方,后来发现权限管理那边也想复用订单系统里的一个金额格式化组件,才意识到这套体系本质上是个网状结构,不是树状的父子关系。
1// permission-app/webpack.config.js —— 既暴露也消费 2new ModuleFederationPlugin({ 3 name: 'permissionApp', 4 filename: 'remoteEntry.js', 5 exposes: { 6 './PermissionPanel': './src/components/PermissionPanel.vue' 7 }, 8 remotes: { 9 orderApp: 'orderApp@https://order.internal.com/remoteEntry.js' 10 }, 11 shared: { 12 vue: { singleton: true, requiredVersion: '^3.0.0' } 13 } 14})
这种网状结构带来一个新的风险点:循环引用。如果权限管理消费订单系统的组件,订单系统又反过来消费权限管理的组件,两边的 remoteEntry.js 互相依赖对方才能完整加载,理论上会形成加载时序上的循环等待。实际项目里我们目前刻意避免了这种写法——约定每个应用要么只做暴露方,要么只做消费方,如果确实需要双向共享,就把被共享的那部分再抽出来做成第三个独立的、只暴露不消费的应用,两边都去消费这第三方,而不是互相直接依赖。这和前面处理 ESM/CommonJS 循环依赖时"抽出公共模块打破环"的思路是同一个道理,只是这里的"模块"换成了整个独立部署的应用。
这个第三方应用团队内部干脆管它叫"基座"——不承载任何业务页面,只负责 exposes 一批真正跨业务通用的东西:金额格式化、权限校验的 hook、统一的埋点上报函数。它自己不 remotes 任何人,永远只被别人依赖,这样整张依赖图就退化成了一个简单的星形结构而不是网状的,排查问题时至少不用担心"到底谁先加载谁"这种时序上的死结。这个基座应用发布节奏比业务应用更保守,改动前必须过一遍所有已知消费方的兼容性检查,团队里对它的态度更接近对待一个正式的公共 npm 包,只是分发方式换成了运行时的远程模块而已。
这套"星形优于网状"的取舍其实和很多分布式系统里的经验是相通的——节点之间互相直接依赖,图的复杂度是节点数量的平方级增长,排查一个问题要顺着好几条边去追;引入一个中心节点把边的数量收敛成线性,代价是这个中心节点自身的稳定性要求更高。前端这几年很少在应用架构层面碰到这类问题,Module Federation 算是把一部分分布式系统的设计考量第一次实实在在地带进了前端团队的日常讨论里。
动态获取 remote 地址:别把域名写死在配置里
前面的例子里 remotes 配置写的是一个固定的 URL,这在真实项目里几乎不能用——测试环境、预发环境、生产环境的权限管理服务地址肯定不一样,总不能每个环境维护一份不同的 webpack.config.js。Module Federation 支持把 remote 地址延迟到运行时再确定,做法是在 remotes 里只写一个变量名当占位符,实际地址通过一段运行时脚本注入:
1// webpack.config.js 2new ModuleFederationPlugin({ 3 name: 'orderApp', 4 remotes: { 5 permissionApp: 'permissionApp' 6 } 7})
1<!-- index.html,脚本里的地址来自环境变量或者接口下发 --> 2<script> 3 window.__REMOTE_ENTRIES__ = { 4 permissionApp: 'https://permission-test.internal.com/remoteEntry.js' 5 } 6</script>
1// 应用启动时手动加载 remoteEntry,再走 Module Federation 内部的容器机制去初始化 2async function loadRemote(scope, url) { 3 await new Promise((resolve, reject) => { 4 const script = document.createElement('script') 5 script.src = url 6 script.onload = resolve 7 script.onerror = reject 8 document.head.appendChild(script) 9 }) 10 await window[scope].init(__webpack_share_scopes__.default) 11 return window[scope] 12}
这段手动加载的代码看着比声明式的 remotes 配置繁琐不少,但换来的是环境地址可以从配置中心或者接口动态下发,不用为了换一个环境重新构建整个应用。团队内部试点这块的时候,直接把 remote 地址列表放进了现有的前端配置服务里,和其它环境变量一起管理,没有另起一套机制。
权衡:这东西现在适合上到什么程度
试点下来,Module Federation 明显不是一个能替代所有场景的方案。它解决的是"多个独立部署应用之间需要在运行时共享、且发布节奏不同步"这个具体问题,如果团队的场景是单仓库、统一发布节奏的中后台系统,动态 import() 加上路由级和组件级拆分已经够用,没必要为了追新引入这一整套跨应用的运行时依赖管理。
真正让人犹豫的是排查成本。远程模块加载失败、版本不兼容、shared 依赖没对齐,这些问题的报错信息目前还比较原始,经常需要打开 Network 面板去看 remoteEntry.js 到底请求成功没有、返回的模块清单里到底暴露了哪些 chunk。调试体验和"一个仓库里 import 一个本地文件"完全不是一个量级,团队里目前还在摸索一套统一的排查手册,没有踩完之前不太敢往生产核心链路上放。
另一个顾虑是运行时的这层耦合看不见摸不着。订单系统的页面里嵌了一个权限管理暴露出来的组件,权限管理团队某次发布如果不小心改了这个组件对外的 props 约定,订单系统这边不会在构建阶段收到任何提示——因为构建的时候压根不知道对方长什么样,只有等用户实际打开这个页面、组件渲染出错,才会在浏览器控制台里看到报错。这种"构建期零感知、运行期才暴露"的耦合方式,比 npm 包版本锁定要松散得多,对暴露方的接口稳定性要求其实更高,团队内部现在的约定是:暴露出去的模块要当成正式对外的 API 看待,改动前先知会消费方,而不是像改内部组件一样随便调整。
版本不兼容排查:从一次报错说起
试点过程中真正遇到过的一次版本不对齐问题,值得完整记一下排查过程,因为这类问题的报错信息目前确实不够直白。权限管理团队升级了 element-plus 到一个包含破坏性改动的小版本,订单系统这边还停留在旧版本,两边构建时 shared 配置里 requiredVersion 分别锁定了各自实际安装的版本号。上线之后订单系统里嵌入的权限面板样式整个错乱,控制台没有报红色的错误,只在很不起眼的位置打出一行警告:
1[webpack-dev-server] Unsatisfied version 2.1.6 of shared singleton module element-plus 2(required ^2.0.0, resolved 2.1.6)
这行警告很容易在一堆其它日志里被忽略,而且它只是警告不是报错,页面表面上仍然能跑,只是样式和交互出现了细微的错位——这就是为什么说这类问题比直接的 500 报错更难被第一时间发现。定位过程大致是:先确认页面运行时到底加载了几份 element-plus,在浏览器里执行 Object.keys(window.__webpack_share_scopes__.default['element-plus']) 能看到当前共享作用域里实际登记了哪些版本号;发现确实存在两个版本共存,说明 singleton: true 加上版本范围不匹配之后的降级策略生效了——遇到版本冲突,webpack 默认不会直接报错崩溃,而是退回到"各自加载各自的版本",只是顺带打一条警告。
解决办法是把两个应用的 shared 配置里 requiredVersion 范围收紧到实际兼容的区间,同时在团队内部约定:暴露方升级被共享的核心依赖时,必须同步通知所有已知的消费方,不能像升级自己内部依赖一样悄悄发布。这其实是把原本能在构建阶段被依赖管理工具拦下来的问题(比如 npm 包的 peerDependencies 版本冲突,装的时候就会报警告),推迟到了运行时才能发现,算是这套方案在便利性之外要付出的额外治理成本。
exports 字段这几年也在悄悄改写模块解析规则
评估 webpack 5 的过程中还顺带碰上另一件事:package.json 的 exports 字段。这个字段不是 webpack 的东西,是 Node 这两年逐步落地的能力,但因为它直接改写了"require/import 一个包到底能拿到什么"这条规则,打包工具这边也得跟着适配,评估升级的时候不能不看。
没有 exports 字段的时候,一个包对外的边界基本等于它整个目录结构——你理论上可以 require('some-package/lib/internal/helper.js'),越过包作者的意图直接扒开内部实现用。加了 exports 字段之后,包作者可以显式声明哪些路径是对外开放的,其它路径即使文件真实存在,也会直接报模块找不到:
1{ 2 "name": "@team/ui-kit", 3 "exports": { 4 ".": "./dist/index.js", 5 "./button": "./dist/button.js", 6 "./style.css": "./dist/style.css" 7 } 8}
这样一来,import Button from '@team/ui-kit/button' 能正常拿到,但 import x from '@team/ui-kit/dist/internal/utils.js' 会直接报错,即便这个文件客观上存在于 node_modules 里。对包的维护者来说,这是把"公开 API"和"内部实现细节"从口头约定变成了强制约束,重构内部目录结构不再担心破坏使用方——反正没在 exports 里列出来的路径本来就拿不到。
exports 字段还能配合条件导出,针对不同的加载环境给出不同的产物路径:
1{ 2 "exports": { 3 ".": { 4 "import": "./dist/index.esm.js", 5 "require": "./dist/index.cjs.js", 6 "types": "./dist/index.d.ts" 7 } 8 } 9}
这实际上是把之前用 main/module 两个字段分别指向 CommonJS 和 ESM 产物的老办法,收进了同一个更精确的声明里——import 语句和 require 调用会分别解析到不同的文件,不用再指望打包工具去猜哪个字段对应哪种环境。团队内部这几个工具库还没来得及全部迁移到这个写法,一方面是 webpack 5 和现有版本的 Babel、Jest 对 exports 字段的支持程度不完全一致,某些工具链条件导出解析出来的路径对不上,另一方面是团队里大部分包的目录结构本来就比较扁平,眼下 main/module 两个字段还够用,没有非改不可的紧迫性,但这是个明确知道以后早晚要迁移过去的方向。
这里还牵出一个容易被忽视的隐患,社区里管它叫"双包陷阱"(dual package hazard):如果一个包同时发布 CommonJS 和 ESM 两份产物,而消费方在同一个依赖树里因为各种间接依赖,一部分走 require 拿到了 CJS 版本,另一部分走 import 拿到了 ESM 版本,实际上会在内存里同时存在两份该模块的实例。对于无状态的工具函数这无所谓,多一份代码而已;但如果这个包内部维护了单例状态——典型的比如某些状态管理库、或者内部做了模块级缓存的请求库——两份实例的状态互不相通,会出现"明明调用的是同一个 API,状态却对不上"的诡异 bug。排查这类问题的入口通常是打印出实际加载的模块路径,确认是不是真的存在重复实例:
1// 在可疑的模块里加一行,两处调用打出来的路径不一样就说明命中了双包陷阱 2console.log('module loaded from:', require.resolve('some-lib'))
exports 字段里如果给 require 和 import 两个条件指向的是完全独立编译、彼此不共享内部状态的两份代码,这个陷阱就会存在;比较稳妥的做法是让 CJS 产物内部去 require 同一份核心逻辑再包一层适配,而不是两份产物从源码各自独立编译,但这属于包作者要操心的事,作为使用方目前能做的主要是留意这类带内部状态的依赖,装的时候检查一下它的 exports 声明是不是有这个风险。
拆分策略要盯着的指标从"体积"变成了 LCP
过去评估拆包效果,团队里习惯性只看构建产物的体积报表,app.js 从 3MB 降到 800KB 就算达标。今年这个评判标准悄悄发生了变化——Google 从今年五月起把 Core Web Vitals 三项指标(LCP、FID、CLS)纳入搜索排名的信号,团队负责 SEO 相关页面的同事拿着这个由头,把"拆包到底有没有用"这件事重新量化了一遍,而不是只看打包体积这个间接指标。
体积小不等于 LCP(最大内容绘制)就一定快。举个真实碰到的反例:把首页某个大图表组件拆成了独立 chunk 之后,app.js 体积确实降了,但这个图表恰好是首屏里视觉面积最大的元素,被拆分之后反而多了一次串行的网络请求——先加载主包、解析执行、再触发这个组件的 import()、再等这次请求回来才能渲染出来,LCP 时间点没有变快,反而因为多了一次请求的等待往后挪了。这种情况下更合适的做法是把这个组件保留在主包里,只把首屏不可见、需要滚动才能看到的模块拆出去:
1// 首屏可见的大组件不拆,同步引入 2import HeroChart from '@/components/HeroChart.vue' 3 4// 滚动到才可见的次要模块,拆分是合理的 5const CommentSection = defineAsyncComponent(() => 6 import('@/components/CommentSection.vue') 7)
这件事让团队里重新校准了"拆分粒度"这个判断标准:不是所有组件都应该延迟加载,拆分收益要看这个模块是不是真的在首屏关键渲染路径之外。用 Chrome DevTools 的 Performance 面板录制一次首屏加载,看清楚 LCP 具体落在哪个元素上,再决定要不要把它挪出主包,比单纯盯着打包体积报表要可靠得多。这也是这一年评估 webpack 5 升级时顺带养成的一个习惯——升级构建工具带来的收益,最终要落到这几个可衡量的性能指标上,而不是停留在"chunk 数量变多了""产物体积变小了"这类构建层面的自证。
持久化缓存:webpack 5 升级里另一个真实收益
评估 webpack 5 的过程里,比 Module Federation 更早、更实在地感受到收益的其实是持久化缓存。webpack 4 时代的构建缓存基本靠内存,每次重启进程,之前分析过的模块信息全部作废,重新构建又是从头跑一遍。webpack 5 支持把这份缓存直接落盘:
1// webpack.config.js 2module.exports = { 3 // ... 4 cache: { 5 type: 'filesystem', 6 buildDependencies: { 7 config: [__filename] 8 } 9 } 10}
配置很简单,type: 'filesystem' 意味着 webpack 会把模块的编译结果和依赖关系缓存到 node_modules/.cache/webpack 这类目录下,下次启动构建时如果对应的源文件没有变化,直接复用磁盘上的缓存结果,不用重新走一遍解析、转译的流程。buildDependencies 这个配置项容易被忽略但很关键——它告诉 webpack "如果这些文件变了,整份缓存都得作废",通常至少要把 webpack.config.js 自身放进去,否则改了构建配置、缓存却没有失效,会拿着旧的编译结果去跑新的配置,排查起来很费劲。
这个特性在我们的项目里最直接的体感是二次冷启动的构建时间:第一次构建照常要走完整流程,但只要没有清理 node_modules/.cache,第二次开发服务器启动或者构建,哪怕是重启了电脑之后再来,也能省下一大截时间,因为大部分没改动的模块直接从磁盘缓存里读出来,不用重新过一遍 Babel 转译和依赖分析。团队里之前热衷讨论的 thread-loader 多进程编译方案,某种程度上解决的是同一类"重复劳动"问题,只是思路不同——一个是把没必要重复的工作直接跳过,一个是把必须做的工作分摊到多个进程里并行做,两者并不冲突,实际项目里可以一起用。
目前的取舍结论
这一轮 webpack 5 的评估里,持久化缓存是可以立刻拿来用的确定收益,风险很低,配置也简单。exports 字段的适配是个明确方向,但目前团队内部工具库的规模还没到非改不可的地步,可以放在下一次统一整理内部包的时候顺手做掉。拆分粒度要盯着 LCP 这类真实指标去调整,而不是只看构建体积报表,这个判断标准的转变比任何具体配置都更值得记下来。Module Federation 是几项里最有想象空间、但也最需要谨慎推进的一个——它解决的是真实存在、且会随着子系统数量增加而越来越明显的痛点,多团队各自发布节奏不同步、又要共享同一批业务组件,这件事目前没有更好的运行时方案。但它引入的调试成本和跨应用的隐性耦合也是实打实的,眼下的态度是继续在非核心链路的内部工具项目里跑一段时间,把版本协调、错误排查这些坑趟得差不多了,再考虑往订单、权限这些主链路上挪。
这几项加在一起,其实反映的是同一件事:webpack 5 这次升级里,真正确定的收益(持久化缓存)往往是最不起眼的,需要评估权衡的新能力(Module Federation)反而是发布会上最吸引眼球的那个。团队内部现在的节奏是先把确定收益的部分尽快落地,再拿不确定的部分做小范围试点,试点期间同步把排查手册、版本协调的约定慢慢补全,而不是等一整套流程都成熟了才开始动手——真实项目里很少有机会等到一个新技术方方面面都摸透了才第一次拿来用,边用边补规则反而是更常见的节奏。
对于还没排上升级计划的团队,这几项的优先级建议其实很清楚:持久化缓存几乎是零成本的配置项,splitChunks 的分组策略值得跟着现有项目的依赖结构重新审视一遍,exports 字段可以留意但不必急着全面铺开,Module Federation 则要看团队自身是不是真的存在"多个独立部署应用需要共享组件"这个具体场景——没有这个场景,这项特性再新也没有引入的必要,技术选型终归要服务于实际问题,而不是反过来为了用新特性去制造场景。