一、什么是 Webpack 的代码分割?有什么好处?有哪些策略?
1. 核心思路(一句话)
代码分割就是把原本需要一起加载的模块拆成多个 Chunk,让“首屏必需代码”和“非首屏代码”按需加载,同时把稳定的公共代码独立出来,从而减少首屏资源、提高缓存复用率。
二、先搞清楚几个核心概念
这是这道题最容易混淆的地方。
源代码模块 │ │ Webpack 构建 ▼ Module │ │ 根据入口、动态 import、SplitChunks 等进行组织 ▼ Chunk │ │ 经过编译、压缩、代码生成 ▼ Asset │ ▼ 最终输出的 JS / CSS / 图片等文件Module、Chunk、Bundle、Asset 不要混为一谈
| 概念 | 含义 |
|---|---|
| Module | 源代码中的模块,例如react、App.js |
| Chunk | Webpack 根据依赖关系组织出来的一组模块 |
| Bundle | 通常泛指最终输出的代码包,实际开发中经常泛称输出文件 |
| Asset | 最终生成的资源,例如main.js、vendors.js、lazy.js |
面试重点:
代码分割本质上是 Chunk 层面的拆分,最终表现为多个资源文件。
所以不要简单说:
“Webpack 把一个 bundle 文件拆成几个文件。”
更准确的是:
Webpack 根据入口、动态
import()和代码分割策略,把模块图划分成多个 Chunk,再生成多个资源文件。
三、为什么需要代码分割?
主要矛盾
主要矛盾:首屏不应该加载暂时用不到的代码
假设一个后台系统:
整个应用 ├── 登录 ├── 首页 ├── 用户管理 ├── 商品管理 ├── 订单管理 ├── 数据分析 ├── 富文本编辑器 └── 图表库如果全部打进首屏:
main.js ├── React ├── React Router ├── 图表库 ├── 富文本编辑器 ├── 用户管理 ├── 商品管理 ├── 订单管理 ├── 数据分析 └── 首页用户只是打开首页,却下载了:
首页需要的代码 + 暂时不需要的代码 + 大量第三方依赖于是:
JS 体积大 ↓ 下载时间增加 ↓ 解析 / 编译 / 执行时间增加 ↓ 主线程压力增加 ↓ 页面交互变慢次要矛盾:缓存复用率低
假设:
main.js ├── React ├── React Router ├── 公共工具 ├── 首页 ├── 用户页面 └── 商品页面这次只修改:
商品页面重新构建以后:
main.js → 内容发生变化如果使用内容哈希:
main.abc123.js ↓ 修改 ↓ main.def456.js那么原来的整个main.js不能直接复用。
如果合理拆分:
vendors.hash.js runtime.hash.js home.hash.js user.hash.js product.hash.js修改商品页面:
vendors.hash.js 不变 runtime.hash.js 不变 home.hash.js 不变 user.hash.js 不变 product.hash.js 变化浏览器就可以继续复用其他资源。
四、代码分割解决方案流程图
Webpack 构建 │ ▼ 分析模块依赖图 │ ┌────────────┼────────────┐ │ │ │ ▼ ▼ ▼ 多入口 动态 import() SplitChunks │ │ │ ▼ ▼ ▼ 多个入口 异步 Chunk 公共 Chunk │ │ │ └────────────┼────────────┘ ▼ Chunk 合理拆分 │ ▼ 多个 JS / CSS Asset │ ┌────────────┴────────────┐ ▼ ▼ 首屏只加载必要代码 非首屏按需加载 │ │ └────────────┬────────────┘ ▼ 性能 + 缓存优化五、Webpack 常见代码分割策略
实际面试建议记住4 类。
Webpack 代码分割 │ ├── 1. 多入口 Entry │ ├── 2. 动态 import() │ ├── 3. SplitChunksPlugin │ └── 4. Runtime Chunk其中:
动态
import()解决“什么时候加载”;SplitChunksPlugin解决“公共代码怎么拆”。
这是非常重要的区分。
六、第一种:多入口分包
核心思路
不同入口本身代表不同的应用入口,Webpack 可以根据入口形成不同的 Chunk。
例如:
后台管理系统 ├── admin └── mobile配置:
constpath=require("path");module.exports={mode:"production",entry:{admin:"./src/admin.js",mobile:"./src/mobile.js",},output:{path:path.resolve(__dirname,"dist"),filename:"[name].[contenthash:8].js",clean:true,},};最终可能得到:
dist/ ├── admin.a1b2c3d4.js └── mobile.e5f6g7h8.js访问后台:
/admin只需要:
admin.js访问移动端:
/mobile只需要:
mobile.js适用场景
适合:
多页面应用 多端应用 管理后台 + 官网 PC + Mobile 不同业务入口边界问题
如果两个入口大量依赖相同模块:
admin ├── React └── lodash mobile ├── React └── lodash可能出现:
admin.js └── React + lodash mobile.js └── React + lodash导致重复打包。
所以通常还需要:
SplitChunksPlugin提取公共依赖。
七、第二种:动态import()——SPA 最重要的代码分割方式
核心思路
动态
import()是天然的异步边界,Webpack 会把它后面的模块构建成异步 Chunk,在真正执行到这里时再加载。
例如:
// src/router.jsexportasyncfunctionloadUserPage(){// 动态 import() 会告诉 Webpack:// UserPage 不需要跟当前主入口一起加载,// 可以作为一个异步 Chunk 单独输出。constmodule=awaitimport("./UserPage.js");// 动态 import() 返回的是一个 Promise,// Promise fulfilled 后才能拿到真正的模块。returnmodule.default;}Webpack 可能生成:
main.js UserPage.xxxxx.js加载关系:
浏览器打开页面 │ ▼ main.js │ │ 用户进入用户页面 ▼ 执行 import("./UserPage.js") │ ▼ Webpack Runtime │ ▼ 请求 UserPage.xxxxx.js │ ▼ 加载完成 │ ▼ 执行 UserPage八、React 路由懒加载就是这个原理
例如:
import { lazy, Suspense } from "react"; import { BrowserRouter, Routes, Route } from "react-router-dom"; // React.lazy 底层依赖的就是动态 import()。 // UserPage 不会和首页代码强制打在同一个初始 Chunk 中。 const UserPage = lazy(() => import("./pages/UserPage")); const OrderPage = lazy(() => import("./pages/OrderPage")); function App() { return ( <BrowserRouter> {/* 动态 Chunk 加载期间显示 loading */} <Suspense fallback={<div>页面加载中...</div>}> <Routes> <Route path="/user" element={<UserPage />} /> <Route path="/order" element={<OrderPage />} /> </Routes> </Suspense> </BrowserRouter> ); } export default App;最终:
初始加载 │ ├── main.js └── 首页需要的代码 进入 /user │ └── UserPage.xxx.js 进入 /order │ └── OrderPage.xxx.js这就是 SPA 中非常典型的路由级代码分割。
九、第三种:SplitChunksPlugin——实际项目非常重要
Webpack 提供:
optimization.splitChunks用于从多个 Chunk 中识别和提取公共模块。
例如:
pageA ├── React ├── lodash └── A业务代码 pageB ├── React ├── lodash └── B业务代码如果不合理拆分:
pageA.js ├── React ├── lodash └── A pageB.js ├── React ├── lodash └── B公共代码重复。通过 SplitChunks:
vendors.js ├── React └── lodash pageA.js └── A pageB.js └── B于是:
公共代码 → 单独缓存 业务代码 → 独立变化十、完整 SplitChunks 配置示例
constpath=require("path");module.exports={mode:"production",entry:{main:"./src/main.js",},output:{path:path.resolve(__dirname,"dist"),// [name]:Chunk 名称// [contenthash]:根据最终内容生成哈希// 内容不变时,文件名也尽可能保持稳定,从而提高缓存命中率。filename:"[name].[contenthash:8].js",// 动态 import() 产生的异步 Chunk 使用这个命名规则。chunkFilename:"[name].[contenthash:8].chunk.js",clean:true,},optimization:{splitChunks:{// async:只处理异步加载的 Chunk。// initial:处理初始 Chunk。// all:初始 Chunk 和异步 Chunk 都处理。chunks:"all",// 只有模块被至少两个 Chunk 共同使用时,// 才考虑把它提取出来。minChunks:2,// 防止拆出来的 Chunk 太小,造成大量网络请求。minSize:20000,cacheGroups:{// 第三方依赖通常来自 node_modules。vendors:{test:/[\\/]node_modules[\\/]/,// 设置较高优先级,// 优先把第三方依赖提取出来。priority:10,// 多个入口/异步 Chunk 可以复用这个公共 Chunk。reuseExistingChunk:true,name:"vendors",},// 自己的公共业务代码。common:{minChunks:2,priority:5,reuseExistingChunk:true,name:"common",},},},},};这里需要理解:
动态 import() │ ▼ 产生异步 Chunk │ ▼ SplitChunksPlugin │ ├── 判断是否存在公共模块 │ ├── 判断模块使用次数 │ ├── 判断 Chunk 大小 │ └── 根据 cacheGroups 决定如何提取 │ ▼ 公共 Chunk / Vendor Chunk十一、第四种:Runtime Chunk
Webpack 生成的代码中有一部分不是业务代码,而是:
模块加载机制 Chunk 加载机制 模块映射关系 动态 import() 相关运行时代码例如:
main.js ├── 业务代码 ├── 第三方依赖 └── Webpack Runtime可以进一步拆成:
runtime.js main.js vendors.jsWebpack 配置:
module.exports={optimization:{runtimeChunk:"single",},};形成:
runtime.js vendors.js main.js十二、为什么 Runtime 单独拆出来?
重点不是“Runtime 很大”。
恰恰相反:
Runtime 通常比较小,但它可能随着 Chunk 结构变化而发生变化。
例如:
runtime.js │ └── 保存 Chunk / 模块加载相关映射信息如果业务代码发生变化:
main.js → 变化可能导致:
runtime.js → 也变化如果把 Runtime 独立出来,可以改善长期缓存策略。
但是:
Runtime 分包不是首屏优化的核心手段,主要价值是缓存隔离和 Chunk 管理。
所以优化价值通常不是很大。
十三、真正的分包策略应该怎么设计?
不要机械地:
一个页面 = 一个 Chunk也不要:
一个模块 = 一个 Chunk真正应该按照:
加载边界 + 业务边界 + 缓存边界 + 复用关系来设计。
推荐架构
应用代码 │ ┌──────────┴──────────┐ │ │ 首屏代码 非首屏代码 │ │ │ dynamic import() │ │ ▼ ▼ main Chunk async Chunk │ │ └──────────┬──────────┘ ▼ SplitChunksPlugin │ ┌──────────┴──────────┐ ▼ ▼ 第三方公共代码 业务公共代码 vendors.js common.js │ ▼ Runtime │ ▼ runtime.js十四、分包不是越细越好
这是面试中的一个高级边界问题。
很多人会说:
“分包以后文件越小越好。”
这是错误的。
例如:
main.js a.js b.js c.js d.js e.js f.js g.js h.js可能出现:
HTTP 请求数量增加 ↓ 请求调度成本增加 ↓ Chunk 加载关系复杂 ↓ JS 解析 / 执行碎片化 ↓ 整体性能反而下降所以真正目标是:
合理拆分,而不是无限拆分。
十五、什么时候适合分包?
场景一:路由级分包
最典型。
首页 用户 订单 商品 数据分析用户通常不会同时访问所有页面。
因此:
首页 → 首屏加载 用户 → 进入用户页面再加载 订单 → 进入订单页面再加载 商品 → 进入商品页面再加载 数据分析 → 进入数据分析页面再加载场景二:大型第三方库
例如:
ECharts Monaco Editor 富文本编辑器 PDF Viewer 地图 SDK如果首页根本用不到:
constEditor=lazy(()=>import("./Editor"));不要把几十 MB 的编辑器代码直接塞进首屏。
场景三:低频功能
例如:
设置 高级搜索 报表 帮助中心 管理功能这些功能访问频率较低,非常适合异步加载。
十六、边界场景:哪些代码不应该随便拆?
例如:
React ReactDOM 核心 UI 框架 首屏必须使用的公共组件 首屏核心业务代码如果这些代码拆得过度:
main.js ↓ 请求 React ↓ 请求 ReactDOM ↓ 请求 UI ↓ 请求业务可能反而增加加载成本。
因此:
首屏强依赖、体积合理、复用率高的代码,通常应该保留在合理的初始 Chunk 或公共 Chunk 中。
十七、代码分割和懒加载不是一回事
这是非常容易被问到的追问。
代码分割
解决:
代码怎么拆。
懒加载
解决:
代码什么时候加载。
动态:
import("./UserPage");同时完成:
代码分割 + 延迟加载但是两者概念不同。
十八、代码分割和缓存优化的关系
可以用一句话记忆:
代码分割负责建立缓存边界,内容哈希负责让这个缓存边界长期稳定。
例如:
vendors.abc123.js common.def456.js home.ghi789.js user.jkl012.js修改:
user.js理想结果:
vendors.abc123.js ← 不变 common.def456.js ← 不变 home.ghi789.js ← 不变 user.xxx999.js ← 变化浏览器:
vendors → 命中缓存 common → 命中缓存 home → 命中缓存 user → 下载新版本这才是分包带来的长期缓存价值。
十九、分包完整实现示例
下面给一个比较接近真实项目的 Webpack 配置:
constpath=require("path");module.exports={mode:"production",// 一个 SPA 只有一个主入口。entry:{main:"./src/main.js",},output:{path:path.resolve(__dirname,"dist"),// 初始 Chunk 的文件名。// contenthash 根据最终内容生成,// 内容不变时可以尽可能复用浏览器缓存。filename:"[name].[contenthash:8].js",// 动态 import() 产生的异步 Chunk 使用这个文件名。chunkFilename:"[name].[contenthash:8].chunk.js",clean:true,},optimization:{/* * 将 Webpack Runtime 单独提取。 * * Runtime 负责模块/Chunk 的加载管理, * 与业务代码隔离以后,有利于长期缓存。 */runtimeChunk:"single",/* * 公共代码提取。 */splitChunks:{/* * all: * 同时处理初始 Chunk 和异步 Chunk。 */chunks:"all",/* * 一个模块至少被两个 Chunk 使用, * 才考虑把它提取成公共 Chunk。 */minChunks:2,/* * 避免产生大量过小的 Chunk。 */minSize:20000,cacheGroups:{/* * 第三方依赖。 * * 例如: * React * ReactDOM * React Router * lodash */vendors:{test:/[\\/]node_modules[\\/]/,name:"vendors",// 第三方依赖优先提取。priority:10,// 如果已经存在合适的 Chunk,则尽量复用。reuseExistingChunk:true,},/* * 项目自己的公共业务代码。 */common:{minChunks:2,name:"common",priority:5,reuseExistingChunk:true,},},},},};业务代码:
import { lazy, Suspense } from "react"; /* * 首页属于首屏核心功能, * 因此直接进入初始 Chunk。 */ import HomePage from "./pages/HomePage"; /* * 用户页面属于非首屏功能。 * * 动态 import() 建立异步边界, * Webpack 会把 UserPage 构建为异步 Chunk。 */ const UserPage = lazy(() => import("./pages/UserPage")); /* * 订单页面同样进行路由级代码分割。 */ const OrderPage = lazy(() => import("./pages/OrderPage")); function App() { const path = window.location.pathname; /* * 首页: * 直接使用已经加载的 HomePage。 */ if (path === "/") { return <HomePage />; } /* * 用户页和订单页: * 进入页面以后才加载对应 Chunk。 */ if (path === "/user") { return ( <Suspense fallback={<div>用户页面加载中...</div>}> <UserPage /> </Suspense> ); } if (path === "/order") { return ( <Suspense fallback={<div>订单页面加载中...</div>}> <OrderPage /> </Suspense> ); } return <div>404</div>; } export default App;最终可能形成:
dist/ │ ├── runtime.xxxxxxxx.js │ ├── vendors.xxxxxxxx.js │ ├── common.xxxxxxxx.js │ ├── main.xxxxxxxx.js │ ├── UserPage.xxxxxxxx.chunk.js │ └── OrderPage.xxxxxxxx.chunk.js二十、Webpack 分包底层原理
这是面试官继续追问时真正需要讲的。
源代码 │ ▼ Webpack 从 Entry 开始 │ ▼ 递归解析 import │ ▼ 建立 Module Graph │ ▼ 根据依赖关系形成 Chunk Graph │ ├── Entry → Initial Chunk │ ├── dynamic import() → Async Chunk │ └── SplitChunks → 公共 Chunk │ ▼ Runtime 管理 Chunk 加载 │ ▼ 代码生成 │ ▼ Asset │ ▼ main.js / vendors.js / xxx.chunk.js二十一、动态import()为什么真的可以做到“进入页面才加载”?
关键不是 JavaScript 自己“神奇地延迟执行”。
而是:
import("./UserPage");在 Webpack 构建阶段被识别为:
异步依赖边界Webpack:
UserPage ↓ 独立 Chunk ↓ UserPage.xxx.js运行时:
执行 import() ↓ Webpack Runtime ↓ 发现目标 Chunk 尚未加载 ↓ 创建 script 标签 ↓ 请求 UserPage.xxx.js ↓ Chunk 下载完成 ↓ 注册模块 ↓ Promise resolve ↓ 继续执行因此:
动态
import()的核心是“构建阶段形成异步 Chunk,运行阶段通过 Webpack Runtime 按需加载这个 Chunk”。
二十二、如何判断自己的分包是否合理?
不要凭感觉。
应该结合:
Bundle Analyzer + Chrome Performance + Lighthouse + 真实用户性能数据重点观察:
1. 首屏 JS 体积 2. 初始请求数量 3. JS 下载时间 4. JS Parse / Compile 时间 5. JS 执行时间 6. Chunk 重复率 7. 缓存命中率 8. 路由切换时的 Chunk 加载情况例如:
修改一个按钮 ↓ 发现 vendors.hash.js 也变化 ↓ 说明公共 Chunk 的缓存边界可能不稳定 ↓ 检查模块拆分、依赖关系、构建配置二十三、这道题的主要矛盾和次要矛盾
主要矛盾
首屏加载什么代码
这是代码分割最核心的问题:
首屏真正需要的代码 ↓ 尽快加载 ↓ 非首屏代码 ↓ 延迟加载次要矛盾
1. 公共代码复用
多个 Chunk 共用 ↓ 提取公共 Chunk2. 长期缓存
稳定模块 ↓ 稳定 Chunk ↓ contenthash ↓ 浏览器长期缓存3. 请求数量
不能无限拆分4. Chunk 大小
不能太大 也不能太碎二十四、这道题最容易踩的坑
错误 1
Webpack 默认只有一个 bundle。
更准确:
Webpack 可以根据入口、动态
import()和优化策略生成一个或多个 Chunk / Asset。
错误 2
分包一定能提高首屏性能。
不准确。
正确:
合理的代码分割可以减少首屏必须下载、解析和执行的 JavaScript,但过度分割也可能增加请求和运行时开销。
错误 3
分包就是一个页面一个文件。
错误。
应该按照:
加载边界 业务边界 公共依赖 缓存边界综合决定。
错误 4
动态
import()只是异步加载。
不完整。
它同时是:
构建阶段: 异步 Chunk 边界 运行阶段: 按需加载 Chunk错误 5
Runtime 分包主要是为了减小文件体积。
不准确。
更主要的是:
隔离 Webpack Runtime,使业务 Chunk 变化时尽量不影响其他长期缓存资源。
错误 6
只要使用动态
import()就不需要 SplitChunks。
错误。
两者解决的问题不同:
dynamic import() ↓ 哪里建立异步边界? SplitChunks ↓ 公共模块怎么提取?二十五、面试回答的结构化逻辑
建议你以后遇到这道题直接按照:
① 是什么 ↓ 代码分割 = 合理划分 Chunk ② 为什么 ↓ 首屏代码减少 + 缓存边界更稳定 ③ 怎么做 ↓ 多入口 + 动态 import() + SplitChunks + Runtime Chunk ④ 怎么设计 ↓ 首屏 / 非首屏 公共依赖 业务公共代码 缓存边界 ⑤ 有什么坑 ↓ 不能过度分割 不能只看文件数量 需要结合性能数据分析二十六、满分答案
Webpack 的代码分割,本质是把模块合理划分成多个 Chunk,让首屏只加载必要代码,非首屏代码按需加载,同时把公共代码独立出来,从而降低首屏加载成本、提高缓存复用率。
一、整体流程
源代码 │ ▼ Webpack 分析 Module Graph │ ▼ 划分 Chunk │ ├── 多入口 Entry ├── 动态 import() → 异步 Chunk └── SplitChunks → 公共 Chunk │ ▼ Runtime 管理 Chunk 加载 │ ▼ 生成多个 Asset │ ├── main.js ├── vendors.js ├── common.js ├── runtime.js └── xxx.chunk.js二、为什么要分包?
主要解决两个问题:
- 首屏性能:不要让用户打开首页时,把暂时用不到的业务代码和大型第三方库全部下载、解析和执行。
- 长期缓存:把变化频率不同的代码拆开。修改业务页面时,React、公共库等未变化的 Chunk 可以继续使用浏览器缓存。
代码合理拆分 ↓ 首屏代码减少 ↓ 下载 / 解析 / 执行成本降低 稳定的 Chunk 边界 ↓ contenthash 稳定 ↓ 缓存复用率提高三、常见策略
1. 多入口
适合多页面、多端等场景:
entry:{admin:"./src/admin.js",mobile:"./src/mobile.js",}不同入口形成不同的初始 Chunk。
2. 动态import()
适合 SPA 路由、低频功能、大型第三方库:
constUserPage=lazy(()=>import("./pages/UserPage"));import()是异步 Chunk 的边界,Webpack 会把它构建成独立 Chunk,运行到这里时再通过 Webpack Runtime 请求对应资源。
3.SplitChunksPlugin
用于提取公共模块,例如:
pageA ──┐ ├── React ──→ vendors.js pageB ──┘这样可以避免公共依赖重复打包,并形成独立缓存边界。
4. Runtime Chunk
optimization:{runtimeChunk:"single",}把 Webpack 的运行时代码独立出来,主要目的是改善 Chunk 之间的缓存隔离,而不是单纯为了减小文件体积。
四、核心原则
不是“拆得越多越好” ↓ 而是找到合理的加载边界 ↓ 首屏代码尽量小 非首屏代码按需加载 公共代码合理复用 变化频率不同的代码尽量隔离 ↓ 同时避免产生大量过小 Chunk所以真正的代码分割策略应该结合:
首屏加载需求 + 路由边界 + 公共依赖 + 缓存边界 + Chunk 大小 + 网络请求数量
综合设计。
五、一句话总结
代码分割不是简单地“把一个大文件拆成多个小文件”,而是通过 Entry、动态
import()和SplitChunksPlugin建立合理的加载边界和缓存边界:首屏只加载必须代码,非首屏按需加载,公共代码复用并长期缓存,同时避免过度拆分。