线上构建突然出了问题,定位到是ESBuild的一个Bug。从发现问题到找到根因再到解决,整整花了一个晚上。记录一下整个排查过程,希望对遇到类似问题的人有帮助。

先交代一下背景。我们团队最近在尝试把前端构建工具从Webpack迁移到ESBuild,主要是看中了它的构建速度。ESBuild是用Go写的,比基于Node.js的Webpack快了一个数量级,对于我们这种有几十个页面的中大型项目来说,构建时间从原来的三分钟降到了十几秒,体验提升非常明显。

迁移工作进行了大概两周,开发环境已经全部切到了ESBuild,测试环境也跑了几天没发现问题。于是上周我们把生产环境的构建也切到了ESBuild,一开始一切正常,连续发了两个版本都没问题。结果昨天晚上发第三个版本的时候,线上出问题了。

一、问题出现

事情是这样的。昨天晚上八点多,我们准备发一个新版本,按照正常流程跑构建、部署、验证。部署完成之后,测试同学反馈说有一个页面打开是白屏,控制台报了一个JavaScript错误。我当时心里咯噔一下,因为这个页面在测试环境是好的,怎么到了生产环境就坏了。

我赶紧打开浏览器控制台,看到的错误信息是:Uncaught SyntaxError: Unexpected token '?'。这个错误很明确,是JavaScript语法错误,代码里出现了浏览器不认识的语法。但奇怪的是,我们的代码里并没有用什么特别新的语法,而且测试环境是好的,说明代码本身应该没问题。

第一反应是构建出了问题。因为测试环境和生产环境用的是同一份代码,唯一的区别就是构建配置。测试环境的构建是没有开压缩和优化的,而生产环境开了全量优化。所以问题很可能出在生产环境的构建优化环节。

我先回滚了上一个版本,保证线上服务正常,然后开始排查问题。当时是晚上九点,我没想到这一查就是一整夜。

二、初步排查

回滚之后,我先在本地复现问题。用生产环境的配置跑了一遍构建,然后在浏览器里打开打包后的文件,果然复现了同样的错误。这说明问题是稳定复现的,不是偶发的,这就好办了,稳定复现的问题总是能找到原因的。

我先看了一下报错的位置。错误指向打包后的文件中的某一行,我打开那一行看了一下,发现了问题所在:代码里出现了??操作符,也就是空值合并操作符(Nullish Coalescing Operator)。这个操作符是ES2020的新特性,比较新的浏览器支持,但我们需要兼容的一些老版本浏览器不支持。

但问题是,我们的源码里并没有用??操作符啊。我全局搜索了一下源码,确实没有找到任何??的使用。那这个??是从哪里来的呢?

答案只有一个:是构建工具在打包的过程中生成的。ESBuild在做代码转换或者压缩优化的时候,把某些代码转换成了??操作符的形式。这就解释了为什么测试环境没问题——测试环境没有开优化,不会做这种转换;而生产环境开了优化,就触发了这个转换。

找到问题的直接原因之后,接下来要搞清楚的是:ESBuild为什么会做这种转换?是配置问题还是ESBuild本身的Bug?

三、深入排查

我先检查了ESBuild的配置。ESBuild有一个target选项,用来指定目标浏览器的版本,ESBuild会根据这个选项来决定把代码转换成什么语法级别。如果target设置得比较低,比如es2015,那ESBuild就不应该生成??这种ES2020的语法。

我看了一下我们的配置,target设置的是es2015,这就奇怪了。按照ESBuild的文档,当target是es2015的时候,它应该把所有高于es2015的语法都降级转换,不应该输出??操作符才对。

为了确认这个问题,我做了一个最小复现。写了一个最简单的JavaScript文件,里面只有一行代码:var a = b || c;。然后用ESBuild以target: 'es2015'minify: true来构建。结果输出的代码是:var a=b??c;

果然!ESBuild在压缩优化的时候,把||操作符优化成了??操作符!这明显是一个Bug,因为当target是es2015的时候,不应该生成es2020的语法。

我又试了几种情况:

  • minify: false的时候,输出是var a = b || c;,没问题。
  • target: 'es2020'的时候,输出是var a=b??c;,这是符合预期的。
  • target: 'es2015'minify: true的时候,输出是var a=b??c;,这就是Bug。

所以问题的触发条件很明确:当同时设置了较低的target(低于es2020)和开启了minify的时候,ESBuild会错误地把||优化成??,而没有考虑target的限制。

四、为什么会这样

找到了Bug的触发条件之后,我想进一步搞清楚ESBuild为什么会犯这个错误。于是我去翻了ESBuild的源码(ESBuild是开源的,代码在GitHub上)。

ESBuild的压缩优化逻辑里有一个步骤,会分析||&&操作符的操作数类型。如果它判断左边的操作数只会是null或者undefined(而不会是0''false这些falsy值),那么它就会认为||??在这里是等价的,于是把||替换成??,因为??在某些情况下可以生成更短的代码。

这个优化本身的思路是对的,问题在于它做这个优化的时候没有检查target。也就是说,不管target是什么,只要满足了类型分析的条件,它就会做这个替换。但当target低于es2020的时候,输出??是不对的,因为目标浏览器可能不支持这个语法。

这是一个典型的"优化pass没有考虑目标平台限制"的Bug。ESBuild的各个优化pass是独立运行的,每个pass负责一种优化,但是有些pass在做优化的时候没有检查target设置,导致生成了目标平台不支持的语法。

我去GitHub上搜了一下issue,发现已经有人报告了类似的问题,而且ESBuild的作者已经在最新版本中修复了。我们用的版本是0.5.x,比较旧,修复是在0.6.x版本里做的。所以解决方案也很简单:升级ESBuild到最新版本。

五、解决方案

找到根因之后,解决方案就很明确了。有两个选择:

方案一:升级ESBuild到最新版本。这是最根本的解决方案,因为新版本已经修复了这个Bug。

方案二:临时 workaround,在构建配置中禁用某些优化,或者在ESBuild构建之后再用Babel做一次语法降级。

我先试了方案一,把ESBuild从0.5.x升级到了最新的0.6.x。升级之后重新构建,检查输出文件,??操作符消失了,代码被正确地降级成了es2015兼容的形式。然后在测试环境验证了一下,所有页面都正常,构建速度也没有受到影响。

但是升级版本毕竟有风险,谁知道新版本会不会引入新的问题呢。为了保险起见,我又做了一个临时的workaround,以防新版本有其他问题可以快速回退。

workaround的做法是在ESBuild的配置中设置target: 'es2020',然后在ESBuild构建完成之后,用Babel再做一次语法降级,把es2020的语法降级到es2015。这样虽然多了一步,但可以确保输出的代码是兼容目标浏览器的。不过这个方案的缺点是构建速度会变慢,因为多了一步Babel转换。

最后我们决定采用方案一,升级ESBuild版本。理由是:第一,新版本修复了这个Bug,而且我们看了一下release notes,0.6.x主要是Bug修复,没有大的功能变更,风险可控;第二,用Babel做二次转换会影响构建速度,违背了我们用ESBuild的初衷;第三,长期来看,保持依赖版本更新是好的实践。

升级之后,我们重新跑了完整的测试,包括单元测试、集成测试和E2E测试,全部通过。然后在测试环境观察了一天,没有发现任何问题。今天晚上重新发布了生产环境,一切正常。

六、排查过程中的一些教训

这次排查花了整整一个晚上,从晚上九点到第二天早上六点。虽然最终解决了问题,但过程中走了不少弯路,总结了一些教训。

第一个教训是,遇到问题先想"最近改了什么"。这次问题出现之前,我们最大的变更就是把构建工具从Webpack换成了ESBuild。如果一开始就从这个方向入手,应该能更快定位到问题。但我一开始怀疑的是代码问题,花了不少时间在代码里找??操作符,结果当然是找不到的。

第二个教训是,最小复现非常重要。当我怀疑是ESBuild的问题之后,我没有直接在我们的大项目里调试,而是写了一个最简单的测试用例,一行代码,用ESBuild构建,看输出结果。这样很快就确认了问题,而且排除了项目中其他因素的干扰。如果在大项目里调试,变量太多,很难说清楚到底是什么导致的问题。

第三个教训是,要善用开源社区的力量。找到问题之后,我第一时间去GitHub搜了issue,发现这个问题已经有人报告了,而且已经修复了。如果我没有去搜,而是自己埋头研究怎么修,可能会花更多时间。开源项目的好处就是,你遇到的问题大概率别人也遇到过,站在巨人的肩膀上可以省很多事。

第四个教训是,新工具引入要谨慎。ESBuild是一个很新的工具,虽然速度快,但成熟度不如Webpack,可能会有一些边缘情况的Bug。我们在迁移的时候,测试环境验证了几天,但测试环境的构建没有开全量优化,所以没有发现这个问题。如果在测试环境也用生产环境的配置跑一遍,应该能在上线之前发现这个问题。以后引入新工具的时候,一定要在尽可能接近生产环境的条件下做充分的测试。

第五个教训是,要有回滚机制。这次问题出现之后,我们能快速回滚到上一个版本,保证线上服务不受影响,这得益于我们完善的发布和回滚机制。如果没有回滚机制,线上白屏的时间会更长,影响会更大。所以不管什么时候,回滚机制都是必须的,它是你做变更的底气。

七、对ESBuild的一些看法

最后说说我对ESBuild的看法吧。虽然这次出了Bug,但我对ESBuild整体还是很认可的。它的构建速度确实是碾压级的,对于开发体验的提升非常明显。而且它的作者非常活跃,Bug修复很快,社区也在快速成长。

但是,ESBuild目前还不够成熟,不适合直接用在对稳定性要求极高的生产环境中,至少现在还不行。它更适合用在开发环境,或者对构建速度要求很高、可以接受一定风险的场景。如果要用在生产环境,一定要做充分的测试,而且要密切关注输出的代码质量。

另外,ESBuild的定位和Webpack不太一样。Webpack是一个全功能的构建工具,插件生态非常丰富,几乎什么都能做。而ESBuild更像是一个专注于速度的构建工具核心,它的插件系统比较简单,很多功能需要自己实现或者配合其他工具使用。所以对于复杂的项目,可能需要ESBuild配合其他工具一起使用,而不是完全替代Webpack。

我们团队目前的策略是:开发环境用ESBuild,追求构建速度;生产环境暂时还是用Webpack,等ESBuild更成熟之后再考虑全面切换。这次的Bug也验证了这个策略的合理性。

八、写在最后

这次排查经历虽然熬了一夜,但收获还是挺大的。不仅解决了问题,还深入了解了ESBuild的内部实现,积累了排查构建工具问题的经验。做技术就是这样,每次出问题都是一次学习的机会,关键是要把问题搞清楚,而不是简单地绕过它。

如果你也在用ESBuild,建议检查一下自己的版本和配置,确保没有踩到类似的坑。如果遇到了奇怪的构建问题,可以先试试最小复现,看看是不是ESBuild本身的问题,然后去GitHub上搜搜issue,大概率能找到答案。

技术的道路上没有银弹,每个工具都有它的优点和缺点。我们能做的就是了解它们,用好它们,在出问题的时候有能力解决它们。与诸君共勉。