常见问题解答
这是一个关于 esbuild 的常见问题解答集。你也可以在 GitHub 问题跟踪器 上提问。
#为什么 esbuild 速度很快?
几个原因
它是用 Go 编写的,并编译成原生代码。
大多数其他打包器是用 JavaScript 编写的,但命令行应用程序对于 JIT 编译语言来说是最糟糕的性能情况。每次运行打包器时,JavaScript VM 都会第一次看到打包器的代码,没有任何优化提示。当 esbuild 忙于解析你的 JavaScript 代码时,node 忙于解析打包器的 JavaScript 代码。当 node 完成解析打包器的代码时,esbuild 可能已经退出,而你的打包器甚至还没有开始打包。
此外,Go 从核心设计上就支持并行处理,而 JavaScript 则不支持。Go 在线程之间共享内存,而 JavaScript 必须在线程之间序列化数据。Go 和 JavaScript 都有并行垃圾收集器,但 Go 的堆在所有线程之间共享,而 JavaScript 每个 JavaScript 线程都有一个单独的堆。这似乎将 JavaScript 工作线程的并行处理能力降低了一半 根据我的测试,可能是因为你的一半 CPU 内核忙于为另一半内核收集垃圾。
并行处理被大量使用。
esbuild 中的算法经过精心设计,尽可能地充分利用所有可用的 CPU 内核。大约有三个阶段:解析、链接和代码生成。解析和代码生成是大部分工作,并且可以完全并行化(链接在很大程度上是一个固有的串行任务)。由于所有线程共享内存,因此在打包导入相同 JavaScript 库的不同入口点时,可以轻松共享工作。大多数现代计算机都有多个内核,因此并行处理是一个很大的优势。
esbuild 中的一切都是从头开始编写的。
自己编写所有内容而不是使用第三方库有很多性能优势。你可以从一开始就将性能放在首位,可以确保所有内容都使用一致的数据结构以避免昂贵的转换,并且可以根据需要进行广泛的架构更改。当然,缺点是工作量很大。
例如,许多打包器使用官方的 TypeScript 编译器作为解析器。但它是为了满足 TypeScript 编译器团队的目标而构建的,他们并没有将性能作为首要任务。他们的代码大量使用了 巨型对象形状 和不必要的 动态属性访问(这两种都是众所周知的 JavaScript 速度瓶颈)。而且 TypeScript 解析器似乎仍然运行类型检查器,即使类型检查被禁用。这些对于 esbuild 的自定义 TypeScript 解析器来说都不是问题。
内存使用效率很高。
编译器理想情况下在输入长度上主要是 O(n) 复杂度。因此,如果你正在处理大量数据,内存访问速度很可能会严重影响性能。你必须对数据进行的遍历次数越少(以及你必须将数据转换为的不同表示形式越少),编译器运行速度就越快。
例如,esbuild 只对整个 JavaScript AST 进行了三次访问
- 用于词法分析、解析、作用域设置和声明符号的遍历
- 用于绑定符号、最小化语法、JSX/TS 到 JS 以及 ESNext 到 ES2015 的遍历
- 用于最小化标识符、最小化空白、生成代码和生成源映射的遍历
这最大限度地重用了 AST 数据,同时它仍然在 CPU 缓存中处于热状态。其他打包器在单独的遍历中执行这些步骤,而不是交织在一起。它们也可能在数据表示之间进行转换以将多个库粘合在一起(例如 string→TS→JS→string,然后 string→JS→older JS→string,然后 string→JS→minified JS→string),这会使用更多内存并减慢速度。
Go 的另一个好处是它可以将内容紧凑地存储在内存中,这使得它可以使用更少的内存并将更多内容放入 CPU 缓存中。所有对象字段都有类型,并且字段紧密地打包在一起,例如,几个布尔标志只占用一个字节。Go 也有值语义,可以将一个对象直接嵌入另一个对象中,因此它“免费”提供,无需另外分配。JavaScript 没有这些功能,而且还有其他缺点,例如 JIT 开销(例如隐藏类槽)和低效的表示(例如非整数数字是使用指针进行堆分配的)。
这些因素中的每一个都只是略微提高了速度,但它们加在一起可以产生一个打包器,其速度比目前常用的其他打包器快几个数量级。
#基准测试详情
以下是每个基准测试的详细信息
此基准测试通过将 three.js 库复制 10 次并从头开始构建一个包来模拟一个大型 JavaScript 代码库,没有任何缓存。可以在 esbuild 仓库 中使用 make bench-three
运行基准测试。
打包器 | 时间 | 相对减速 | 绝对速度 | 输出大小 |
---|---|---|---|---|
esbuild | 0.39s | 1x | 1403.7 kloc/s | 5.80mb |
parcel 2 | 14.91s | 38x | 36.7 kloc/s | 5.78mb |
rollup 4 + terser | 34.10s | 87x | 16.1 kloc/s | 5.82mb |
webpack 5 | 41.21s | 106x | 13.3 kloc/s | 5.84mb |
每次报告的时间都是三次运行中最好的。我使用 --bundle
运行 esbuild。我使用了 @rollup/
插件,因为 Rollup 本身不支持最小化。Webpack 5 使用 --mode=
。Parcel 2 使用默认选项。绝对速度基于总行数,包括注释和空行,目前为 547,441 行。测试是在一台 6 核 2019 款 MacBook Pro 上进行的,该机器配备 16gb 内存,并且禁用了 macOS Spotlight。
此基准测试使用旧的 Rome 代码库(在他们使用 Rust 重写之前)来模拟一个大型 TypeScript 代码库。所有代码必须合并到一个最小化的包中,并带有源映射,并且生成的包必须正常工作。可以在 esbuild 仓库 中使用 make bench-rome
运行基准测试。
打包器 | 时间 | 相对减速 | 绝对速度 | 输出大小 |
---|---|---|---|---|
esbuild | 0.10s | 1x | 1318.4 kloc/s | 0.97mb |
parcel 2 | 6.91ѕ | 69x | 16.1 kloc/s | 0.96mb |
webpack 5 | 16.69ѕ | 167x | 8.3 kloc/s | 1.27mb |
每次报告的时间都是三次运行中最好的。我使用 --bundle
运行 esbuild。Webpack 5 使用 ts-loader
,并使用 transpileOnly:
和 --mode=
。Parcel 2 在 package.json
中使用 "engines":
。绝对速度基于总行数,包括注释和空行,目前为 131,836 行。测试是在一台 6 核 2019 款 MacBook Pro 上进行的,该机器配备 16gb 内存,并且禁用了 macOS Spotlight。
结果不包括 Rollup,因为我无法让它工作,原因与 TypeScript 编译有关。我尝试了 @rollup/
,但你无法禁用类型检查,我还尝试了 @rollup/
,但没有办法提供 tsconfig.json
文件(这是正确路径解析所必需的)。
#未来路线图
这些功能正在开发中,并且是首要任务
这些是潜在的未来功能,但可能不会实现,或者实现范围更小
- HTML 内容类型 (#31)
在那之后,我认为 esbuild 已经相对完整了。我计划让 esbuild 达到一个基本稳定的状态,然后停止添加更多功能。这将涉及对添加 esbuild 本身的主要功能的请求说“不”。我认为 esbuild 不应该成为所有前端需求的一站式解决方案。特别是,我想避免“webpack 配置”模型的痛苦和问题,在这种模型中,底层工具过于灵活,导致可用性下降。
例如,我不计划在 esbuild 的核心本身中包含这些功能
我希望我正在添加到 esbuild 中的可扩展性点(插件 和 API)将使 esbuild 有用,可以将其包含在更定制的构建工作流程中,但我并不打算或期望这些可扩展性点涵盖所有用例。如果你有非常定制的要求,那么你应该使用其他工具。我还希望 esbuild 能激励其他构建工具通过彻底改造它们的实现来大幅提高性能,这样每个人都能从中受益,而不仅仅是那些使用 esbuild 的人。
我计划在 esbuild 达到稳定状态后,继续维护 esbuild 现有范围内的所有内容。这意味着实现对新发布的 JavaScript 和 TypeScript 语法功能的支持,例如。
#生产就绪
该项目尚未达到 1.0.0 版本,并且仍在积极开发中。也就是说,它已经远远超出了 alpha 阶段,并且非常稳定。我认为它处于一个晚期的 beta 阶段。对于一些早期采用者来说,这意味着它已经足够好,可以用于实际事物。其他人认为这意味着 esbuild 还没有准备好。本节不会试图说服你任何一方。它只是试图为你提供足够的信息,以便你能够自己决定是否要使用 esbuild 作为你的打包器。
一些数据点
- 被其他项目使用
API 已经被许多其他开发工具用作库。例如,Vite 和 Snowpack 使用 esbuild 将 TypeScript 转换为 JavaScript,而 Amazon CDK(云开发工具包)和 Phoenix 使用 esbuild 来打包代码。
- API 稳定性
尽管 esbuild 的版本尚未达到 1.0.0,但我们仍然致力于保持 API 的稳定性。补丁版本旨在进行向后兼容的更改,而次要版本旨在进行向后不兼容的更改。如果您计划将 esbuild 用于实际项目,您应该要么固定确切的版本(最大安全性),要么固定主版本和次要版本(只接受向后兼容的升级)。
- 只有一个主要开发者
这个工具主要由 我 构建。对于某些人来说这很好,但对于其他人来说,这意味着 esbuild 不适合他们的组织。我对此没有意见。我构建 esbuild 是因为我发现构建它很有趣,并且因为它是我想要使用的工具。我与世界分享它,因为其他人也想要使用它,因为反馈使工具本身变得更好,并且因为我认为它将激励生态系统创建更好的工具。
- 并不总是开放范围扩展
我不打算包含我不感兴趣构建和/或维护的主要功能。我还想限制项目的范围,使其不会变得过于复杂和难以驾驭,无论是从架构角度、测试和正确性角度,还是从可用性角度。将 esbuild 视为 Web 的“链接器”。它知道如何转换和捆绑 JavaScript 和 CSS。但是,您的源代码如何最终以纯 JavaScript 或 CSS 形式呈现的细节可能需要使用第三方代码。
我希望 插件 能够让社区添加主要功能(例如 WebAssembly 导入),而无需为 esbuild 本身做出贡献。但是,并非所有内容都暴露在插件 API 中,并且您可能无法向 esbuild 添加您可能想要添加的特定功能。这是故意的;esbuild 并非旨在成为满足所有前端需求的一站式解决方案。
#防病毒软件
由于 esbuild 是用原生代码编写的,因此防病毒软件有时会错误地将其标记为病毒。这并不意味着 esbuild 是病毒。我不会发布恶意代码,并且非常重视供应链安全。
esbuild 的几乎所有代码都是第一方代码,除了对 Google 的补充 Go 包集的 一个依赖项。我的开发工作是在与我用来发布构建的机器不同的隔离的机器上完成的。我已经做了额外的工作来确保 esbuild 的发布构建是完全可重现的,并且在每次发布后,发布的构建都会 自动与 在无关环境中本地构建的构建进行比较,以确保它们是按位相同的(即 Go 编译器本身没有被破坏)。您也可以自己从源代码构建 esbuild,并将您的构建工件与发布的工件进行比较,以独立验证这一点。
不得不处理误报是使用防病毒软件的一个不幸现实。如果您的防病毒软件不允许您使用 esbuild,以下是一些可能的解决方法
- 忽略您的防病毒软件,并将 esbuild 从隔离区中删除
- 向您的防病毒软件供应商报告特定的 esbuild 原生可执行文件为误报
- 使用
esbuild-wasm
而不是esbuild
来绕过您的防病毒软件(它可能不会像标记原生可执行文件那样标记 WebAssembly 文件) - 使用其他构建工具,而不是 esbuild
#过时的 Go 版本
如果您使用自动依赖项漏洞扫描器,您可能会收到有关 esbuild 使用的 Go 编译器版本和/或 golang.org/x/sys
(esbuild 的唯一依赖项)版本过时的报告。这些报告是良性的,应该忽略。
发生这种情况是因为 esbuild 的代码故意旨在与 Go 1.13 兼容。更高版本的 Go 已放弃对某些我想要 esbuild 能够运行的旧平台的支持(例如,旧版本的 macOS)。虽然 esbuild 的发布二进制文件是用更新版本的 Go 编译器编译的(因此在旧版本的 macOS 上不起作用),但您目前仍然能够使用 Go 1.13 为自己编译最新版本的 esbuild,并在旧版本的 macOS 上使用它,因为 esbuild 的代码仍然可以使用 Go 1.13 及更早版本进行编译。
人们和/或自动化工具有时会看到 go.mod
中的 go 1.13
行,并抱怨 esbuild 的发布二进制文件是用 Go 1.13 构建的,这是一个非常旧的 Go 版本。但是,事实并非如此。go.mod
中的该行仅指定了最低编译器版本。它与 esbuild 的发布二进制文件使用的 Go 版本无关,该版本是更新版本的 Go。 请阅读文档。
人们有时还希望 esbuild 更新 golang.org/x/sys
依赖项,因为 esbuild 使用的版本存在已知漏洞(特别是关于 Faccessat
函数的 GO-2022-0493)。阻止 esbuild 更新到更新版本的 golang.org/x/sys
依赖项的问题是,更新版本已开始使用 unsafe.Slice
函数,该函数是在 Go 1.17 中首次引入的(因此无法在旧版本的 Go 中编译)。但是,此漏洞报告无关紧要,因为 a) esbuild 根本不会调用该函数,并且 b) esbuild 是一个构建工具,而不是沙箱,esbuild 的文件系统访问不是安全敏感的。
我不会放弃对旧平台的兼容性,也不会阻止某些人使用 esbuild,仅仅是为了解决无关的漏洞报告。请忽略有关上述问题的任何报告。
#压缩换行符
人们有时会惊讶地发现,esbuild 的压缩器通常会将 JavaScript 字符串中的字符转义序列 \n
更改为模板字面量中的换行符。但这是故意的。这不是 esbuild 的错误。压缩器的任务是生成尽可能紧凑的输出,该输出等效于输入。字符转义序列 \n
长度为两个字节,而换行符长度为一个字节。
例如,此代码长度为 21 字节
var text="a\nb\nc\n";
而此代码长度为 18 字节
var text=`a
b
c
`;
因此,第二段代码是完全压缩的,而第一段代码不是。压缩代码并不意味着将所有代码放在一行上。相反,压缩代码意味着生成使用尽可能少字节的等效代码。在 JavaScript 中,未标记的模板字面量等效于字符串字面量,因此 esbuild 在这里做的是正确的。
#避免名称冲突
入口点模块中的顶级变量在浏览器中运行 esbuild 的输出时永远不会出现在全局范围内。如果发生这种情况,则意味着您没有遵循 esbuild 关于输出格式的文档,并且正在错误地使用 esbuild。这不是 esbuild 的错误。
具体来说,在浏览器中运行 esbuild 的输出时,您必须执行以下两种操作之一
--format=
与iife <script
src="..."> 如果您在全局范围内运行代码,那么您应该使用
--format=
。这会导致 esbuild 的输出包装您的代码,以便顶级变量在嵌套范围内声明。iife --format=
与esm <script
src="..." type="module"> 如果您使用
--format=
,那么您必须将代码作为模块运行。这会导致浏览器包装您的代码,以便顶级变量在嵌套范围内声明。esm
使用 --format=
与 <script
将以微妙且令人困惑的方式破坏您的代码(省略 type="
意味着所有顶级变量都将出现在全局范围内,这将与其他 JavaScript 文件中具有相同名称的顶级变量发生冲突)。
#顶级 var
人们有时会惊讶地发现,esbuild 有时会将顶级 let
、const
和 class
声明重写为 var
声明。这样做有几个原因
- 为了正确性
捆绑有时需要延迟初始化模块。例如,当您使用捆绑包中模块的路径调用
require()
或import()
时,就会发生这种情况。这样做涉及通过将初始化移动到闭包中来分离顶级符号的声明和初始化。因此,例如,class
语句被重写为将类表达式分配给变量。将声明保留在延迟初始化闭包之外对于性能至关重要,因为它意味着其他模块可以直接通过名称引用它们,而不是通过较慢的属性访问间接引用它们。另一个需要这样做的情况是转换顶级
using
声明。这涉及将整个模块主体包装在try
块中,这也涉及分离顶级符号的声明和初始化。顶级符号可能需要导出,这意味着它们不能在try
块中声明。在这两种情况下,如果源代码包含对
const
符号的变异,esbuild 将会失败并出现构建错误,因此 esbuild 将顶级const
重写为var
不会导致常量的变异。由于 esbuild 的当前架构,执行此转换的 esbuild 部分(解析器)无法知道当前模块是否最终会被延迟初始化。此决定的信息可能只在构建的后期阶段被发现,或者甚至可能在重用相同 AST 的未来增量构建中发生变化(每个文件的 AST 在解析期间被转换一次,然后被缓存并在增量构建中重复使用)。因此,当捆绑处于活动状态时,始终会执行此转换。
- 为了性能
多个 JavaScript VM 曾经并且仍然存在着 TDZ(即“时间死区”)检查的性能问题。这些检查验证
let
、const
或class
符号是否在初始化之前使用。以下是两个知名 VM 的问题- V8:https://bugs.chromium.org/p/v8/issues/detail?id=13723(速度降低 10%)
- JavaScriptCore:https://bugs.webkit.org/show_bug.cgi?id=199866(速度降低 1,000%!)
JavaScriptCore 存在一个严重的性能问题,因为他们的 TDZ 实现的时间复杂度与同一范围内需要 TDZ 检查的变量数量成二次方关系(顶级范围通常是最糟糕的罪魁祸首)。V8 持续存在着 TDZ 检查在他们的 JIT 生成的代码中始终存在的问题,即使它们已经在同一函数中被检查过,或者所讨论的函数已经被运行过(因此检查已经发生过)。
在 JavaScript 中,
let
、const
和class
声明都会引入 TDZ 检查,而var
声明不会。由于捆绑通常将多个模块合并为一个非常大的顶级范围,因此这些 TDZ 检查的性能影响可能非常严重。将顶级let
、const
和class
声明转换为var
有助于自动使您的代码更快。
请注意,esbuild 不会保留顶级 TDZ 副作用,因为模块可能需要延迟初始化(如上所述),这意味着将声明与初始化分离。顶级符号的 TDZ 检查可以通过生成额外的代码来支持,该代码在使用顶级符号之前进行检查,如果它尚未初始化,则抛出异常(有效地手动实现真正的 JavaScript VM 会做的事情)。但是,这对于代码大小和运行时来说似乎是过度的开销,并且似乎不是面向生产的捆绑器应该做的事情。