Vite 为什么比 Webpack 快?从 bundleless 到 esbuild 的设计哲学

1 阅读1分钟

Vite 为什么比 Webpack 快?从 bundleless 到 esbuild 的设计哲学

一个真实的崩溃瞬间

项目大了之后,npm run dev 的等待时间会变成一种修行。

我之前维护过一个 Vue2 + Webpack 的中后台项目,200 多个页面,启动一次 dev server 要 90 秒。改一行 CSS,HMR 要转 3~5 秒。你盯着终端看编译进度,手里的咖啡都凉了,它还在 building modules...

后来迁到 Vite,冷启动 1.2 秒,HMR 几乎无感。

快了 70 倍,不是调参调出来的,是底层设计思路完全不同

这篇文章不聊配置,聊的是:Vite 到底做了什么不同的决策,才把速度拉到这个量级?Webpack 慢的根因又是什么?


Webpack 慢在哪?不是它不努力

先搞清楚一件事:Webpack 不是写得烂,它是做得多

Webpack 的核心流程长这样:

入口文件 → 递归分析所有依赖 → 全部转译 → 打成 bundle → 启动 dev server

注意这里的关键词:全部

不管你改的是第 1 个文件还是第 200 个文件,启动时 Webpack 都要把整棵依赖树走一遍。用简化代码模拟一下这个过程:

// Webpack 的核心逻辑(极简版)
function build(entry) {
  const graph = {} // 依赖图

  function walk(file) {
    if (graph[file]) return // 已处理过,跳过
    const code = fs.readFileSync(file, 'utf-8')
    const ast = parse(code)                    // 解析成 AST
    const deps = extractImports(ast)           // 提取 import 语句
    const transformed = transform(ast)         // Babel/TS 转译
    graph[file] = { code: transformed, deps }
    deps.forEach(dep => walk(resolve(dep)))    // 递归处理每个依赖
  }

  walk(entry)             // 从入口开始,走完整棵树
  return bundle(graph)    // 全部拼成一个(或几个)文件
}

// 项目有 1000 个模块?那就走 1000 次。
// 你只想看首页?不好意思,它全都要。

这就是问题的本质:Webpack 在你看到页面之前,已经把整个项目编译了一遍

它不是慢,它是干的活太多了


Vite 的核心思路:你要什么我给什么

Vite 换了一个完全不同的策略——不打包,按需编译

它的 dev server 启动流程是这样的:

启动 dev server → 等浏览器请求 → 请求哪个文件就编译哪个

没错,启动的时候 Vite 几乎什么都不做。它就起了个 HTTP 服务器,坐在那等着。

浏览器加载你的 index.html,发现里面有个 <script type="module" src="/src/main.ts">,就向 dev server 请求这个文件。Vite 收到请求,这时候才去编译 main.ts。编译完发现它 import 了 App.vue,浏览器又会发一个请求来要 App.vue,Vite 再编译。

整个过程像自助餐——你拿什么我做什么,而不是一上来就把满汉全席做齐。

// Vite 的 dev server 核心逻辑(极简版)
const server = http.createServer(async (req, res) => {
  const url = req.url  // 比如 /src/App.vue

  // 收到请求才编译,不提前干活
  const file = resolve(root, url)
  const code = await fs.readFile(file, 'utf-8')
  const result = await transform(code, file)   // 单文件编译

  // 把 import 路径改成浏览器能识别的
  // import { ref } from 'vue'  →  import { ref } from '/node_modules/.vite/vue.js'
  const rewritten = rewriteImports(result)

  res.setHeader('Content-Type', 'application/javascript')
  res.end(rewritten)
})

// 1000 个模块的项目,首页只用了 30 个?
// 那就只编译 30 个。剩下 970 个根本不碰。

这就是所谓的 bundleless——开发阶段不打 bundle,直接利用浏览器原生的 ES Module 加载机制。


为什么以前不这么做?

你可能会想:这思路这么简单,为什么 Webpack 不这么干?

因为以前不行

Webpack 诞生于 2014 年,那时候浏览器不支持 ES Module。import/export 语法浏览器根本不认识,你必须打成 bundle 用 <script> 标签加载。Webpack 做的事情本质上是给浏览器补课

到了 2020 年,主流浏览器全面支持 <script type="module">,这个前提条件才成立。Vite 踩在了正确的时间点上。

这不是技术的胜利,是时机的胜利


esbuild:Vite 的第二个杀手锏

光靠 bundleless 还不够。

虽然 Vite 按需编译,但每个文件还是要经过转译(TypeScript → JavaScript、JSX → JavaScript 等)。如果用 Babel 或 tsc 来做这个事情,单文件编译也不会很快。

Vite 的选择是 esbuild——一个用 Go 写的 JavaScript 打包器/转译器。

它有多快?看一组官方数据:

工具编译 Three.js(10 次)语言
esbuild0.37sGo
Webpack 541.2sJavaScript
Parcel 232.1sJavaScript
Rollup + Terser34.1sJavaScript

快了约 100 倍。不是百分之一百,是一百

为什么 esbuild 这么快?三个核心原因:

原因一:Go vs JavaScript

// JavaScript 是单线程的(就算有 worker,调度开销也大)
// Go 天生支持多核并行,goroutine 切换成本极低

// 类比:
// JavaScript → 一个厨师做满汉全席
// Go         → 十个厨师同时开干,还共享一个备菜台

原因二:编译,不解释

JavaScript 是解释型语言,V8 需要先解析再 JIT 编译再执行。Go 是直接编译成机器码,没有中间商赚差价。

原因三:esbuild 从零设计,不背历史包袱

Babel 要兼容各种插件、各种 preset、各种边界情况。esbuild 只做最核心的事:解析 + 转译 + 打包。没有插件系统(到后来才加了有限的 plugin API),没有复杂的配置,极简到极致。

这就是为什么 Vite 的预构建(pre-bundling)也用 esbuild:

// Vite 启动时会对 node_modules 做一次预构建
// 把 lodash-es 的 600+ 个文件合成一个
// 否则浏览器要发 600 个 HTTP 请求,直接卡死

// vite.config.ts
export default {
  optimizeDeps: {
    include: ['lodash-es', 'axios']   // 这些依赖会被 esbuild 预打包
    // esbuild 处理 lodash-es:~130ms
    // Webpack 处理 lodash-es:~3200ms
    // 写到这里我开始怀疑以前的等待是不是都白费了
  }
}

HMR:改了一行代码,更新几个文件?

Hot Module Replacement 是开发体验的核心。改一行代码,页面要多久才能更新?

Webpack 的 HMR 是这样的:

文件变化 → 重新编译受影响的 chunk → 把整个 chunk 发给浏览器 → 替换

一个 chunk 可能包含几十个模块。你改了一行,它要重新编译和传输整个 chunk。项目越大,chunk 越大,HMR 越慢。

Vite 的 HMR 完全不同:

文件变化 → 只编译这一个文件 → 通过 WebSocket 通知浏览器 → 浏览器重新请求这一个文件
// Vite HMR 的核心:模块级别的精准更新
// 改了 Button.vue,只有 Button.vue 重新编译和传输
// 不管项目有 100 个还是 10000 个组件,HMR 速度恒定

// Button.vue 的 HMR 边界
if (import.meta.hot) {
  import.meta.hot.accept()  // 告诉 Vite:这个模块变了,我自己处理更新
  // 不需要冒泡到父组件,不需要刷新页面
}

这就是为什么 Vite 的 HMR 几乎不受项目规模影响——它的更新粒度是单个模块,而不是 chunk。


设计权衡:Vite 不是银弹

说了这么多优点,该说说代价了。

代价一:开发和生产环境不一致

Vite 开发用 esbuild + 原生 ESM,生产用 Rollup 打包。两套系统意味着:

// 开发环境正常,生产环境挂了——这种事真的会发生
// 最常见的场景:

// 1. 某个依赖在 ESM 下跑得好好的,Rollup 打包时 CJS 转换出问题
import something from 'some-legacy-lib'
// dev: ✅  build: ❌ "xxx is not a function"

// 2. 环境变量的处理时机不同
// dev 环境 import.meta.env 是运行时注入
// build 环境是编译时静态替换

这是 Vite 被吐槽最多的点。不过 Vite 6 已经在推进 Environment API,目标就是统一这两套体系。

代价二:首屏请求瀑布

bundleless 的另一面是请求数爆炸

main.ts → import App.vue
  App.vue → import Header.vue, Sidebar.vue, Content.vue
    Header.vue → import Logo.vue, Nav.vue
      Nav.vue → import NavItem.vue
        ...

浏览器要一层层加载,形成请求瀑布。Webpack 打成一个 bundle,一次请求全搞定。

Vite 的应对策略:

<!-- Vite 会自动注入 modulepreload,告诉浏览器提前加载 -->
<link rel="modulepreload" href="/src/components/Header.vue" />
<link rel="modulepreload" href="/src/components/Sidebar.vue" />
<!-- 把串行请求变成并行预加载,缓解瀑布效应 -->

但对于超深依赖链,体验还是不如 bundle。这就是为什么生产环境 Vite 仍然选择打包。

代价三:esbuild 的能力边界

esbuild 快是快,但它不做类型检查

// esbuild 遇到 TypeScript,直接把类型标注删掉,不检查
const x: number = "hello"  // esbuild:没问题,删掉类型标注就是合法 JS
                            // tsc:报错!string 不能赋值给 number

// 所以你还是需要单独跑 tsc --noEmit 或者 vue-tsc 做类型检查
// Vite 把"编译"和"检查"拆成了两件事

这个设计哲学很有意思:做一件事,做到极致。类型检查交给专业工具,esbuild 只管编译速度。


从更高的视角看:这是什么类型的架构决策?

Vite vs Webpack 的本质,是两种经典的系统设计思路之争:

Webpack:Eager Evaluation(急切求值)

  • 提前把所有事做完
  • 首次慢,后续稳定
  • 像编译型语言

Vite:Lazy Evaluation(惰性求值)

  • 需要什么算什么
  • 首次快,按需付出代价
  • 像解释型语言
// 这个模式在计算机科学中到处都是:

// 数据库:全表扫描 vs 索引查询
// React:全量 diff vs 信号(Signals)精准更新
// 操作系统:进程预加载 vs 按需加载(动态链接库)
// 后端:预计算缓存 vs 实时计算

// Vite 的选择本质上是:
// "在开发阶段,大部分代码你根本用不到,何必提前编译?"

这不是谁对谁错的问题,是场景匹配的问题。


什么时候 Webpack 仍然是更好的选择?

别急着删 webpack.config.js,这些场景 Vite 还不够香:

  1. 微前端宿主应用——Module Federation 目前 Webpack 生态更成熟
  2. 需要深度定制编译流程——Webpack 的 loader + plugin 生态无人能敌
  3. 老项目迁移成本高——几百个 Webpack loader 配置,迁移不是改个 config 的事
  4. 对产物一致性要求极高——银行、政府类项目,两套编译系统是个风险点

总结:快不是目的,快在对的地方才是

Vite 快的原因可以浓缩成三句话:

  1. 少干活——bundleless 按需编译,不做无用功
  2. 用对工具——esbuild 用 Go 写,比 JavaScript 工具链快两个数量级
  3. 踩对时机——浏览器原生支持 ESM,才让 bundleless 成为可能

但更值得记住的是它背后的设计哲学:

不要试图把一件事做得更快,而是思考这件事是否需要做。

Webpack 花了十年优化"如何更快地打包"。Vite 说:开发阶段根本不需要打包。

这种换个角度看问题的思维方式,比任何工具链的配置技巧都有价值。下次你的系统慢了,别急着加缓存、加并发。先问自己一个问题:

这件事,是不是根本不需要做?