常见问题
这是关于esbuild的常见问题集合。你也可以在 GitHub问题跟踪器上提问。
#为什么esbuild这么快?
有几个原因:
它是用Go编写的,并编译为原生代码。
大多数其他打包工具是用JavaScript编写的,但命令行应用程序 对于JIT编译语言来说是最糟糕的性能场景。每次运行打包工具时, JavaScript VM都是第一次看到你的打包工具的代码,没有任何优化提示。 当esbuild正在解析你的JavaScript时,node正忙于解析你的打包工具的JavaScript。 当node完成解析你的打包工具的代码时,esbuild可能已经退出, 而你的打包工具甚至还没有开始打包。
此外,Go从核心设计上就支持并行性,而JavaScript则不然。 Go在线程之间共享内存,而JavaScript必须在线程之间序列化数据。 Go和JavaScript都有并行垃圾收集器,但Go的堆在所有线程之间共享, 而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
运行此基准测试。
Bundler | Time | Relative slowdown | Absolute speed | Output size |
---|---|---|---|---|
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 |
报告的每个时间是三次运行中的最佳结果。我运行esbuild时使用 --bundle
。我使用了 @rollup/
插件,因为Rollup本身不支持压缩。Webpack 5使用 --mode=
。 Parcel 2使用默认选项。绝对速度基于总行数,包括注释和空行,目前为547,441行。 测试是在一台6核2019 MacBook Pro上进行的,配备16GB RAM,并禁用了 macOS Spotlight。
这个基准测试使用旧的Rome代码库 (在他们的Rust重写之前)来近似一个大型TypeScript代码库。所有代码 必须合并成一个带有源映射的压缩包,并且生成的包必须正确工作。 可以在esbuild仓库中使用 make bench-rome
运行此基准测试。
Bundler | Time | Relative slowdown | Absolute speed | Output size |
---|---|---|---|---|
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 |
报告的每个时间是三次运行中的最佳结果。我运行esbuild时使用 --bundle
。 Webpack 5使用ts-loader
,配置为transpileOnly:
和 --mode=
。Parcel 2使用 "engines":
在package.json
中。绝对 速度基于总行数,包括注释和空行,目前为131,836行。测试是在一台6核2019 MacBook Pro上 进行的,配备16GB RAM,并禁用了macOS Spotlight。
结果不包括Rollup,因为我无法让它工作,原因与TypeScript编译有关。 我尝试了@rollup/
, 但你不能禁用类型检查,我也尝试了 @rollup/
, 但没有办法提供tsconfig.json
文件(这对于正确的路径解析是必需的)。
#即将到来的路线图
这些功能已经处于开发中,并且是优先级最高的:
这些是潜在的未来功能,但可能不会发生或可能只会以更有限的方式发生:
- HTML内容类型(#31)
在那之后,我会认为esbuild相对完整。 我计划让esbuild达到大部分稳定的状态,然后停止积累更多的功能。 这将涉及对esbuild本身添加主要功能的请求说"不"。 我不认为esbuild应该成为一个所有前端需求的"一站式"解决方案。 特别是,我不想避免"webpack config"模型带来的痛苦和问题, 其中底层工具过于灵活,可用性受到影响。
例如,我_不_计划在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(Cloud Development Kit) 和Phoenix正在使用 esbuild来捆绑代码。
- API稳定性
即使esbuild的版本尚未达到1.0.0,仍会努力保持API稳定。 补丁版本旨在用于向后兼容的更改和次要版本旨在用于向后不兼容的更改。 如果你计划将esbuild用于某些实际操作,你应该要么固定确切版本(最大安全性), 要么固定主要和次要版本(仅接受向后兼容的升级)。
- 只有一个主要开发者
这个工具主要由我构建。 对于一些人来说这很好,但对于其他人来说,这意味着esbuild不是 适合他们的工具。那没关系。我构建esbuild是因为我发现构建它很有趣, 而且因为它是我想要使用的工具。我与世界分享它,因为还有其他人 想要使用它,因为反馈使工具本身变得更好, 而且因为我认为它将激励生态系统构建更好的工具。
- 不总是开放范围扩展
我无意包括我不再感兴趣构建和/或维护的主要功能。 我也想限制项目范围,以免项目变得过于复杂和难以使用, 从架构角度、测试和正确性角度以及可用性角度。 将esbuild视为"链接器"以进行网络。它知道如何转换和捆绑JavaScript和CSS。 但你的源代码如何最终成为纯JavaScript或CSS的细节可能需要是3rd-party代码。
我希望插件将允许社区添加主要功能(例如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在其JIT生成的代码中存在持续的TDZ检查问题, 即使这些检查已经在同一函数的早期进行过,或者当相关函数已经运行过(因此检查已经发生)。
在JavaScript中,
let
、const
和class
声明都引入了TDZ检查,而var
声明则没有。 由于打包通常将许多模块合并到一个非常大的顶级作用域中, 这些TDZ检查的性能影响可能相当严重。 将顶级的let
、const
和class
声明转换为var
有助于自动使你的代码更快。
请注意,esbuild不保留顶级TDZ副作用,因为模块可能需要延迟初始化(如上所述), 这意味着将声明与初始化分离。顶级符号的TDZ检查理论上仍然可以通过生成额外的代码来支持, 该代码在每次使用顶级符号之前进行检查,如果该符号尚未初始化则抛出错误 (有效地手动实现真正的JavaScript VM会做的事情)。 然而,这对代码大小和运行时间来说似乎是一个过度的开销, 并且似乎不是一个面向生产的打包工具应该做的事情。