乐趣区

一文读懂 babel7 的配置文件加载逻辑

近期,在波洞星球的 PC 官网项目中,我们采用了新版的 babel7 作为 ES 语法转换器。而 babel7 中的一大变更就是对配置文件的加载逻辑进行了改进,然而实际上对于不熟悉 babel 配置逻辑的朋友往往会带来更多问题。本文就是 babel7 配置文件的中文指南,它是英语渣渣的救星,是给懒人送到口边的一道美味。如有错误 概不负责 欢迎指正。
前言
babel7 从 2018 年 3 月开始进入 alpha 阶段,时隔 5 个月直到 2018 年 8 月份 release 第一个版本,目前的最新版是 2019 年 2 月 26 号发布的 7.3.4. 时光如梭,在这美好的 9012 年,ES2019 都快要发布了的时刻,我想: 是时候用一用 babel7 了。
本文不是 babel7 的升级教程,而是对 babel7 的新变化和配置逻辑的一点心得。babel7 对 monorepo 结构项目的优化恰好符合我们目前项目架构的预期,这简化了我们配置的复杂度,但其难以理解的配置加载逻辑,却让我踩了不少坑,这也正是本文的来源。
说点变化
在开始讲 babel7 的配置逻辑之前,我们先从以下几个方面来啰嗦几句 babel7 所做的变更及其逻辑意义。
proposal 语法特性
在历史上(babel6)的时代,人们通常使用 babel 提供的 preset-stage 预设来体验 ES6 之后的处于建议阶段的语法特性。例如做如下的 babel 配置:
“presets”: [“es2015”, “react”, “stage-0”]
其中,es2015 预设会包含 ES6 标准中所有语法特性;stage- 0 预设会包含当前 (安装该预设 npm 包的时刻) 的 ES 语法进展中的 stage 0 到 3 的特性(数字小的包含数字大的)。但事实上 babel 官方这样提供 stage 预设,会有不少问题
例如:

随着 es 标准的不断发展,大量的新特性几乎已经成为标准。与此同时,stage0- 3 阶段的特性必然也发生变化。可以说,stage0- 3 的阶段特性他们是不稳定的,极有可能在某个时机被 TC39 委员会除名、变更阶段、改变语法。尽管 babel-preset-* 预设会跟随 TC39 保持一致的更新,但这样的用法需要使用者也不断保持更新 才能跟标准一致
历史上的 preset-es2015 配合 preset-stage-0 的做法极易产生疑惑,例如没有人知道他所需要的特性在 stage 几
一个语言特性如果从 stage3 变更为 stage4,往往会导致以前的 stage0(包含了 1、2、3) 的配置出问题。因为特性推进后,新的 stage0 中就不再包含该特性内容,但使用者可能不知道要把该特性所在的 ES 标准 加入到配置中
大量的社区工具 eslint 等等都依赖 babel;babel 的 preset-stage 预设更新就会导致这些社区工具频频出现问题。

如今,babel 官方认为,把不稳定的 stage0-3 作为一种预设是不太合理的,因此废弃了 stage 预设,转而让用户自己选择使用哪个 proposal 特性的插件,这将带来更多的明确性(用户无须理解 stage,自己选的插件,自己便能明确的知道代码中可以使用哪个特性)。所有建议特性的插件,都改变了命名规范,即类似 @babel/plugin-proposal-function-bind 这样的命名方式来表明这是个 proposal 阶段特性。
ES 标准特性
对于正经的 ES 标准特性,babel 从 6 开始就建议使用 babel-preset-env 这个能根据环境进行自动配置的预设。到了 babel7,我们就可以完全告别这几个历史预设了: preset-es2015/es2016/es2017/latest
为什么 preset-env 要更好呢?
我认为,对于开发者而言,关注目标用户平台(兼容哪些浏览器)要比关注 “ 编译为哪份 ES 标准 ” 要更易理解。把选择编译插件的事情交给 preset-env 就好了。它会根据 compat table 和你设置的目标用户平台选择正确的插件。
polyfill
跟 stage 预设的结局一样,对于处于建议阶段的特性,polyfill 里面也移除了对他们的支持。
以前的 babel-polyfill 是这么实现的:
import “core-js/shim”; // included < Stage 4 proposals

import “regenerator-runtime/runtime”
现在的 @babel/polyfill 就直接引入 core-js v2 的属于 ES 正式标准的模块。这意味着,如果你需要使用处于 proposal 阶段的语法特性,你需要手工 import core-js 中的对应模块。
命名空间
从 babel7 开始,所有的官方插件和主要模块,都放在了 @babel 的命名空间下。从而可以避免在 npm 仓库中 babel 相关名称被抢注的问题。有必要说一下的,比如 @babel/node @babel/core @babel/clil @babel/preset-env
transform-runtime
以前的 babel-transform-runtime 是包含了 helpers 和 polyfill。而现在的 @babel/runtime 只包含 helper,如果需要 polyfill,则需主动安装 core-js 的 runtime 版本 @babel/runtime-corejs2。并在 @babel/plugin-transform-runtime 的插件中做配置。
说重点: 配置
这是本文的重点,先来看一段 babel7 对配置的变更说明
Babel has had issues previously with handling node_modules, symlinks, and monorepos. We’ve made some changes to account for this: Babel will stop lookup at the package.json boundary instead of looking up the chain. For monorepo’s we have added a new babel.config.js file that centralizes our config across all the packages (alternatively you could make a config per package). In 7.1, we’ve introduced a rootMode option for further lookup if necessary.
段落的意思大概有这么几点:

Babel 将停止在 package.json 边界查找而不是查找链。译者注:这说明以前 babel 会递归向上查找 babelrc 而现在检索行为会停在 package.json 所在层级。这可以解决部分符号链接的 js 向上查找 babelrc 错乱的问题。
添加了一个新的项目全局 babel.config.js 文件,可以将整个项目的配置集中在所有包中。译者注:除了新增的这个全局配置,也可以同时支持以前的基于文件的.babelrc 的配置
引入了一个 rootMode 选项,以便在必要时按一定策略查找 babel.config.js

除此之外,babel7 还有一个特性是:
默认情况下,不会加载 monorepo 项目的任何独立子项目中的 .babelrc 文件
然而,对上面的解释,你可能: 每个字都认识,连在一起却不知道在说什么。下面我们来剖析一下
概念
为了理解 babel7 的配置逻辑,我们就以 babel7 真正所解决的痛点 [monorepo 类型的项目] 为例来剖析。在此之前,我们需要预先确定几个概念。

monorepo。这是个自造词。按我的理解,它的含义是说 单个大项目但是包含多个子项目 的含义。如果还是不能理解的话,就把 项目 二字 换成 npm 模块包 (以 package.json 文件作为分界线)。即 单个 npm 包中又包含多个子 npm 包 的项目。例如,波洞的 PC 版采用的是 Node.js 作为前端接入层的方式,在我们的项目结构组织上,是这样的:
|- backend
|-package.json
|- frontend
|-package.json
|- node_modules
|- config.js
|- babel.config.js
|- package.json
这就是典型的 monorepo 结构。

全局配置。在 babel 文档中又叫 项目级别的配置,特指 babel.config.js。如上图的 monorepo 结构,其 babel.config.js 就是全局配置 / 项目配置,该 babel 配置对 backend、frontend、甚至 node_modules 中的模块全部生效。
局部配置。在 babel 文档中可能叫 相对于文件的配置。这种配置就是特指的 .babelrc 或 .babelrc.js 了。他们的生效范围是与待编译文件的位置有关的。

规则
懂了几种配置文件的概念和作用范围之后,我们就可以来根据文档和代码测试结果来精确描述 babel7 的配置规则。这里我们直接以 monorepo 类型项目为例来说,因为普通项目会更简单。
下文中可能用到的名词解释:
我们用 package 来代指一个具有独立 package.json 的项目,如上面案例中的 frontend 可以称作一个 package,backend 也可以称作一个 package;我们用 相对配置 这个名词来表达所谓的 .babelrc 和 .babelrc.js,用全局配置来代指 babel.config.js 这份配置
对 monorepo 类型项目,babel7 的处理逻辑是:
【全局配置】全局配置 babel.config.js 里的配置默认对整个项目生效,包括 node_modules。除非通过 exclude 配置进行剔除。【全局配置】全局配置中如果没有配置 babelrcRoots 字段,那么 babel 默认情况下不会加载任何子 package 中的相对配置 (如.babelrc 文件)。除非在全局配置中通过 babelrcRoots 字段进行配置。【全局配置】babel 全局配置文件所在的位置就决定了你的项目根目录在哪里,默认就是执行 babel 的当前工作目录,例如上面的例子,你在根目录执行 babel,babel 才能找到 babel.config.js,从而确定该 monorepo 的根目录,进而将配置对整个项目生效【相对配置】相对配置可被加载的前提是在 babel.config.js 中配置了 babelrcRoots. 如 babelrcRoots: [‘.’, ‘./frontend’],这表示要对当前根目录和 frontend 这个子 package 开启 .babelrc 的加载。(注意: 项目根目录除了可以拥有一个 babel.config.js,同时也可以拥有一个 .babelrc 相对配置)【相对配置】相对配置加载的边界是当前 package 的最顶层。假设上文案例中要编译 frontend/src/index.js 那么,该文件编译时可以加载 frontend 下的 .babelrc 配置,但无法向上检索总项目根目录下的 .babelrc
实战
还是以上面的代码结构为例。
|- backend
|-package.json
|- frontend
|-package.json
|- node_modules
|- config.js
|- babel.config.js
|- package.json
该案例中,我们思考发现,我们需要利用 babel7 的全局配置能力。原因在于,monrepo 中存在多个 子 package。由于 babel7 默认检索 babelrc 的边界是 当前 package。因此每个 package 中撰写的 babelrc 只会对当前 package 生效,这会导致我们的 frontend 中依赖根目录的 config.js 时无法得到正确的编译;另一个问题是: frontend 和 backend 中的相同的 babel 配置部分无法共享 存在一定冗余。为此,我们需要在项目根目录设置一个 babel.config.js 的配置,用它再配合 babelrc 来做 babel 配置的共享和融合。
但是,问题很快来了:当工作目录不在根目录时,无法加载到全局配置。我们的前端编译脚本通常放置在 frontend 目录下,(我们执行编译的工作目录是在 frontend 中),此时 babel build 行为的 工作目录 便是 frontend. 由于 babel 默认只在当前目录寻找 babel.config.js 这个全局配置,因此会导致无法找到根目录的 babel.config.js,这样我们所设想的整个项目的全局配置就无法生效。幸好,babel7 提供了 rootMode 选项,可以将它指定为 “upward”, 这样 babel 会自动向上寻找全局配置,并确定项目的根目录位置。
设置方法:
CLI: babel –rootMode=upward
webpack: 在 babel-loader 的配置上设置 rootMode: ‘upward’
现在,全局配置有了,我们可以在里面配置 babel 转译规则,它可以对全项目生效,frontend 下的 vue.js 编译自然没有问题了。
不过,假设我们 backend 项目中也要使用 babel 转译(目前我们实际在 backend 中并没有使用,因为我们认为只图 esmodule 而多加一层编译得不偿失),那么必然 backend 与 frontend 中的编译配置是不同的,frontend 需要加载 vue 的 jsx 插件和 polyfill(useBuiltIns: usage,modules: false), 而 backend 只需要转译基本模块语法(modules: true, useBuiltIns: false)。该场景的解决方案便是,为每个子 package 提供独立的 .babelrc 相对配置,在全局 babel.config.js 中设置共用的配置。此时项目组织结构如下:
|- backend
|- .babelrc.js
|-package.json
|- frontend
|- .babelrc.js
|-package.json
|- node_modules
|- config.js
|- .babelrc.js // 这份配置在本场景下不需要(如果根目录下的代码有区别于子 package 的 babel 配置,则需要使用)
|- babel.config.js
|- package.json
根目录的 babel.conig.js 配置应该如下:
const presets = [
// 根、frontend、backend 的公共预设
]
const plugins = [
// 根、frontend、backend 的公共插件
]

module.exports = {
presets,
plugins,
babelrcRoots: [‘.’, ‘./frontend’, ‘./backend’] // 允许这两个子 package 加载 babelrc 相对配置
}
以为此时已经高枕无忧了?navie,由于我们前端 Vue.js 采用 webpack 打包。实际开发过程中发现,这种配置会造成 webpack 打包模块时出现故障,故障原因在于:同一个模块中错误混用 esmodule 和 commonjs 语法会造成 webpack 故障。
前文讲到 全局配置 global.config.js 会作用到 整个项目,甚至包括 node_modules。因此 babel 编译时会同时编译 node_modules 下的模块,虽然模块作者不可能在一个 js 文件中混用不同模块语法,但他们作为释出包 通常是 commonjs 的模块语法。而 preset-env 预设在编译时会通过 usage 方式 默认注入 import 语法的 polyfill
Since Babel defaults to treating files are ES modules, generally these plugins/presets will insert import statements
这便是蛋疼的来源:babel 加载过的 node_modules 模块会变成 同一个 js 文件里既有 commonjs 语法又有 esmodule 语法。
解决方案:不要对 node_modules 下的模块采用 babel 编译。我们需要在 babel.config.js 配置中增加选项:
exclude: /node_modules/
总结
至此,我们的 monorepo 项目就可以使用一份 全局配置 + 两份相对配置,实现分别对 前端和后端 进行合理的 ES6+ 语法的编译了。这是我们配置工程师的一小步,但是前端走向未来语法的一大步。
总结 babel7 的配置加载逻辑如下:

babel.config.js 是对整个项目 (父子 package) 都生效的配置,但要注意 babel 的执行工作目录。
.babelrc 是对 待编译文件 生效的配置,子 package 若想加载.babelrc 是需要 babel 配置 babelrcRoots 才可以(父 package 自身的 babelrc 是默认可用的)。
任何 package 中的 babelrc 寻找策略是: 只会向上寻找到本包的 package.json 那一级。
node_modules 下面的模块一般都是编译好的,请剔除掉对他们的编译。如有需要,可以把个例加到 babelrcRoots 中。
虽然写的很乱,但您有收获吗,有的话点个赞吧.
或许你还没有看明白。没关系,知道最终的配置代码怎么粘贴就好了~

退出移动版