7AA7 Blog

前端构建工具演进:Webpack、Vite 与 esbuild

晚棠 UID:77
2026-09-28 20:10
49 3 1

构建工具的迭代方向很清晰:开发时尽可能少做工作,生产时尽可能多做优化。理解这条主线,就能看懂冷启动速度差异的来源。

bundle 与 no-bundle:dev server 的本质差异

传统打包式 dev server(Webpack Dev Server)的工作方式:启动时从入口开始遍历整个模块图,把所有模块合并成若干 bundle,存进内存,然后才能提供服务。改一个文件,它需要重新计算受影响的那部分 bundle。

原生 ESM 方案(Vite 为代表)不打包:浏览器直接请求 /src/main.js,服务器把这个文件转译后返回;浏览器解析其中的 import 语句,再发起下一个请求。

<!-- 开发态直接交给浏览器 -->
<script type="module" src="/src/main.js"></script>
// 浏览器按 import 逐个拉取,服务端按需转译
import { createApp } from '/node_modules/.vite/deps/vue.js';
import App from '/src/App.vue';

这意味着冷启动时间与项目模块总数基本无关——服务器只处理当前页面真正请求到的模块。项目越大,差距越明显。

冷启动慢的根因

Webpack 冷启动耗时主要花在这几处:

  • 模块图遍历:几千个模块逐个读取、解析、生成 AST、建立依赖关系;
  • loader 链串行:一个 .vue 文件可能依次经过 vue-loader、babel-loader、ts-loader,每个都是完整的文件读取与 AST 转换;
  • source map 生成:开发模式下的 eval-source-map 成本不低;
  • JS 单线程:解析工作都跑在一个 V8 线程上,多核帮不上忙。

no-bundle 方案把”遍历全图”这一步删掉了,只保留”请求到哪个转译哪个”。剩下的耗时主要是依赖预构建,只跑一次并缓存。

依赖预构建解决什么

直接用原生 ESM 有个硬伤:node_modules 里的很多包不是 ESM 格式(仍是 CJS),而且一个包可能由几百个细碎文件组成。

// lodash-es 展开后可能有几百个 import,浏览器要发几百个请求
import { debounce } from 'lodash-es';

如果不处理,一个 import 会引发瀑布式请求,HTTP/1.1 下的队头阻塞会让首屏加载极慢。

预构建(pre-bundle / optimizeDeps)做两件事:

  1. 格式转换:把 CJS/UMD 转成 ESM,让浏览器能直接 import;
  2. 合并:把内部有大量小文件的依赖打成单个文件,减少请求数。
// vite.config.js
export default {
  optimizeDeps: {
    include: ['lodash-es', 'echarts/core'],  // 强制预构建
    exclude: ['some-esm-only-pkg'],          // 明确排除
  },
};

踩坑点在于预构建的触发时机是运行时扫描。如果一个依赖是动态引入、或者由插件在转译后才产生 import,扫描器第一次没发现,就会在运行中途触发重新预构建并强制刷新页面。用 include 显式声明能消除这类”开发时页面突然 reload”的怪现象。

另外预构建产物会缓存到 node_modules/.vite,换分支、升级依赖后偶尔需要手动清缓存。

HMR 的实现层次

热更新有不同的精细度:

层次 表现 状态保留
整页刷新 Live Reload 全部丢失
模块级替换 HMR 取决于 accept 边界
组件级替换 框架插件(React Refresh / Vue HMR) 组件状态保留
样式替换 CSS HMR 无需刷新

关键在于更新边界。原生 HMR API 需要模块自己声明接受更新:

if (import.meta.hot) {
  import.meta.hot.accept('./render.js', (newMod) => {
    newMod.render();       // 手动重新执行
  });
  import.meta.hot.dispose(() => { clearTimer(); }); // 清理副作用
}

框架插件做的是把这个手写过程自动化:Vue 的 SFC 热更新会保留组件实例的响应式状态;React Fast Refresh 会保留函数组件状态(在 hook 顺序不变的前提下)。

会退化成整页刷新的情况值得记住:修改了没有 accept 边界的模块、修改了 hook 调用顺序、修改了被非热更新模块静态导入的模块。看到页面刷新时先判断是不是这几类。

生产构建为什么还要打包

开发时可以懒,生产不行。原因很实际:

  • 请求数:即使 HTTP/2 多路复用,几千个请求仍有头部开销、连接调度成本、服务端压力;
  • 压缩率:打包后整个 bundle 共享一份字典,gzip/brotli 压缩率显著高于逐个压缩小文件;
  • tree shaking:需要静态分析整个模块图才能确定哪些导出没被用到,未打包的 ESM 做不到跨文件的死代码消除;
  • 作用域提升(scope hoisting):把模块合并进同一个作用域,减少函数包装与闭包开销;
  • 代码分割与长缓存:按路由切 chunk、提取稳定的 vendor chunk 配合内容哈希。
export default {
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          vendor: ['vue', 'vue-router'],
          charts: ['echarts'],
        },
      },
    },
  },
};

所以 Vite 生产环境默认走 Rollup:开发用 esbuild 换速度,生产用 Rollup 换产物质量。esbuild 也能用于生产构建,但它在代码分割和 CSS 处理上的策略与 Rollup 不同,切换前要评估产物差异。

代码分割的粒度取舍

分割不是越细越好。每个 chunk 都有固定开销:请求头、连接调度、浏览器解析与编译新脚本的成本、以及无法跨 chunk 做作用域提升。

// 过细:每个小组件一个 chunk,可能产出上百个几 KB 的文件
const Widget = lazy(() => import('./widgets/Counter'));

粒度判断的经验:

  • 按路由分是基本盘,首屏不需要的代码一律不进主 chunk;
  • 体积大且不是每个用户都用得到的模块(图表库、编辑器、PDF 预览)单独切;
  • 被多个 chunk 共用的模块提取成共享 chunk,避免重复打包;
  • 单个 chunk 压缩后只有几 KB 时,合并的收益通常大于分割;
  • HTTP/2 下请求数不再是硬约束,但解析与编译成本仍在。

import() 的触发时机同样重要。纯路由级懒加载意味着点击后才开始下载,可以配合悬停或空闲时段提前拉取:

link.addEventListener('mouseenter', () => import('./pages/Order'), { once: true });

构建速度与调试信息的平衡

开发态 sourcemap 的选择直接影响构建速度与堆栈可用性。全量 source-map 最准确也最慢,eval-cheap-module-source-map 在速度与可用堆栈之间比较平衡。

生产态一般不在产物里放 map 文件(避免暴露源码),而是生成后上传到内部的符号服务,配合监控平台还原堆栈。这样既不拖慢产物加载,又保留了排查能力。

原生语言工具快在哪

esbuild(Go)、SWC(Rust)这类工具比 Babel/Webpack 快,不是因为”写得更好”,而是结构性原因:

  • 编译型语言,无 JIT 预热:Go/Rust 编译成机器码直接执行,没有 V8 的解释执行与优化编译阶段;
  • 真正的多线程:esbuild 把解析、转译、生成三个阶段做成并行流水线,多核直接吃满;Babel 即使开多进程,进程间通信与序列化开销也吃掉不少收益;
  • 内存布局紧凑:esbuild 全程只遍历 AST 三次,尽量复用数据结构,避免 JS 对象的高分配率与 GC 压力;
  • 单一职责:转译器只做转译,不做插件调度、模块图管理、chunk 策略,自然轻。
// 只用 esbuild 做一件事:TS 转 JS
await esbuild.transform(code, { loader: 'ts', target: 'es2020' });

代价是可扩展性。Babel 的插件生态能做任何 AST 变换,esbuild/SWC 的插件能力相对受限,自定义 AST 操作往往得用 Rust/Go 写或者退回 JS 工具链。

选型时的现实约束

  • 老项目迁移:大量自定义 Webpack loader/plugin、Module Federation 依赖、特殊的 chunk 策略,迁移成本可能远超收益;
  • Monorepo 与库开发:库产物通常需要多格式(ESM/CJS/UMD)+ dts,这类场景的构建器选型看产物控制能力,不看 dev server 速度;
  • 浏览器兼容目标:若必须支持很旧的浏览器,转译与 polyfill 链会变长,构建时间优势会被抵消一部分;
  • 团队工具链一致性:CI、测试、部署脚本都绑定了现有构建产物结构,替换工具意味着全套联调。

一个折中做法很常见:保留 Webpack 做生产构建,用更快的转译器(把 babel-loader 换成 swc-loader)改善开发体验。或者反过来,新项目直接上 Vite,把自定义能力收敛到少量插件里。

1.6kViews
49Likes
3Comments
3Participants
11 minread time

Comments (3)

  • 舒野 · 2026-09-28 22:15

    第三段那个表总结得好,比我看过的书都清楚。

    Reply
  • 周牧野 · 2026-09-29 07:42

    有个小错:例子里的参数名好像写反了,不影响理解。

    Reply
  • NullRef · 2026-09-29 16:57

    这个我可以证明,确实有效,就是前期配置挺折腾。

    Reply