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