0%

esbuild 详解

【面试速答版】

Q1: esbuild 是什么?它为什么这么快?

esbuild 是一个用 Go 语言 编写的 JavaScript 打包和转译工具,由 Evan Wallace 于 2020 年创建。

它快的原因主要有三点:

  1. Go 语言编写,直接编译为机器码:传统的 JavaScript 工具(如 Babel、Webpack)是用 JS 写的,在 Node.js 中解释执行。而 esbuild 是 Go 写的,编译成机器码直接运行,没有解析和编译的开销。在 CPU 密集型任务(如词法分析、语法解析、代码生成)上,Go 比 JS 快 10-100 倍。

  2. 重度并行和 CPU 利用率:Go 的 goroutine 和线程模型让 esbuild 能充分利用多核 CPU。esbuild 在解析、转译、代码生成等阶段都会并行处理多个文件,而 JS 工具受限于 Node.js 的单线程模型,即使加 worker_threads 也很难达到同样的并行效率。

  3. 从零实现的优化架构:esbuild 没有依赖任何第三方解析器或编译器,它的词法分析器、语法解析器、代码生成器都是从零手写的。这意味着它可以做全局的端到端优化——例如解析时直接生成中间表示,绕过 AST 序列化/反序列化的开销。同时它使用了 双缓冲的字符串处理 等技术减少内存分配和拷贝。

实际数据:esbuild 打包一个包含 10 个 Three.js 副本的代码库(约 31MB 源码),耗时约 0.37 秒,而 Webpack 需要 45 秒,Rollup 需要 32 秒,差距在 80-120 倍

Q2: esbuild 的主要功能和限制是什么?

主要功能

  • 打包(Bundling):将多个模块合并为一个或多个文件,支持 tree-shaking
  • 转译(Transpiling):将 TypeScript、JSX、ES2025+ 代码转为兼容的 JS
  • 压缩(Minification):删除空白、缩短变量名、消除死代码
  • 代码分割(Code Splitting):支持动态 import() 拆分输出
  • CSS 处理:打包和压缩 CSS 文件(限 2025 年 v0.25+ 版本,功能仍在扩展)

核心限制

  1. 不支持 TypeScript 类型检查:esbuild 会剥离类型注解,但不会做类型检查。编译时即使有类型错误也会直接输出。你需要另外运行 tsc --noEmit 做检查。
  2. 转译能力有限:esbuild 的转译目标是 ES2015(ES6),不支持老式语法(如装饰器、Object.assign 的精确 polyfill),也不像 Babel 那样有海量插件生态来精确控制转换行为。
  3. 插件系统较简单:esbuild 的插件 API 远不如 Webpack/Rollup 丰富。不支持自定义 resolve 逻辑中的钩子,插件间通信能力弱。
  4. 不支持 ES5 及以下:esbuild 无法将代码转译到 ES5(即不支持 IE11)。如果项目需要兼容 IE,必须配合 Babel。

Q3: esbuild 在前端工程化中通常用在哪些场景?

最佳场景是「性能瓶颈处的加速引擎」,而不是替代全链路构建工具

  1. Vite 的开发服务器转译引擎:Vite 用 esbuild 做 TypeScript/JSX 到 JS 的即时转译和依赖预构建。这是 esbuild 最广泛的应用场景。
  2. 代码压缩:在 Webpack 中通过 ESBuildMinifyPlugin 替代 TerserPlugin,压缩速度提升 10-20 倍。
  3. 生产构建的转译链:在复杂构建流程中,esbuild 做快速转译(TS→JS),再交给 Webpack/Rollup 做更精细的打包和兼容处理。
  4. CLI 工具/脚手架:用 esbuild 打包 CLI 工具,冷启动快、产物小。
  5. 微前端 / 独立子应用构建:需要快速构建多个独立的子应用时,esbuild 的速度优势非常明显。
  6. 后端构建:Node.js 服务端代码的构建(如 tsx 运行时),esbuild 被广泛用于即时编译 TS 代码。

不建议用 esbuild 做全部的场景:需要精确 TypeScript 类型检查的项目、需要兼容 IE11 的项目、依赖复杂 Babel 插件的项目。


【深入理解版】

1. esbuild 要解决什么问题?

1.1 构建瓶颈:当一个 10MB 的项目需要等 45 秒

假设你负责一个中大型前端项目,代码量约 10 万行,依赖 500+ 个 npm 包。日常工作流大概是这样的:

1
2
3
4
# 修改了一行代码,保存
# 等待 Webpack 重新打包...
# 等 HMR 完成... 通常 3-8 秒
# 刷新浏览器,看到效果

这在 2015 年是可以接受的。但到了 2025 年,前端项目的规模远超当年——一个普通中大型项目可能有 30-50 万行业务代码、1000+ 个 npm 依赖,微前端架构下多应用聚合后更夸张。

传统 JS 工具在这类场景下的表现:

操作传统 JS 工具用户等待时间
首次冷启动Babel + Webpack45 秒(大型项目)
增量构建Webpack HMR3-8 秒
生产构建Webpack + Terser5-10 分钟
TypeScript 编译tsc30 秒 - 2 分钟

每修改一行代码,等 3-8 秒看效果。如果一天改 100 次,就是 300-800 秒浪费在等待上。一年下来,一个开发团队可能浪费数周时间。

1.2 传统工具的瓶颈在哪

为什么 Webpack、Babel、Terser 这些 JS 工具慢?根本原因不是它们的代码写得差,而是 架构层面的天花板

瓶颈 1:JavaScript 语言的本质开销

1
2
3
4
// Babel 转译一行代码的 CPU 工作
sourceCode → parse → AST → traverse → transform → generate → outputCode
// 每一步都在 Node.js 中解释执行
// 内存分配、GC 停顿、动态类型检查... 这些开销在 CPU 密集型任务中放大

拿 Babel 举例。假设你有 10000 个 TypeScript 文件需要转译:

步骤Babel 做的事为什么慢
词法分析将源码拆分为 token 流纯 JS 循环,大量对象分配
语法解析根据 token 构建 AST递归下降解析,大量函数调用开销
遍历和转换遍历 AST 节点,应用每个插件每个插件都要遍历一次 AST
代码生成将 AST 转回字符串字符串拼接,大量内存碎片

瓶颈 2:单线程的局限

1
2
3
4
// JS 工具在 Node.js 中的执行模型(简化)
// 主线程: ──parse──parse──parse──parse── (串行)
// 即使使用 worker_threads(Node.js v10+),
// 通信开销和线程管理成本也不低

Node.js 是单线程事件循环模型。对于 I/O 密集型任务(文件读写、网络请求)这不是问题,但对于 CPU 密集型任务(语法解析、代码生成),单线程严重限制了性能。

你可能会想:「那我用 Node.js 的 worker_threads 并行不就好了?」

有两个问题:

  1. worker 通信成本高:每个 worker 是独立的 V8 实例,启动开销大,传递 AST 对象需要序列化/反序列化。
  2. 共享内存复杂:不像 Go 的 goroutine 能天然共享内存,JS worker 通过 postMessage 传递数据,遇到大对象时拷贝成本极高。

瓶颈 3:第三方依赖的叠加开销

1
2
3
4
5
6
7
8
9
10
11
12
13
// Babel 的插件系统
{
plugins: [
"@babel/plugin-transform-arrow-functions",
"@babel/plugin-transform-classes",
"@babel/plugin-transform-destructuring",
// ... 可能 10-20 个插件
]
}

// 每个插件独立遍历 AST → 修改 → 返回
// AST 被反复遍历 N 次(N = 插件数量)
// 每个插件都有自己的对象创建和内存分配

Webpack 的 loader 链也是类似问题:css-loader → postcss-loader → sass-loader 每个 loader 独立处理内容,大量中间数据被反复序列化。

1.3 esbuild 的破局思路

esbuild 的作者 Evan Wallace 在 2020 年初面临同样的痛点。他当时是 Figma 的联合创始人,Figma 的前端代码量巨大,传统构建工具已难以忍受。

他的思路很简单:既然 CPU 密集型的构建任务在 JS 中跑不快,为什么不换一种语言?

选 Go 的原因:

  • 编译为机器码,无解析执行开销:Go 直接编译成 .exe 文件,不需要 VM 解释。
  • 原生线程 + goroutine:Go 的 goroutine 开销极小(约 2KB 栈),可以轻松创建数万个并发的解析任务。
  • 内存管理高效:Go 的 GC 针对高并发场景优化,不像 V8 的 GC 在大量临时对象时会产生明显的停顿。
  • 跨平台编译友好:交叉编译非常方便,适合作为 CLI 工具分发。

类比:如果把 Babel/Webpack 比作一辆高性能跑车(JS),引擎虽强但被限速在市区道路(单线程);esbuild 就是一辆高铁(Go),有独立轨道(机器码 + 多线程)和专用信号系统(手写优化),能跑出数量级的速度差距。

2. 核心原理:esbuild 为什么这么快?

2.1 从零手写,不做「缝合怪」

esbuild 的架构是完全自研的,没有依赖任何一个现成的解析器或编译器。

1
2
3
4
5
6
7
8
9
10
11
源代码

词法分析器(Lexer)── 手写,极简状态机

语法解析器(Parser)── 手写,递归下降,一次遍历生成 AST + 中间表示

链接/打包(Linker) ── 模块图构建、tree-shaking、代码合并

代码生成(CodeGen)── 手写,双缓冲字符串处理

目标代码

为什么每个组件都手写很重要?因为跨语言的序列化开销是性能杀手

如果 esbuild 用了现成的 JS 解析器(比如用 Go 调用一个 C 语言的解析器),那么每一步之间都需要:

1
解析结果 → 转换为通用表示 → 内存拷贝 → 传递 → 反向转换

而 esbuild 全部手写,可以跨越各个阶段做全局优化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 伪代码:esbuild 内部各阶段的紧密耦合
// 词法分析器直接向解析器传递 token(共享内存,零拷贝)
type lexer struct {
source []byte
pos int
// 直接指向输出 buffer,省去中间存储
output *codegen.Buffer
}

// 解析器能直接跳过不用的节点(为 tree-shaking 提供信息)
func (p *parser) parseIfUnused() bool {
// 如果这个导出未被使用,解析器直接跳过详细解析
// 只记录类型签名,不构建完整 AST 子树
return p.lexer.skipToNextStatement()
}

2.2 词法分析:极简状态机

大多数 JS 词法分析器(包括 Babel 和 V8)都是基于 DFA(确定性有限自动机) 实现的,每一步都要查表计算状态转移。

esbuild 的词法分析器用一种更激进的方式实现:直接根据字节值做分支判断,没有状态转移表。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// esbuild lexer 的核心思想(简化伪代码)
func (l *lexer) next() token {
switch {
case l.current() == '/' && l.peek(1) == '/':
// 行注释,直接跳到行尾
l.advanceWhileNot('\n')
case l.current() == '/' && l.peek(1) == '*':
// 块注释,跳过
l.advanceUntil("*/")
case l.current() == '"' || l.current() == '\'':
// 字符串字面量
return l.readString()
case isDigit(l.current()):
// 数字字面量
return l.readNumber()
// ... 其他分支
}
}

这种手写分支的好处:

  • 没有虚函数调用:每个字符的判断是简单的字节比较和跳转
  • 分支预测友好:现代 CPU 的分支预测器能很好地优化这类简单的条件分支
  • 零内存分配:token 不创建对象,直接在输入流上通过指针偏移标记位置

2.3 并行解析:分块 + 按需

esbuild 对并行做了精心设计。它的策略比简单的「开 N 个 worker 并行解析」要精细得多。

解析阶段的三层并行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
第一层:文件级别的并行
并发池
├── 解析文件 1 (goroutine 1)
├── 解析文件 2 (goroutine 2)
├── 解析文件 3 (goroutine 3)
├── ...
└── 解析文件 N (goroutine N)

第二层:单个文件内的并行(对大型文件)
如果单个文件极大(如 10MB 的 vendor chunk),
esbuild 会将其分割为多个 chunk,分配给多个 goroutine 解析。

第三层:按需解析(懒解析)
对于未被使用的导出,esbuild 只解析到知道「有这个导出」为止,
不解析完整 AST。这个决策在 parse 阶段实时做出。

最关键的设计:解析后的模块直接进入共享内存。因为所有 goroutine 运行在同一个进程内,解析结果可以直接通过指针引用,不需要序列化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// esbuild 的并行解析架构(概念验证)
func (b *bundler) parseFiles(files []string) []*Module {
results := make([]*Module, len(files))
var wg sync.WaitGroup

// 使用工作池限制并发数(通常 = CPU 核数)
sem := make(chan struct{}, runtime.NumCPU())

for i, file := range files {
wg.Add(1)
go func(idx int, path string) {
defer wg.Done()
sem <- struct{}{} // 获取信号量
defer func() { <-sem }() // 释放信号量

source := readFile(path)
m := parseFile(source) // 在 goroutine 中解析
results[idx] = m // 直接写入共享切片,无拷贝
}(i, file)
}

wg.Wait()
return results
}

对比 JS 工具的 worker 方案:

特性esbuild (Go goroutine)JS 工具 (Node worker_threads)
启动开销~2KB 栈~2MB 内存 + 独立 V8 实例
共享数据指针直接访问必须 postMessage 序列化
创建数量可创建数十万通常不超过 CPU 核数
通信延迟纳秒级(同进程)微秒级(序列化/反序列化)

2.4 字符串处理:双缓冲技术

代码生成阶段的字符串处理是构建工具中非常容易被低估的瓶颈。想象一下在一个 10 万行的大项目里,esbuild 要把所有模块的代码拼接成一个输出文件,这个过程中产生的字符串操作次数在百万级。

JS 工具的做法通常是:

1
2
3
4
5
6
7
// Babel 的代码生成器(简化)
let code = '';
for (const node of ast.body) {
code += generate(node); // 每次 += 都创建新字符串
}
// 这样写的问题:O(n²) 的时间复杂度
// 每次 += 都要创建新的字符串对象,老的字符串等 GC 回收

esbuild 使用了 双缓冲技术

1
2
3
4
5
6
7
8
// esbuild 的代码生成器(概念)
type CodeGen struct {
a bytes.Buffer // 主缓冲
b bytes.Buffer // 辅缓冲(用于预生成)
}

// 在解析阶段就开始生成部分代码
// 将代码生成嵌入到解析过程中,而不是等解析完再单独遍历 AST

具体来说,esbuild 在解析阶段就已经开始生成输出代码(不是完全等解析完再做 codegen)。它的做法是:

  1. 预分配大 buffer:提前估算输出大小,一次性分配够大的字节数组。
  2. 双缓冲切换:两个 buffer 轮流做「当前写入」和「后台刷新」,当 buffer A 写入时,buffer B 的内容已经被输出或复用。
  3. 零拷贝字符串处理:对于模块间的共享代码(如 __export 等 helper 函数),esbuild 只在最终输出中保留一份,其他位置引用指针而非复制字符串。

这样做的效果:内存分配次数大幅减少,GC 压力极低,字符串拼接接近 O(n) 的时间复杂度

2.5 模块链接中的「字节级」优化

打包工具的「链接(linking)」阶段通常是最复杂的——需要处理模块间的依赖关系、循环引用、动态导入等。esbuild 在这个阶段做了非常多字节级别的优化。

场景:模块合并

假设有两个模块:

1
2
3
4
5
6
// a.js
export const greet = (name) => `Hello, ${name}!`;

// b.js
import { greet } from './a.js';
console.log(greet('World'));

大多数打包工具的处理方式:

1
b.js 的 import → 替换为 a.js 的内容 → 添加运行时标记

esbuild 的做法更激进:

1
2
3
4
5
6
7
8
9
10
// esbuild 最终输出(概念)
// 模块 a 的内容 + 模块 b 的内容直接合并
// 不需要任何运行时开销
(() => {
// a.js
const greet = (name) => `Hello, ${name}!`;

// b.js
console.log(greet('World'));
})();

注意 esbuild 不添加任何模块运行时(像 Webpack 的 __webpack_require__、Rollup 的命名空间对象)。如果模块之间没有循环依赖、没有动态导入、没有 side effect,esbuild 可以直接把所有模块「拍平」到一个作用域中。

这是 esbuild 打包结果通常比 Webpack/Rollup 更小的原因之一——省去了大量的运行时胶水代码

3. 实际应用场景与代码示例

3.1 场景 1:用 esbuild API 构建一个 CLI 工具

想象你要写一个 Node.js CLI 工具,它能扫描当前目录下的所有 index.ts 文件并输出其模块依赖图。你希望这个工具:

  • 用 TypeScript 编写
  • 打包成一个独立的可执行 .js 文件
  • 尽可能小、启动快

Step 1:安装 esbuild

1
npm install esbuild --save-dev

Step 2:编写源代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// src/cli.ts
import { readdirSync } from 'fs';
import { join } from 'path';
import { build, analyzeMetafile } from 'esbuild';

async function main() {
const cwd = process.cwd();
const files = readdirSync(cwd).filter(f => f.endsWith('.ts'));

// 使用 esbuild 的 API 来「构建但不输出」,
// 只分析模块依赖
const result = await build({
entryPoints: files.map(f => join(cwd, f)),
bundle: false, // 不打包,只分析
metafile: true, // 输出元数据
write: false, // 不写入磁盘
outdir: '/dev/null',
});

if (result.metafile) {
const text = await analyzeMetafile(result.metafile);
console.log(text);
}
}

main().catch(console.error);

Step 3:用 esbuild 打包这个 CLI 工具

1
2
3
4
5
6
7
8
9
10
11
12
13
node -e "
require('esbuild').buildSync({
entryPoints: ['src/cli.ts'],
bundle: true,
platform: 'node',
target: 'node18',
outfile: 'dist/cli.cjs',
format: 'cjs',
minify: true, // 压缩
sourcemap: true, // 保留 sourcemap 便于调试
external: ['esbuild'], // esbuild 本身作为 external
});
"

Step 4:验证结果

1
2
3
4
5
ls -lh dist/cli.cjs
# 输出: -rw-r--r-- 1 user staff 12K Jul 15 10:00 dist/cli.cjs

# 运行
node dist/cli.cjs

这个过程中 esbuild 做了什么:

  1. 解析 TypeScript:去掉类型注解,将 import/export 解析为模块依赖图。
  2. 打包:将所有依赖(除了 esbuild 本身)打包到同一个文件中。
  3. 平台适配platform: 'node' 会让 esbuild 智能处理 Node.js 内置模块(如 fspath),将它们标记为 external 而不是试图打包进去。
  4. 代码生成:生成 CommonJS 格式(format: 'cjs'),确保在 Node.js 中可以用 require() 加载。
  5. 压缩minify: true 会删除空白、缩短变量名、去除死代码。

3.2 场景 2:在 Webpack 中使用 esbuild 加速压缩

如果你有一个已有的 Webpack 项目,不想完全迁移到 esbuild,只想加速最慢的环节——压缩。

改造前:Webpack + TerserPlugin(JS 实现)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// webpack.config.js
const TerserPlugin = require('terser-webpack-plugin');

module.exports = {
optimization: {
minimize: true,
minimizer: [new TerserPlugin({
parallel: true,
terserOptions: {
compress: { drop_console: true },
},
})],
},
};

压缩 10MB 的 JS 产物,TerserPlugin 可能需要 20-30 秒。

改造后:Webpack + ESBuildMinifyPlugin

1
npm install esbuild-loader --save-dev
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// webpack.config.js
const { ESBuildMinifyPlugin } = require('esbuild-loader');

module.exports = {
optimization: {
minimize: true,
minimizer: [
new ESBuildMinifyPlugin({
target: 'es2015', // 目标语法版本
minify: true,
sourcemap: false,
drop: ['console'], // 等同于 terser 的 drop_console
}),
],
},
};

效果对比(10MB JS 产物,4 核机器):

指标TerserPluginESBuildMinifyPlugin提升倍数
压缩耗时24.5 秒1.8 秒13.6x
产物大小3.2 MB3.3 MB-3%
配置复杂度需了解 terser 配置API 简洁-

压缩速度提升 10 倍以上。注意产物大小有小幅增加(~3%),这是因为 esbuild 的 tree-shaking 策略不如 terser 精细——但在大部分业务场景下,这 3% 的差距完全可以接受。

可以同时用 esbuild 转译 TypeScript

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// webpack.config.js
const { ESBuildPlugin, ESBuildMinifyPlugin } = require('esbuild-loader');

module.exports = {
module: {
rules: [
{
test: /\.tsx?$/,
use: [
{
loader: 'esbuild-loader',
options: {
target: 'es2015',
tsconfigRaw: require('./tsconfig.json'),
},
},
],
},
],
},
plugins: [
new ESBuildPlugin(), // 自动处理 esbuild 相关配置
new ESBuildMinifyPlugin({
target: 'es2015',
}),
],
};

这样替换后,Webpack 的构建流程变为:

1
源码 → esbuild-loader 转译(快) → Webpack 打包 → ESBuildMinifyPlugin 压缩(快)

替换 Babel-loader 后,转译速度通常能提升 10-30 倍

3.3 场景 3:Vite 内部如何用 esbuild

Vite 在三个关键环节使用了 esbuild,理解了这些,你就理解了 Vite「快」的底层原因。

环节 1:依赖预构建

当你第一次运行 vite dev 时,Vite 会用 esbuild 对 node_modules 中的依赖做预构建:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 伪代码:Vite 的 optimizeDeps 简化版
const { build } = require('esbuild');

async function optimizeDeps(deps) {
const result = await build({
entryPoints: deps.map(d => resolveDepPath(d)),
bundle: true,
format: 'esm',
// 将所有依赖打包为独立的 ESM chunk
outdir: 'node_modules/.vite',
// 用 esbuild 将 CJS 转换为 ESM
// 这样浏览器就可以直接从 .vite 目录加载 ESM 版本的依赖
plugins: [cjsToEsmPlugin()],
});

return result;
}

环节 2:开发模式下的即时转译

当浏览器请求一个 .ts.vue 文件时,Vite 的开发服务器用 esbuild 在做转译:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 伪代码:Vite 开发服务器中的 transform 中间件
async function transformMiddleware(req, res, next) {
const filePath = urlToFilePath(req.url);

// 对于 .ts / .tsx / .jsx 文件
if (isTransformable(filePath)) {
const source = await readFile(filePath);

// esbuild 只做转译(去掉类型注解、转 JSX),不做打包
const result = await esbuild.transform(source, {
loader: getLoader(filePath), // 'ts', 'tsx', 'jsx'
sourcemap: 'both',
sourcefile: filePath,
});

// 返回转译后的代码给浏览器
res.setHeader('Content-Type', 'application/javascript');
res.end(result.code);
return;
}

next();
}

环节 3:生产构建的压缩

在生产环境(vite build)中,虽然打包主体由 Rollup 完成,但代码压缩阶段 Vite 默认使用 esbuild 替代 terser:

1
2
3
4
5
6
7
8
// vite.config.js
export default defineConfig({
build: {
// Vite 5 默认使用 esbuild 压缩(minify: 'esbuild')
// 如果要切换回 terser:
// minify: 'terser',
},
});

在 Vite 5 中,build.minify 的默认值已经是 'esbuild'。你可以用 build.outDir 验证构建产物中被压缩后的代码大小,或者在 vite build --debug 中看到构建时间统计:

1
2
3
4
✓ built in 1.2s
✔ esbuild minify: 200ms
✔ rollup bundle: 800ms
...

4. 常见误区 & 实际项目中的坑

4.1 误区:esbuild 能替代 TypeScript 编译器(tsc)

错误写法

1
2
3
4
5
6
7
8
9
// 用 esbuild 编译 TS 后,认为类型安全已确保
const { build } = require('esbuild');

build({
entryPoints: ['src/index.ts'],
bundle: true,
outfile: 'dist/bundle.js',
}).catch(() => process.exit(1));
// 即使有类型错误,上面这段代码也不会报错!

为什么错

esbuild 在编译 TypeScript 时,不会读取类型信息。它只做语法层面的处理:去掉类型注解(type annotations),保留运行时代码。

1
2
3
4
5
6
7
8
9
10
// 源代码(有类型错误)
function greet(name: string): string {
// 注意:这里误用了 number 方法,但 esbuild 不会发现
return name.toFixed(2);
// string 没有 toFixed 方法,tsc 会报错
}

const user = { age: 25 };
// 访问不存在的属性,tsc 会报错,esbuild 不会
console.log(user.name.toUpperCase());

esbuild 编译后的产物:

1
2
3
4
5
6
7
8
9
10
// esbuild 输出(正常运行!)
function greet(name) {
return name.toFixed(2);
// 运行时 Uncaught TypeError: name.toFixed is not a function
}

const user = { age: 25 };
console.log(user.name.toUpperCase());
// 运行时 Uncaught TypeError: Cannot read properties of undefined
// (reading 'toUpperCase')

正确做法

1
2
3
4
5
6
7
8
// package.json
{
"scripts": {
"type-check": "tsc --noEmit", // 独立做类型检查
"build": "npm run type-check && esbuild src/index.ts --bundle --outfile=dist/bundle.js"
// 先检查类型,再用 esbuild 编译
}
}

或者在 CI/CD 中:

1
2
3
4
5
6
# .github/workflows/ci.yml
jobs:
build:
steps:
- run: npx tsc --noEmit # 类型检查
- run: npx esbuild src/index.ts --bundle --outfile=dist/bundle.js

4.2 误区:esbuild 能完全兼容 Babel

错误写法

1
2
3
4
5
6
// 以为 esbuild 会像 Babel 一样 polyfill 所有语法
build({
entryPoints: ['src/index.ts'],
target: 'es2015',
// 期望: esbuild 会为 Array.prototype.flat 注入 polyfill
});

为什么错

esbuild 只做 语法转译,不做 运行时 polyfill。语法转译是指把高版本的语法结构(如箭头函数、可选链)转成低版本等价语法。polyfill 是指为低版本环境补充缺少的原生 API(如 PromiseArray.prototype.flat)。

1
2
3
4
5
6
7
8
9
// 源代码
const arr = [1, [2, [3]]];
const flat = arr.flat(2); // ES2019 的 Array.prototype.flat

// esbuild target: es2015 输出(不变!)
const arr = [1, [2, [3]]];
const flat = arr.flat(2);
// ⚠️ esbuild 不会注入 Array.prototype.flat 的 polyfill
// 如果运行环境不支持 flat 方法,会报 TypeError

对比 Babel:

1
2
3
4
5
6
7
// Babel + @babel/preset-env + core-js(配置 useBuiltIns: 'usage')
// 自动检测代码中用到的 API,按需注入 polyfill
"use strict";
require("core-js/modules/es.array.flat.js");
require("core-js/modules/es.array.unscopables.flat.js");
var arr = [1, [2, [3]]];
var flat = arr.flat(2);

正确做法

如果项目需要 polyfill,有几种方案:

  1. 手动 polyfill(推荐)
1
2
3
4
5
6
// 显式导入需要 polyfill 的 API
import 'core-js/actual/array/flat';

// 然后放心使用
const arr = [1, [2, [3]]];
arr.flat(2);
  1. Vite 项目中用 @vitejs/plugin-legacy
1
2
3
4
5
6
7
8
9
10
11
12
// vite.config.js
import legacy from '@vitejs/plugin-legacy';

export default defineConfig({
plugins: [
legacy({
targets: ['ie >= 11'],
// 内部使用 Babel + core-js 做 polyfill
polyfills: ['es.array.flat'],
}),
],
});
  1. Webpack 项目中混合使用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
const { ESBuildPlugin } = require('esbuild-loader');

module.exports = {
module: {
rules: [
{
test: /\.ts$/,
use: [
// 第一层:Babel 做 polyfill
{
loader: 'babel-loader',
options: {
presets: [
['@babel/preset-env', {
useBuiltIns: 'usage',
corejs: 3,
}],
],
},
},
// 第二层:esbuild-loader 做语法转译(快)
{
loader: 'esbuild-loader',
options: { target: 'es2015' },
},
],
},
],
},
};

实际项目中的折中建议:大部分现代项目(Chrome >= 80, Edge >= 80, Safari >= 14)不需要 polyfill,因为浏览器已经原生支持了大多数 ES2015+ API。如果你只支持这些浏览器,直接使用 esbuild 的转译完全够用。

4.3 实际坑:CSS 打包的局限性

如果你把 esbuild 作为生产打包工具,需要注意它对 CSS 的处理不如 Webpack/Rollup 成熟。

:esbuild 不支持 CSS 的 @import 内联

1
2
3
4
5
6
7
/* src/styles/main.css */
@import url('variables.css');
@import url('reset.css');

body {
color: var(--primary-color);
}
1
2
3
4
5
6
// esbuild 打包
build({
entryPoints: ['src/styles/main.css'],
bundle: true,
outfile: 'dist/styles.css',
});

你会得到:

1
2
3
4
/* dist/styles.css */
/* esbuild 将 @import 合并到了输出文件中 */
/* 但是 CSS 中的 @import 依然保留 */
/* 浏览器需要额外下载 variables.css 和 reset.css */

而 Webpack 的 css-loader + style-loaderMiniCssExtractPlugin 能正确处理 CSS @import 内联。

解决方案

用 PostCSS 插件(postcss-import)预处理 CSS,再将结果给 esbuild:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// build.js
const postcss = require('postcss');
const postcssImport = require('postcss-import');

async function build() {
const cssContent = await readFile('src/styles/main.css');

// 先用 postcss-import 处理 @import
const result = await postcss([postcssImport()]).process(cssContent, {
from: 'src/styles/main.css',
});

// 再将处理后的 CSS 传给 esbuild
await require('esbuild').build({
stdin: {
contents: result.css,
loader: 'css',
sourcefile: 'main.css',
},
bundle: true,
outfile: 'dist/styles.css',
minify: true,
});
}

esbuild 的主要定位是 JS/TS 打包,在 CSS 处理上还有很多短板。如果你的项目对 CSS 处理要求高(如需要 PostCSS 插件、CSS Modules、提取独立 CSS 文件到不同目录),建议:

  • 开发模式:用 Vite(内部用 esbuild 转译 + Rollup 处理 CSS)
  • 生产构建:用 Vite/Rollup/Webpack 做完整构建

5. 与相关知识的关联 & 对比

5.1 esbuild vs Babel

维度esbuildBabel
语言GoJavaScript
速度快 10-100 倍
TypeScript✅ 剥离类型注解,不做检查❌ 需要单独配置 @babel/preset-typescript
JSX✅ 原生支持✅ 需要 @babel/preset-react
插件生态有限(~100 个)极其丰富(数千个)
Polyfill 能力❌ 无✅ 通过 core-js + useBuiltIns
自定义插件简单、有限强大、灵活
ES5 兼容❌ 不支持✅ 支持
代码压缩✅ 内置❌ 需要第三方插件

选型建议

  • 需要兼容 IE11 / ES5 → Babel
  • 需要复杂的语法转换(如装饰器、import() 的精确 polyfill)→ Babel
  • 追求速度,兼容现代浏览器 → esbuild
  • 新项目,不确定需求 → esbuild 做转译 + Babel 做 polyfill(如果需要)

5.2 esbuild vs SWC

SWC(Speedy Web Compiler)是 esbuild 目前最直接的竞争对手,也是用 Rust 编写的 JS/TS 转译器。

维度esbuild (Go)SWC (Rust)
语言GoRust
速度极快极快(略快于 esbuild)
转译标准语法转译标准语法转译
压缩✅ 内置✅ 内置
TypeScript✅ 剥离类型✅ 剥离类型 + 部分类型检查
插件系统Go/JS 插件WASM 插件 + JS 插件
CJS ↔ ESM
CSS 处理基础支持基础支持
Next.js 内置✅(SWC 是 Next.js 的默认编译器)
社区生态更广的打包场景(Vite 等)主要由 Next.js 驱动

实际差异

  • SWC 在个别基准测试中转译速度比 esbuild 快 10-20%,但在实际项目中差距不明显。
  • esbuild 的 打包能力更强(esbuild 是一个完整的打包器,SWC 主要是转译器,打包需用 webpack/rollup 配合)。
  • SWC 提供了 更丰富的 AST 操作 API,适合做代码分析和自定义转换。
  • esbuild 的配置 API 更简洁,文档质量更好,上手更容易。

选型建议

  • 需要打包器功能 → esbuild
  • 用 Next.js 框架 → SWC(已经内置,无需选)
  • 需要自定义 AST 转换 / 代码分析工具 → SWC
  • 需要快速搭建 CLI 工具打包 → esbuild

5.3 esbuild vs Webpack vs Rollup(定位对比)

这个对比能帮你理解 esbuild 在构建工具中的位置:

维度esbuildWebpackRollup
核心定位极速打包/转译器全功能模块打包器库打包器(tree-shaking 最优)
速度★★★★★★★★★★
灵活性★★★★★★★★★★★
生态★★★★★★★★★★★★
配置复杂度★(极简)★★★★★(复杂)★★★(中等)
HMR❌ 无❌ 无
Dev Server✅ 基础✅ 丰富❌ 无
代码分割✅ 基础✅ 完善✅ 完善
Tree-shaking✅ 基础✅ 好✅ 最好
CSS 处理⚠️ 基础✅ 完善✅ 通过插件
插件系统简单强大强大

定位总结

  • esbuild:不是要替代 Webpack/Rollup,而是作为它们性能瓶颈处的加速器。在需要快速迭代和开发工具的场景下使用。
  • Webpack:全栈打包方案,适合大型复杂应用,特别是需要精细化控制构建过程的场景。
  • Rollup:库发布的打包器,tree-shaking 最优,产物最干净。Vite 也使用 Rollup 做生产构建。

6. 现代最佳实践(2025-2026)

6.1 项目中的分层构建策略

不要用 esbuild 做所有事,而是用它的「速度优势」覆盖构建流程中的热点路径。

推荐的分层构建架构

1
2
3
4
5
6
7
8
开发模式:
源代码 → esbuild(转译 TS/JSX) → Vite dev server → 浏览器

生产模式(复杂项目):
源代码 → esbuild(转译) → Webpack/Rollup(打包 + 代码分割) → esbuild(压缩)

生产模式(简单项目/CLI):
源代码 → esbuild(转译 + 打包 + 压缩)

6.2 esbuild 配置文件的最佳实践

创建一个独立的 esbuild 配置文件,管理不同环境的构建配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
// esbuild.config.js
const esbuild = require('esbuild');

const sharedConfig = {
entryPoints: ['src/index.ts'],
bundle: true,
platform: 'node',
target: ['node18'],
external: [
// 不打包 Node.js 内置模块
'fs', 'path', 'os', 'crypto',
// 不打包大型原生模块
'sharp',
],
loader: {
// 对于 .node 文件,直接用 copy 模式
'.node': 'copy',
},
};

async function main() {
// 开发:Watch 模式 + Sourcemap
if (process.argv.includes('--dev')) {
const ctx = await esbuild.context({
...sharedConfig,
outfile: 'dist/index.js',
sourcemap: 'inline',
minify: false,
});

await ctx.watch();
console.log('[esbuild] Watching for changes...');
return;
}

// 生产:压缩 + 输出多种格式
const formats = ['cjs', 'esm'];

for (const format of formats) {
await esbuild.build({
...sharedConfig,
outfile: `dist/index.${format === 'cjs' ? 'cjs' : 'mjs'}`,
format,
minify: true,
sourcemap: true,
// 生产环境清除 console
drop: process.argv.includes('--drop-console') ? ['console'] : [],
});
}

console.log('[esbuild] Build complete');
}

main().catch((err) => {
console.error(err);
process.exit(1);
});

然后在 package.json 中:

1
2
3
4
5
6
7
{
"scripts": {
"dev": "node esbuild.config.js --dev",
"build": "node esbuild.config.js",
"build:clean": "node esbuild.config.js --drop-console"
}
}

6.3 结合 TypeScript 的类型检查

始终遵循「职责分离」原则:

1
2
3
4
5
6
7
8
{
"scripts": {
"type-check": "tsc --noEmit",
"type-check:watch": "tsc --noEmit --watch",
"build": "npm run type-check && node esbuild.config.js",
"dev": "concurrently \"npm run type-check:watch\" \"node esbuild.config.js --dev\""
}
}

concurrently 包可以同时运行类型检查的 watch 模式和 esbuild 的 watch 模式,互不干扰。

6.4 使用 esbuild 的 plugin API 扩展功能

如果你需要 esbuild 做更复杂的事情,可以利用它的插件 API。注意 esbuild 的插件能力有限,但在某些场景下够用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// plugins/copy-files-plugin.js
const fs = require('fs/promises');
const path = require('path');

/**
* 拷贝非代码资源到输出目录
* 在所有文件处理完成后执行
*/
const copyFilesPlugin = {
name: 'copy-files',
setup(build) {
// 在构建结束时触发
build.onEnd(async (result) => {
if (result.errors.length > 0) return;

const sourceDir = 'src/assets';
const outDir = 'dist/assets';

try {
const files = await fs.readdir(sourceDir);

await Promise.all(files.map(async (file) => {
await fs.copyFile(
path.join(sourceDir, file),
path.join(outDir, file)
);
}));

console.log(`[copy-files] Copied ${files.length} assets`);
} catch (err) {
console.error('[copy-files] Error:', err.message);
}
});
},
};

// 在构建中使用
require('esbuild').build({
entryPoints: ['src/index.ts'],
bundle: true,
outfile: 'dist/bundle.js',
plugins: [copyFilesPlugin],
});

6.5 性能调试:测量 esbuild 各阶段的耗时

1
2
3
4
5
# 启用详细日志,查看各阶段耗时
ESBUILD_DEBUG=true npx esbuild src/index.ts --bundle --outfile=dist/bundle.js

# 或者使用 profiling 参数
npx esbuild src/index.ts --bundle --outfile=dist/bundle.js --log-level=debug

如果你用 esbuild API,可以在构建配置中加入性能分析:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const result = await esbuild.build({
entryPoints: ['src/index.ts'],
bundle: true,
outfile: 'dist/bundle.js',
metafile: true, // 输出构建元数据
banner: {
js: '/* built at ' + new Date().toISOString() + ' */',
},
});

// 分析构建信息
if (result.metafile) {
const text = await esbuild.analyzeMetafile(result.metafile, {
verbose: true,
color: true,
});
console.log(text);

// 输出每个文件的压缩比
const { outputs } = result.metafile;
Object.entries(outputs).forEach(([file, info]) => {
const ratio = (info.bytes / info.inputs[0]?.bytesInOutput * 100).toFixed(1);
console.log(`${file}: ${(info.bytes / 1024).toFixed(1)} KB (${ratio}%)`);
});
}

7. 常见疑问解答(自问自答)

Q1: esbuild 这么强,为什么不让 Vite 的生产构建也用 esbuild 替代 Rollup?

这是一个很好的问题。Vite 在开发模式用 esbuild,生产模式却用 Rollup,很多人觉得「不够彻底」。

原因在于 esbuild 对代码分割(code splitting)和 tree-shaking 的支持不如 Rollup 成熟

具体来说:

特性esbuildRollup
Tree-shaking✅ 基础(删除未使用的导出)✅ 高级(跨模块的副作用分析)
代码分割✅ 基础(只能按入口分割)✅ 高级(支持 splitChunks 等)
CSS 代码分割✅ 通过插件
动态导入优化⚠️ 有限✅ 丰富(合并小 chunk 等)

esbuild 的 tree-shaking 不如 Rollup 精细。举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// a.js
import { format } from 'date-fns'; // 只导入 format 函数
export function greet(name) {
return `Hello, ${name}`;
}
export function internalHelper() {
// 这个函数未被外部使用
return 'internal';
}

// b.js
import { greet } from './a.js';
console.log(greet('World'));

// date-fns 的 index.js
// 导出 format、addDays、subMonths 等数百个函数
  • Rollup tree-shaking:能删除 internalHelper,能精确到 date-fns 中只保留 format 函数。
  • esbuild tree-shaking:能删除 internalHelper,但分析 date-fns 时可能保留更多未用的导出(因为 esbuild 的分析粒度不如 Rollup 细)。

不过这个差距在逐年缩小。esbuild 的 tree-shaking 在 v0.18+ 已经有了显著改进。如果 Vite 未来切换到 esbuild 做生产构建,也是有可能的——但目前 Rollup 仍然更可靠。

Q2: esbuild 的 Go 版本和 JS 版本有什么区别?我 npm 安装的是哪个?

你 npm 安装的 esbuild 包是一个 Node.js 原生模块(native addon)

当你运行 npm install esbuild 时:

  1. npm 会下载对应平台的预编译二进制文件(如 esbuild-win32-x64.exe)。
  2. 这个二进制文件是用 Go 写的 esbuild 核心。
  3. 同时会安装一个 JS 封装层(esbuild/lib/main.js),它通过 child_process 调用这个二进制文件。
1
2
3
4
5
6
7
8
9
// esbuild 的 JS API 实际上是在 Go 二进制上做的封装
const esbuild = require('esbuild');

// 调用 esbuild.build() 时,内部流程:
// 1. JS 层将构建配置序列化为 JSON
// 2. 通过 stdin 传给 Go 二进制
// 3. Go 二进制执行构建
// 4. 构建结果通过 stdout(JSON 格式)返回
// 5. JS 层反序列化并返回结果

验证方式:

1
2
3
4
5
6
7
8
9
10
11
# 查看 esbuild 安装的二进制文件
ls node_modules/esbuild/bin/
# esbuild (可执行文件)

# 直接运行二进制
node_modules/.bin/esbuild --version
# 输出类似: 0.25.0

# 查看 API 源码,确认是 native 调用
head -20 node_modules/esbuild/lib/main.js
# 会看到类似 "ESBUILD_BINARY_PATH" 或 "install" 相关的代码

没有「纯 JS 版本」的 esbuild。esbuild 的核心就是那个 Go 编译的二进制文件,JS 部分只是封装层。

Q3: esbuild 的 loader 是什么?怎么理解它?

esbuild 的 loader 概念比 Webpack 的 loader 要简单得多:

loader 告诉 esbuild「这个文件应该被当作什么类型来处理」

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// esbuild 的 loader 配置
build({
entryPoints: ['src/index.ts'],
loader: {
'.ts': 'ts', // TypeScript 文件 → 去掉类型注解
'.tsx': 'tsx', // TSX 文件 → 去掉类型 + 转 JSX
'.js': 'js', // JS 文件 → 不做处理(或按 target 转译)
'.jsx': 'jsx', // JSX 文件 → 转 JSX
'.css': 'css', // CSS 文件
'.json': 'json', // JSON 文件 → 转为 JS 对象
'.png': 'dataurl', // 图片 → 转为 data URL(Base64)
'.svg': 'text', // SVG → 转为字符串
'.wasm': 'binary', // WASM → 转为二进制 buffer
},
});

esbuild 支持的 loader 类型:

loader说明输出
jsJavaScript根据 target 转译后输出 JS
jsxJSX编译 JSX 语法后输出 JS
tsTypeScript去掉类型注解后输出 JS
tsxTypeScript + JSX去掉类型 + 编译 JSX
cssCSS打包 CSS,或注入 JS
jsonJSON转为 ESM 默认导出
text文本文件以字符串形式导入
base64二进制文件转为 Base64 字符串
binary二进制文件转为 Uint8Array
dataurl任意文件转为 Data URL
file任意文件复制到输出目录,返回 URL
copy任意文件直接复制不处理
empty任意文件返回空对象 {}
needs-cssCSS 模块特殊 loader(内部用)
local-cssCSS 模块特殊 loader(内部用)
global-cssCSS 模块特殊 loader(内部用)

对比 Webpack 的 loader

Webpack 的 loader 是一个处理函数链,每个 loader 可以将文件内容转换后传给下一个 loader:

1
2
3
4
5
6
7
8
9
// Webpack loader 链
{
test: /\.scss$/,
use: [
'style-loader', // 1. 将 CSS 注入 DOM
'css-loader', // 2. 解析 CSS 中的 import/url
'sass-loader', // 3. 将 SCSS 编译为 CSS
],
}

esbuild 的 loader 只是一个类型声明,它根据文件扩展名决定内置的处理策略,不支持自定义 loader 链。

如果你需要自定义处理逻辑,只能用 esbuild 的 plugin 系统

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// esbuild 自定义处理 svg 文件
const svgLoaderPlugin = {
name: 'svg-loader',
setup(build) {
// 拦截所有 .svg 文件的加载
build.onLoad({ filter: /\.svg$/ }, async (args) => {
const svg = await fs.promises.readFile(args.path, 'utf8');

// 对 SVG 做优化处理
const optimized = optimizeSvg(svg);

return {
// 返回 JS 代码,esbuild 会将其当作 JS 模块处理
contents: `export default ${JSON.stringify(optimized)}`,
loader: 'js',
};
});
},
};

Q4: esbuild 的 watch 模式和 Webpack 的 watch 模式有什么不同?

功能上基本等价:两者都会监听文件变化并重新构建。但底层实现不同。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// esbuild watch
async function watchWithEsbuild() {
const ctx = await esbuild.context({
entryPoints: ['src/index.ts'],
bundle: true,
outfile: 'dist/bundle.js',
});

// 启用 watch
await ctx.watch();

// watch 模式下,每次重新构建只编译变更的文件
// 然后重新执行链接(linking)生成输出

// 停止 watch
// await ctx.dispose();
}

// Webpack watch
const webpack = require('webpack');
const compiler = webpack(config);
compiler.watch({}, (err, stats) => {
// watch 回调
});

核心差异

  1. 增量编译粒度

    • Webpack 的 watch 是「模块级别」的增量——只重新编译变更的模块及其依赖链。
    • esbuild 的 watch 也是增量编译,但 esbuild 因为速度够快,在某些场景下即使做全量重新编译也远快于 Webpack 的增量。
  2. 持久化缓存

    • Webpack 5 有 persistent caching,可以将编译结果缓存到磁盘,跨进程复用。
    • esbuild 的 watch 没有持久化缓存,每次重启都是冷启动。
  3. 稳定性

    • Webpack watch 经过了十多年的生产验证,在极端场景(如大量循环引用、动态导入)下更稳定。
    • esbuild watch 在 v0.16+ 后比较稳定,但在复杂场景下偶有问题。

建议:简单项目直接用 esbuild watch,复杂项目(特别是使用大量动态导入和复杂代码分割策略的)建议继续使用 Webpack。

Q5: 什么是 esbuild 的「双引擎」策略?SWC 有这个吗?

「双引擎」策略是 Vite 的特定设计,不是 esbuild 自己的概念。它指的是 Vite 在开发模式和生产模式使用不同的构建引擎

1
2
开发模式引擎 → esbuild(转译 + 预构建)
生产模式引擎 → Rollup + esbuild(打包 + 转译 + 压缩)

Vite 选择这个架构的原因前面已经分析过了(esbuild 的生产构建能力不如 Rollup 成熟)。

SWC 在 Next.js 中的角色与此不同:Next.js 13+ 的编译器完全基于 SWC,开发和生产的转译都使用 SWC,没有「双引擎」问题,因为 SWC 是被深度集成到 Next.js 的自定义打包流程中的。

如果你自己搭建项目,可以参考这个决策矩阵:

场景推荐引擎原因
CLI 工具打包esbuild极快,产物干净,配置简单
前端应用(现代浏览器)Vite(esbuild + Rollup)最佳开发体验
前端应用(需要 IE11 兼容)Webpack最成熟的兼容方案
前端应用(Next.js)SWC(框架内置)无需选择
库发布(npm package)Rolluptree-shaking 最佳,产物最干净
微前端子应用esbuild(独立构建)或 Webpack(共享运行时)视架构需求而定

8. 参考资料 & 推荐学习路径

官方资源

深度阅读

推荐学习路径

1
2
3
4
5
6
1. 先读本文【面试速答版】 → 建立整体框架
2. 尝试用 esbuild 打包一个小型项目(CLI 工具最合适)
3. 阅读 esbuild 官方文档中的 Plugins 和 API 章节
4. 在你的 Webpack 项目中尝试用 esbuild-loader 替换 babel-loader
5. 阅读 Vite 源码中 esbuild 相关的部分(repo: vite/packages/vite/src/node/optimizer/)
6. 如果感兴趣,阅读 esbuild 的源码架构笔记

关联知识点索引

  • 模块打包基础 → [Webpack 详解](./Webpack 详解.md)
  • Vite 如何利用 esbuild → [Vite 详解](./Vite 详解.md)
  • 模块化标准对比 → ES6和CommonJS模块

Vite 详解

1. 什么是 Vite?

Vite(发音为 “veet”)是由 Vue.js 创始人尤雨溪创建的前端构建工具,于 2020 年首次发布。Vite 的核心思想是利用浏览器的原生 ES 模块支持和编译工具的快速冷启动能力,为前端开发提供极速的开发体验。

Vite 的名字来源于法语单词 “vitesse”,意为 “速度”,这也体现了其设计初衷:提供更快的前端开发体验。

1.1 Vite 的诞生背景

在传统的前端构建工具(如 Webpack)中,开发服务器启动时需要打包整个应用,这导致了以下问题:

  • 启动时间长:随着项目规模的增大,打包时间会越来越长,有时甚至需要数分钟。
  • 热更新慢:每次修改代码后,需要重新打包相关模块,导致热更新速度变慢。
  • 开发体验差:长时间的等待会严重影响开发效率和体验。

Vite 正是为了解决这些问题而诞生的,它通过创新的设计理念,彻底改变了前端开发的构建方式。

2. Vite 的核心功能特性

2.1 开发服务器

Vite 的开发服务器采用了创新的架构设计,具有以下特点:

  • 极速冷启动:Vite 在开发模式下不需要打包整个应用,而是使用浏览器的原生 ES 模块支持,直接请求所需的模块。当浏览器请求一个模块时,Vite 会实时编译该模块并返回,大大减少了启动时间。
  • 热模块替换(HMR):Vite 的 HMR 实现非常快速,因为它只需要更新修改的模块,而不是整个应用。Vite 会跟踪模块之间的依赖关系,当一个模块发生变化时,只会更新该模块及其直接依赖的模块。
  • 按需编译:只有当模块被请求时才会进行编译,避免了不必要的编译工作。这意味着即使项目很大,启动时间也不会显著增加。
  • 原生 ES 模块支持:Vite 利用浏览器的原生 ES 模块支持,将开发服务器作为一个模块服务器,直接向浏览器提供 ES 模块。

2.2 构建系统

Vite 的构建系统在生产环境中使用 Rollup 进行打包,具有以下特点:

  • 生产环境优化:在生产环境中,Vite 使用 Rollup 进行打包,利用 Rollup 的 tree-shaking 和代码分割能力,生成优化后的静态资源。Rollup 能够更有效地消除未使用的代码,生成更小的 bundle。
  • 多页面应用支持:Vite 支持多页面应用的构建,通过配置可以生成多个 HTML 入口。这对于需要多个页面的应用非常方便。
  • 库模式:Vite 支持构建库,生成适合发布到 npm 的库文件。在库模式下,Vite 会生成不同格式的输出(如 ESM、CommonJS、UMD 等),以满足不同的使用场景。
  • 预构建依赖:在开发模式下,Vite 会预构建第三方依赖,将它们转换为 ES 模块格式,提高后续请求的速度。

2.3 插件系统

Vite 的插件系统设计灵活,具有以下特点:

  • 丰富的插件生态:Vite 拥有活跃的插件生态系统,可以通过插件扩展其功能。目前已有数百个官方和社区插件,涵盖了各种功能需求。
  • 与 Rollup 插件兼容:Vite 的插件系统设计与 Rollup 兼容,许多 Rollup 插件可以直接在 Vite 中使用。这大大扩展了 Vite 的功能范围。
  • 插件钩子:Vite 提供了丰富的插件钩子,允许插件在构建过程的不同阶段执行自定义逻辑。

2.4 类型支持

Vite 对 TypeScript 和 JSX 提供了良好的支持:

  • TypeScript 集成:Vite 内置了对 TypeScript 的支持,无需额外配置。Vite 会自动处理 TypeScript 文件,并在开发模式下提供类型检查。
  • JSX 支持:Vite 支持 JSX 语法,适用于 React 等框架。对于 React 项目,Vite 提供了专门的插件 @vitejs/plugin-react 来优化 JSX 的处理。

2.5 其他特性

Vite 还提供了许多其他实用特性:

  • CSS 预处理:内置支持 CSS 预处理器,如 Sass、Less、Stylus。只需安装相应的预处理器依赖,Vite 就会自动处理。
  • 环境变量:支持 .env 文件和环境变量的管理。Vite 会根据当前环境加载对应的 .env 文件,并将环境变量注入到代码中。
  • 动态导入:支持动态导入语法,实现代码分割。这对于大型应用的性能优化非常重要。
  • 模块热替换:除了 HMR 外,Vite 还支持模块热替换,允许在不刷新页面的情况下更新模块。
  • 静态资源处理:Vite 对图片、字体、JSON 等静态资源的处理非常方便,可以直接导入使用。

3. Vite 的基本用法

3.1 初始化项目

使用 npm create vite 命令初始化一个新的 Vite 项目:

1
2
3
4
5
6
7
8
# 使用 npm
npm create vite@latest my-vite-app

# 使用 yarn
yarn create vite my-vite-app

# 使用 pnpm
pnpm create vite my-vite-app

初始化过程中,Vite 会提示选择框架(Vue、React、Svelte 等)和变体(JavaScript 或 TypeScript)。例如:

1
2
3
4
5
6
7
8
9
10
11
? Select a framework: › - Use arrow-keys. Return to submit.
Vanilla
Vue
React
Preact
Lit
Svelte

? Select a variant: › - Use arrow-keys. Return to submit.
JavaScript
TypeScript

选择完成后,Vite 会生成相应的项目结构和配置文件。

3.2 项目结构

一个典型的 Vite 项目结构如下:

1
2
3
4
5
6
7
8
9
10
11
my-vite-app/
├── index.html # 入口 HTML 文件
├── package.json # 项目配置文件
├── vite.config.js # Vite 配置文件
├── public/ # 静态资源目录(不会被处理)
└── src/ # 源代码目录
├── main.js # 应用入口文件
├── App.vue # 根组件(Vue 项目)
├── components/ # 组件目录
├── assets/ # 资源目录
└── style.css # 全局样式文件

重要文件说明

  • index.html:Vite 项目的入口 HTML 文件,与传统项目不同,它位于项目根目录,而不是 public 目录。
  • vite.config.js:Vite 的配置文件,用于配置 Vite 的各种选项。
  • **public/**:存放静态资源的目录,这些资源不会被 Vite 处理,会直接复制到构建输出目录。
  • **src/**:存放源代码的目录,包括 JavaScript/TypeScript 文件、Vue 组件、样式文件等。

3.3 开发服务器

启动开发服务器:

1
2
3
4
5
6
7
8
# 使用 npm
npm run dev

# 使用 yarn
yarn dev

# 使用 pnpm
pnpm dev

开发服务器默认运行在 http://localhost:5173。启动后,Vite 会在终端显示开发服务器的地址和其他信息:

1
2
3
4
5
6
7
> vite

VITE v5.0.0 ready in 500 ms

➜ Local: http://localhost:5173/
➜ Network: use --host to expose
➜ press h to show help

3.4 构建生产版本

构建生产版本:

1
2
3
4
5
6
7
8
# 使用 npm
npm run build

# 使用 yarn
yarn build

# 使用 pnpm
pnpm build

构建结果会输出到 dist 目录。构建完成后,Vite 会显示构建结果的统计信息:

1
2
3
4
5
6
7
8
9
> vite build

vite v5.0.0 building for production...
done in 1.2s

✓ 3 modules transformed.
dist/index.html 0.50 kB
dist/assets/index-123456.js 1.20 kB
dist/assets/index-123456.css 0.30 kB

3.5 预览生产版本

构建完成后,可以使用 Vite 的预览命令来预览生产版本:

1
2
3
4
5
6
7
8
# 使用 npm
npm run preview

# 使用 yarn
yarn preview

# 使用 pnpm
pnpm preview

预览服务器默认运行在 http://localhost:4173。

4. Vite 的常用操作

4.1 配置 Vite

Vite 的配置文件是 vite.config.js,可以在其中配置各种选项。以下是一些常用的配置选项:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'

export default defineConfig({
// 插件配置
plugins: [vue()],

// 开发服务器配置
server: {
port: 3000, // 服务器端口
open: true, // 自动打开浏览器
proxy: { // 代理配置
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
}
}
},

// 构建配置
build: {
outDir: 'dist', // 构建输出目录
minify: 'terser', // 代码压缩工具
sourcemap: true, // 生成 sourcemap
rollupOptions: { // Rollup 配置
output: {
manualChunks: {
vendor: ['vue'],
common: ['lodash']
}
}
}
},

// 解析配置
resolve: {
alias: {
'@': resolve(__dirname, 'src') // 路径别名
}
},

// CSS 配置
css: {
preprocessorOptions: {
scss: {
additionalData: `@import "./src/styles/variables.scss";`
}
}
}
})

4.2 使用插件

Vite 的插件系统非常强大,可以通过插件扩展其功能。以下是一些常用的 Vite 插件:

  • @vitejs/plugin-vue:Vue 官方插件,用于处理 Vue 单文件组件。
  • @vitejs/plugin-react:React 官方插件,用于处理 React 组件和 JSX。
  • @vitejs/plugin-legacy:用于生成传统浏览器兼容的代码。
  • vite-plugin-pwa:用于添加 PWA 支持。
  • vite-plugin-svg-icons:用于处理 SVG 图标。

安装并使用插件的示例:

1
2
3
4
5
6
7
8
9
10
11
# 安装插件
npm install @vitejs/plugin-react

# 在配置文件中使用
// vite.config.js
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
plugins: [react()]
})

4.3 处理静态资源

Vite 对静态资源的处理非常方便,支持多种静态资源类型:

  • 图片:可以直接导入图片文件,Vite 会自动处理。对于小图片,Vite 会将其转换为 Base64 编码,减少网络请求。
    1
    2
    3
    4
    5
    import logo from './assets/logo.png'

    function App() {
    return <img src={logo} alt="Logo" />
    }
  • 字体:可以直接导入字体文件。
    1
    import './assets/fonts/my-font.css'
  • JSON:可以直接导入 JSON 文件,Vite 会将其转换为 JavaScript 对象。
    1
    2
    import config from './config.json'
    console.log(config.apiBaseUrl)
  • CSS:可以直接导入 CSS 文件,Vite 会将其注入到页面中。
    1
    import './style.css'

    4.4 环境变量

Vite 支持环境变量,通过 .env 文件定义。Vite 会根据当前环境加载对应的 .env 文件:

  • .env:所有环境通用的配置
  • .env.local:所有环境通用的本地配置(不会被 git 跟踪)
  • .env.development:开发环境配置
  • .env.production:生产环境配置

环境变量的定义:

1
2
3
# .env
VITE_API_BASE_URL=https://api.example.com
VITE_APP_TITLE=My App

在代码中使用环境变量:

1
2
3
4
5
6
7
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL
const appTitle = import.meta.env.VITE_APP_TITLE

// 内置环境变量
const isDev = import.meta.env.DEV // 开发环境为 true
const isProd = import.meta.env.PROD // 生产环境为 true
const baseUrl = import.meta.env.BASE_URL // 应用的基础路径

4.5 代码分割

Vite 支持动态导入实现代码分割,这对于大型应用的性能优化非常重要:

1
2
3
4
5
6
7
8
9
10
11
// 动态导入
async function loadComponent() {
const module = await import('./HeavyComponent.vue')
return module.default
}

// 在需要时加载
button.addEventListener('click', async () => {
const HeavyComponent = await loadComponent()
// 使用 HeavyComponent
})

Vite 还支持在路由层面进行代码分割,例如在 Vue Router 中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// router/index.js
import { createRouter, createWebHistory } from 'vue-router'

const routes = [
{
path: '/',
component: () => import('../views/Home.vue')
},
{
path: '/about',
component: () => import('../views/About.vue')
}
]

const router = createRouter({
history: createWebHistory(),
routes
})

export default router

5. Vite 的工作原理

5.1 开发模式

在开发模式下,Vite 的工作原理如下:

  1. 启动开发服务器:Vite 启动一个开发服务器,监听特定端口。
  2. 处理 HTML 请求:当浏览器请求 HTML 文件时,Vite 会读取根目录下的 index.html 文件,并对其进行处理。
  3. 处理模块请求:当浏览器请求 JavaScript 模块时,Vite 会:
    • 检查模块是否是第三方依赖
    • 如果是第三方依赖,使用预构建的版本
    • 如果是本地模块,实时编译并返回
  4. 热模块替换:当文件发生变化时,Vite 会:
    • 重新编译修改的模块
    • 通过 WebSocket 通知浏览器更新
    • 浏览器只更新变化的模块,而不是整个应用

5.2 生产模式

在生产模式下,Vite 的工作原理如下:

  1. 预构建依赖:Vite 会预构建第三方依赖,将它们转换为 ES 模块格式。
  2. 打包应用代码:Vite 使用 Rollup 打包应用代码,进行 tree-shaking 和代码分割。
  3. 生成静态资源:Vite 会生成优化后的静态资源,包括 JavaScript、CSS、图片等。
  4. 生成 HTML 文件:Vite 会生成 HTML 文件,并注入打包后的静态资源。

6. Vite 与 Webpack 的对比分析

6.1 核心差异

特性ViteWebpack
开发模式使用浏览器原生 ES 模块,按需编译打包整个应用
冷启动速度极快(毫秒级)较慢(秒级,大型项目可能需要数分钟)
HMR 速度极快(即时)较慢(随着项目规模增大而变慢)
生产构建使用 Rollup使用内置打包器
配置复杂度简单(默认配置即可满足大部分需求)复杂(需要配置多个 loader 和 plugin)
生态系统正在发展中(插件数量快速增长)成熟(拥有大量插件和 loader)
适用场景现代前端项目,对开发体验要求高各种前端项目,特别是需要复杂构建配置的项目

6.2 性能对比

6.2.1 开发启动时间

Vite 的开发启动时间通常比 Webpack 快 10-100 倍,特别是对于大型项目。这是因为:

  • Vite 不需要打包整个应用,而是按需编译
  • Vite 利用浏览器的原生 ES 模块支持,减少了构建步骤
  • Vite 会预构建第三方依赖,提高后续请求的速度

6.2.2 热更新速度

Vite 的 HMR 速度几乎是即时的,而 Webpack 的 HMR 速度会随着项目规模的增大而变慢。这是因为:

  • Vite 只需要更新修改的模块,而不是整个应用
  • Vite 会跟踪模块之间的依赖关系,只更新受影响的模块
  • Vite 的 HMR 实现更加高效,不需要重新打包整个应用

6.2.3 生产构建速度

两者在生产构建速度上相差不大,Vite 使用 Rollup 可能会略快一些。这是因为:

  • Vite 使用 Rollup 进行生产构建,Rollup 的 tree-shaking 能力更强
  • Vite 的构建配置更加优化,默认情况下就有较好的性能

6.3 适用场景

6.3.1 Vite 适用场景

  • 快速原型开发:Vite 的极速启动速度非常适合快速原型开发,可以立即看到修改效果。
  • 中小型项目:对于中小型项目,Vite 可以提供更好的开发体验。
  • 对开发体验要求高的项目:如果团队非常注重开发体验和效率,Vite 是一个很好的选择。
  • 使用现代前端框架的项目:Vite 对 Vue、React、Svelte 等现代前端框架有很好的支持。
  • 使用 ES 模块的项目:Vite 原生支持 ES 模块,对于使用 ES 模块的项目非常友好。

6.3.2 Webpack 适用场景

  • 大型项目:Webpack 在处理大型项目时更加稳定,拥有更多的优化选项。
  • 需要复杂构建配置的项目:如果项目需要复杂的构建配置,Webpack 提供了更多的灵活性。
  • 对兼容性要求高的项目:Webpack 可以通过各种 loader 和 plugin 处理各种兼容性问题。
  • 已有 Webpack 配置的项目:如果项目已经有了完整的 Webpack 配置,迁移到 Vite 可能需要一定的成本。
  • 需要特殊构建功能的项目:Webpack 拥有更多的插件和 loader,可以满足各种特殊的构建需求。

6.4 迁移建议

如果您正在考虑从 Webpack 迁移到 Vite,可以按照以下步骤进行:

  1. 评估项目需求:首先评估您的项目是否适合使用 Vite,特别是是否需要 Vite 不支持的功能。

  2. 创建测试项目:在正式迁移之前,可以创建一个测试项目,尝试使用 Vite 构建您的应用,看看是否能够满足需求。

  3. 逐步迁移:可以先在小项目或新功能中尝试 Vite,积累经验后再逐步迁移整个项目。

  4. 调整配置:Vite 的配置与 Webpack 有所不同,需要进行适当调整。例如:

    • Vite 使用 vite.config.js 而不是 webpack.config.js
    • Vite 的插件系统与 Webpack 不同
    • Vite 的路径解析规则与 Webpack 有所不同
  5. 处理依赖问题:某些依赖可能与 Vite 不兼容,需要寻找替代方案或进行适当的配置。

  6. 测试:迁移后需要进行充分的测试,确保功能正常,特别是在生产环境中。

6.5 混合使用

在某些情况下,您可能需要混合使用 Vite 和 Webpack:

  • 开发环境使用 Vite:利用 Vite 的极速开发体验
  • 生产环境使用 Webpack:利用 Webpack 的成熟生态和复杂配置能力

这种方式可以结合两者的优势,但需要维护两套构建配置,增加了维护成本。

7. Vite 的最佳实践

7.1 项目配置

  • 合理使用路径别名:使用路径别名可以简化导入路径,提高代码可读性。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    // vite.config.js
    import { defineConfig } from 'vite'
    import { resolve } from 'path'

    export default defineConfig({
    resolve: {
    alias: {
    '@': resolve(__dirname, 'src')
    }
    }
    })
  • 配置代理:在开发环境中配置代理,解决跨域问题。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    // vite.config.js
    export default defineConfig({
    server: {
    proxy: {
    '/api': {
    target: 'http://localhost:8080',
    changeOrigin: true
    }
    }
    }
    })
  • 优化构建配置:根据项目需求优化构建配置,提高构建效率和产出质量。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    // vite.config.js
    export default defineConfig({
    build: {
    rollupOptions: {
    output: {
    manualChunks: {
    vendor: ['vue', 'vue-router'],
    common: ['lodash', 'axios']
    }
    }
    }
    }
    })

    7.2 代码组织

  • 合理使用动态导入:对于大型组件或第三方库,使用动态导入实现代码分割,提高首屏加载速度。

  • 优化资源加载:对于图片等静态资源,合理使用导入方式,小图片使用 Base64 编码,大图片使用正常导入。

  • 模块化组织代码:将代码按照功能模块进行组织,提高代码的可维护性。

7.3 性能优化

  • 使用生产模式构建:在部署前,确保使用 npm run build 构建生产版本,Vite 会自动进行各种优化。

  • 启用 gzip 压缩:在服务器端启用 gzip 压缩,减少传输体积。

  • 使用 CDN:对于第三方库,可以考虑使用 CDN 加载,减少打包体积。

  • 优化图片:对图片进行压缩和优化,减少图片体积。

7.4 开发技巧

  • 使用 Vite 的 HMR:充分利用 Vite 的热模块替换功能,提高开发效率。

  • 使用环境变量:通过环境变量管理不同环境的配置,避免硬编码。

  • 使用插件扩展功能:根据项目需求,使用合适的 Vite 插件扩展功能。

  • 利用 Vite 的调试工具:Vite 提供了一些调试工具,如 vite debug 命令,可以帮助排查问题。

8. 总结

Vite 是一款现代化的前端构建工具,通过利用浏览器的原生 ES 模块支持和快速的编译能力,为前端开发提供了极速的开发体验。它的核心优势在于:

  • 极速的开发服务器启动速度:Vite 的冷启动速度通常比传统构建工具快 10-100 倍,大大提高了开发效率。
  • 快速的热模块替换:Vite 的 HMR 速度几乎是即时的,让开发者可以立即看到修改效果。
  • 简单易用的配置:Vite 的配置非常简单,默认配置即可满足大部分需求,减少了配置的复杂性。
  • 与现代前端框架的良好集成:Vite 对 Vue、React、Svelte 等现代前端框架有很好的支持,提供了专门的插件。
  • 强大的构建能力:在生产环境中,Vite 使用 Rollup 进行打包,生成优化后的静态资源。

虽然 Vite 的生态系统还在发展中,但它已经成为许多前端开发者的首选构建工具,特别是对于使用现代前端框架的项目。随着 Vite 的不断发展和完善,它有望成为前端构建工具的主流选择。

与 Webpack 相比,Vite 在开发体验上有明显的优势,但 Webpack 在某些复杂场景下仍然具有不可替代的地位。开发者可以根据项目的具体需求选择合适的构建工具,或者在不同的场景下使用不同的工具。

无论选择哪种构建工具,最重要的是它能够满足项目的需求,提高开发效率,保证应用的性能和质量。

9. 答疑解惑

9.1 浏览器的 ESM 支持

现代浏览器(2017 年以后)原生支持 ES Module(ESM)规范。

  • 支持情况:Chrome 61+、Firefox 60+、Safari 16.4+、Edge 79+ 都已经原生支持 <script type="module"> 语法。
  • 支持的内容:浏览器可以原生解析 import / export 语句,能识别 import { foo } from './bar.js' 这种路径,并能发起 HTTP 请求去加载依赖的模块。
  • 重要澄清:浏览器支持的是 ESM 的加载机制(模块化规范),而不是支持所有 ES6+ 新语法(如箭头函数、可选链、装饰器等)。后者依然需要 Babel 或 esbuild 转译。

你困惑的“浏览器能支持 ES6 吗”其实混淆了两个概念:语法特性(如箭头函数)vs 模块加载机制(如 import/export)。浏览器原生支持的是后者,前者需要按需转译。


既然浏览器原生支持 ESM,为什么 Webpack 不直接用?

因为 Webpack 诞生于 2012 年,而浏览器原生 ESM 在 2017 年才开始普及。

Webpack 的设计前提是:浏览器没有模块系统,所以它必须在构建时把所有模块打包成一个(或几个)bundle 文件,让浏览器一次性加载。

但更关键的是,就算现在浏览器支持了 ESM,Webpack 也不能简单切换,原因在于“生态依赖”

维度Webpack 的依赖为什么不能直接用浏览器 ESM
非 JS 资源需要处理 CSS、图片、字体等浏览器 ESM 只能加载 JS,而 Webpack 的 loader 体系(如 css-loader)是在构建时把 CSS 转为 JS 模块。如果用浏览器原生加载,这些非 JS 资源无法处理。
Node.js 模块大量包使用 require()module.exports浏览器不认识 CommonJS。Webpack 通过静态分析将所有 CJS 转为 ESM 语法,这个过程必须在构建时完成,浏览器做不到。
代码分割策略Webpack 有自己的 import() 动态加载逻辑虽然浏览器也支持动态 import(),但 Webpack 的拆分策略(如 splitChunks)极其精细,需要构建时计算最优切割点,无法在浏览器运行时动态决策。

那 Vite 为什么能在开发模式下用浏览器原生 ESM?

Vite 的策略是“开发态”和“生产态”完全分开

开发模式(dev server):

  • 不打包:Vite 启动一个开发服务器,浏览器直接请求源码文件(.vue.tsx 等)。
  • 按需转译:Vite 使用 esbuild(极快的转译器)在请求到来时即时将 TypeScript、JSX 转为纯 JS,但保留 import/export 语法不打包
  • 依赖预构建:首次启动时,Vite 会用 esbuild 将 node_modules 中的 CJS 包(如 lodash)预转为 ESM 格式并缓存,这样浏览器就能直接加载了。
  • 结果:浏览器通过 <script type="module"> 加载入口文件,然后递归发起 HTTP 请求加载所有依赖。这个过程没有“打包”动作,所以启动极快。

生产模式(build):

  • 依然使用 Rollup 打包:Vite 在生产环境下会像 Webpack 一样做完整的打包、Tree Shaking、代码分割。因为生产环境要兼容老旧浏览器(需要转译到 ES5),且要合并请求减少 HTTP 数量。

为什么 Webpack 不学 Vite 这样做?

不是因为技术做不到,而是因为设计理念和生态包袱不同:

对比维度WebpackVite
诞生年份2012 年(彼时无 ESM)2020 年(ESM 已成标准)
核心设计一切资源都是模块,全量打包开发用原生 ESM,生产用 Rollup
启动速度需要全量编译,项目越大越慢无需打包,只编译请求的文件
历史包袱海量的 loader/plugin 生态都基于“构建时”设计无包袱,可激进使用新特性
运行时依赖依赖自己的 runtime 代码(处理 CJS 模拟等)完全依赖浏览器原生能力

Webpack 如果要改成 Vite 的模式,相当于重构整个架构,且会破坏数百万项目的兼容性。所以 Webpack 选择了在 5.0 版本中优化缓存和持久化构建来提速,而非改变底层范式。


一张图总结差异

1
2
3
4
5
6
Webpack 开发模式:
源码 → 全量打包(bundle) → 启动服务器 → 浏览器加载大文件

Vite 开发模式:
启动服务器 → 浏览器请求入口 → 按需转译并返回 → 递归加载依赖
(省去了“全量打包”这一步)

Q:开发模式下 Vite 用浏览器 ESM,那如何处理 CSS / 图片?
A:Vite 会把 CSS 转为 JS 模块,注入 <style> 标签;图片会返回 URL 字符串,这些都是通过插件在请求时即时转换的。

Q:生产环境用原生 ESM 不是更快吗?为什么还要打包?
A:因为生产环境要优化:合并请求(减少 200 个文件请求为 1 个)、压缩代码、Tree Shaking、兼容旧浏览器。打包是“性能优化”手段,而不是“模块加载”必需品。


9.2 Vite 为什么选择 esbuild?

选择 esbuild 而非 Babel,核心就是一个字:。而且这个转译功能确实是 Vite 默认自带的,开箱即用。

Vite 选择 esbuild,是基于 “性能优先于功能” 的务实考量。

  • 极致的速度是根本原因:esbuild 用 Go 语言编写,直接编译成机器码执行;而 Babel 是 JavaScript 编写的,需要解释执行。在处理 JSX、TypeScript 等文件的转译时,esbuild 比 Babel 快 10 到 100 倍。这种速度差异,让 Vite 能够在开发服务器启动和热更新时,提供近乎即时的响应。

  • 性能与灵活性的取舍:Babel 的强大之处在于其海量的插件生态,能做到精细化的语法转换和 Polyfill 注入。而 esbuild 的设计哲学非常纯粹:只做标准且通用的语法转译,追求极致性能,不提供扩展性强的插件系统。这种取舍让 esbuild 无法像 Babel 那样支持 Vue 的 v-model 等框架特定语法,但换来了绝大部分场景下都能胜任的转译速度。

  • 日常开发够用:对于 Vite 默认支持的现代浏览器(如 Chrome >=111, Edge >=111, Firefox >=114, Safari >=16.4),esbuild 默认或通过简单配置(如 build.target)就能将代码转译到兼容版本。它的能力足以覆盖绝大多数常规项目需求,也因此被 Vite 内部大量用于依赖预打包、TS/JSX 转译和生产环境代码压缩等多个环节。

转译是默认自带的吗?

是的,完全默认自带,无需额外配置。

Vite 内置了 esbuild 作为其转译引擎。你可以通过 vite.config.js 中的 build.target 选项来精细控制转译的目标环境。

  • **默认目标 ('modules')**:Vite 会假设你的代码运行在支持原生 ES Module 的现代浏览器上,并据此进行转译。
  • 自定义目标:如果需要兼容更低版本的浏览器,你可以将 build.target 设置为 es2015 或更具体的浏览器版本(如 chrome64)。

需要留意的是:Vite 默认 只处理语法转译,不包含 Polyfill。如果需要兼容非常老的浏览器(如 IE11),并注入 Polyfill,官方推荐使用 @vitejs/plugin-legacy 插件,该插件内部会调用 Babel 来完成更复杂的兼容工作。

9.3 原生 JS 导入能力

这个问题问得非常棒,直击了 “原生 ESM”和“Vite 增强能力” 之间的核心边界。

先给你最直接的答案:JS 原生模块(ESM)确实不能直接 import 图片、CSS 等非 JS 资源,浏览器会报错。Vite 的“方便处理”本质上是把这些非 JS 资源“伪装”成 JS 模块,让浏览器能正常加载。

根据 ES Module 规范,import 语句只能导入 JavaScript 模块.js.mjs 文件)。

  • ✅ 可以导入
    1
    2
    import { foo } from './bar.js';    // 正常
    import lodash from 'lodash'; // 正常(指向 package.json 的 main 字段)
  • ❌ 直接导入会报错
    1
    2
    3
    import logo from './logo.png';     // ❌ 浏览器报错:Cannot load image
    import './style.css'; // ❌ 浏览器报错:Cannot load CSS
    import data from './data.json'; // ❌ 浏览器报错:Cannot load JSON
    浏览器遇到非 JS 文件时,会抛出类似 Failed to load module script: Expected a JavaScript module script but the server responded with a MIME type of "image/png" 的错误,因为服务器返回的 MIME 类型不是 application/javascript

Vite 是如何“处理”这些资源的?

Vite 在开发服务器中做了“拦截和转换”,把非 JS 资源变成一个可被浏览器执行的 JS 模块

举例 1:图片导入

源码

1
import logo from './logo.png';

Vite 在开发模式下实际返回给浏览器的 JS 内容(经过转换):

1
2
// 实际返回的是 JS 代码,不是图片二进制
export default "/src/assets/logo.png" // 一个 URL 字符串

浏览器执行的是一段 JS,它导出了一个字符串(图片的访问路径),而不是图片本身。这样 import 语法就合法了。

举例 2:CSS 导入

源码

1
import './style.css';

Vite 在开发模式下实际返回的 JS 内容

1
2
3
4
5
// 动态创建 <style> 标签,将 CSS 内容注入到页面
const style = document.createElement('style');
style.textContent = `/* 这里是你 CSS 文件的内容 */`;
document.head.appendChild(style);
export default {}; // 空对象,满足模块导出

浏览器执行这段 JS 后,CSS 就被动态注入到页面了。

举例 3:JSON 导入

源码

1
import data from './data.json';

Vite 在开发模式下实际返回的 JS 内容

1
2
3
4
5
// 把 JSON 对象直接作为默认导出
export default {
"name": "Vite",
"version": "5.0.0"
};

这背后 Vite 做了什么?

Vite 的开发服务器(基于 Connect)会拦截所有 .png.css.json 等文件的 HTTP 请求:

  1. 浏览器请求GET /src/assets/logo.png
  2. Vite 拦截:识别到请求的是非 JS 文件
  3. 即时转换:读取文件内容,根据文件类型生成对应的 JS 代码
  4. 返回 JS:将转换后的 JS 代码返回给浏览器(Content-Type 设为 application/javascript

整个过程是即时、按需的,你不需要任何配置。


为什么 Vite 能做到而原生 ESM 不行?

维度原生 ESMVite
加载方式浏览器直接请求文件浏览器请求 → Vite 服务器拦截 → 动态转换 → 返回 JS
处理能力只能执行 .js / .mjs能处理图片、CSS、JSON、WASM 等任意资源
实现原理依赖浏览器原生实现依赖开发服务器的 HTTP 拦截和动态代码生成

Vite 的本质是一个 “开发服务器 + 按需转换器”,它利用浏览器的原生 ESM 加载机制,但在服务器端做了“魔法转换”,让非 JS 资源也能以 JS 模块的形式被浏览器消费。


生产环境呢?

生产构建(vite build)时,Vite 会用 Rollup 做静态分析和打包:

  • 图片:会被复制到输出目录,文件名带 hash,import 返回的是最终 URL。
  • CSS:会被提取成独立的 .css 文件(或基于配置内联)。
  • JSON:会被直接打包进 JS bundle 中。

但无论哪种方式,最终产物都是浏览器能正确执行的代码,这是构建工具的核心职责。


总结一句话

JS 原生模块只能导入 JS,不能导入图片/CSS/JSON。Vite 的“方便处理”是通过开发服务器拦截请求,动态把非 JS 资源“翻译”成 JS 模块,让浏览器愉快地执行,从而让你写 import 时感觉“天然支持”。

10. 参考资料