FAQ
本文是 esbuild 常见问题的集合。 你也可以在 GitHub issue 上提问。
# 为何 esbuild 如此之快?
若干原因:
它由 Go 编写,并被编译成原生代码。
大多数构建工具都是用 JavaScript 编写的, 但对于需要 JIT(即时)编译的语言来说,命令行程序的性能是他们的噩梦。 每次运行你的构建工具时,对于 JavaScript 虚拟机来说,都是第一次运行你的代码, 没有任何优化提示。 当 esbuild 忙着解析你代码的 JavaScript 时, Node 可能还忙着解析你构建工具的 JavaScript。 当 Node 解析完你构建工具的代码时,esbuild 可能已经退出了, 而你的构建工具还未开始构建。
此外,Go 在核心设计上就采用了并行性,而 JavaScript 却没有。 Go 在线程间共享内存, 而 JavaScript 必须在线程间对数据进行序列化。 尽管 Go 和 JavaScript 都有并行的垃圾收集器, 但 Go 的堆是所有线程之间共享的, 而 JavaScript 则是每个线程都拥有一个单独的堆。 根据我的测试, 这似乎将 JavaScript 工作线程可能的并行量减少了一半。 这大概是因为一半 CPU 的核在忙着帮另一半进行垃圾回收。
极大的利用了并行性。
esbuild 内部的算法经过了精心设计,在可能的情况下, 使得所有可用的 CPU 核完全饱和。 这过程中大概分为三个阶段:解析(parse)、链接(link)和代码生成(code generation)。 解析和代码生成是占据了大部分的工作, 并且完全是可并行的(链接在大部分情况下是一个固有的串行任务)。 由于所有线程间共享内存, 因此当构建引入相同 JavaScript 库的不同入口点时,可以很容易地共享内存。 大多数现代计算机都有许多核,所以并行性是 esbuild 的最大优势之一。
esbuild 中的所有内容都是从 0 开始编写的。
完全自己编写而不使用第三方库, 会带来很多性能上的好处。 从 0 开始就考虑到性能, 可以确保所有东西都采用一致的数据结构以避免昂贵的转换过程, 在必要时进行完全地架构变更。 当然,最大缺点就是相当的耗时。
例如,许多构建工具均使用官方的 TypeScript 编译器作为解析器。 但它是为了服务于 TypeScript 编译器团队的目标而被建立, 他们并没有将性能作为首要指标。 他们的代码中大量使用了 megamorphic object shapes 以及不必要的动态属性访问 (这些都是众所周知的 JavaScript 性能杀手)。 而 TypeScript 解析器即便在类型检查被禁用的情况下, 仍会运行类型检查器。而使用 esbuild 自定义的 TypeScript 解析器,就不会遇到上述问题。
内存得到有效的利用。
理想情况下的编译器,输入内容的长度大多为 O(n) 的复杂度。 所以如果要处理大量的数据, 内存访问速度很可能会严重影响性能。 在数据上进行的访问次数越少(同时数据转化成的不同表现形式也要越少), 这样你的编译器就会越快。
例如,esbuild 仅访问 JavaScript 的 AST 三次:
- 第一次用于词法、解析、作用域设置以及声明符号;
- 第二次用于绑定符号、压缩语法、将 JSX/TS 转为 JS 以及将 ESNext 转为 ES2015;
- 最后一次则用于对标识符进行压缩、压缩空格、生成代码以及生成 source map。
当 AST 的数据仍在 CPU 热缓存(译注:术语,CPU 缓存策略分为热缓存和冷缓存)中时, 可以最大限度地重复使用 AST 的数据。 其他构建工具会将这些步骤分开执行,而不会交错进行。 他们还可能会在数据的表现形式间进行转换,将多个库一同使用 (例如 string→TS→JS→string,然后 string→JS→older JS→string, 再然后 string→JS→minified JS→string)这将使用大量内存并使得构建变慢。
而 Go 的另外一个好处是,它可以将内容紧密的存储在内存中, 这使得它可以使用更少的内存,更适合 CPU 缓存。 所有的对象字段的类型和字段都紧密的包裹在一起, 例如,几个布尔类型的标志每个只占一个字节。 Go 还具有值语义,可以把一个对象直接嵌入到另一个对象中, 而不需要额外分配空间。 JavaScript 则没有这些特性,而且还有其他的缺点, 比如 JIT 的开销(比如 hidden class slots) 和低效的表示方式(比如非整数使用指针进行堆分配)
这些因素中每一点都只是有显著的提速, 但综合起来, 它们可以使得构建工具的速度比目前其他常用的构建工具快好几个数量级。
# Benchmark 详情
以下是每个 benchmark 的详细信息:
此 benchmark 通过复制 three.js 库 10 次, 来模拟构建一个大型 JavaScript 代码库的情况, 并从 0 开始构建了一个单一 bundle,且未使用任何缓存。 你可以在 esbuild 仓库中运行此 benchmark, 使用 make bench-three
。
Bundler | Time | Relative slowdown | Absolute speed | Output size |
---|---|---|---|---|
esbuild | 0.33s | 1x | 1658.9 kloc/s | 5.80mb |
parcel 2 | 32.48s | 98x | 16.9 kloc/s | 5.87mb |
rollup + terser | 34.95s | 106x | 15.7 kloc/s | 5.81mb |
webpack 5 | 41.53s | 126x | 13.2 kloc/s | 5.84mb |
每次报告的数据均为三次中最好的一次。 我通过 --bundle
来运行 esbuild (单线程时则使用 GOMAXPROCS=1
)。 在运行 Rollup 时,我使用了 rollup-
的 plugin, 因为 Rollup 本身并不支持压缩。而在运行 webpack 时,则使用了 --mode=
的选项。 Parcel 则相对简单,使用默认选项即可。数据中的绝对速度是基于包括注释和空白行在内的总行数计算的, 目前为 547,441 行。 测试环境为一台 6 核,16gb 内存,macOS Spotlight 禁用的 2019 款 MacBook Pro。
此 benchmark 则选用 Rome, 来模拟构建一个大型的 TypeScript 代码库。 所有代码必须合并成一个带有 source map, 且被压缩过的 bundle,并且此 bundle 必须正常运行。 你可以在 esbuild 仓库中运行此 benchmark,使用 make bench-rome
。
Bundler | Time | Relative slowdown | Absolute speed | Output size |
---|---|---|---|---|
esbuild | 0.10s | 1x | 1318.4 kloc/s | 0.97mb |
parcel 2 | 8.73s | 87x | 15.1 kloc/s | 0.97mb |
webpack 5 | 18.89s | 189x | 7.0 kloc/s | 1.27mb |
每次报告的数据均为三次中最好的一次。 我通过 --bundle
来运行 esbuild(单线程时则使用 GOMAXPROCS=1
)。在运行 webpack 时,则使用了 ts-loader
,并设置 transpileOnly:
以及其他配置项 --mode=
。在运行 Parcel 1 时,使用了 --target
选项。 运行 Parcel 2 时,在 package.json
中设置了 "engines":
,同时还需要使用 @parcel/
转换器来处理 benchmark 中使用的 TypeScript 代码。 数据中的绝对速度是基于包括注释和空白行在内的总行数计算的, 目前为 131,836 行。测试环境为一台 6 核,16gb 内存的 2019 款 MacBook Pro。
此结果集并未包含 Rollup,因为我没办法让其正常工作。 我尝试了 rollup-
, @rollup/
,以及 @rollup/
, 但他们都因为与 TypeScript 编译相关的原因而无法正常工作。
# 即将发布的路线图
这些特性已在进行中,处于第一优先级:
下面这些是未来可能会开发的特性,但也可能不会, 亦或是会进行开发,但开发的程度有限:
在这之后,我认为 esbuild 已相对完善。 我计划让 esbuild 达到一个基本稳定的状态, 然后停止增加更多的特性。 这将拒绝对 esbuild 本身增加主要特性的请求。 我不认为 esbuild 应该成为满足一切前端需求的一体化解决方案。 特别是,我希望避免 ”webpack config“ 模式的麻烦和问题, 因为该模式的底层过于灵活, 其易用性会受到影响。
例如,我并不打算在 esbuild 中加入如下特性:
- 支持其他前端语言(例如 Elm, Svelte,Vue 以及 Angular 等)
- TypeScript 的类型检查(单独运行
tsc
即可) - 用于自定义 AST 操作的 API
- 热更新
- 模块联邦(module federation)
我希望我添加到 esbuild 中的扩展点(plugins 和 API) 能让 esbuild 成为更多定制化构建工作流的一部分, 但我并未期望这些扩展点能覆盖所有的用例。 如果你有非常强烈的自定义需求,那么你应使用其他工具。 我也希望 esbuild 能激励其他的构建工具, 通过彻底改变他们的底层实现来大幅提高他们的性能 让每个人都能受益, 而不仅仅是那些使用 esbuild 的人。
我计划在 esbuild 达到稳定后, 继续维持其现有范围内的一切特性。 这意味着将继续实现对后续新增的 JavaScript 语法和 TypeScript 语法特性的支持。
# 投入生产环境的准备情况
此项目尚未达到 1.0.0 版本,仍处于积极开发阶段。 就目前而言,它已早已达到 alpha 阶段,并且非常稳定。 我认为这是一个后期测试版本。 对于早期的参与者来说,它足以用于生产环境。 当然还有一些开发者认为 esbuild 还未准备好。 本章节并不试图改变你的观点, 只是试图给你足够多的信息, 让你自行决定是否使用 esbuild 作为你的构建工具。
一些其他信息:
- 被其他项目所使用
esbuild 的 API 已被其他开发者工具使用。例如,Vite 和 Snowpack 使用了 esbuild 的 transform API,用于将 TypeScript 转换为 JavaScript。而 Hugo 则在构建过程中使用 esbuild 的构建工具来构建 JavaScript 代码。 我也听到有些小伙伴在生产环境成功使用它的消息,但我并不知道细节。 一旦 esbuild 有足够的功能, 我也会将 esbuild 投入生产中使用, 但目前来说,还未这样做。
- API 稳定性
尽管 esbuild 的版本尚未达到 1.0.0, 但我们仍然努力保持着 API 的稳定性。 补丁版本的目的是为了向后兼容变化, 次要版本的目的是为了向后不兼容的变化 (正如 npm 所推荐的那样)。 如果你计划使用 esbuild 投入生产使用, 你应该锁住确切的版本(最安全的方式)或者锁住主要版本和次要版本(只接受向后兼容的升级)。
- 仅有一位主要开发者
本工具主要由 evanw 搭建。 对于一些人来说,这很友好, 但是对另外一些人来说,这意味着 esbuid 可能不是一个适合他们组织的工具。 这些对我来说都无所谓。我构建 esbuild 是因为我觉得构建它很有趣, 也是因为它是我想要使用的工具。 我把它与大家共享,是因为也有其他人想要使用它, 并且反馈会让工具本身变得更加美好, 而且我认为它将激励生态系统做出更好的工具。
- 并不会一直扩大项目的作用范围
我不打算引入我对构建/维护不感兴趣的主要特性。 无论是从架构的角度、测试和正确性的角度,还是从可用性的角度, 我都想限制项目的范围, 使它不至于看起来太复杂,或太笨重。 你可以把 esbuild 看作是 web 的 "链接器(linker)" 它只知道如何转换和构建 JavaScript 和 CSS。 但是,你的源码如何成为普通的 JavaScript 或 CSS 代码的细节 可能还需要第三方代码来做。
我希望 plugins 系统可以在不对 esbuild 本身做出任何修改的情况下, 添加主要功能(例如 WebAssembly 的引入)。 然而,并非所有内容都会在 plugin 中暴露出来, 可能会出现无法向 esbuild 中添加你所想添加特定功能的情况。 这是有意而为, 请谨记 esbuild 并不是一个能满足所有前端需求的 一体化解决方案。