一文读懂 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
这便是蛋疼的来源: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 中。
- 虽然写的很乱,但您有收获吗,有的话点个赞吧.
- 或许你还没有看明白。没关系,知道最终的配置代码怎么粘贴就好了~