Skip to content
前端构建工具的演进:从打包到按需
在浏览器还没有原生 ES modules 的年代,前端代码组织严重依赖打包工具。开发者写好多个 JS 文件,再通过 Webpack、Rollup 等把它们合并成一个完整的 bundle,浏览器只需加载这一个(或少数几个)脚本即可运行整个应用。
这个模式被广泛使用,但它有一个性能瓶颈:打包器必须在浏览器能够展示任何内容之前,先将整个应用处理一遍。项目规模越大,等待时间越长——大型项目中启动一次开发服务器可能需要 30 秒甚至更久,修改代码后看到效果更新又要等上数十秒。消耗的时间主要花在了模块解析、依赖图构建和打包输出上。
浏览器的能力也在进化。2017 年起,主流浏览器陆续支持 <script type="module">,可以直接加载和解析 ES modules。于是出现了另一种思路:让浏览器在处理模块加载这件事上做回主角。
这就是 Vite 的切入点。
Vite 的核心理念:基于原生 ES modules 的开发服务器
Vite 的开发服务器不进行全量打包。它利用浏览器对 ES modules 的原生支持,把应用代码拆成两部分区别对待:
- 依赖(node_modules):这些几乎不变化的第三方库,在首次启动时用 esbuild 预构建一次,后续只要依赖不变就复用缓存。
- 源码(src 目录):开发者自己的应用代码,以 ESM 形式直接提供给浏览器。浏览器按 import 关系逐层加载,Vite 在服务端拦截请求,对每个文件进行必要的编译后再返回。
启动一个 Vite 项目,运行:
bash
npx viteVite 会立即启动一个开发服务器,输出类似:
VITE v5.x.x ready in 320 ms
➜ Local: http://localhost:5173/
➜ Network: use --host to expose注意 ready in 320 ms。在没有缓存的首次启动下,它只需要做依赖预构建;源码部分不做任何预处理,启动几乎即时完成。再次启动项目时,依赖缓存依然有效,服务器甚至能在 100ms 内就绪。
对比之下,传统打包器必须在启动阶段遍历整个依赖图并打包,起步等待时间与项目复杂性成正比。Vite 避开了这一步。
浏览器如何利用模块加载能力?入口 HTML 文件在 Vite 中是这样子的:
html
<html>
<head>...</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>浏览器解析到 <script type="module"> 时,会发起对 /src/main.js 的 HTTP 请求。Vite 开发服务器收到请求后,在内存中读取文件,经过编译流水线(比如把 Vue 单文件组件拆成 JS 和 CSS,或对 TypeScript 进行转译),将结果以 application/javascript MIME 类型返回给浏览器。
一个简单的 main.js 可能这样写:
js
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')浏览器加载这段代码时,又会发起对 vue 和 ./App.vue 的请求。Vite 将 vue 映射到预构建好的 ESM 模块,将 ./App.vue 编译成 JS 后返回。所有这些都是按浏览器请求“逐个提供”,而不是一次性打包送出。
依赖预构建:预先优化依赖加载
node_modules 中的依赖有两个问题必须解决,否则原生 ESM 无法直接使用:
- 模块格式不兼容:大量 npm 包仍以 CommonJS 形式发布(
module.exports = .../require(...)),浏览器不认识这种格式。 - 过多的模块请求:有些 ESM 包会将功能拆分成众多内部模块。例如
lodash-es包含 600 多个单独的 JS 文件,如果浏览器逐个请求这些模块,光是网络往返开销就足以让页面加载变慢。
Vite 的解决方式叫作依赖预构建(Dependency Pre-Bundling)。首次运行 vite 时,它会自动扫描源码中来自 node_modules 的 bare import(即 import xxx from 'xxx' 这种没有相对路径的导入),将这些依赖用 esbuild 打包成单个 ESM 文件,缓存到 node_modules/.vite/deps/ 目录下。
预构建流程对开发者透明,但如果想看看具体做了什么,可以检查缓存目录:
text
node_modules/.vite/deps/
vue.js
lodash-es.js
...和其他预构建产物预构建完成后,浏览器对 vue 的请求,实际上会被 Vite 映射到 node_modules/.vite/deps/vue.js 这个单个文件。同时 Vite 会在响应头里设置 Cache-Control: max-age=31536000,immutable 强缓存,让浏览器即使刷新页面也不用重新请求这些不变的依赖。这带来两个效果:
- CommonJS 依赖被自动转换成 ESM,开发者可以直接使用
import React, { useState } from 'react'而无须关心兼容性。 - 原本需要成百上千个请求的模块,现在一个 HTTP 请求就搞定。
如果需要手动调整预构建行为,可以在 vite.config.js 中配置:
js
import { defineConfig } from 'vite'
export default defineConfig({
optimizeDeps: {
include: ['lodash-es', 'some-cjs-package'],
exclude: ['tiny-esm-lib']
}
})配置项的作用:
include:强制预构建指定依赖。比如某个深层依赖没有被自动扫描到,或者一个较大的 CommonJS 包需要提前打包,就显式加入。exclude:排除已经很小且格式干净的 ESM 依赖,避免不必要的预构建开销。
预构建仅对开发模式生效。Vite 会根据 package-lock.json(或 yarn.lock、pnpm-lock.yaml)的内容以及 vite.config.js 相关字段的变化,自动决定是否需要重新预构建。如果强制重启服务器(vite --force),也会清除缓存并重新构建。
开发体验:冷启动与 HMR
冷启动:避免全量打包
传统打包器的冷启动时间与项目规模成正比。曾经有项目使用 Webpack 启动 dev server 需要 50 秒以上,这直接影响了开发者的专注度和迭代节奏。
Vite 冷启动快的核心原因就是“不打包”。源码文件仅在浏览器真正需要时才被请求和编译,初期不做任何全量分析。运行 npx vite 后,几乎立刻就能在浏览器中看到页面,后续第一次加载各个路由时才会按需编译对应模块。这种策略确保了无论项目多庞大,启动时间始终维持在一个较低常数级。
HMR:精准替换模块
热模块替换(HMR)是开发体验的另一关键。传统打包器在文件变化时,往往需要重新构建受影响的 chunk,然后推送到浏览器。如果构建工具不能精确定位到变化边界,可能会触发全量刷新,导致页面状态丢失。
Vite 基于原生 ESM 实现的 HMR 精确到了模块级别。当一个文件被修改,开发服务器只向浏览器发送受影响模块的更新信息,浏览器在运行状态下替换掉对应模块的代码,页面上其他部分的状态保持不变。
用一个简单例子说明:有一个 counter.js 文件:
js
export let count = 0
export function increment() {
count++
}页面中通过以下方式使用:
js
import { count, increment } from './counter.js'
// 更新 UI...当你编辑 counter.js,比如把 increment 改成每次加 2,浏览器只替换掉 counter.js 这个模块,然后重新执行依赖该模块的回调。计数器之前累加到的数值可以被保留(取决于具体实现和 HMR 钩子的处理),页面不会整体刷新。
这种基于 ESM 边界的 HMR 不需要重新计算整个依赖图,更新速度几乎与文件大小和项目模块数量无关。无论项目包含几千个模块还是几十个,HMR 通常都在几十毫秒内完成。
与 Webpack 的体验对比
同一中大型项目的平均数据:
| 指标 | Webpack | Vite |
|---|---|---|
| 冷启动时间 | 10~60+ 秒 | < 1 秒(首次预热构建后) |
| 普通文件修改热更新 | 1~5 秒 | < 100ms |
| 依赖缓存失效后重构建 | 需重新打包整个项目 | 重新预构建依赖(用 esbuild,通常 < 1 秒) |
Vite 并不是抛弃打包这个概念,而是把打包从开发阶段移到了最终构建阶段。这种分工带来的开发体验提升是全方位的。
构建输出为什么选择 Rollup
在开发阶段 Vite 以速度优先,尽量不打包、灵活编译。但到了需要交付给用户的时候,这种非打包的 ESM 方式就不合适了——过多的模块嵌套导入会带来大量网络往返,而且没有 tree-shaking、代码压缩等优化。因此构建阶段仍然需要打包。
Vite 的构建命令 vite build 内部集成了 Rollup,原因有三:
- Tree-shaking 能力出色:Rollup 对 ES modules 的静态分析最为彻底,能最大程度消除未使用的代码。
- 插件生态成熟:大量 npm 包提供 Rollup 插件,覆盖了各类代码转换和优化需求。Vite 插件系统本身基于 Rollup 插件 API,因此这些生态可以直接复用。
- 产出结构干净:Rollup 的打包输出非常适合部署到 CDN,没有太多的运行时代码。
构建直接运行:
bash
npx vite buildVite 会调用 Rollup,根据 vite.config.js 中的构建配置输出优化后的静态资源到 dist/ 目录。配置文件可以指明输出格式、代码分割策略等。
值得留意的是,Vite 团队正在用 Rust 开发一个名为 Rolldown 的新打包器,目标是统一开发和生产阶段的打包引擎,并与现有 Rollup 插件 API 兼容。截至本文编写时(2026 年 8 月),该项目的稳定版尚未正式取代 Rollup,Vite 的最终构建默认仍使用 Rollup,但未来的走向是让 Rolldown 在开发和实际使用中提供更一致的体验和更好的性能。[^1]
适用场景与限制
Vite 在下面这些场景中表现尤佳:
- 现代 Web 应用的开发:SPA、SSR 应用(如 Nuxt、SvelteKit、Astro 等框架都以 Vite 为基础)
- 库的开发与发布:利用 Vite 的 Library Mode 可以快速构建用于 npm 的包
- 与后端框架集成:Laravel、Rails 等框架通过 Vite 处理前端资源,替代了传统的 asset pipeline
也存在明确的限制:
- 源代码必须是 ESM 形式:虽然可以通过插件或配置支持其他模块系统,但这样做会违背 Vite 的初衷,且可能引入额外的复杂度。
- 非 ESM 依赖需要预构建:开发阶段虽然自动处理了,但在配置中有时需要手动介入。
- 需要现代浏览器:开发环境假定使用支持原生 ESM 的浏览器;对于过时的浏览器(如 IE 11),必须通过官方插件
@vitejs/plugin-legacy在构建阶段额外处理。 - 不能在浏览器代码中直接使用 Node.js 模块:类似
fs、path等模块在浏览器端无法使用,需要明确哪些是纯前端依赖。
另外,在 Monorepo 中如果包含了软链接的本地依赖,Vite 会将其视为源码,不进入预构建流程。如果这些链接依赖本身不是 ESM,就要将它们加入到 optimizeDeps.include 中,并且修改后需要执行 vite --force 来让预构建感知变化。
下一步
本篇覆盖了 Vite 的整体定位和核心设计思路。通过以上内容,应当建立起这些认知:
- Vite 把开发阶段的模块加载交给浏览器原生 ES modules,避免了全量打包;
- 通过依赖预构建解决了 CommonJS 兼容和请求数量问题;
- HMR 精准到模块,不受项目规模影响;
- 最终构建复用 Rollup 的优化能力,未来由 Rolldown 统一。
