再谈 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 🙄)。

image.png

那么这个变量怎么来呢?有三个办法:

一是可以自己手动引入 📦 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 个功能:

  1. 如果代码中有 async/await,对原本的转换做了一些增强,即使全局没有 ⚠️ regeneratorRuntime  也能工作。 默认开启
  2. polyfill 代码中用到的新的 API,而且是不污染 global 的那种 polyfill(🧰 @babel/preset-env  的 polyfill 会污染),但是无法设置 target 。 默认关闭
  3. babel 转换后的代码会存在大量 inline 的 helper,比如 objectSpread、classCallCheck 等等(源码来自于 @babel/helpers 仓库),这个插件可以把这些 inline 的 runtime helper 提取成  import xxx from '@babel/runtime/helpers/xxx' ,减少重复代码,从而减小最终产物的体积。  默认开启

所以这个插件有这么几个应用场景:

  1. 对于组件库编译来说,三个功能都很好,让 async/await 可以「开箱即用」、也许可以减少编译产物体积(综合考虑 2+3)、帮助组件库 polyfill 而且还不污染 global(无副作用)。
  2. 对于应用编译来说:
  1. 功能 1 挺好,就是类似于 useBuiltIns: 'usage' 的全自动模式了,而且不会污染全局。
  2. 功能 2 用处不大,毕竟 🧰 @babel/preset-env  已经提供了,而且应用执行时候污染全局也没啥问题。
  3. 功能 3 很有用,可以减少编译产物体积。

所以像 Umi 等应用研发框架,都是默认开启了这个插件的,father 作为库与组件研发框架则内置了一个独立配置项,需要手动开启。

那么功能 1 转换后的代码具体是怎样的呢?看下文档里的例子,是把原本裸用的 ⚠️ regeneratorRuntime  改成了从 @babel/runtime/regenerator 引入(因此需要安装 @babel/runtime):

image.png

打开 7.17.x 版本的 @babel/runtime,可以看到他的代码就一行:

image.png

所以这个方式基本等价于import 'regenerator-runtime/runtime',区别是这种方式(貌似)不会污染 global,但是其实不然,下节分解。

regenerator-runtime

📦 regenerator-runtime   是来自 Facebook meta 的一个开源库,内部原理就不展开了,但是要记住他最后那十几行代码,他把 ⚠️ regeneratorRuntime  挂载到了全局:

image.png

这就是上面一节说,⚙️ @babel/plugin-transform-runtime  插件转换后的代码貌似不会污染 global,但是其实不然,毕竟只要源头是这个库,⚠️ regeneratorRuntime  就会被挂载到全局。


babel 改啥了



总算把准备知识讲完了,可以说说 babel 这次变更了,如果你是一个有洁癖的程序员,一定会觉得当前设计有很大的不合理:

  1. ❌ 为什么 ascyn/await 语法转换后,居然要依赖一个全局变量?而这个全局变量要么得自己手动引入,要么得通过一些配置来实现所谓的「自动」,给开发者造成了很大的理解和使用成本。
  2. ❌ 抛开全局这个吐槽点,这个变量本身也很奇怪,它不是 ECMA 规范中定义的新的类/对象/方法,所以他的引入不属于 polyfill 的范畴,但是却要开发者像 core-js polyfill 一样去对待它,还记得最早看 babel-polyfill 文档的时候就觉得很奇怪,core-js 为啥没收纳 📦 regenerator-runtime  呢?

这些问题,就是 PR 中作者要解决的,他 PR 原文摘要一下,大致做了 3 件事:

  1. ✅ async/await 转换后的代码不再需要依赖全局 ⚠️ regeneratorRuntime  了,我们可以把 regeneratorRuntime 变成类似 objectSpread、classCallCheck 这样的  runtime helper ,直接 inline 到编译后的代码中了,这样开发者不需要再做任何其他事情了,也不存在 ⚠️ regeneratorRuntime  不是 polyfill 这种尴尬的问题;这一步由 🧰 @babel/preset-env  里的 🔧 @babel/plugin-transform-regenerator  实现。
  2. ✅ regeneratorRuntime 正式成为了一种 babel runtime helper ,虽然我知道这个 helper 的代码大概有 10k 很大,但是对体积敏感的开发者会用 ⚙️ @babel/plugin-transform-runtime  的啊,我再更新下这个插件,让他可以提取这个 inline helper 成为 import(前面所提的功能 3),就可以解决体积问题了。
  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:

image.png

他的脚本把原先  📦 regenerator-runtime  的代码解析成 ast 以后,只取了第一个赋值语句,丢掉了后面那十几行把 ⚠️ regeneratorRuntime  挂载到全局的 try catch !

也就是说 @babel 全家桶的 7.18.0 版本里是不会把 ⚠️ regeneratorRuntime  挂载到全局的(包括 @babel/runtime 和 @babel/helpers 中的代码),虽然这个特性官方文档里没有提及,但是这种变更本质上属于非兼容性变更了。

理一理

所以洗把脸清醒一下🤦,咱们理一下这个问题的触发条件💡

  1. 以蚂蚁内部的移动端研发框架 Smallfish 来举例,他的封装(套娃)结构是这样的:

image.png

  1. 由于没锁死 🔧 @babel/plugin-transform-regenerator  的版本,根据上面所说 babel 变更,regeneratorRuntime 被 inline 到转换后的代码中。
  2. 在移动端,为了体积(小 50k 呢)或性能很多应用会关闭 polyfill 的引入,虽然没有了全局的 📦 regenerator-runtime  的引入,但是由于 7.18.0 这个 inline 的变更,让业务代码中的 async/await 也是可用的,但是这时候全局是没有 ⚠️ regeneratorRuntime  变量的。 此处不挂载
  3. 假设离线包引入了两个 npm 库 A 和 B,他们都是在 babel 7.18.0 之前发的版本。
  4. A 是用 father 构建的,用到了 async/await,他的编译开启了 plugin-transform-runtime,这样生成的代码中会提取 helper 为 import regeneratorRuntime from '@babel/runtime/regeneratorRuntime'
  5. 虽然 A 是在 babel 7.18.0 之前发的版本,但是当他参与到应用构建时,由于他自身没有锁死 @babel/runtime 的依赖,所以是用 7.18.0 构建的,也就是依然没有往全局挂载 ⚠️ regeneratorRuntime  。此处也不挂载
  6. B 也是用 father 构建的,也用到了 async/await,但是他的编译没开启 🔧 @babel/plugin-transform-regenerator ,而且由于他是在 babel 7.18.0 之前发的版,他参与构建的源码是需要全局有 regeneratorRuntime 的。
  7. 那么这时候,你不挂我不挂,整个应用就得挂,全局没有 ⚠️ regeneratorRuntime , 所以报错了!

反向理一理

🙅 等等!那再次清醒一下🤦🤦!回头反问下!在 7.18.0 之前为什么 B 组件不会报错?那是因为以前有很多途径的可以挂载 ⚠️ regeneratorRuntime  的,比如:

  1. 之前 A 组件参与到应用构建时,用的是 @babel/runtime  老版本呀,老版本的 @babel/runtime/regeneratorRuntime 是会挂载 ⚠️ regeneratorRuntime  到全局的。
  2. 就算没有 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 依赖的结构图:

image.png

可以看到虽然 🔧 @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(有体积问题),那么重新构建发布一次就可以解决。


其他



总体来说,大问题已经基本解决;前端构建生态复杂、历史负担重,极容易出现抽象泄露,蚂蚁域内的重要业务还是应当尽量选择内部主流研发框架,有专门团队投入时间维护,好过自己去面对整个复杂生态的不确定性。




相关文章
|
6月前
|
JavaScript 前端开发 编译器
js开发: 请解释什么是Babel,以及它在项目中的作用。
**Babel是JavaScript编译器,将ES6+代码转为旧版JS以保证兼容性。它用于前端项目,功能包括语法转换、插件扩展、灵活配置和丰富的生态系统。Babel确保新特性的使用而不牺牲浏览器支持。** ```markdown - Babel: JavaScript编译器,转化ES6+到兼容旧环境的JS - 保障新语法在不同浏览器的运行 - 支持插件,扩展编译功能 - 灵活配置,适应项目需求 - 富强的生态系统,多样化开发需求 ```
53 4
|
6月前
|
存储 JavaScript
如何理解TypeScript 对象
如何理解TypeScript 对象
70 1
|
资源调度 JavaScript 前端开发
探索Babel:现代JavaScript开发中的编译利器
Babel是一个流行的JavaScript编译工具,用于将新的ECMAScript标准(如ES6、ES7等)转换为向后兼容的JavaScript版本,以便在不同浏览器和环境中运行。它在现代JavaScript开发中扮演着关键角色,帮助开发者编写可读性强、高效且兼容性良好的代码。在本博客中,我们将深入研究Babel的核心概念、配置、插件和预设,以帮助您更好地了解这个强大的工具。
64 0
|
10天前
|
开发框架 自然语言处理 JavaScript
babel 原理,怎么写 babel 插件
【10月更文挑战第23天】要深入理解和掌握如何编写 Babel 插件,需要不断实践和探索,结合具体的项目需求和代码结构,灵活运用相关知识和技巧。你还可以进一步扩展和深入探讨各个方面的内容,提供更多的实例和细节,以使文章更加丰富和全面。同时,关注 Babel 插件开发的最新动态和研究成果,以便及时了解其发展和变化。
|
6月前
|
JavaScript 前端开发 开发者
今日讲讲JSX
今日讲讲JSX
29 0
|
自然语言处理 前端开发 JavaScript
Babel 的工作原理以及怎么写一个 Babel 插件
Babel 的工作原理以及怎么写一个 Babel 插件
205 0
|
6月前
|
JavaScript 前端开发 编译器
10分钟让你吃透 《TypeScript》 函数
TypeScript提供了丰富的函数类型定义方式,可以对函数参数、返回值进行类型注解,从而提供了更为强大的类型检查。
|
6月前
|
JavaScript 前端开发 安全
一篇文章搞懂TypeScript
TypeScript 是 JavaScript 的超集,一方面给动态类型的 js 增加了类型校验,另一方面扩展了 js 的各种功能。
111 0
|
6月前
|
JavaScript 前端开发 编译器
什么是TypeScript模块?为啥那么重要?
什么是TypeScript模块?为啥那么重要?
87 0
|
JavaScript 前端开发 编译器
TypeScript深度剖析: TypeScript 中函数的理解?与 JavaScript 函数的区别?
TypeScript深度剖析: TypeScript 中函数的理解?与 JavaScript 函数的区别?
116 0