代码分割是什么?如何优化首屏加载?
简化版
代码分割是把一个大 bundle 拆成多个 chunk,让首屏只下载当前路径必需的代码,其他代码按路由、组件或交互动态加载。它能减少初始 JS 的下载、解析和执行成本,但拆得过细会制造请求瀑布、重复模块和切页等待,因此要结合用户路径、缓存与预加载设计。
详细版
常见手段是动态 import()、路由懒加载、低频重组件懒加载,以及构建工具的公共依赖去重。拆分边界应优先贴合路由和业务能力,而不是盲目按文件拆包。
优化时同时观察初始 JS 体积、关键请求链、主线程执行时间和交互后的 chunk 命中情况。高概率下一跳可使用谨慎的 prefetch,当前导航必需资源才考虑 preload;错误使用 preload 会抢占首屏带宽。
发布时必须保留旧 hash 资源一段时间。否则用户打开旧页面后再触发动态导入,可能请求已经被删除的旧 chunk,产生 ChunkLoadError。
完整版教学
一、代码分割优化的不是只有下载体积
一个 1.2 MB 的未压缩 JS 包即使通过压缩只传输 350 KB,浏览器仍要解压、解析、编译并执行接近原始规模的代码。移动设备主线程较弱,CPU 成本可能比网络成本更明显。
如果首屏实际只需要 300 KB 源码,把其余 900 KB 延迟加载,初始处理量理论上减少 75%。真实收益还取决于压缩率、缓存、设备性能和模块初始化副作用,不能只看文件数量。
心法:代码分割的目标是缩短“当前用户路径”的关键链,不是把一个大文件切成最多的小文件。
二、动态 import 如何形成异步 chunk
静态 import 参与同步模块图,构建器通常把它纳入当前入口;动态 import() 返回 Promise,形成可以异步加载的边界。只有执行到这条路径时,运行时才请求对应 chunk。
button.addEventListener('click', async () => {
const { openEditor } = await import('./editor.js')
openEditor()
})
入口 main.js ──静态依赖──> router.js
│
├──动态边界──> editor.[hash].js
└──动态边界──> chart.[hash].js
动态导入不等于一定“用户点击后才加载”;如果它在应用初始化时立即执行,异步 chunk 仍会很早请求。面试中要区分构建时分包和运行时懒加载两个层次。
三、怎样选择拆分边界
路由是天然边界,因为用户一次通常只进入少数页面。富文本编辑器、地图、图表和视频处理器体积大且使用频率低,也适合组件级拆分;多个入口共享的稳定依赖可由构建器去重。
| 边界 | 适用场景 | 主要风险 |
|---|---|---|
| 路由级 | SPA 页面 | 首次切页等待 |
| 组件级 | 低频重组件 | 触发时出现空白或抖动 |
| 依赖级 | 多入口共享库 | vendor 包过大、缓存一起失效 |
| 功能级 | 编辑、导出等独立流程 | 边界设计和错误处理复杂 |
不要把所有 node_modules 固定塞进一个巨型 vendor 包。某个依赖升级可能使整个包 hash 变化,而且用户仍需下载当前页面根本不用的库。
四、拆分过细为什么会形成瀑布
模块 A 加载执行后才知道要导入 B,B 执行后再请求 C,就会形成串行链。假设网络 RTT 为 100 ms,忽略传输时间,3 层纯串行发现至少引入约 300 ms 等待;HTTP/2/3 能并发传输,却无法消除“前一个执行后才发现下一个”的依赖关系。
HTML ─100ms→ main ─100ms→ page ─100ms→ widget
发现1 发现2 发现3
构建器的依赖预加载、框架路由清单和 SSR 输出的 preload 提示可以提前暴露资源。优化时应看浏览器 Network 瀑布,不能只看 bundle analyzer 的矩形面积。
五、prefetch、preload 与加载优先级
preload 表示当前导航很快需要的资源,通常优先级较高;prefetch 更像为未来导航准备,浏览器可在空闲时获取。二者都是提示,不保证一定按开发者想象的时机执行。
假设首屏只有 1 Mbps 可用带宽,一个误设 preload 的 500 KB 编辑器理论传输就需约 500×8/1000=4 秒,会与首屏图片和关键 JS 竞争。预加载不是越多越快,必须基于命中概率和带宽成本。
可在鼠标悬停、链接进入视口或路由稳定后预取高概率下一页,但要去重并尊重省流量模式。低概率大资源宁可让用户触发后加载,也不要挤占关键请求。
六、上线后的缓存、失败与观测
带内容 hash 的 chunk 适合长期不可变缓存,HTML 则需要及时重新验证。发布顺序应先上传新资源,再切换 HTML,并保留旧资源;这样旧会话和新会话都能完成动态导入。
懒加载界面要有 loading、超时、重试和错误边界。遇到版本切换导致的 chunk 404,可以提示刷新或在受控条件下重新加载,但不能无限自动刷新。
| 指标 | 优化前 | 优化后示例 | 解释 |
|---|---|---|---|
| 初始压缩 JS | 420 KB | 180 KB | 下载负担下降 |
| 主线程 JS 时间 | 900 ms | 430 ms | 解析执行下降 |
| 首次打开编辑器 | 0 ms | 280 ms | 成本转移到交互 |
最终要用 RUM 观察首屏与后续交互的共同变化。若 LCP 变好但关键功能每次都等待 2 秒,这不是成功的整体体验。
七、常见误区与追问
- 误区:chunk 越多,首屏一定越快。 过多边界会增加请求、运行时开销和串行发现,必须依据关键路径判断。
- 误区:动态 import 写了就一定按用户操作加载。 调用时机决定请求时机,初始化阶段调用仍可能立即加载。
- 误区:HTTP/2 消除了所有请求瀑布。 它改善连接并发,但无法消除运行时才发现依赖的先后关系。
- 追问:路由懒加载后切页慢怎么办? 对高概率下一跳做有预算的预取,同时提供加载态并监测命中率。
- 追问:公共依赖为什么不应全部放进一个 vendor? 巨包会带入无关代码,并扩大单个依赖变化造成的缓存失效范围。
- 追问:新版本发布后为什么会出现 ChunkLoadError? 旧页面运行时仍引用旧 hash chunk,而服务器可能已经删除了该文件。
- 追问:如何判断拆包方案有效? 同时比较初始体积、关键请求链、主线程时间、切页延迟和真实用户指标。
八、加强记忆
把方案串成“减首屏、断瀑布、稳后续”:先按路由和低频重功能移走非关键代码,再用网络瀑布检查是否产生串行发现,对高概率下一跳有限预取,最后用 hash 缓存、旧资源保留和错误边界保证上线稳定。这样回答既说明为什么拆,也覆盖了拆分的代价与发布边界。