用了好几年脚手架,npm run dev 一敲就能跑,npm run build 一敲就能部署,但一旦被问到"你知道背后在做什么吗",我发现自己答得很模糊。这篇是我补课后的学习笔记,把工程化里几个绕不开的概念串起来,尽量用具体例子说清楚每一个"为什么"。
一、为什么需要打包工具?
先回答一个基础问题:浏览器能直接运行你写的代码吗?
大多数时候,不能。
你写的 JSX,浏览器不认识:
// 你写的
const App = () => <div className="app">Hello</div>;
// 浏览器能认的
const App = () => React.createElement('div', { className: 'app' }, 'Hello');
TypeScript 的类型注解,浏览器也不认:
// 你写的
function add(a: number, b: number): number {
return a + b;
}
// 浏览器能认的(类型全部去掉)
function add(a, b) {
return a + b;
}
还有 SCSS/Less → 需要编译成 CSS,路径别名 @/components → 需要解析成真实相对路径。这些"翻译工作",都是打包工具在做。
除了翻译,打包工具还负责:
- 合并文件:把几百个模块合并,减少 HTTP 请求数
- 压缩代码:删空格、缩短变量名,减小文件体积
- 生产优化:Tree Shaking(删无用代码)、代码分割、注入环境变量
脚手架(Create React App、Vite 官方模板)封装了这些配置,所以你平时感知不到——但它们一直在运转。
二、Webpack vs Vite:核心差异
这是工程化里问得最多的对比问题,但我觉得先搞清楚它们在开发模式下做了什么不同的事,才能真正理解选型的理由。
开发模式的根本差异
Webpack 的思路:启动时,先把所有文件分析依赖,打包成 bundle,再启动开发服务器。
启动 dev server
↓
分析所有文件依赖图
↓
打包成 bundle(内存中)
↓
服务器就绪,可以访问
项目小时没问题,但文件数量上去之后,这个"先打包再启动"的过程会越来越慢——几百个模块轻松让你等上十几秒。
Vite 的思路:利用现代浏览器对 原生 ESM(ES Module) 的支持,启动时完全不打包。
启动 dev server(几乎瞬间)
↓
浏览器请求 index.html
↓
浏览器解析 HTML,发现 import 语句
↓
浏览器向 Vite 请求具体文件
↓
Vite 按需处理(转换 JSX/TS 等),返回给浏览器
浏览器请求哪个文件,Vite 才处理哪个文件。没被请求到的文件,完全不处理。这就是冷启动快的根本原因。
HMR(热更新)也同理——Webpack 改一个文件要重新打包受影响的整条链路,Vite 只处理那一个被改动的模块。
生产模式呢?
这里有个容易被忽略的细节:Vite 生产环境用的是 Rollup 打包,不是 ESM 直出。
原因也很直接:开发时浏览器一个个请求文件还好,但生产环境如果有几百个模块文件,几百个 HTTP 请求并发,网络开销太大。生产环境必须合并、压缩。
所以 Vite 的"快",主要体现在开发体验上,生产构建两者差异没那么大。
Vite 开发模式的瀑布问题
Vite 并不是没有代价的。因为浏览器需要逐个请求文件,如果依赖链很深:
App.js
└── ComponentA.js
└── utils.js
└── lodash-es(几十个文件)
浏览器可能需要串行等待多轮请求,才能把所有依赖拿齐。这在依赖关系复杂的大型项目里,开发模式下首次加载会有明显延迟。
Vite 用依赖预构建(esbuild 提前处理 node_modules)来缓解这个问题,但在超大型项目中仍然值得注意。
选型参考
| 场景 | 推荐 |
|---|---|
| 新项目 / 中小型项目 | Vite(启动快,开发体验好) |
| 已有大型 Webpack 项目 | 不必强行迁移,维护成本不低 |
| 需要丰富的 Loader/Plugin 生态 | Webpack(生态更成熟) |
| 对构建细节有复杂自定义需求 | Webpack(配置更灵活) |
三、ESM 是什么?为什么它这么重要?
ESM(ES Module)就是你每天在写的 import / export 语法,但它背后有一个特性让整个现代工程化工具链得以成立。
// ESM:静态导入,必须写在顶层
import { useState } from 'react';
import { formatDate } from '@/utils';
export function MyComponent() { ... }
这里的"必须写在顶层"不是语法挑剔,而是一个刻意的设计约束:让工具在代码运行前,就能静态分析出整个依赖关系图。
对比 CommonJS(Node.js 的老模块系统):
// CommonJS:动态 require,可以写在任意位置
const express = require('express');
if (condition) {
const extra = require('./extra'); // 运行时才知道要不要加载这个
}
function loadPlugin(name) {
return require(`./plugins/${name}`); // 运行前完全不知道加载什么
}
require 是普通函数,可以在 if 里、函数里、拼接字符串动态决定加载什么。这样的代码,工具在运行前根本无法确定"这个项目到底依赖了什么"。
这个差异,直接决定了 Tree Shaking 能不能做。
| 特性 | ESM | CommonJS |
|---|---|---|
| 语法 | import/export | require/module.exports |
| 执行时机 | 静态分析,运行前确定依赖 | 动态执行,运行时确定依赖 |
| 浏览器支持 | ✅ 原生支持 | ❌ 需要打包工具处理 |
| Tree Shaking | ✅ 支持 | ❌ 不支持 |
| 在 if/函数中使用 | ❌ 不允许 | ✅ 允许 |
四、Tree Shaking:摇掉没用的代码
字面意思就是"摇树"——把树上枯掉的叶子(未使用的代码)摇下来。
一个具体例子
假设有一个工具函数文件:
// utils.js
export function formatDate(date) {
return date.toLocaleDateString();
}
export function formatCurrency(amount) {
return `¥${amount.toFixed(2)}`;
}
export function debounce(fn, delay) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
}
项目里只用了 formatDate:
// App.js
import { formatDate } from './utils';
Tree Shaking 之后,打包产物里 formatCurrency 和 debounce 的实现代码会被完全删掉。最终 bundle 里只有 formatDate 的代码。
为什么 ESM 才能做 Tree Shaking?
因为 import 是静态声明,打包工具在运行代码之前,就能建立一张完整的"模块使用图":
App.js 用了 utils.js 的 formatDate
→ formatDate 保留
→ formatCurrency 没有任何地方引用 → 删掉
→ debounce 没有任何地方引用 → 删掉
如果换成 require,工具无法在运行前做这个分析,只能保守地把所有导出都打进去。
Tree Shaking vs ESLint no-unused-vars,傻傻分不清楚
这两个经常被混淆,但它们解决的是不同层面的问题:
ESLint no-unused-vars | Tree Shaking | |
|---|---|---|
| 工作时机 | 编写代码时 / pre-commit | 打包时 |
| 检查对象 | 声明了但没用到的变量/import | 导出了但没被任何地方引用的代码 |
| 处理方式 | 报错,提示开发者手动删除 | 静默从 bundle 中移除,不报错 |
| 目标 | 代码质量 | 产物体积 |
简单说:ESLint 管的是你的源码,Tree Shaking 管的是打包产物。两者互补,不冲突。
五、动态导入 import():按需加载
静态 import 在文件顶层声明,意味着无论用户有没有触发相关功能,这些依赖都会在页面加载时下载。
// 静态导入:页面加载时就下载,不管用不用得到
import ECharts from 'echarts';
import RichTextEditor from 'some-heavy-editor';
动态 import() 返回一个 Promise,只在真正需要时才发起加载:
// 动态导入:调用时才下载
button.addEventListener('click', async () => {
const { default: ECharts } = await import('echarts');
// 用户点击后才加载 ECharts
});
React 路由懒加载就是动态导入
import { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';
// lazy() 接收一个返回动态 import 的函数
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
const Analytics = lazy(() => import('./pages/Analytics'));
function App() {
return (
<Suspense fallback={<div>加载中...</div>}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
<Route path="/analytics" element={<Analytics />} />
</Routes>
</Suspense>
);
}
打包时,每个 lazy() 对应的页面会被分割成独立的 chunk 文件。用户访问 /settings 时,才会下载 Settings 页面的代码,其他页面的代码不下载。
什么时候用,什么时候不用?
| 适合用 lazy | 不适合用 lazy |
|---|---|
| 路由级页面组件 | 首屏核心组件 |
| 重型第三方库(ECharts、富文本编辑器) | 全局 Layout、导航栏 |
| 低频触发的弹窗/抽屉 | 公共基础组件(Button、Input) |
一个判断原则:用户可能根本不会访问到的功能,适合懒加载。
六、浏览器缓存:强缓存 vs 协商缓存
这部分和打包策略直接挂钩,理解缓存才能理解为什么要给文件名加哈希。
强缓存
服务器在响应头里声明:这个文件在未来一年内都有效,不用再问我了。
HTTP/1.1 200 OK
Cache-Control: max-age=31536000 // 缓存 365 天
Content-Type: application/javascript
缓存期内,浏览器不发任何请求,直接从本地磁盘/内存读取文件。速度最快,但也最"固执"——即使服务器上的文件已经更新,在缓存过期之前,浏览器仍然用旧版本。
协商缓存
强缓存过期后(或者服务器声明不走强缓存),浏览器会发请求问服务器:我本地有这个文件,你那边有没有更新?
// 浏览器请求(带上上次拿到的标识)
GET /main.js
If-None-Match: "abc123" // 上次响应里的 ETag
If-Modified-Since: Mon, 10 Apr 2026 08:00:00 GMT
// 服务器:没变,用你本地的
HTTP/1.1 304 Not Modified
// (不返回文件体,只返回头部,省流量)
// 服务器:变了,给你新的
HTTP/1.1 200 OK
ETag: "def456"
// (返回新文件)
两者对比
| 强缓存 | 协商缓存 | |
|---|---|---|
| 是否发请求 | ❌ 不发 | ✅ 发(但可能不下载) |
| 状态码 | 200(from cache) | 304 或 200 |
| 速度 | 最快(本地读取) | 较快(省了文件传输) |
| 文件更新能否及时生效 | ❌ 缓存期内不感知 | ✅ 每次都验证 |
| 适合的文件类型 | 带哈希的静态资源 | 经常变化的文件 |
七、文件名哈希 + splitChunks:完整的缓存策略
理解了缓存机制,就能理解打包配置里这两个常见设置的意义了。
文件名哈希:解决强缓存更新问题
强缓存的最大问题:文件内容更新了,但缓存还没过期,用户拿不到新版本。
解决思路很优雅:把文件内容的哈希值嵌入文件名。
// Webpack/Vite 打包输出
main.a3f8c2d1.js // 文件名里有内容哈希
vendor.9b12e5a7.js
style.4c7d9f2e.css
- 内容没变 → 哈希不变 → 文件名不变 → 浏览器继续用缓存 ✅
- 内容变了 → 哈希变了 → 文件名变了 → 浏览器当作新文件下载 ✅
HTML 文件里引用的是最新的文件名,所以 HTML 本身不能强缓存(否则用户拿不到新的文件名引用)。
在 Webpack 中的配置:
// webpack.config.js
module.exports = {
output: {
// [contenthash] 会被替换成文件内容的哈希值
filename: '[name].[contenthash:8].js',
chunkFilename: '[name].[contenthash:8].chunk.js',
},
};
splitChunks:分离第三方库,最大化缓存复用
如果不做代码分割,所有代码(你的业务逻辑 + React + lodash + 其他库)都打进一个 bundle:
main.a3f8c2d1.js (3MB:业务代码 100KB + React 300KB + 第三方库 2.6MB)
你改了一行业务代码 → 哈希变了 → 用户重新下载整个 3MB 文件。
配置 splitChunks 后:
// webpack.config.js
module.exports = {
optimization: {
splitChunks: {
cacheGroups: {
// 第三方库单独打包
vendor: {
test: /[\/]node_modules[\/]/,
name: 'vendor',
chunks: 'all',
},
},
},
},
};
打包产物变成:
main.a3f8c2d1.js (100KB:业务代码,经常变)
vendor.9b12e5a7.js (2.9MB:React + 第三方库,几乎不变)
发布新版本时,只有 main 的哈希变了:
main.js→ 用户重新下载(100KB)✅vendor.js→ 哈希没变,继续用缓存(2.9MB 不用动)✅
完整的缓存策略参考
| 文件类型 | 缓存策略 | 原因 |
|---|---|---|
index.html | 不缓存 / 协商缓存 | 每次部署需要拿最新,里面有最新哈希文件名 |
| 业务 JS/CSS(含哈希) | 强缓存,max-age=31536000 | 哈希变了就是新文件,旧哈希的文件内容永远不变 |
vendor.js(含哈希) | 强缓存,max-age=31536000 | 第三方库极少变,可以缓存一年 |
| 图片/字体(含哈希) | 强缓存,max-age=31536000 | 同上,内容不变则哈希不变 |
这套策略的核心思路:内容哈希保证文件名唯一,强缓存保证极致速度,HTML 不缓存保证入口永远是最新的。
延伸思考
梳理这篇的过程中,冒出了几个还没深入研究的问题:
- Vite 的依赖预构建具体做了什么? esbuild 把
node_modules里的 CommonJS 模块转成 ESM,这个转换过程有什么坑? - Module Federation(模块联邦)是什么? 微前端架构里它和 splitChunks 的关系是什么,运行时共享依赖怎么做到的?
- Turbopack 值得关注吗? Vercel 用 Rust 写的新一代打包工具,宣称比 Webpack 快 700 倍,生产可用了吗?
🧠 面试常问版(核心记忆点)
5 条浓缩,面试前快速过一遍:
- 打包工具的必要性:浏览器不认 JSX/TS/SCSS,打包工具负责"翻译 + 合并 + 压缩 + 优化",脚手架只是封装了这些配置。
- Webpack vs Vite 核心差异:Webpack 启动时全量打包;Vite 开发时利用浏览器原生 ESM 按需处理,冷启动快。两者生产环境都需要打包合并。
- Tree Shaking 的前提是 ESM:
import静态声明让工具运行前就能分析依赖图,require动态执行做不到——这就是为什么 ESM 才能 Tree Shaking。 - 强缓存 vs 协商缓存:强缓存缓存期内不发请求(速度最快,但更新不感知);协商缓存每次发请求验证,文件没变返回 304。两者配合文件名哈希使用效果最好。
- 文件名哈希 + splitChunks 是黄金搭档:哈希解决强缓存更新问题,splitChunks 把稳定的第三方库和频繁变化的业务代码分离,发布新版本用户只需重下几十 KB 的业务代码,第三方库继续用缓存。
参考资料
- Vite 官方文档 - 为什么选 Vite - Vite 设计理念
- Webpack 官方文档 - Code Splitting - splitChunks 配置
- MDN - HTTP 缓存 - 强缓存与协商缓存详解
- MDN - import() - 动态导入语法
- web.dev - Tree shaking - Tree Shaking 实践