把一个 React 项目中的入口文件交给构建工具,最后通常会得到这样的产物:
src/main.tsx
→ 解析 import
→ 转换 TSX、TypeScript 和 CSS
→ 建立模块依赖图
→ 拆分业务代码与第三方依赖
→ 压缩并生成 hash
→ dist/assets/index-a8f31c.js
如果只学习 webpack.config.js 或 vite.config.ts 怎么写,我们能够把项目跑起来,却很难解释下面这些问题:
- 为什么 Webpack 开发环境通常先构建 bundle,而 Vite 可以很快启动?
- Loader 和 Plugin 分别介入构建流程的哪个阶段?
import()为什么能够拆出新的 chunk?- Tree Shaking 为什么依赖 ESM,写了 ESM 又为什么不一定生效?
- 修改一个组件后,Vite 如何知道只更新这个模块?
- 一个构建缓慢或首屏包过大的项目应该从哪里排查?
学习打包构建的目标,不应该是记住几十个配置项,而是建立一条稳定的主线:
模块系统
→ AST 与代码转换
→ Mini Bundler
→ Webpack
→ Rollup 与 Vite
→ 工程化体系
→ 构建和加载性能
本文给出一条偏原理、流程和工程实践的学习路线。完成以后,我们不仅要会使用 Webpack 和 Vite,还应该能独立分析一个真实项目的构建问题。
第一阶段:先理解构建工具处理的输入
构建工具处理的不是孤立文件,而是一组存在依赖关系的模块。理解模块系统,是后续学习依赖分析、Tree Shaking 和 Code Splitting 的前提。
浏览器如何加载 JavaScript
先从三个常见的 script 标签开始:
<script src="main.js"></script>
<script defer src="main.js"></script>
<script type="module" src="main.js"></script>
它们在下载时机、执行时机和模块语义上并不相同。学习时至少要弄清:
- 普通脚本为什么可能阻塞 HTML 解析;
async和defer的执行顺序有什么区别;type="module"为什么默认延迟执行;- ESM 为什么具有独立作用域;
- 浏览器如何根据 URL 请求模块依赖。
可以先写一个不使用任何构建工具的原生 ESM 项目:
// main.js
import { add } from './math.js';
console.log(add(1, 2));
// math.js
export function add(a, b) {
return a + b;
}
然后打开 Network 面板观察:浏览器先请求 main.js,解析到 import 后,再请求 math.js。这张由请求形成的关系网,就是最直观的模块依赖图。
CommonJS 和 ESM 的差异
CommonJS 通常在运行时加载模块:
const math = require('./math');
if (process.env.NODE_ENV === 'development') {
require('./debug');
}
ESM 的静态导入位于模块顶层:
import { add } from './math.js';
构建工具不执行代码,也能从 ESM 的语法结构中提前得到大部分导入导出关系。这正是静态分析和 Tree Shaking 的基础。
这里需要重点掌握:
- CommonJS、ESM、UMD 分别解决什么问题;
- 静态导入和动态
import()的区别; - 循环依赖在 CJS 和 ESM 中的表现;
- Node.js 如何解析相对路径、包名和子路径;
package.json中main、module、exports、type、browser、sideEffects的作用。
这一阶段的练习不是配置 Webpack,而是发布一个最小 npm 包,同时提供 ESM 和 CJS 入口,并在两个消费项目中验证它们。
第二阶段:用 AST 理解代码转换
下面是一段普通源码:
import { add } from './math.js';
console.log(add(1, 2));
对于构建工具来说,它会经历一条类似的转换链路:
源码字符串
→ Token
→ AST
→ 遍历和修改 AST
→ 生成新代码
→ Source Map
不需要先完整学习一门编译原理课程,但要理解五个核心概念:词法分析、语法分析、AST、代码转换和代码生成。
可以使用 Babel 做一个小实验:
import { parse } from '@babel/parser';
import traverse from '@babel/traverse';
const ast = parse(sourceCode, {
sourceType: 'module',
});
traverse(ast, {
ImportDeclaration(path) {
console.log(path.node.source.value);
},
});
解析器会把 import 变成 ImportDeclaration 节点。遍历这些节点,就能收集当前文件依赖了哪些模块。
接下来可以完成两个练习:
- 写一个删除
console.log()的 Babel 插件。 - 读取一个入口文件,输出它的所有直接依赖。
同时要理解 Source Map 解决的问题。代码经过转换和压缩后,浏览器执行的是生成代码;Source Map 保存生成位置与原始位置之间的映射,让 DevTools 和错误监控平台能够重新定位源码。
第三阶段:手写一个 Mini Bundler
Mini Bundler 是整条路线中最值得投入时间的项目。它能把分散的概念连接成完整流程:
读取入口
→ 解析 AST
→ 收集依赖
→ 递归构建依赖图
→ 转换模块语法
→ 生成模块表和运行时
→ 输出 bundle
第一步:建立依赖图
假设入口是 src/index.js:
import message from './message.js';
document.body.textContent = message;
分析每个文件后,可以得到类似的数据:
{
id: './src/index.js',
dependencies: {
'./message.js': './src/message.js'
},
code: '...转换后的代码...'
}
从入口开始递归处理依赖,最终得到完整的 Module Graph。这里要注意模块去重,否则循环依赖会让递归永远无法结束。
第二步:生成模块运行时
最小运行时可以简化为:
const modules = {
'./src/index.js': function (require, module, exports) {
// 转换后的模块代码
},
};
const cache = {};
function require(id) {
if (cache[id]) return cache[id].exports;
const module = { exports: {} };
cache[id] = module;
modules[id](require, module, module.exports);
return module.exports;
}
require('./src/index.js');
这段代码解释了几个重要问题:
- 多个文件为什么可以被组织到一个 bundle 中;
- 模块作用域如何通过函数形成;
module.exports如何返回模块结果;- 模块缓存为什么能避免重复执行。
完成基础版本后,再逐步增加 JSON 模块、路径解析、Loader、多入口和动态导入。不要一开始就实现完整 Webpack,先保证每增加一个能力,都能说清它改变了构建流程的哪一步。
第四阶段:按生命周期学习 Webpack
学习 Webpack 时,先建立下面这条主线:
读取配置
→ 创建 Compiler
→ 注册 Plugin
→ 从 Entry 开始编译
→ Resolver 定位模块
→ Loader 转换模块
→ Parser 分析依赖
→ 创建 Module Graph
→ 生成 Chunk Graph
→ 生成 Asset
→ 写入文件系统
配置项只是这条流程上的参数或扩展点。
区分 Module、Chunk 和 Asset
这三个概念经常混在一起:
| 概念 | 含义 |
|---|---|
| Module | 源码中的模块以及经过 Loader 处理后的模块记录 |
| Chunk | 构建过程中组织的一组模块,通常与入口或动态导入有关 |
| Asset | 最终输出到文件系统中的 JavaScript、CSS、图片等文件 |
一个入口可能形成一个初始 chunk,动态 import() 可能形成异步 chunk。经过模板渲染、压缩和 hash 计算后,chunk 才会对应到最终的 asset。
Loader 负责模块转换
最简单的 Loader 就是一个函数:
module.exports = function replaceLoader(source) {
return source.replaceAll('__BUILD_ENV__', 'production');
};
它接收上一个阶段的内容,返回转换后的内容。学习 Loader 时要继续验证:
- 多个 Loader 为什么通常从右向左执行;
- Pitch Loader 在普通阶段之前做什么;
this.async()如何把同步 Loader 变成异步 Loader;- Loader 之间如何传递 Source Map;
- Loader 的结果如何进入模块解析阶段。
建议自己写两个 Loader:一个把 Markdown 转成 HTML 字符串,一个自动删除特定调试代码。
Plugin 负责扩展构建生命周期
Plugin 通常通过 Tapable 提供的 Hook 介入 Compiler 或 Compilation:
class AssetManifestPlugin {
apply(compiler) {
compiler.hooks.done.tap('AssetManifestPlugin', (stats) => {
console.log(stats.toJson().assets);
});
}
}
Loader 主要处理模块内容,Plugin 可以观察或修改更广泛的构建过程。可以依次实现:
- 输出构建耗时的插件;
- 生成资源清单的插件;
- 检查单个资源体积,超限时让构建失败的插件。
Tree Shaking 不只是“删除没用的代码”
更准确的流程是:
ESM 静态结构
→ 分析 import/export 关系
→ 标记导出是否被使用
→ 结合副作用信息
→ 压缩器删除可安全移除的代码
下面的导出有机会被标记为未使用:
export function add(a, b) {
return a + b;
}
export function subtract(a, b) {
return a - b;
}
但如果模块导入时会修改全局状态,构建工具不能简单删除整个模块:
window.analyticsReady = true;
因此还要学习 sideEffects、模块副作用、Scope Hoisting,以及压缩器在最终删除阶段扮演的角色。
第五阶段:把 Vite 拆成开发和生产两部分
Vite 最容易被误解的一句话是“Vite 不打包”。更准确的说法是:Vite 开发服务器主要利用浏览器原生 ESM 按需提供模块,而生产环境默认仍需要通过 Rollup 构建优化后的产物。
开发环境为什么启动快
当浏览器请求入口模块时,Vite 才转换当前请求涉及的文件:
浏览器请求 /src/main.tsx
→ Vite 转换 TSX
→ 重写 import
→ 浏览器继续请求依赖模块
→ Vite 按需响应
它不需要在服务器启动前,把整个应用的所有业务模块先合并成完整 bundle。项目越大,这种按需处理与全量预构建之间的差异越明显。
可以创建一个 Vite 项目,打开 Network 面板观察这段代码:
import React from 'react';
import App from './App.jsx';
./App.jsx 是相对模块,浏览器可以继续请求。react 是裸模块导入,浏览器不知道它对应 node_modules 中的哪个文件,因此 Vite 需要解析并重写它。
依赖预构建解决什么问题
Vite 使用 esbuild 预构建依赖,主要处理两类问题:
- 把 CommonJS 或 UMD 依赖转换成浏览器开发环境可用的 ESM。
- 把内部包含大量细粒度模块的依赖合并,减少开发阶段的请求数量。
业务源码经常变化,适合按需转换;第三方依赖相对稳定,适合预构建并缓存。这也是 Vite 开发模式的关键分工。
HMR 如何找到需要更新的模块
热更新可以简化为:
文件监听发现变化
→ 根据 Module Graph 找到受影响模块
→ 判断 HMR Boundary
→ WebSocket 通知浏览器
→ 浏览器重新请求带时间戳的新模块
→ 执行模块更新回调
原生接口可以这样体验:
if (import.meta.hot) {
import.meta.hot.accept((newModule) => {
console.log('module updated', newModule);
});
}
还要区分普通 HMR 与 React Fast Refresh。普通 HMR 负责替换模块,Fast Refresh 还需要尽量保留 React 组件状态,并判断当前模块是否满足刷新边界。
生产环境继续学习 Rollup
生产构建要面对开发服务器不必解决的问题:
- 如何拆分 chunk;
- 如何提取和拆分 CSS;
- 如何删除无用代码;
- 如何压缩资源;
- 如何生成内容 hash;
- 如何生成 manifest;
- 如何控制浏览器缓存。
因此学习 Vite 不能停在 Dev Server,还要继续理解 Rollup 的输入、输出、插件钩子、Tree Shaking 和 chunk 生成策略。
第六阶段:横向理解不同构建工具
掌握 Webpack 和 Vite 后,可以把其他工具放到同一张地图中:
| 工具 | 重点关注 |
|---|---|
| Webpack | 完整应用构建、模块图、Loader 与 Plugin 生态 |
| Rollup | ESM、Tree Shaking、库构建与输出格式 |
| Vite | 原生 ESM 开发服务器、依赖预构建、HMR、Rollup 集成 |
| esbuild | Go 实现、并行处理、快速转换与压缩 |
| SWC | Rust 实现、JavaScript 与 TypeScript 转换 |
| Babel | JavaScript AST 转换和插件生态 |
| Rspack | Rust 实现与 Webpack 生态兼容 |
| Turbopack | 增量计算和大型应用开发构建 |
| Parcel | 自动发现配置和零配置体验 |
| tsup | 基于 esbuild 的 TypeScript 库构建封装 |
不需要同时深入所有源码。更有效的顺序是:
Webpack
→ Rollup
→ Vite
→ esbuild 或 SWC
→ Rspack 或 Turbopack
每学习一个新工具,都用同一组问题比较:它怎样发现依赖、怎样转换源码、怎样生成 chunk、怎样缓存、怎样增量更新,以及怎样开放扩展能力。
第七阶段:补齐前端工程化知识
构建工具只是工程化链路中的一环。真实项目还需要处理依赖、兼容性、质量检查、测试和交付。
包管理与 Monorepo
需要理解 npm、pnpm 和 Yarn 如何安装与解析依赖,而不只是会运行 install:
- lockfile 为什么要提交到仓库;
- SemVer 如何影响依赖升级;
- npm 的依赖扁平化怎样产生幽灵依赖;
- pnpm 怎样使用内容寻址存储、硬链接和符号链接;
- workspace 如何管理多个包;
- Monorepo 中如何共享配置、缓存任务和控制发布版本。
语法转换与 API 兼容
要明确区分三类兼容工作:
语法降级:箭头函数 → 普通函数
API Polyfill:Promise、Array.from
CSS 兼容:语法转换与浏览器前缀
Babel 通常负责 JavaScript 语法转换,core-js 提供常见 API 的 polyfill,PostCSS 与 Autoprefixer 处理 CSS 兼容。Browserslist 则为这些工具提供目标浏览器范围。
同时要弄清 TypeScript 的职责。tsc 可以检查类型并输出 JavaScript,但 esbuild、SWC 和 Babel 也可以只移除类型语法。后者速度很快,却不等于完成了类型检查,因此 CI 中往往仍需要单独运行 tsc --noEmit。
环境变量是编译时还是运行时
前端代码中常见的环境变量通常会在构建时被替换:
if (import.meta.env.PROD) {
enableAnalytics();
}
这和服务端进程启动后读取 process.env 不完全相同。需要继续理解:
- 哪些变量会进入客户端 bundle;
- 为什么前端环境变量不能保存秘密;
- 多环境构建如何管理配置;
- 如何用运行时配置避免为每个环境重新构建;
- Feature Flag 与环境变量分别适合什么场景。
质量检查与交付链路
一个完整练习项目可以加入:
TypeScript
→ ESLint / Stylelint
→ Vitest
→ Playwright
→ 构建
→ Bundle 体积检查
→ Docker / CDN 部署
把这些步骤放进 CI,才能真正理解“本地能运行”和“工程可持续交付”之间的差别。
第八阶段:用数据做性能优化
不要从复制 splitChunks 配置开始优化。先明确问题属于构建阶段、产物阶段还是浏览器加载阶段。
构建性能
关注这些指标:
- 冷启动时间;
- 全量构建时间;
- 增量构建和 HMR 时间;
- Loader 与 Plugin 耗时;
- 缓存命中率;
- 文件系统和模块解析耗时。
Webpack 可以使用 Profiling 数据分析构建阶段;Node.js CPU Profile 可以定位 CPU 热点;Vite 可以通过调试日志观察依赖优化、插件转换和 HMR 过程。
产物性能
先生成可视化报告,再回答:
- 哪些依赖占用体积最大?
- 是否存在同一个库的多个版本?
- 首屏是否加载了当前路由不需要的代码?
- 公共 chunk 是提高缓存命中,还是制造了过多请求?
- Tree Shaking 为什么没有移除预期代码?
Webpack 可以使用 webpack-bundle-analyzer,Rollup 和 Vite 可以使用 rollup-plugin-visualizer。
浏览器加载性能
构建产物最终还要经过网络和浏览器执行,因此要继续学习:
- 文件名内容 hash 与长期缓存;
- CDN 缓存策略;
- gzip 与 Brotli;
preload与prefetch;- HTTP/2 下请求数量与资源拆分的平衡;
- JavaScript 下载、解析、编译和执行成本。
“包更小”通常有帮助,但不是唯一目标。过度拆分可能增加调度和请求成本,把所有内容合成一个文件又会破坏按需加载和长期缓存。优化需要依赖真实数据,而不是固定答案。
第九阶段:用问题驱动源码阅读
源码阅读不适合从仓库第一个文件一路读到最后一个文件。更有效的方式,是沿着一个具体问题追踪调用链。
Webpack 源码阅读顺序
可以按下面的对象逐步阅读:
webpack() 入口
→ Compiler
→ Compilation
→ NormalModuleFactory
→ NormalModule
→ Resolver
→ Loader Runner
→ Parser
→ Module Graph
→ Chunk Graph
→ Runtime 与 Emit
每次只追一个问题:
- Entry 在哪里变成 Module?
- Loader 在哪个阶段运行?
- Parser 怎样记录
import()? - 动态导入怎样形成异步 chunk?
- Plugin 如何增加一个 asset?
- content hash 在哪里生成?
Vite 源码阅读顺序
Vite 可以围绕一次浏览器请求阅读:
createServer
→ Transform Middleware
→ Plugin Container
→ Import Analysis
→ Module Graph
→ HMR
→ Dependency Optimizer
→ Build 与 Rollup Integration
对应的问题包括:
- 浏览器请求
.tsx文件时经过了哪些插件? - 裸模块导入在哪里被重写?
- Module Graph 如何记录导入者和被导入者?
- 文件变化后如何找到 HMR Boundary?
- 什么情况下会退化为整页刷新?
先在自己的 Mini Bundler 和 Mini Vite 中实现简化版本,再读真实源码,会比直接进入大型仓库更容易建立对应关系。
四个项目把知识连接起来
项目一:Mini Bundler
至少实现:
- ESM 依赖分析;
- Module Graph;
- 模块运行时和缓存;
- 一个自定义 Loader;
- 一个构建生命周期 Plugin;
- 多入口或动态导入中的一项。
项目二:Mini Vite
至少实现:
- HTTP Dev Server;
- 原生 ESM 模块响应;
- 裸导入重写;
- TypeScript 或 JSX 转换;
- CSS 转成 JavaScript 并注入页面;
- WebSocket 通知;
- 简化的 Module Graph 和 HMR。
项目三:真实业务项目工程化
选择一个 React 或 Vue 项目,加入:
- TypeScript 与 ESLint;
- 单元测试和端到端测试;
- CSS Modules 或其他 CSS 方案;
- 环境变量和运行时配置;
- 路由级 Code Splitting;
- Bundle 分析和体积预算;
- CI 构建与部署。
最终提交一份优化报告,记录修改前后的构建时间、首屏资源体积和 chunk 结构。
项目四:组件库或工具库
产出一个真正可安装的 npm 包,处理:
- ESM 和 CJS 输出;
- TypeScript 类型声明;
exports子路径;- CSS 产物;
- Tree Shaking;
- Source Map;
- 版本和发布流程。
应用构建和库构建的目标不同。应用更关注路由拆分、缓存和运行时体验;库更关注输出格式、外部依赖、类型和消费兼容性。做完这个项目,很多配置项才会具有真实语境。
一份可执行的 12 周安排
| 时间 | 学习内容 | 阶段产出 |
|---|---|---|
| 第 1-2 周 | 浏览器加载、ESM/CJS、Node 模块解析、AST、Source Map | AST 转换插件和原生 ESM Demo |
| 第 3-4 周 | 依赖图、模块运行时、Loader、Plugin | 可在浏览器运行的 Mini Bundler |
| 第 5-6 周 | Webpack 生命周期、Code Splitting、Tree Shaking、缓存 | Webpack 项目和两个自定义扩展 |
| 第 7-8 周 | Vite Dev Server、依赖预构建、Module Graph、HMR、Rollup | Mini Vite |
| 第 9-10 周 | Rollup、esbuild、SWC、库构建、npm 包设计 | 可安装的组件库或工具库 |
| 第 11-12 周 | Monorepo、CI/CD、构建分析、加载优化 | 工程化项目和性能报告 |
每周学习都应该留下可运行代码、流程图或测量报告。只看文档容易产生“已经理解”的错觉,真正实现一次模块缓存、裸导入重写或 HMR 通知后,才会暴露理解中的空白。
学习时反复回答这 12 个问题
无论面对 Webpack、Vite、Rollup,还是下一个构建工具,都可以反复回答:
- 输入是什么?
- 输出是什么?
- 如何发现依赖?
- 如何解析模块路径?
- 如何转换源码?
- 如何建立和维护模块图?
- 如何生成 chunk 与运行时代码?
- 如何识别和处理副作用?
- 如何实现缓存与增量构建?
- 如何把文件变化同步到浏览器?
- 如何通过 Loader、Plugin 或 Hook 扩展?
- 性能瓶颈出现在哪个阶段?
一条有效的学习路线,最终应该让我们从“这个配置怎么抄”转向“这一步为什么存在”。
先理解模块系统,再用 AST 分析依赖;先手写 Mini Bundler,再进入 Webpack 生命周期;先观察浏览器原生 ESM 请求,再研究 Vite 的开发服务器和 HMR。按照这个顺序,Webpack、Vite 和后续工具就不再是彼此割裂的配置集合,而是对同一组工程问题的不同回答。