再谈 babel 7.18.0 引发的问题
本文约 3800 字,阅读时长约 20min,有一定阅读门槛。阅读开始前你需要了解的:
🧰 @babel/preset-env 一个整合 babel 大量插件与配置,减少使用成本的封装。 🔧 @babel/plugin-transform-regenerator 一个处理 generator 语法转换的 babel 插件,被集成进了 🧰 @babel/preset-env 。 ⚙️ @babel/plugin-transform-runtime 一个做了很多奇妙事情的 babel 插件。 ⚠️ regeneratorRuntime 引发所有问题的源头,一个该死的人人都假设应该会存在的全局变量。 📦 regenerator-runtime facebook 的一个开源库,他会把 ⚠️ regeneratorRuntime 挂载到全局。
5 月末 babel 做了一个小小的变更 (https://github.com/babel/babel/pull/14538) 。
babel 版本从 7.17.x 升级到了 7.18.0,按 semver 来说这应该是一个兼容性的变更,但是却引发出了各种问题,在蚂蚁域内出现了regeneratorRuntime 找不到、构建产物体积变大等等问题,这篇文档会详细分析下一下这些问题的原因及解决方法。
要准备的知识太多了,大致列一下,希望能讲明白。
语法与 API polyfill
ECMA 规范版本的升级包含了语法(比如箭头函数、async/await)与 API(比如 String.prototype.replaceAll)两部分。
babel 体系中的一坨 plugin-transform-* 插件(比如@babel/plugin-transform-arrow-functions)是用来转换语法的,而 API 则需要 core-js 和 📦 regenerator-runtime 的 polyfill,两者被 🧰 @babel/preset-env (https://babeljs.io/docs/en/babel-preset-env) 所整合,提供「开箱即用」的方案。
async/await
这个 es7 -> es5 语法的转换分两步,第一步是 async -> generator 的转换,第二步则是 generator 本身的转换。
第二步最为关键,他是由 🧰 @babel/preset-env 里的 🔧 @babel/plugin-transform-regenerator 完成的,转换之后还需要全局有一个 ⚠️ regeneratorRuntime 的变量才能工作(问为什么就是 by design 🙄)。
那么这个变量怎么来呢?有三个办法:
一是可以自己手动引入 📦 regenerator-runtime import 'regenerator-runtime/runtime'
;
二是如果用 🧰 @babel/preset-env ,配置 useBuiltIns 为 usage 那么 babel 会按需自动帮你 import 'regenerator-runtime/runtime'
;
至于第三种办法,往下看 ⚙️ @babel/plugin-transform-runtime ;
@babel/plugin-transform-runtime
babel 体系内定位最奇怪的一个插件,读一下它的文档,知道他有 3 个功能:
如果代码中有 async/await,对原本的转换做了一些增强,即使全局没有 ⚠️ regeneratorRuntime 也能工作。 默认开启 polyfill 代码中用到的新的 API,而且是不污染 global 的那种 polyfill(🧰 @babel/preset-env 的 polyfill 会污染),但是无法设置 target 。 默认关闭 babel 转换后的代码会存在大量 inline 的 helper,比如 objectSpread、classCallCheck 等等(源码来自于 @babel/helpers 仓库),这个插件可以把这些 inline 的 runtime helper 提取成 import xxx from '@babel/runtime/helpers/xxx'
,减少重复代码,从而减小最终产物的体积。 默认开启
所以这个插件有这么几个应用场景:
对于组件库编译来说,三个功能都很好,让 async/await 可以「开箱即用」、也许可以减少编译产物体积(综合考虑 2+3)、帮助组件库 polyfill 而且还不污染 global(无副作用)。 对于应用编译来说: 功能 1 挺好,就是类似于 useBuiltIns: 'usage' 的全自动模式了,而且不会污染全局。 功能 2 用处不大,毕竟 🧰 @babel/preset-env 已经提供了,而且应用执行时候污染全局也没啥问题。 功能 3 很有用,可以减少编译产物体积。
所以像 Umi 等应用研发框架,都是默认开启了这个插件的,father 作为库与组件研发框架则内置了一个独立配置项,需要手动开启。
那么功能 1 转换后的代码具体是怎样的呢?看下文档里的例子,是把原本裸用的 ⚠️ regeneratorRuntime 改成了从 @babel/runtime/regenerator 引入(因此需要安装 @babel/runtime):
打开 7.17.x 版本的 @babel/runtime,可以看到他的代码就一行:
所以这个方式基本等价于import 'regenerator-runtime/runtime'
,区别是这种方式(貌似)不会污染 global,但是其实不然,下节分解。
regenerator-runtime
📦 regenerator-runtime 是来自 Facebook meta 的一个开源库,内部原理就不展开了,但是要记住他最后那十几行代码,他把 ⚠️ regeneratorRuntime 挂载到了全局:
这就是上面一节说,⚙️ @babel/plugin-transform-runtime 插件转换后的代码貌似不会污染 global,但是其实不然,毕竟只要源头是这个库,⚠️ regeneratorRuntime 就会被挂载到全局。
总算把准备知识讲完了,可以说说 babel 这次变更了,如果你是一个有洁癖的程序员,一定会觉得当前设计有很大的不合理:
❌ 为什么 ascyn/await 语法转换后,居然要依赖一个全局变量?而这个全局变量要么得自己手动引入,要么得通过一些配置来实现所谓的「自动」,给开发者造成了很大的理解和使用成本。 ❌ 抛开全局这个吐槽点,这个变量本身也很奇怪,它不是 ECMA 规范中定义的新的类/对象/方法,所以他的引入不属于 polyfill 的范畴,但是却要开发者像 core-js polyfill 一样去对待它,还记得最早看 babel-polyfill 文档的时候就觉得很奇怪,core-js 为啥没收纳 📦 regenerator-runtime 呢?
这些问题,就是 PR 中作者要解决的,他 PR 原文摘要一下,大致做了 3 件事:
✅ async/await 转换后的代码不再需要依赖全局 ⚠️ regeneratorRuntime 了,我们可以把 regeneratorRuntime 变成类似 objectSpread、classCallCheck 这样的 runtime helper ,直接 inline 到编译后的代码中了,这样开发者不需要再做任何其他事情了,也不存在 ⚠️ regeneratorRuntime 不是 polyfill 这种尴尬的问题;这一步由 🧰 @babel/preset-env 里的 🔧 @babel/plugin-transform-regenerator 实现。 ✅ regeneratorRuntime 正式成为了一种 babel runtime helper ,虽然我知道这个 helper 的代码大概有 10k 很大,但是对体积敏感的开发者会用 ⚙️ @babel/plugin-transform-runtime 的啊,我再更新下这个插件,让他可以提取这个 inline helper 成为 import(前面所提的功能 3),就可以解决体积问题了。 ✅ 既然是 babel runtime helper 了,我们决定把 📦 regenerator-runtime 的代码直接 copy 进 babel 仓库,不要再依赖外部库了。
这 3 点从逻辑看基本是合理的,于是我们在 5.20 这天迎来了 babel 的 7.18.0 版本。
但是 1,2 在实际落地上会遇到一些 babel 一系列组件之间配合的问题,而第 3 点的处理作者又带了点私货,这就是 5 月底至今各种问题的源头。
1、regeneratorRuntime is undefined
原先这是个老问题,比如是因为用到了 async/await 但是没有 import regenerator-runtime 或者开启 plugin-transform-runtime,各大框架(如 Umi 或者蚂蚁内部的 Smallfish 等)基本都有默认的处理。
不过在这次变更发布了 babel 系列的 7.18.0 之后,这个报错又在各个群被提起,甚至还引发了某个业务的线上故障,问题就在上面所提,babel 变更的第3点,看下作者是如何 copy 的 regenerator-runtime:
他的脚本把原先 📦 regenerator-runtime 的代码解析成 ast 以后,只取了第一个赋值语句,丢掉了后面那十几行把 ⚠️ regeneratorRuntime 挂载到全局的 try catch !
也就是说 @babel 全家桶的 7.18.0 版本里是不会把 ⚠️ regeneratorRuntime 挂载到全局的(包括 @babel/runtime 和 @babel/helpers 中的代码),虽然这个特性官方文档里没有提及,但是这种变更本质上属于非兼容性变更了。
理一理
所以洗把脸清醒一下🤦,咱们理一下这个问题的触发条件💡
以蚂蚁内部的移动端研发框架 Smallfish 来举例,他的封装(套娃)结构是这样的:
由于没锁死 🔧 @babel/plugin-transform-regenerator 的版本,根据上面所说 babel 变更,regeneratorRuntime 被 inline 到转换后的代码中。 在移动端,为了体积(小 50k 呢)或性能很多应用会关闭 polyfill 的引入,虽然没有了全局的 📦 regenerator-runtime 的引入,但是由于 7.18.0 这个 inline 的变更,让业务代码中的 async/await 也是可用的,但是这时候全局是没有 ⚠️ regeneratorRuntime 变量的。 此处不挂载 假设离线包引入了两个 npm 库 A 和 B,他们都是在 babel 7.18.0 之前发的版本。 A 是用 father 构建的,用到了 async/await,他的编译开启了 plugin-transform-runtime,这样生成的代码中会提取 helper 为 import regeneratorRuntime from '@babel/runtime/regeneratorRuntime'
。虽然 A 是在 babel 7.18.0 之前发的版本,但是当他参与到应用构建时,由于他自身没有锁死 @babel/runtime 的依赖,所以是用 7.18.0 构建的,也就是依然没有往全局挂载 ⚠️ regeneratorRuntime 。此处也不挂载 B 也是用 father 构建的,也用到了 async/await,但是他的编译没开启 🔧 @babel/plugin-transform-regenerator ,而且由于他是在 babel 7.18.0 之前发的版,他参与构建的源码是需要全局有 regeneratorRuntime 的。 那么这时候,你不挂我不挂,整个应用就得挂,全局没有 ⚠️ regeneratorRuntime , 所以报错了!
反向理一理
🙅 等等!那再次清醒一下🤦🤦!回头反问下!在 7.18.0 之前为什么 B 组件不会报错?那是因为以前有很多途径的可以挂载 ⚠️ regeneratorRuntime 的,比如:
之前 A 组件参与到应用构建时,用的是 @babel/runtime 老版本呀,老版本的 @babel/runtime/regeneratorRuntime
是会挂载 ⚠️ regeneratorRuntime 到全局的。就算没有 A 组件,应用本身应该也是开了 🔧 @babel/plugin-transform-regenerator 的(Umi、内部各种框架都会默认开启),那么同样会增强 async/await 的转换,自动引入 @babel/runtime/regeneratorRuntime
,而且因为是老版本,也会挂载到全局。
所以本质上之前 B 组件能 work 是一种「偶然」,恰好有人为他兜底了,这次 7.18.0 的更新反而有点拨乱反正的意思。
解法与结论
作者很快意识到这个问题,赶紧又把那段 try catch 加回来发了新版本(不知道是不是因为紧张还写错了一版 😄):
https://github.com/babel/babel/pull/14581
所以结论是:目前 babel 7.18.x 与之前 7.17.x 特性上是等价的,没有不兼容问题,以前能跑的现在还能跑,不能跑的现在也依然不能跑,这个 7.18.0 引发的 regeneratorRuntime is undefined 的问题已经解决了。
2、产物体积变大
虽然 babel 发了新版本解决了全局 ⚠️ regeneratorRuntime 的问题,但是产物体积变大的问题咨询在蚂蚁内部依然不时出现,这跟蚂蚁内研发框架的封装策略有关系;再看一遍 Smallfish 依赖的结构图:
可以看到虽然 🔧 @babel/plugin-transform-regenerator 没锁住更新到 7.18.x ,但是 ⚙️ @babel/plugin-transform-runtime 却被锁死在了 7.12.1。这样就会造成这两个插件配合的问题:
高版本 🔧 @babel/plugin-transform-regenerator 转换出的 inline 的 helper 不能被低版本 ⚙️ @babel/plugin-transform-runtime 识别并提取!
所以应用代码中各处的 async/await 都会各自带入 inline 的 10k 的 regeneratorRuntime,体积肯定会膨胀很多。
这个问题的本质矛盾是,蚂蚁内企业级封装为了稳定性锁定了 babel 全家桶的版本,但是 babel 社区的 preset-env 这层封装却没锁版本;所以这个问题并不太好解,Umi4 通过应锁尽锁(依赖库的代码都 bundle 进仓库)的方式实现了彻底的稳定,而 Smallfish 目前连 Umi 的版本都锁定了不太好升级,只能临时通过一个自定义的 babel 插件 monkey patch 了 availableHelper 的判断,让 babel 认为 regeneratorRuntime 不可用而跳过 inline 处理,从而 hack 式地解决了此问题。
而其余存在类似问题的研发框架,也可以通过 pacakge.json 中的 resolutions 把 🔧 @babel/plugin-transform-regenerator 锁定到 ~7.12.0 来临时解决。
3、npm 包的问题
最后理一下 npm 包的问题。
开启 plugin-transform-runtime
不管是为了解决以前依赖 ⚠️ regeneratorRuntime 全局变量的问题,还是为了提取 inline helper 减少体积,都建议构建时开启 ⚙️ @babel/plugin-transform-runtime 。
对 father 来说需要通过 runtimeHelpers 配置开启,记得不时更新下 package.json 中 @babel/runtime 申明的版本,比如从 ^7.17.0 更新为 ^7.18.0,虽然安装的版本都是一样的,但是对代码转换过程却有影响,这里有一个 father 和 babel 的小小潜规则,暂不展开。
体积问题
这与上面的产物体积问题类似,如果构建过程存在多层封装,那么就可能有 babel 插件间的配合问题。
比如 father 的 2.30.20 就有这个问题,导致在那几天通过 father 构建的库,产物中可能存在 inline 的 regeneratorRuntime;这个问题 father 已经解决了,只要 npm 不锁 father 版本,将来重新构建发布就 ok 了。
结论是,如果发现使用 father 构建的某个组件的 esm 或者 cjs 源码中存在多处 inline 的 regeneratorRuntime helper(有体积问题),那么重新构建发布一次就可以解决。
总体来说,大问题已经基本解决;前端构建生态复杂、历史负担重,极容易出现抽象泄露,蚂蚁域内的重要业务还是应当尽量选择内部主流研发框架,有专门团队投入时间维护,好过自己去面对整个复杂生态的不确定性。
关注「Alibaba F2E」微信公众号把握阿里巴巴前端新动向
微信扫码关注该文公众号作者