创见博客
从模块系统到 Mini Bundler:Webpack、Vite 与前端工程化学习路线
七崽爱吃小饼干2026/08/25阅读 0专栏 前端构建与工程化

把一个 React 项目中的入口文件交给构建工具,最后通常会得到这样的产物:

text
src/main.tsx
  → 解析 import
  → 转换 TSX、TypeScript 和 CSS
  → 建立模块依赖图
  → 拆分业务代码与第三方依赖
  → 压缩并生成 hash
  → dist/assets/index-a8f31c.js

如果只学习 webpack.config.js 或 vite.config.ts 怎么写,我们能够把项目跑起来,却很难解释下面这些问题:

  1. 为什么 Webpack 开发环境通常先构建 bundle,而 Vite 可以很快启动?
  2. Loader 和 Plugin 分别介入构建流程的哪个阶段?
  3. import() 为什么能够拆出新的 chunk?
  4. Tree Shaking 为什么依赖 ESM,写了 ESM 又为什么不一定生效?
  5. 修改一个组件后,Vite 如何知道只更新这个模块?
  6. 一个构建缓慢或首屏包过大的项目应该从哪里排查?

学习打包构建的目标,不应该是记住几十个配置项,而是建立一条稳定的主线:

text
模块系统
  → AST 与代码转换
  → Mini Bundler
  → Webpack
  → Rollup 与 Vite
  → 工程化体系
  → 构建和加载性能

本文给出一条偏原理、流程和工程实践的学习路线。完成以后,我们不仅要会使用 Webpack 和 Vite,还应该能独立分析一个真实项目的构建问题。

第一阶段:先理解构建工具处理的输入

构建工具处理的不是孤立文件,而是一组存在依赖关系的模块。理解模块系统,是后续学习依赖分析、Tree Shaking 和 Code Splitting 的前提。

浏览器如何加载 JavaScript

先从三个常见的 script 标签开始:

html
<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 项目:

js
// main.js
import { add } from './math.js';

console.log(add(1, 2));
js
// math.js
export function add(a, b) {
  return a + b;
}

然后打开 Network 面板观察:浏览器先请求 main.js,解析到 import 后,再请求 math.js。这张由请求形成的关系网,就是最直观的模块依赖图。

CommonJS 和 ESM 的差异

CommonJS 通常在运行时加载模块:

js
const math = require('./math');

if (process.env.NODE_ENV === 'development') {
  require('./debug');
}

ESM 的静态导入位于模块顶层:

js
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 理解代码转换

下面是一段普通源码:

js
import { add } from './math.js';
console.log(add(1, 2));

对于构建工具来说,它会经历一条类似的转换链路:

text
源码字符串
  → Token
  → AST
  → 遍历和修改 AST
  → 生成新代码
  → Source Map

不需要先完整学习一门编译原理课程,但要理解五个核心概念:词法分析、语法分析、AST、代码转换和代码生成。

可以使用 Babel 做一个小实验:

js
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 节点。遍历这些节点,就能收集当前文件依赖了哪些模块。

接下来可以完成两个练习:

  1. 写一个删除 console.log() 的 Babel 插件。
  2. 读取一个入口文件,输出它的所有直接依赖。

同时要理解 Source Map 解决的问题。代码经过转换和压缩后,浏览器执行的是生成代码;Source Map 保存生成位置与原始位置之间的映射,让 DevTools 和错误监控平台能够重新定位源码。

第三阶段:手写一个 Mini Bundler

Mini Bundler 是整条路线中最值得投入时间的项目。它能把分散的概念连接成完整流程:

text
读取入口
  → 解析 AST
  → 收集依赖
  → 递归构建依赖图
  → 转换模块语法
  → 生成模块表和运行时
  → 输出 bundle

第一步:建立依赖图

假设入口是 src/index.js:

js
import message from './message.js';

document.body.textContent = message;

分析每个文件后,可以得到类似的数据:

js
{
  id: './src/index.js',
  dependencies: {
    './message.js': './src/message.js'
  },
  code: '...转换后的代码...'
}

从入口开始递归处理依赖,最终得到完整的 Module Graph。这里要注意模块去重,否则循环依赖会让递归永远无法结束。

第二步:生成模块运行时

最小运行时可以简化为:

js
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 时,先建立下面这条主线:

text
读取配置
  → 创建 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 就是一个函数:

js
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:

js
class AssetManifestPlugin {
  apply(compiler) {
    compiler.hooks.done.tap('AssetManifestPlugin', (stats) => {
      console.log(stats.toJson().assets);
    });
  }
}

Loader 主要处理模块内容,Plugin 可以观察或修改更广泛的构建过程。可以依次实现:

  1. 输出构建耗时的插件;
  2. 生成资源清单的插件;
  3. 检查单个资源体积,超限时让构建失败的插件。

Tree Shaking 不只是“删除没用的代码”

更准确的流程是:

text
ESM 静态结构
  → 分析 import/export 关系
  → 标记导出是否被使用
  → 结合副作用信息
  → 压缩器删除可安全移除的代码

下面的导出有机会被标记为未使用:

js
export function add(a, b) {
  return a + b;
}

export function subtract(a, b) {
  return a - b;
}

但如果模块导入时会修改全局状态,构建工具不能简单删除整个模块:

js
window.analyticsReady = true;

因此还要学习 sideEffects、模块副作用、Scope Hoisting,以及压缩器在最终删除阶段扮演的角色。

第五阶段:把 Vite 拆成开发和生产两部分

Vite 最容易被误解的一句话是“Vite 不打包”。更准确的说法是:Vite 开发服务器主要利用浏览器原生 ESM 按需提供模块,而生产环境默认仍需要通过 Rollup 构建优化后的产物。

开发环境为什么启动快

当浏览器请求入口模块时,Vite 才转换当前请求涉及的文件:

text
浏览器请求 /src/main.tsx
  → Vite 转换 TSX
  → 重写 import
  → 浏览器继续请求依赖模块
  → Vite 按需响应

它不需要在服务器启动前,把整个应用的所有业务模块先合并成完整 bundle。项目越大,这种按需处理与全量预构建之间的差异越明显。

可以创建一个 Vite 项目,打开 Network 面板观察这段代码:

js
import React from 'react';
import App from './App.jsx';

./App.jsx 是相对模块,浏览器可以继续请求。react 是裸模块导入,浏览器不知道它对应 node_modules 中的哪个文件,因此 Vite 需要解析并重写它。

依赖预构建解决什么问题

Vite 使用 esbuild 预构建依赖,主要处理两类问题:

  1. 把 CommonJS 或 UMD 依赖转换成浏览器开发环境可用的 ESM。
  2. 把内部包含大量细粒度模块的依赖合并,减少开发阶段的请求数量。

业务源码经常变化,适合按需转换;第三方依赖相对稳定,适合预构建并缓存。这也是 Vite 开发模式的关键分工。

HMR 如何找到需要更新的模块

热更新可以简化为:

text
文件监听发现变化
  → 根据 Module Graph 找到受影响模块
  → 判断 HMR Boundary
  → WebSocket 通知浏览器
  → 浏览器重新请求带时间戳的新模块
  → 执行模块更新回调

原生接口可以这样体验:

js
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 生态
RollupESM、Tree Shaking、库构建与输出格式
Vite原生 ESM 开发服务器、依赖预构建、HMR、Rollup 集成
esbuildGo 实现、并行处理、快速转换与压缩
SWCRust 实现、JavaScript 与 TypeScript 转换
BabelJavaScript AST 转换和插件生态
RspackRust 实现与 Webpack 生态兼容
Turbopack增量计算和大型应用开发构建
Parcel自动发现配置和零配置体验
tsup基于 esbuild 的 TypeScript 库构建封装

不需要同时深入所有源码。更有效的顺序是:

text
Webpack
  → Rollup
  → Vite
  → esbuild 或 SWC
  → Rspack 或 Turbopack

每学习一个新工具,都用同一组问题比较:它怎样发现依赖、怎样转换源码、怎样生成 chunk、怎样缓存、怎样增量更新,以及怎样开放扩展能力。

第七阶段:补齐前端工程化知识

构建工具只是工程化链路中的一环。真实项目还需要处理依赖、兼容性、质量检查、测试和交付。

包管理与 Monorepo

需要理解 npm、pnpm 和 Yarn 如何安装与解析依赖,而不只是会运行 install:

  • lockfile 为什么要提交到仓库;
  • SemVer 如何影响依赖升级;
  • npm 的依赖扁平化怎样产生幽灵依赖;
  • pnpm 怎样使用内容寻址存储、硬链接和符号链接;
  • workspace 如何管理多个包;
  • Monorepo 中如何共享配置、缓存任务和控制发布版本。

语法转换与 API 兼容

要明确区分三类兼容工作:

text
语法降级:箭头函数 → 普通函数
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。

环境变量是编译时还是运行时

前端代码中常见的环境变量通常会在构建时被替换:

js
if (import.meta.env.PROD) {
  enableAnalytics();
}

这和服务端进程启动后读取 process.env 不完全相同。需要继续理解:

  • 哪些变量会进入客户端 bundle;
  • 为什么前端环境变量不能保存秘密;
  • 多环境构建如何管理配置;
  • 如何用运行时配置避免为每个环境重新构建;
  • Feature Flag 与环境变量分别适合什么场景。

质量检查与交付链路

一个完整练习项目可以加入:

text
TypeScript
  → ESLint / Stylelint
  → Vitest
  → Playwright
  → 构建
  → Bundle 体积检查
  → Docker / CDN 部署

把这些步骤放进 CI,才能真正理解“本地能运行”和“工程可持续交付”之间的差别。

第八阶段:用数据做性能优化

不要从复制 splitChunks 配置开始优化。先明确问题属于构建阶段、产物阶段还是浏览器加载阶段。

构建性能

关注这些指标:

  • 冷启动时间;
  • 全量构建时间;
  • 增量构建和 HMR 时间;
  • Loader 与 Plugin 耗时;
  • 缓存命中率;
  • 文件系统和模块解析耗时。

Webpack 可以使用 Profiling 数据分析构建阶段;Node.js CPU Profile 可以定位 CPU 热点;Vite 可以通过调试日志观察依赖优化、插件转换和 HMR 过程。

产物性能

先生成可视化报告,再回答:

  1. 哪些依赖占用体积最大?
  2. 是否存在同一个库的多个版本?
  3. 首屏是否加载了当前路由不需要的代码?
  4. 公共 chunk 是提高缓存命中,还是制造了过多请求?
  5. Tree Shaking 为什么没有移除预期代码?

Webpack 可以使用 webpack-bundle-analyzer,Rollup 和 Vite 可以使用 rollup-plugin-visualizer。

浏览器加载性能

构建产物最终还要经过网络和浏览器执行,因此要继续学习:

  • 文件名内容 hash 与长期缓存;
  • CDN 缓存策略;
  • gzip 与 Brotli;
  • preload 与 prefetch;
  • HTTP/2 下请求数量与资源拆分的平衡;
  • JavaScript 下载、解析、编译和执行成本。

“包更小”通常有帮助,但不是唯一目标。过度拆分可能增加调度和请求成本,把所有内容合成一个文件又会破坏按需加载和长期缓存。优化需要依赖真实数据,而不是固定答案。

第九阶段:用问题驱动源码阅读

源码阅读不适合从仓库第一个文件一路读到最后一个文件。更有效的方式,是沿着一个具体问题追踪调用链。

Webpack 源码阅读顺序

可以按下面的对象逐步阅读:

text
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 可以围绕一次浏览器请求阅读:

text
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 MapAST 转换插件和原生 ESM Demo
第 3-4 周依赖图、模块运行时、Loader、Plugin可在浏览器运行的 Mini Bundler
第 5-6 周Webpack 生命周期、Code Splitting、Tree Shaking、缓存Webpack 项目和两个自定义扩展
第 7-8 周Vite Dev Server、依赖预构建、Module Graph、HMR、RollupMini Vite
第 9-10 周Rollup、esbuild、SWC、库构建、npm 包设计可安装的组件库或工具库
第 11-12 周Monorepo、CI/CD、构建分析、加载优化工程化项目和性能报告

每周学习都应该留下可运行代码、流程图或测量报告。只看文档容易产生“已经理解”的错觉,真正实现一次模块缓存、裸导入重写或 HMR 通知后,才会暴露理解中的空白。

学习时反复回答这 12 个问题

无论面对 Webpack、Vite、Rollup,还是下一个构建工具,都可以反复回答:

  1. 输入是什么?
  2. 输出是什么?
  3. 如何发现依赖?
  4. 如何解析模块路径?
  5. 如何转换源码?
  6. 如何建立和维护模块图?
  7. 如何生成 chunk 与运行时代码?
  8. 如何识别和处理副作用?
  9. 如何实现缓存与增量构建?
  10. 如何把文件变化同步到浏览器?
  11. 如何通过 Loader、Plugin 或 Hook 扩展?
  12. 性能瓶颈出现在哪个阶段?

一条有效的学习路线,最终应该让我们从“这个配置怎么抄”转向“这一步为什么存在”。

先理解模块系统,再用 AST 分析依赖;先手写 Mini Bundler,再进入 Webpack 生命周期;先观察浏览器原生 ESM 请求,再研究 Vite 的开发服务器和 HMR。按照这个顺序,Webpack、Vite 和后续工具就不再是彼此割裂的配置集合,而是对同一组工程问题的不同回答。

评论
0/100