Skip to content

构建工具

现代前端早已不是"写 HTML/CSS/JS 上传服务器",而是源码组织 → 编译转换 → 产物优化 → 增量缓存一条链。本文先把工具演进脉络讲清楚,再逐个拆 Vite 与 Webpack 的核心机制,最后落到代码分割与缓存策略——这是构建优化的两条主线。

工具谱系一图流(2026 视角)

工具定位内核典型场景
esbuild极速转译/压缩GoVite 预构建、TS/JSX 转译、压缩
Vite应用级开发/构建dev: esbuild + ESM;build: Rollup(Rolldown 逐步接管)主流新项目首选
Rollup库级打包JS →(Rolldown 为 Rust 原生版)发布 npm 库、tree-shaking 标杆
Webpack应用级打包(生态最老最全)JS存量大型项目、复杂 loader 生态
RspackWebpack 兼容替代Rust迁移 Webpack 提速(约 10x)
TurbopackNext.js 内置RustNext.js 应用
Parcel零配置JS/Rust快速原型

趋势一句话:esbuild 解决"转译要快",Rust 化(Rolldown / Rspack / Turbopack)解决"打包要快",而"怎么拆包、怎么缓存"的方法论几十年不变。

一、Vite:开发与生产两套引擎

1.1 dev server 为什么快:ESM + 按需编译

text
请求 /src/main.ts
   └─ Vite 只转换「这个文件及其依赖」:
      1. import 解析:/src/foo.ts → /@fs/...foo.ts(带 query)
      2. 转译:esbuild 把 TS/JSX 变成 ESM(单文件毫秒级)
      3. 返回给浏览器,浏览器原生 <script type="module"> 按需拉取
  • 依赖预构建(optimize deps):启动时用 esbuild 把 node_modules 依赖预打包成 ESM,解决两个问题——① 浏览器对 CommonJS 不友好;② 上千个 npm 子模块逐个请求太慢(打包成若干 chunk 后浏览器少发几百个请求)。
  • 首次访问某文件才编译它 → 冷启动秒开、修改只重编改动的模块。

1.2 HMR 为什么快:模块热替换边界

text
修改 foo.ts
  → Vite 收到文件变更(chokidar)
  → 判断更新边界:foo.ts 是否被 import.meta.hot.accept() 接住
  → 能接住:只重发该模块,其「接受者」组件重新执行 update
  → 没接住:向上冒泡到最近能 accept 的模块,否则整页刷新

框架层(vue/react 插件)自动给组件注册 accept,于是改组件不丢状态。自定义模块若想热更新不刷新页面,自己写 import.meta.hot.accept

1.3 生产构建:Rollup 的活

vite build 默认走 Rollup:Tree-shaking(基于 ESM 静态分析)、产物 minify、代码分割、资源指纹。Vite 与 Rollup 生态共享插件机制(对象 { name, transform, load ... })。

演进:Rolldown(Rust 重写 Rollup,兼容其 API)自 Vite 6 起以实验形态(rolldown-vite)推进,目标是把 dev 的 esbuild 与 build 的 Rollup 统一到一个内核。日常使用以官方发布说明为准,不必追版本号。

1.4 一个 mini Vite 插件

ts
// vite-plugin-example.mjs
/** 去掉源码里 console.debug(生产环境) */
export default function stripDebug() {
  return {
    name: 'strip-debug',
    transform(code, id) {
      if (id.endsWith('.ts') && !id.includes('node_modules')) {
        return code.replace(/console\.debug\([\s\S]*?\)/g, '/* stripped */')
      }
    }
  }
}

二、Webpack:loader + plugin 的流水线

Webpack 把一切资源当作模块,构建 = 从入口出发构建依赖图,再按规则拆分 chunk。概念只要四个:

text
entry(入口)→ loaders(把非 JS 变成模块)→ plugins(干预任何生命周期)→ output(chunk 文件)

2.1 loader:文件 → 模块

loader 是纯转换函数:输入文件源码,输出可被 Webpack 理解的 JS 模块。链式执行(从右到左):

js
// webpack.config.js(示意,仅用于讲解机制)
module: {
  rules: [
    { test: /\.ts$/, use: ['ts-loader'] },
    { test: /\.scss$/, use: ['style-loader', 'css-loader', 'sass-loader'] },
    // scss 先被 sass-loader 编译成 css → css-loader 处理 import/url
    // → style-loader 注入 <style>(开发环境)
  ]
}

2.2 plugin:Tapable 钩子上的副作用

plugin 本质是"在打包生命周期各阶段挂钩子做事情"(产物注入、拆包、压缩、环境变量、进度上报……)。经典的 DefinePlugin

js
plugins: [
  new webpack.DefinePlugin({
    __DEV__: JSON.stringify(process.env.NODE_ENV !== 'production')
  })
]

2.3 chunk 与缓存策略

js
optimization: {
  splitChunks: {
    cacheGroups: {
      // 官方推荐思路:把「几乎不变」的三方库单独成包,靠文件名指纹长期缓存
      vendor: { test: /node_modules/, chunks: 'all', name: 'vendor' }
    }
  }
},
output: {
  filename: '[name].[contenthash:8].js' // 内容变了 hash 才变 → 命中缓存
}

坑 1(全工具通病):hash 粒度。contenthash 按文件内容算,改动一行只让对应 chunk 失效;用 hash(全局)会让所有缓存全部失效。

坑 2:vendor 拆太狠反而有害——HTTP/1.1 下并发连接数有限、请求头开销大;HTTP/2 下"多而小"还好,但拆包的最优解永远是"按需动态 import",而不是盲拆 vendor。

三、代码分割与加载策略(所有工具的同一目标)

ts
// 1. 路由级懒加载(最有效的一刀)
// React
const Product = lazy(() => import('./pages/Product'))
// Vue
const Product = () => import('./pages/Product.vue')

// 2. 重型库按需加载(编辑器、图表、PDF 等)
const previewPDF = async () => {
  const { PDFViewer } = await import('./pdf-viewer') // 首次点击才下载
  previewPDFEl = new PDFViewer()
}

// 3. 条件性预取:空闲时预加载「下一步很可能用到的」路由
<link rel="modulepreload" href="/assets/product-page.hash.js">
// 构建工具可自动把动态 import 页面的依赖打 modulepreload 列表

为什么这才是性能正道:首屏只加载首屏代码;其余代码(约占大应用 60–80%)分散到"用到时再拉"。配合 resource hints(preload/prefetch/modulepreload)能让"用户点之前"就开始下载。

四、构建速度优化(工程级速效方案)

手段原理收益
升级原生内核esbuild/Rolldown/Rspack 替代纯 JS 打包转译与打包 5–10x
持久化缓存Vite: build.cacheDir 默认开;Webpack: cache: { type: 'filesystem' }二次构建接近 0
缩小范围exclude node_modules、只编译变更(watch 增量)dev 更快
并行/懒加载大项目拆 Monorepo 分包构建、turbo 缓存全体提速
减少 loader 重复转换同一依赖被多份 transform → 统一走预构建/resolve.alias 到单版本编译量骤降
产物体检rollup-plugin-visualizer / vite-bundle-visualizer / source-map-explorer定位体积大户
sh
# Monorepo(pnpm workspace + turbo)典型脚本
# 只重跑受影响包及其依赖,命中缓存的任务瞬间完成
pnpm turbo run build --filter=my-app

坑 3:优化构建速度前先用可视化工具看体积分布。常见"慢"其实是:① dev 阶段没开产物缓存;② 项目里混了两个版本的同一库(axios 1.x 与 0.x 共存);③ 某个包体积 5MB 还同步 import 在入口。

五、工程要点与常见坑速查

  1. 循环依赖:A ↔ B 相互 import,Webpack/Vite 都能转但执行顺序不可控(出现 undefined)。改法:抽公共层 / 依赖注入 / 用 import type(类型循环无运行时影响)。
  2. 动态 import 的变量陷阱import(pathVar) 需要完整静态前缀才能被识别——import('./pages/' + name) 可用(会打包整个 pages 目录做 chunk 拆分),import(fullVar) 通常无法拆包甚至报错。用 /* @vite-ignore */ / webpackIgnore 显式声明"别分析我"。
  3. Tree-shaking 的敌人:CommonJS 动态 require、有副作用的模块(顶层 console.log、polyfill、CSS import 副作用被误标 tree-shake 掉)。库发布请用 ESM 且标记 "sideEffects": false(有副作用文件显式列出)。
  4. CSS 顺序竞态:CSS-in-JS / 多 chunk 注入 CSS 顺序不稳定。用独立 CSS 文件 + 让构建器按 chunk 依赖排序注入。
  5. .env 与构建产物VITE_/REACT_APP_ 前缀的变量会在编译时被打进 bundle。密钥永远不能进前端代码——安全由服务端背。
  6. sourcemap 泄露:生产默认不开 sourcemap: true,需要定位线上问题用 hidden-source-map(不暴露 map 地址)或接监控平台,别把源码直接外发。

参考链接

基于 VitePress 构建 · 内容以知识共享方式沉淀