ESBuild最近很火,号称比webpack快100倍。它为什么这么快?背后的原理是什么?今天深入剖析ESBuild的底层机制,从语言选择、架构设计、算法优化等方面,一次说清楚。

先简单介绍一下ESBuild。ESBuild是一个JavaScript打包工具,由Evan Wallace开发,第一个版本发布于2020年初。它的最大特点就是快,官方说它比webpack快10到100倍。在ESBuild出现之前,前端构建工具基本上都是用JavaScript写的,比如webpack、rollup、parcel等,构建速度一直是个痛点,尤其是大项目,构建一次可能要几分钟甚至十几分钟。ESBuild的出现,给前端构建领域带来了新的可能性。

我第一次知道ESBuild,是在2020年初,看到有人在推特上分享ESBuild的benchmark,说它比webpack快了几十倍。我当时觉得不太可能,以为是营销噱头。后来自己试了一下,发现确实很快,一个中等规模的项目,webpack要构建30秒,ESBuild只要1秒钟。当时就被震撼了,于是开始研究它为什么这么快。

今天就把我研究的成果分享出来,从语言选择、架构设计、算法优化等方面,深入剖析ESBuild的底层机制。

一、为什么这么快:语言选择

ESBuild快的第一个原因,也是最直观的原因,是它用Go语言写的,而不是JavaScript。

Go语言是Google开发的一种编译型语言,它的性能接近C/C++,比JavaScript这种解释型语言快很多。JavaScript是动态类型的解释型语言,虽然V8引擎做了很多优化(比如JIT编译),但本质上还是比编译型语言慢。尤其是在CPU密集型的任务中(比如代码解析、转换、生成),Go语言的性能优势非常明显。

具体来说,Go语言相比JavaScript有以下几个性能优势:

第一,编译执行 vs 解释执行。Go语言是编译型语言,代码在运行之前就被编译成了机器码,运行的时候直接执行机器码,速度很快。JavaScript是解释型语言,虽然V8有JIT编译,但JIT编译是在运行时进行的,需要预热,而且有些代码无法被优化(比如动态类型变化的代码),只能解释执行,速度比较慢。

第二,静态类型 vs 动态类型。Go语言是静态类型的,变量的类型在编译时就确定了,编译器可以做很多优化,比如内联、死代码消除、寄存器分配等。JavaScript是动态类型的,变量的类型在运行时才能确定,JIT编译器虽然可以做类型推测和优化,但一旦类型发生变化,就需要去优化(deoptimize),重新解释执行,这会影响性能。

第三,并发模型。Go语言原生支持goroutine和channel,可以很方便地写并发程序,充分利用多核CPU。JavaScript是单线程的,虽然有worker_threads和cluster,但使用起来比较麻烦,而且进程间通信的开销比较大。前端构建是一个可以高度并行化的任务,比如解析多个文件、转换多个模块,都可以并行处理。Go语言的并发模型让ESBuild可以很容易地利用多核CPU,而JavaScript的构建工具在这方面就比较吃亏。

第四,内存管理。Go语言有自动垃圾回收(GC),但Go的GC是并发的、低延迟的,对程序运行的影响比较小。JavaScript也有GC,但V8的GC在回收大内存的时候,会暂停程序(stop-the-world),影响性能。而且,JavaScript的对象是基于哈希表的,内存开销比较大,而Go语言的结构体是值类型的,内存布局更紧凑,缓存命中率更高。

当然,语言选择只是ESBuild快的原因之一,不是全部。如果只是用Go重写webpack,可能会快一些,但不会快100倍。ESBuild的快,还得益于它的架构设计和算法优化。

二、架构设计:高度并行化

ESBuild快的第二个原因,是它的架构设计高度并行化,充分利用了多核CPU。

传统的JavaScript打包工具(比如webpack),因为JavaScript是单线程的,所以大部分处理都是串行的。虽然webpack也有一些并行化的尝试(比如thread-loader、cache-loader),但本质上还是串行的,并行化的程度不高。

ESBuild从一开始就设计成高度并行化的。它的整个构建流程,从文件解析、依赖收集、代码转换、到打包生成,都尽量并行化。Go语言的goroutine让并行化变得很容易,创建一个goroutine的开销很小(只有几KB的栈空间),可以同时创建成千上万个goroutine,由Go的运行时调度到多个CPU核心上执行。

具体来说,ESBuild的并行化体现在以下几个方面:

第一,文件解析并行化。ESBuild在解析入口文件的时候,会同时解析它的所有依赖模块,每个模块的解析都是一个独立的goroutine。比如,入口文件依赖了10个模块,ESBuild会同时创建10个goroutine来解析这10个模块,而不是一个一个地解析。这样,如果有10个CPU核心,解析速度就能提升10倍。

第二,代码转换并行化。解析完文件之后,需要对代码进行转换,比如把TypeScript转换成JavaScript,把JSX转换成JavaScript,把ES6+转换成ES5等。每个模块的转换都是独立的,可以并行处理。ESBuild会把所有需要转换的模块分配到多个goroutine中并行处理,充分利用多核CPU。

第三,依赖收集并行化。在解析和转换的同时,ESBuild还会收集每个模块的依赖关系。这个过程也是并行的,每个模块在解析的时候,就会同时收集它的依赖,然后把依赖加入到待解析的队列中,由其他goroutine来处理。

第四,IO操作并行化。文件的读取是IO操作,比较耗时。ESBuild在读取文件的时候,也是并行的,同时读取多个文件,而不是一个一个地读。Go语言的网络和IO操作也是异步的,goroutine在等待IO的时候会被挂起,不会占用CPU,等IO完成了再继续执行,这样CPU的利用率很高。

当然,并行化也不是没有代价的。并行化需要处理好线程安全的问题,比如多个goroutine同时访问共享数据的时候,需要加锁或者使用并发安全的数据结构。ESBuild在这方面做了很多优化,尽量减少锁的使用,比如使用不可变数据结构、线程本地存储、无锁队列等,来降低并发的开销。

另外,并行化也不是线性的,不是说有多少个CPU核心,速度就能提升多少倍。因为有些任务是串行的,比如最终的代码生成和打包,需要等所有模块都处理完之后才能进行,这部分是无法并行的。而且,并行化会带来一些额外的开销,比如goroutine的调度、锁的竞争、缓存的失效等。所以,ESBuild的实际加速比,会低于CPU核心数,但依然非常可观。

三、算法优化:高效的解析和生成

ESBuild快的第三个原因,是它在算法层面做了很多优化,尤其是在代码解析和生成方面。

代码解析是打包工具的核心步骤,也是最耗时的步骤之一。ESBuild的解析器是自己写的,不是用的第三方库(比如acorn、babel-parser等)。自己写解析器的好处是,可以针对打包的场景做专门的优化,不需要实现完整的JavaScript语法,只需要实现打包需要的部分。

具体来说,ESBuild的解析器有以下几个优化点:

第一,单遍解析(single-pass parsing)。传统的JavaScript解析器,一般是先做词法分析(tokenize),生成token流,然后做语法分析(parse),生成AST(抽象语法树),这是两遍的过程。ESBuild把词法分析和语法分析合并成了一遍,在解析的同时就生成AST,不需要先生成完整的token流。这样既节省了时间,也节省了内存,因为不需要存储所有的token。

第二,跳过不需要的节点。打包工具在解析代码的时候,不需要关心所有的语法节点,只需要关心和模块依赖、代码转换相关的部分,比如import/export语句、函数声明、变量声明等。对于函数体内部的代码,如果不需要转换(比如不需要把ES6转换成ES5),可以跳过不解析,或者只做浅层解析。ESBuild在这方面做了优化,对于不需要详细解析的代码块,会快速跳过,只记录它的位置和长度,这样能节省很多解析时间。

第三,优化的词法分析。词法分析是解析的基础,ESBuild的词法分析器做了很多优化,比如使用查找表(lookup table)来快速判断字符的类型,使用状态机来处理标识符和关键字,避免了正则表达式的开销。而且,ESBuild的词法分析器是和语法分析器紧密结合的,可以根据语法上下文来优化词法分析,比如在解析字符串的时候,不需要检查关键字。

第四,高效的AST表示。传统的JavaScript解析器生成的AST,每个节点都是一个对象,有很多属性,内存开销比较大。ESBuild的AST表示更加紧凑,使用了很多技巧来减少内存占用,比如使用位域(bit field)来存储节点的类型和标志,使用偏移量来表示节点的位置,而不是存储完整的行号和列号。紧凑的AST表示不仅节省内存,还能提高缓存命中率,让后续的处理更快。

除了解析器,ESBuild在代码生成方面也做了很多优化。代码生成是把AST转换成最终的JavaScript代码的过程,也是比较耗时的。ESBuild的代码生成器有以下优化点:

第一,直接从AST生成代码,不需要中间表示。传统的打包工具,在代码生成之前,可能会有一些中间步骤,比如把AST转换成另一种中间表示(IR),然后再生成代码。ESBuild直接从AST生成最终的代码,减少了中间步骤,节省了时间。

第二,高效的字符串拼接。代码生成的过程,本质上是字符串拼接的过程。传统的JavaScript代码生成器,用的是字符串拼接或者数组join,性能比较差。ESBuild用Go语言写的代码生成器,使用了高效的字符串构建方式,比如预分配缓冲区、直接写入字节等,避免了频繁的内存分配和拷贝,性能很高。

第三,并行代码生成。每个模块的代码生成是独立的,可以并行处理。ESBuild会把所有模块的代码生成分派到多个goroutine中并行执行,然后把生成的代码拼接在一起。这样又能利用多核CPU,提升速度。

第四,优化的输出格式。ESBuild在生成代码的时候,会尽量减少输出的体积,比如移除多余的空格和换行,缩短变量名,移除未使用的代码(tree shaking)等。这些优化不仅能减小输出文件的体积,还能减少写入磁盘的时间和网络传输的时间。

四、其他优化

除了上面说的语言选择、架构设计、算法优化,ESBuild还有一些其他的优化,也对性能有帮助。

第一,原生实现,没有JavaScript的开销。传统的JavaScript打包工具,本身是用JavaScript写的,运行在Node.js上。Node.js本身有一些开销,比如启动时间、模块加载时间、JIT编译时间等。对于小项目来说,这些开销可能占了构建时间的很大一部分。ESBuild是用Go写的原生程序,编译成二进制文件直接运行,没有Node.js的这些开销,启动速度很快,小项目的构建时间几乎可以忽略不计。

第二,内置常用功能,不需要插件。webpack等工具的很多功能是通过插件实现的,比如处理TypeScript、处理CSS、压缩代码等。插件的使用会带来一些开销,比如插件的加载、插件之间的通信、插件的钩子调用等。ESBuild把很多常用功能内置了,比如TypeScript转换、JSX转换、CSS处理、代码压缩等,不需要额外的插件,这样既减少了插件的开销,也简化了配置。

第三,优化的文件系统缓存。ESBuild会缓存已经解析和转换过的文件,如果文件没有变化,就不需要重新处理,直接用缓存的结果。而且,ESBuild的缓存是基于文件内容的哈希值,而不是文件的修改时间,这样更可靠,不会因为修改时间变化而导致缓存失效。缓存的使用,能大幅提升增量构建的速度,尤其是在开发模式下,修改一个文件,只需要重新处理这个文件和它的依赖,不需要重新构建整个项目。

第四,优化的依赖解析。Node.js的模块解析算法(node_modules解析)是比较复杂的,需要查找多个目录,读取package.json文件,解析main字段等,比较耗时。ESBuild对依赖解析做了优化,比如缓存解析结果、并行解析多个依赖、优化文件系统调用等,让依赖解析的速度更快。

第五,不做过度优化。ESBuild的设计哲学是"够用就好",不做一些过度的、耗时的优化。比如,ESBuild的tree shaking(移除未使用的代码)比较简单,只做静态的、基于ES模块的分析,不做更复杂的跨模块分析。再比如,ESBuild的代码压缩(minify)也比较基础,只做简单的变量名缩短和空格移除,不做更复杂的优化(比如常量折叠、死代码消除等)。这些简化虽然让ESBuild的输出体积可能比webpack稍大一些,但换来了更快的构建速度。对于大多数项目来说,这点体积差异是可以接受的。

五、ESBuild的局限性

说了这么多ESBuild的优点,也要客观地说说它的局限性。ESBuild不是万能的,它也有一些缺点和不足。

第一,插件生态不完善。ESBuild的插件系统比较简单,功能有限,而且因为ESBuild比较新,插件的数量和质量都不如webpack。很多webpack上常用的插件,在ESBuild上没有对应的实现,或者功能不完善。这意味着,如果你有一些特殊的构建需求,可能需要自己写插件,或者用其他工具来补充。

第二,不支持一些高级特性。ESBuild为了速度,简化了一些功能,不支持一些webpack支持的高级特性。比如,ESBuild不支持CommonJS的动态require(比如require(variable)),不支持模块联邦(Module Federation),不支持HMR(热模块替换)的完整实现,不支持一些复杂的loader链等。如果你的项目依赖这些特性,可能无法直接迁移到ESBuild。

第三,代码分割(code splitting)功能还不完善。ESBuild的代码分割功能还在开发中,目前只支持简单的代码分割,不支持一些高级的场景,比如共享chunk的优化、预加载等。对于复杂的单页应用,代码分割可能不够灵活。

第四,TypeScript支持不完整。ESBuild内置了TypeScript的转换,但它只做语法转换,不做类型检查。也就是说,ESBuild会把TypeScript代码转换成JavaScript,但不会检查类型错误。如果你需要类型检查,还需要单独运行tsc,或者用IDE的类型检查功能。

第五,CSS处理比较基础。ESBuild内置了CSS处理,但功能比较基础,只支持简单的CSS导入和压缩,不支持CSS预处理器(比如Sass、Less),不支持CSS Modules,不支持PostCSS插件等。如果你需要这些功能,需要自己写插件,或者用其他工具预处理CSS。

第六,不是所有项目都能获得100倍的加速。ESBuild的加速比,取决于项目的大小和复杂度。对于小项目,因为Node.js的启动开销占比较大,ESBuild的加速比可能很高(几十倍甚至上百倍)。对于大项目,因为有更多的并行化机会,ESBuild的加速比也不错(10倍左右)。但如果项目有很多复杂的插件和loader,或者有很多串行的处理步骤,ESBuild的加速比可能就没那么高了。

六、ESBuild的应用场景

说了这么多,ESBuild适合用在什么场景呢?

第一个场景是开发环境的构建。在开发环境下,我们最看重的是构建速度和反馈速度,对输出体积和优化的要求不高。ESBuild的快速构建,非常适合开发环境,可以大幅提升开发体验。比如,Vite(新一代前端构建工具)在开发环境下就是用ESBuild来做依赖预构建和代码转换的,速度非常快。

第二个场景是库的构建。如果你在开发一个JavaScript库(比如组件库、工具函数库),需要把源代码打包成发布版本,ESBuild是一个很好的选择。它构建速度快,输出格式支持ESM和CJS,能满足库的发布需求。而且,ESBuild的配置简单,不需要像webpack那样写复杂的配置文件。

第三个场景是简单的应用构建。如果你的项目比较简单,不需要复杂的插件和loader,不需要模块联邦、HMR等高级特性,那ESBuild完全可以胜任。比如,一些简单的单页应用、静态网站、工具类应用等,用ESBuild构建又快又简单。

第四个场景是作为其他工具的补充。即使你的主构建工具是webpack,也可以在某些环节用ESBuild来加速。比如,用ESBuild来做TypeScript的转换(代替ts-loader或者babel-loader),用ESBuild来做依赖预构建,用ESBuild来做代码压缩等。很多项目已经在这么做了,比如webpack的esbuild-loader,就是用ESBuild来代替babel-loader和ts-loader,能大幅提升构建速度。

七、写在最后

ESBuild是一个非常优秀的前端构建工具,它用Go语言重写了构建工具的核心,通过高度并行化的架构和高效的算法,实现了比传统JavaScript构建工具快几十倍甚至上百倍的速度。它的出现,给前端构建领域带来了新的思路,也推动了整个前端构建工具的发展。

当然,ESBuild也不是完美的,它的插件生态还不完善,不支持一些高级特性,代码分割和CSS处理还比较基础。但它的核心优势——速度,是实实在在的,对于很多场景来说,已经足够好了。

我相信,随着ESBuild的不断发展,它的功能会越来越完善,生态会越来越丰富,会有越来越多的项目采用ESBuild作为构建工具。同时,ESBuild也会推动其他构建工具的优化,比如webpack 5也在性能上做了很多优化,Vite等新一代构建工具也在快速发展。最终受益的,是我们这些前端开发者。

如果你还没有试过ESBuild,建议你找一个小项目试一下,感受一下它的速度。相信我,用过之后,你可能就回不去webpack了。

最后,希望我的这篇原理剖析能对你理解ESBuild有所帮助。如果有什么问题或者不同的看法,欢迎在评论区交流。