想象你准备搬家。最省事的做法是什么?把所有东西一股脑塞进一个超大行李箱——衣服、书籍、锅碗瓢盆全挤在一起。箱子倒是只有一个,可你根本提不动,到了新家想要一本书,还得把整个箱子翻个底朝天。 前端工程师面对的问题和搬家如出一辙。构建工具默认会把几百个代码模块合并成一个巨大的文件,用户打开网页的第一秒就要下载并解析这个庞然大物。页面自然“提不动”——加载慢、白屏久、体验糟。 代码分割和懒加载,就是一场“搬家收纳革命”:把书放书箱、衣服放衣箱、厨具放厨具箱,彼此独立;不常用的物品放进储物间,要用时再去取;可能用到的东西提前放在门口。这篇文章就用这套家居智慧,带你理解 Webpack 的 splitChunks 插件如何做公共依赖提取、cacheGroups 分组、路由级分包和 prefetch 预取——一句话,教你优雅地“打包”,让网页真正跑起来。
作为前端或 AI 应用开发者,掌握代码分割与懒加载是优化 Web 应用首屏性能的必备技能。理解 splitChunks 的分包逻辑与缓存策略,你能在真实项目中把页面加载时间缩短 50% 以上,这是工程化能力的重要拼图。
目录
可视化演示
对比SGD、Momentum、Adam、RMSProp四种优化器在损失曲面上的收敛轨迹,展示动量加速和自适应学习率对优化路径的影响。支持轨迹动画与收敛曲线切换。
本节配有交互动效(进入视口后自动加载,约 3KB)。
优化器收敛轨迹对比 SGD沿梯度方向缓慢收敛 Adam自适应学习率快速逼近最优 θ←θ−η∇L(θ) 动量与自适应学习率加速收敛
打包认知:不是越大越好
搬家时,把所有东西塞进一个超大行李箱,表面省事,实则灾难:箱子超重、搬运困难、找东西要翻腾半天。前端工程里,Webpack 默认也会把全部 JavaScript 模块打包成一个巨大的 bundle 文件。用户打开网页,浏览器必须先把整个文件下载完、解析完,页面才能渲染。首页可能只用到全部代码的 20%,剩下 80% 都在“陪跑”。
这里的“模块”是代码世界的基本单元——登录逻辑、商品列表、购物车、图表组件,每个都是独立模块。Webpack 的打包动作,就是把这些零散模块“装箱”。默认一个箱子装所有,就是灾难的根源。
打包体积可以用一个简单的公式刻画:
所以,代码分割的第一步是“拆箱”:把一个大 bundle 按边界拆成多个 chunk(代码块),让用户只下载当前需要的部分。这就如同搬家时把大箱子换成多个分类小箱,每个箱子贴好标签,要什么拿什么。
- 模块体积累加每个 JS 模块都有体积,把 n 个模块的体积逐一加起来就得到模块总体积。
- 运行时固定开销Webpack 自身有一段引导加载代码,负责在浏览器里调度 chunk 的加载,体积虽小但固定存在。
- 合计得到总包体积模块总体积与运行时开销相加,就是用户必须下载的 bundle 大小。
公共依赖提取:客厅的共享书架
三个房间都需要读工具书,如果每间房各买一套,浪费钱又占地方。聪明的做法是把工具书放到客厅公共书架,谁需要谁去取。前端项目里,多个页面(入口)往往共用同一个第三方库——React、axios、工具函数等。默认打包方式下,每个入口都会把这个库复制一份进去,三个页面就打包三份 React,用户被白白重复下载两遍。
splitChunks 插件做的正是“客厅书架”的工作:自动识别被多个入口引用的共享模块,将它们提取成一个独立的公共 chunk。假设有 m 个公共模块,第 k 个模块被 $d_k$ 个入口引用、体积为 $s_k$,提取后节省的体积公式如下:
例如 React 体积 140KB,被三个页面共用,提取后省下 2 × 140 = 280KB 的重复传输。公共 chunk 单独成文件还有个额外好处:它几乎不变,浏览器可以长期缓存,用户第二次访问直接命中缓存。
- 引用次数统计$d_k$ 表示第 k 个公共模块被多少个入口引用,比如 React 被 3 个页面引用,$d_k = 3$。
- 单模块节省量提取前每个入口都打包一份,提取后只需一份公共副本。$(d_k - 1)$ 就是省掉的重复份数。
- 累加所有节省量所有公共模块的节省值加起来,就是整体节省的传输体积。
缓存分组策略:会贴标签的收纳师
有经验的搬家师傅不会给箱子统一命名“杂物一箱”“杂物二箱”。他们会贴标签:书、衣物、厨具、电器。找东西时看标签即知去向。splitChunks 提取公共依赖时,也不是把所有东西都塞进一个箱子,它通过 cacheGroups(缓存组)配置分组规则:用 test 匹配模块身份,用 priority 决定优先级,用 minSize 控制最小提取体积。
每个缓存组就是一个“标签”。假设有两个分组:vendors 匹配 node_modules 下的第三方库,utils 匹配 src 下的公共工具。模块 m 最终归属到所有命中组中 priority 最大的那个,用公式表达:
实际配置可能长这样:
这里 vendors 优先级 10,utils 优先级 5。React 命中 vendors 就进 vendors chunk;src/utils 下的工具函数则进 utils chunk。贴标签的本质目标是让同类模块稳定聚在一起,chunk 文件名长期不变,浏览器缓存才能发挥最大价值。
- 分组命中判断$\text{match}_g(m)$ 是 0 或 1,表示模块 m 是否满足分组 g 的 test 匹配规则。1 表示命中,0 表示未命中。
- 优先级加权模块命中分组后,用该组的 priority 值作为权重。priority 数字越大,分组的话语权越强。
- 选择最大权重归属$\arg\max$ 表示在所有命中的候选中挑出权重最大的那个分组,模块最终进入这个组的 chunk。
splitChunks: { cacheGroups: { vendors: { test: /[\\/]node_modules[\\/]/, priority: 10, name: 'vendors' }, utils: { test: /[\\/]src[\\/]utils[\\/]/, priority: 5, name: 'utils' } }}
路由级分包:按房间就位的行李
到了新家,书放书房、衣服放卧室、厨具放厨房——你不会把书房的工具搬到卧室用。每个房间只留自己需要的东西。对于单页应用,路由就是“房间”。用户一开始只进入“首页”这个房间,完全不需要“个人中心”“订单详情”这些房间的代码。路由级分包让每个路由页面独立成 chunk,访问时才加载。
实现方式就是动态 import。把一个普通的静态导入改为 import('./pages/Home.vue'),Webpack 会立刻明白:这个模块要单独成包。首屏体积用公式表达:
代码示例:
用户从首页跳转到订单页时,浏览器才动态下载订单页的 chunk。这就是“按房间分配行李”的工程实现——每个页面只携带自己的代码,其他代码安心躺在服务器里等待召唤。
- 入口 chunk 体积$S_{entry}$ 是应用启动时必须加载的代码,包含 Webpack 运行时、全局配置、路由表等,这部分无法再分割。
- 当前路由体积$S_{route_1}$ 是初始路由页面的代码。因为其他路由已被拆成独立 chunk,所以首屏完全不包含它们。
- 相加得到首屏总量入口代码 + 当前页面代码,就是首屏需要下载的全部内容。
const routes = [ { path: '/', component: () => import('./pages/Home.vue') }, { path: '/order', component: () => import('./pages/Order.vue') }, { path: '/me', component: () => import('./pages/Me.vue') }];
预取预加载:提前放在门口
上班前知道今天要带文件,昨晚就把文件放进包里、放在门口,出门时顺手拿走,一秒不耽误。这就是预取的智慧。懒加载解决了“暂时不需要”的问题,但有些资源用户“马上可能需要”——等用户点击了才开始下载,还是会有一段白屏等待。
prefetch 会在浏览器空闲时预先下载未来可能用到的 chunk;preload 则立即加载当前页面即将使用的关键资源。Webpack 用一行魔法注释开启:
prefetch 的 chunk 在主资源加载完毕后、浏览器空闲时静默下载,不抢占首屏带宽;preload 的 chunk 与父 chunk 并行加载,优先级更高。用户感知到的等待时间可以用公式刻画:
放在门口的文件,出门时直接拿走,一分一秒都不等。prefetch 就是那个“提前放门口”的帮手——它不抢首屏资源,只在空闲时默默准备,把未来的等待悄悄抹平。
- 按需加载耗时$t_{on\_demand}$ 是用户触发导航后才开始下载 chunk 所需的完整耗时,包括网络请求、解析、执行。
- 预取提前量$t_{prefetch}$ 是浏览器在空闲时段提前下载该 chunk 所花的时间。因为发生在需要之前,所以相当于“提前量”。
- 实际等待差从按需耗时中减去预取提前量,得到用户真正感知到的等待时间——如果预取已完成,这段等待接近 0。
import(/* webpackPrefetch: true */ './pages/Order.vue');import(/* webpackPreload: true */ './components/Modal.vue');
粒度权衡优化:箱子大小有学问
箱子分得太细,100 个小箱子搬运时容易丢还累人;箱子太少太大,搬起来费劲。搬家师傅会根据物品的贵重程度与使用频率权衡箱子配置。代码分割同样讲究“粒度”:粒度太细,chunk 数量爆炸,HTTP 请求过多,浏览器缓存容易失效;粒度太粗,单个 chunk 过大,首屏依旧缓慢。
衡量分割效果的核心指标是缓存命中率。假设 $S_{stable}$ 是长时间不变化的模块体积(第三方库、公共工具),$S_{total}$ 是所有 chunk 的体积和,则缓存命中率公式如下:
配置实践上:给第三方库设置较大的 minSize(比如 30KB),避免小库被反复提取;业务代码设置较小 minSize,保持独立性;chunk 文件用 contenthash 命名,内容不变文件名就不变,缓存长期有效。最优的搬家方案,就是让常动的物品进小箱子、不动的物品进大箱子——稳定代码长期命中缓存,易变代码小而精。
- 稳定代码体积$S_{stable}$ 是版本迭代中几乎不动的模块——React、axios 这类第三方库,以及被广泛复用的公共工具。
- 全量代码体积$S_{total}$ 是所有 chunk 的体积总和,包括易变业务代码和稳定公共代码。
- 计算百分比命中率稳定代码占比越高,浏览器缓存起作用的范围越大,用户每次更新需要重新下载的内容越少。
AI 应用场景
大型电商首页首屏优化
阶段:构建部署、性能优化
解决的问题:电商首页聚合了商品列表、推荐流、购物车、优惠券等多个模块,传统全量打包导致首页 JS 超过 2MB,弱网下白屏 3 秒以上。AI 团队在构建环节用 splitChunks 提取 react/react-dom/axios 到 vendor chunk,按路由拆包首页推荐流与购物车组件,首屏体积从 2MB 降到 450KB。
具体案例:某头部电商前端团队在构建配置中加入缓存分组与路由级分包后,配合 prefetch 预取搜索页 chunk,首屏加载时间从 4.2s 降到 1.3s,转化率提升 12%。
中后台管理系统路由懒加载
阶段:代码架构、工程化
解决的问题:中后台系统常包含几十个业务页面(用户管理、权限配置、数据报表等),一次性全部打包让登录后的首屏加载很慢。传统方案需要手动维护每个页面的动态导入,易出错。
具体案例:某 AI 数据标注平台有 30 多个标注管理页面,团队用动态 import 包裹路由组件,配合 cacheGroups 把良构的标注组件库提取为公共 chunk,首屏从 6s 降到 2s,后续页面切换时按需加载。
移动端 H5 活动页弱网优化
阶段:前端开发、交付
解决的问题:移动端 H5 在网络不稳定的环境下,大 bundle 下载失败率高。拆分后的 chunk 更小,单个请求失败代价低,且配合 preload 让关键交互组件优先加载。
具体案例:某 AI 营销活动页使用路由级分包 + prefetch 预取分享模块,在 4G 弱网环境下首屏加载成功率从 82% 提升至 97%,用户分享转化率提升 20%。
Python 演示
"""splitChunks 分割决策模拟器对比"全量打包"与"公共依赖提取 + 分包"的首屏体积差异""" class Module: """一个 JS 模块""" def __init__(self, name: str, size: int, pages: list): self.name = name self.size = size # 体积(KB) self.pages = set(pages) # 使用该模块的页面 class SplitChunksSimulator: """模拟 splitChunks 的提取逻辑""" def __init__(self, min_shared=2, min_size=30): self.min_shared = min_shared # 至少被几个页面共享才提取 self.min_size = min_size # 低于该体积不提取 def extract_commons(self, modules): """筛选出值得提取为公共 chunk 的模块""" return [ mod for mod in modules if len(mod.pages) >= self.min_shared and mod.size >= self.min_size ] def main(): modules = [ Module("react", 140, ["首页", "订单页", "个人页"]), Module("axios", 28, ["首页", "订单页"]), Module("utils", 45, ["首页", "订单页", "个人页"]), Module("charts", 95, ["订单页", "个人页"]), Module("home-banner", 60, ["首页"]), Module("order-list", 80, ["订单页"]), Module("user-info", 50, ["个人页"]), ] splitter = SplitChunksSimulator(min_shared=2, min_size=30) commons = splitter.extract_commons(modules) common_size = sum(m.size for m in commons) common_names = {m.name for m in commons} home_modules = [m for m in modules if "首页" in m.pages] home_before = sum(m.size for m in home_modules) home_private = [ m for m in home_modules if m.name not in common_names ] home_after = sum(m.size for m in home_private) + common_size print("===== splitChunks 分割决策模拟 =====") print(f"全站模块数:{len(modules)},提取公共模块数:{len(commons)}") print(f"公共 chunk:{', '.join(m.name for m in commons) or '无'}") print(f"公共 chunk 体积:{common_size} KB") print(f"首页分割前体积:{home_before} KB") print(f"首页分割后体积:{home_after:.0f} KB") print(f"首屏下降:{(1 - home_after / home_before) * 100:.1f}%") if __name__ == "__main__": main()
if len(mod.pages) >= self.min_shared and mod.size >= self.min_size:模块必须同时满足“被多个页面共享”和“体积够大”两个条件才值得提取,否则提取收益太低。common_names = {m.name for m in commons}把公共模块名称放进集合,后续判断首页模块是否属于公共部分时,O(1) 即可完成查找。home_private = [m for m in home_modules if m.name not in common_names]首页分割后只需加载自己独有的模块,公共模块统一从公共 chunk 获取。home_after = sum(m.size for m in home_private) + common_size分割后的首页体积 = 首页独有模块体积 + 公共 chunk 体积,公共部分只下载一次。
常见误区
误区把 node_modules 里所有库都放进一个 vendors chunk,认为这是 splitChunks 的标准配置,一劳永逸。
正解把所有第三方库合并成一个巨大 chunk,会让所有页面都加载这份完整代码。有些库只被某个页面使用,比如图表库只在报表页用到,打进 vendors 后其他页面也要白下载。正确做法是用 cacheGroups 细分:核心库(react、vue)放一个组,按需库(图表、日期选择器)单独分组或用 minSize 过滤。相当于搬家时“工具书”和“儿童绘本”也分类,而不是混成一堆。
为何常见:网上教程和脚手架模板常以“一个 vendors 分包”作为示例,初学者照搬后误以为这是最优解。
误区既然代码分割能减小体积,那把每个模块都拆成独立 chunk,不是能做到“用多少下载多少”吗?
正解每个 chunk 都对应一次 HTTP 请求,浏览器对并发请求数有限制,chunk 太多会让加载变成排队串行。而且 chunk 之间有依赖关系,浏览器需要按依赖顺序逐个请求,反而拖慢速度。搬家也是同理——把所有物品拆成 100 个小袋子,搬运时更容易丢、更费时间。最优粒度是“稳定代码进大箱、易变代码进小箱”的平衡。
为何常见:直观上觉得“需要时才加载”就是最优,忽略了网络请求本身的成本和依赖链的串行开销。
误区只要把静态 import 改成动态 import(),Webpack 就会自动做最优分割,配置 splitChunks 是多余的。
正解动态 import() 只是语法层面的“异步加载信号”,分割成多大的 chunk、哪些模块合并、哪些单独拆出,这些决策由 splitChunks 配置决定。如果没有合理配置,Webpack 可能按默认策略把相关模块都打包进一个 chunk,或者生成大量碎片 chunk。就像搬家时你能决定“这个箱子是书”,但箱子具体怎么打包、哪些东西共享一个箱子,需要收纳师的规划。
为何常见:初学者容易把“语法糖”当作完整解决方案,没有意识到代码分割是“语法触发 + 配置规划”两个环节配合的结果。
误区给所有路由 chunk 都加上 `webpackPrefetch: true`,让浏览器提前下载一切,首屏岂不是秒开?
正解prefetch 是在浏览器空闲时加载“未来需要”的资源,它不改变首屏已有的加载路径。给所有 chunk 加 prefetch,会在空闲时段把大量资源下载下来,反而抢占带宽、消耗流量,甚至影响首屏之后的交互性能。首屏关键资源应该用 preload 或正常加载,prefetch 只适合精准预测用户下一步会访问的页面。就像提前放门口的东西,只放确定要带的,而不是把整个家都搬到门口。
为何常见:把“预取”误解为“提前加载所有”,忽略了浏览器资源加载的优先级和带宽分配机制。
面试题
Q1. Webpack 中 splitChunks 插件的主要作用是什么?
- A.压缩 JavaScript 代码体积
- B.将代码拆分为多个 chunk← 正确答案
- C.将 ES6 语法转译为 ES5
- D.自动生成源码映射文件
答案:B。splitChunks 的核心职能是代码分割,把一个大 bundle 拆成多个 chunk,让公共依赖单独成包。代码压缩是 TerserPlugin,ES6 转译是 Babel,source-map 生成也不是 splitChunks 的目标。用搬家的比喻:splitChunks 是决定“哪些东西放哪个箱子”的收纳师。
Q2. 动态 import() 语句的返回值是什么?
- A.一个普通的模块对象
- B.一个 Promise 异步对象← 正确答案
- C.一个包含全部依赖的数组
- D.一个 chunk 配置对象
答案:B。动态 import() 返回的是 Promise 对象,代码加载完成后通过 then 或 await 拿到模块对象。它让 Webpack 知道这个模块需要被单独分割成 chunk。就像你打电话叫搬家公司,电话本身(Promise)只是通知,箱子送到家才拿到东西(模块)。
Q3. 以下哪些属于代码分割的常见方式?
- A.提取公共依赖到独立 chunk← 正确答案
- B.按路由动态 import 分包← 正确答案
- C.删除代码中的所有注释
- D.使用 gzip 压缩传输体积
答案:AB。提取公共依赖和按路由动态 import 都是“改变代码分箱结构”的分割手段。删除注释是代码压缩,gzip 是传输层压缩,两者都不改变代码的打包边界。就像搬家时纸箱分类是分割,把毛巾拧干是压缩——目的不同,手段也不同。
Q4. cacheGroups 中 test 字段的作用是什么?
- A.指定输出的文件名称格式
- B.匹配模块归属哪个分组← 正确答案
- C.设置 chunk 最小体积
- D.配置浏览器缓存过期时间
答案:B。test 字段通过正则表达式匹配模块路径,决定模块归属哪个缓存组。比如 test: /node_modules/ 匹配所有第三方库。输出文件名称由 name 字段控制,minSize 才控制最小体积,浏览器缓存过期时间与 Webpack 配置无关。
Q5. 路由级分包的核心思想是什么?
- A.将路由配置写入独立 JSON 文件
- B.路由代码独立为 chunk 按需加载← 正确答案
- C.将所有路由改为服务端渲染模式
- D.减少路由文件中冗余的注释代码
答案:B。路由级分包让每个路由页面的代码独立成为 chunk,用户访问哪个路由就加载哪个 chunk。这就像按房间分配行李——书房的书只在书房取用。路由配置写入 JSON 或服务端渲染是另一回事,不会改变代码分割的粒度。
Q6. 关于 prefetch 与 preload 的说法,正确的是?
- A.prefetch 并行加载当前页必需资源
- B.preload 空闲时加载未来资源
- C.prefetch 空闲时加载未来资源← 正确答案
- D.preload 与 prefetch 没有区别
答案:C。prefetch 在浏览器空闲时预取未来需要的资源(如下一个路由页),preload 则立即加载当前页面马上要用的高优资源。两者的加载时机和优先级不同。比喻:prefetch 是把文件提前放门口,preload 是出门时顺手拿起身边的人递来的钥匙。
Q7. 关于懒加载的说法,正确的有?
- A.懒加载的 chunk 在需要时才下载← 正确答案
- B.懒加载可以减少首屏请求体积← 正确答案
- C.懒加载的 chunk 不会被浏览器缓存
- D.懒加载需要动态 import() 配合← 正确答案
答案:ABD。懒加载的 chunk 在触发时才下载,因此首屏体积更小,且需要动态 import() 来实现。但一旦加载完成,浏览器同样会缓存该 chunk,下次访问直接命中缓存。就像储物间的箱子,取一次之后放回原处,下次要再拿更快。
Q8. 以下哪种配置最有利于缓存命中率的提升?
- A.将全部代码合并打包为一个 chunk
- B.第三方库单独提取为固定 chunk← 正确答案
- C.为每个模块单独生成一个 chunk 文件
- D.把第三方库混合进每个页面入口 chunk
答案:B。第三方库内容长期不变,单独提取成固定 chunk 后文件名用 contenthash 命名,版本更新时文件名保持不变,浏览器长期命中缓存。全部合并则任何小改动都会让整个 chunk 失效;每个模块一个 chunk 请求数过多;混合打包会让第三方库跟着页面一起更新。
Q9. 关于 cacheGroups 的匹配与优先级,正确的有?
- A.模块可能同时命中多个分组← 正确答案
- B.priority 值大的分组优先获得模块← 正确答案
- C.test 规则可以匹配模块路径← 正确答案
- D.minSize 越大提取的 chunk 越多
答案:ABC。一个模块的路径可能同时满足多个分组的 test 规则,此时 priority 值大的分组胜出。minSize 的作用正好相反:阈值设得越大,会被提取的模块越少(体积不够的模块不参与提取)。就像搬家时,贵重物品箱和书籍箱都可能想“收留”一套精装书,但优先级高的那个说了算。
Q10. 影响分割粒度效果的因素有哪些?
- A.HTTP 请求带来的额外开销← 正确答案
- B.浏览器缓存 chunk 的能力← 正确答案
- C.模块之间的依赖关系← 正确答案
- D.电脑屏幕的尺寸大小
答案:ABC。分割粒度受三方面制约:请求数过多会带来 HTTP 开销,粒度合适才能最大化缓存命中,模块依赖关系决定了哪些模块必须一起加载或可以独立拆包。屏幕尺寸与代码分割完全无关,属于典型干扰项。