构建工具的迭代方向很清晰:开发时尽可能少做工作,生产时尽可能多做优化。理解这条主线,就能看懂冷启动速度差异的来源。
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)做两件事:
- 格式转换:把 CJS/UMD 转成 ESM,让浏览器能直接
import; - 合并:把内部有大量小文件的依赖打成单个文件,减少请求数。
// 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,把自定义能力收敛到少量插件里。
Comments (3)
第三段那个表总结得好,比我看过的书都清楚。
有个小错:例子里的参数名好像写反了,不影响理解。
这个我可以证明,确实有效,就是前期配置挺折腾。
Please Sign in to join the discussion.