外观
04.Babel
8834字约29分钟
2024-05-31
Babel 是一个编译器,主要用于将最新的 JavaScript 代码转化为向后兼容的代码,以便在老版本的浏览器或环境中运行。
例如,你可能在开发时使用了 ES6、ES7 或者更高级的 JavaScript 特性,但是有些浏览器可能并不支持这些新特性,这时就可以用 Babel 来将代码转化为 ES5 或者更早版本的 JavaScript,以确保代码能在多数浏览器中正常运行。
其次,Babel 更像是一个平台,它本身的核心功能就是解析代码到抽象语法树(AST),然后再将 AST 转回 JavaScript 代码。所有的语法转换(例如将 ES6 转化为 ES5)和功能添加(例如 polyfills)都是通过各种插件来实现的。这一点有点类似于前面我们学习 CSS 工具链时介绍的 PostCSS。
Babel 官网:https://babeljs.io/
以下是 Babel 的一些主要功能:
语法转换:将新的 JavaScript 语法(如 JSX,TypeScript,ES2015+ 特性等)转换为旧的 ES5 语法。
源码映射:在编译后的代码中添加源码映射,以方便调试。
Polyfills:添加缺失的特性,如 Promise,Symbol 等,这称为 polyfill。Babel 提供了一个 Polyfill 功能,能自动引入所需的 Polyfill。这个功能通过 core-js 模块实现(Babel v7.4.0 之前使用的是 @babel/polyfill),可以模拟整个 ES2015+ 环境。
Array.prototype.includes 这个 API 是 ES2016 的新特性,但是一些旧的浏览器是不支持,像这种情况就需要通过 polyfill 天补充缺失的特性,polyfill 就是一段 JS 代码而已,polyfill 这段代码会去检查当前的浏览器是否支持该 API,如果不支持,polyfill 里面提供了该 API 的实现
if(!Array.prototype.includes){ Array.prototype.includes = function() {...} }
- 插件和预设:Babel 提供了大量的插件支持,你可以通过插件来使用特定的 JavaScript 特性。预设是一组插件的集合,例如,@babel/preset-env 会根据你的环境自动决定需要使用哪些插件。
在前端开发中,Babel 被广泛用于现代 JavaScript 项目,它能确保你的代码能在各种环境中运行,而不需要你手动处理各种浏览器和 JavaScript 版本的兼容性问题。
Babel 快速入门
新建一个项目 babel-demo,使用 pnpm init 进行一个初始化,之后安装依赖:
pnpm add --save-dev @babel/core @babel/cli @babel/preset-env
- @babel/core: 这个是 Babel 的核心包,提供了核心 API
- @babel/cli:该依赖提供 CLI 命令行工具
- @babel/preset-env:预设环境,Babel 在做代码转换的时候,是需要依赖插件的,但是会有一种情况,就需要的插件很多。所谓预设,指的就是内置了一组插件,这样我们只需要引入一个预设即可,不需要再挨着挨着引入众多的插件
在 src/index.js 中书写我们的测试代码:
const greet = (name) => `Hello, ${name}!`;
console.log(greet('World'));
接下来在项目的根目录下创建 .babelrc 配置文件,书写如下的配置:
{
"presets": ["@babel/preset-env"]
}
该配置就是指定我们的预设是什么。
之后在 package.json 里面添加 script 脚本命令
"scripts": {
// ...
"babel": "babel src --out-dir lib"
},
编译 src 目录下的文件,输出到 lib 目录下面。
编译结果如下:
'use strict';
var greet = function greet(name) {
return 'Hello, '.concat(name, '!');
};
console.log(greet('World'));
之后我们修改配置文件,指定了浏览器范围:
{
"presets": [
[
"@babel/preset-env",
{
"targets": {
"edge": "17",
"firefox": "60",
"chrome": "67",
"safari": "11.1"
},
"useBuiltIns": "usage",
"corejs": "3.6.5"
}
]
]
}
这一次编译出来的结果如下:
'use strict';
const greet = (name) => 'Hello, '.concat(name, '!');
console.log(greet('World'));
为什么两次不一样呢?原因很简单,第二次我们指定了浏览器版本范围,那么在指定的浏览器版本范围里面的这些浏览器,某一些特性已经支持了,所以就不需要再做转换了。
配置文件
- 配置文件格式
- 配置文件选项
配置文件的格式
在 babel 中,配置文件本身又可以分为两种:
- 项目范围的配置文件
- 文件相关配置文件
项目范围配置文件
顾名思义,就是该配置文件针对整个项目生效的一个配置,这种类型的配置文件一般放在项目根目录下面,babel 对项目范围级别的配置文件是有格式要求的,一般是指 babel.config._ 这种格式的配置文件,后面的 _ 支持各种类型的扩展名:.json .js .cjs .mjs .cts ...
- babel.config.js ✅
- babel.config.json ✅
- .babelrc ❌
文件相关配置文件
这种类型的配置文件就是对特定的文件或者特定的目录以及子目录生效。在 babel 中,如下格式的配置文件是文件级别:
- .babelrc.* (.json .js .cjs .mjs .cts)
- .babelrc.js
- .babelrc.json
- .babelrc
- package.json 文件里面的 babel 键
接下来我们来看一个结构示例:
/my-project
|-- frontend
| |-- .babelrc.json
| |-- src
|-- backend
| |-- .babelrc.json
| |-- src
|-- babel.config.json
假设 babel.config.json 配置如下:
{
"presets": [
"@babel/preset-env"
]
}
上面配置文件中所指定的预设,会在整个项目中国都被用到。
假设 frontend/.babelrc.json 有如下的配置:
{
"plugins": [
"@babel/plugin-transform-react-jsx"
]
}
该配置就只会在 frontend 目录范围内生效。babel 在对 frontend 目录下的文件进行编译的时候,会自动的去合并多个 babel 配置文件,最终 frontend 目录下的文件在进行编译的时候,就会使用 @babel/preset-env 预设以及 @babel/plugin-transform-react-jsx 这个插件。
配置文件选项
有关 babel 配置文件所支持的配置项有哪些,可以在官网的 https://babeljs.io/docs/options 看到。
所支持的配置项还是比较多,官方进行一个简单的分类:
- 主要选项
- 配置加载选项
- 插件和预设配置
- 输出目标选项
- 配置合并选项
- 源码映射选项
- 其他选项
- 代码生成器选项
- AMD / UMD / SystemJS 选项
- 选项概念
这里我们不需要一开始就对所有的配置项完全掌握,下面我们就介绍一些常见的配置项。
插件和预设配置
- plugins:对应的值为一个数组,配置要使用的插件,可以配置多个,注意在配置文件中配置的插件需要提前进行安装
{
"plugins": [["@babel/plugin-transform-arrow-functions", {}]]
}
- presets:配置一个预设,对应的值也是一个数组,表示可以配置多个
{
"presets": ["@babel/preset-env"]
}
输出目标选项
- targets: 该配置项目用于指定要兼容的浏览器版本范围
{
"targets": "> 0.25%, not dead"
}
关于指定浏览器范围,有多种多样的形式,例如可以在项目根目录下创建一个 .browserslistrc 配置文件来指定范围,也可以在 package.json 中通过 browserslist 这个键来指定范围。
优先级顺序如下:
- targets
- .browserslistrc
- package.json
- browserlistConfigFile:默认值是 true,表示允许 babel 去搜寻项目中和 browserlist 相关的配置。例如 babel 配置文件中没有 targets 的配置,但是项目中有 .browserslistrc 这个文件,里面指定了浏览器范围,那么 babel 在进行编译的时候,会去搜索和 browserlist 相关的配置,并在编译的时候应用对应的浏览器范围配置。这个配置对应的值还可以是一个字符串形式的路径,该路径就指定了具体的 browserlist 文件的位置
{
"presets": [
["@babel/preset-env", {
"browserslistConfigFile": "./.browserslistrc"
}]
]
}
配置合并选项
- extends:允许你扩展其他的 babel 配置文件,你可以提供一个路径,该路径对应的 babel 配置文件就会作为基础的配置
{
"extends": "./base.babelrc.json"
}
- env:为你不同的环境提供不同的配置,例如在开发环境或者生成环境需要使用不同的插件或者预设,那么就可以通过 env 来指定环境。
{
"env": {
"development": {
"plugins": ["pluginA"]
},
"production": {
"plugins": ["pluginB"]
}
}
}
- overrides :该配置项用于对匹配上的特定文件或者目录应用不同的配置
- test:做匹配
- include:包含哪些目录
- exclude:排除哪些目录
{
"overrides": [
{
"test": ["*.ts", "*.tsx"],
"exclude": "node_modules",
"presets": ["@babel/preset-typescript"]
}
]
}
- ignore 和 only :ignore 控制忽略文件,only 指定特有文件
{
"ignore": ["node_modules"],
"only": ["src"]
}
源码映射选项
- sourceMaps:告诉 babel 是否要生成 source map
{
"sourceMaps": true
}
- sourceFileName:指定 source map 文件的文件名
{
"sourceFileName": "customFileName.js"
}
- sourceRoot:source map 文件对应的 URL 前缀
{
"sourceMaps": true,
"sourceRoot": "/root/path/to/source/files/" // 前缀
}
其他选项
sourceType:指定 babel 应该如何去解析 js 代码
- module:如果你的代码使用的 ESM 模块化,里面涉及到了 export 、import,那么应该指定为这个值
- script:普通的 JS 脚本,没有使用模块化
- unambiguous:让 babel 自己来判断,babel 检查到你的代码使用了 export 、 import,就会视为模块文件,否则就会视为普通的 script 脚本
assumptions:从 babel 7.13.0 开始引入的一项配置项,让开发者对自己的代码做一个假定(更像是对 babel 的一个承诺)
{
"assumptions": {
"noClassCalls": true
}
}
上面配置表示我的代码中不会直接调用类(不会像调用函数一样去调用类),babel 就可以省略生成检查类是否被正确调用的代码。
CLI
关于 babel 所提供的 CLI,你可以在 https://babeljs.io/docs/babel-cli 看到所有所支持的 CLI 命令。
要使用 CLI 命令,首先第一步是安装:
pnpm add --save-dev @babel/core @babel/cli
注意在安装 @babel/cli 这个包的时候,需要同时安装 @babel/core 这个包,这个包是提供 babel 核心 API 的。CLI 背后实际上就是使用的 API 来实现的。
编译文件相关的 CLI
在使用 babel 的 CLI 命令的时候,有一个基本的格式:
babel [file | dir | glob] --out-[file | dir]
如果你没有指定 --out,那么 babel 会将编译后的结果输出到控制台。
常见的格式如下:
# 编译结果输出到控制台
babel script.js
# 编译结果输出到指定文件
babel script.js --out-file script-compiled.js
# 编译整个目录到指定目录下
babel src --out-dir lib
# 编译整个目录下的文件,输出到一个文件里面
babel src --out-file script-compiled.js
# 监视文件,当文件发生变化时自动重新编译
babel script.js --watch --out-file script-compiled.js
我们在进行编译的时候,可以指定是否要生成 source map:
babel script.js --out-file script-compiled.js --source-maps
babel script.js --out-file script-compiled.js --source-maps inline
忽略文件和拷贝文件
有些时候我们在进行编译的时候,想要忽略某些文件
# 忽略 src 目录下面的所有测试文件
babel src --out-dir lib --ignore "src/**/*.spec.js","src/**/*.test.js"
有些文件我们想要原封不动的进行拷贝,不需要 babel 进行编译
# 将 src 目录下的文件原封不动的复制到 lib 目录下
babel src --out-dir lib --copy-files
# 进行拷贝的时候忽略文件中匹配的文件不要拷贝
babel src --out-dir lib --copy-files --no-copy-ignored
使用插件和预设
在 CLI 命令行里面也是可以指定插件和预设的
# 指定插件
babel script.js --out-file script-compiled.js --plugins=@babel/transform-class-properties,@babel/transform-modules-amd
# 指定预设
babel script.js --out-file script-compiled.js --presets=@babel/preset-env,@babel/flow
使用配置文件
通过 --config-file 可以指定配置文件的位置
babel --config-file /path/to/my/babel.config.json --out-dir dist ./src
如果想要忽略已经有了的配置文件中的配置,可以使用 --no-babelrc
babel --no-babelrc script.js --out-file script-compiled.js --presets=@babel/preset-env,@babel/preset-react
使用插件
在 babel 要使用一个插件,步骤实际上非常简单,就分为两步:
- 安装插件
- 在配置文件或者 CLI 中指定插件
举个例子,例如有一个专门将箭头函数转为普通函数的插件:
pnpm add @babel/plugin-transform-arrow-functions -D
之后在配置文件中进行插件配置即可
{
"plugins": ["@babel/plugin-transform-arrow-functions"]
}
插件使用的细节
- 插件的运行顺序
plugins 对应的值为一个数组,说明是可以指定多个插件的
{
"plugins": ["transform-decorators-legacy", "transform-class-properties"]
}
在上面的配置中,插件的运行会从左往右运行,也就是说,会先运行 transform-decorators-legacy 这个插件,然后运行 transform-class-properties 这个插件。
如果配置文件中既配置了插件,又配置了预设,那么 babel 会先运行插件,然后在运行预设里面的插件,也就是说,插件运行的时机是要早于预设的。
- 插件选项
在使用插件的时候,是可以传递插件选项的,例如有三种写法:
{
"plugins": ["pluginA", ["pluginA"], ["pluginA", {}]]
}
上面的三种写法目前来讲是等价的,而第三种写法,数组第二项的对象实际上就是用来传递插件配置项
{
"plugins": [
[
"transform-async-to-module-method",
{
"module": "bluebird",
"method": "coroutine"
}
]
]
}
关于插件究竟有哪些配置项,这个需要去参阅插件相关的文档。
插件列表
你可以在 https://babeljs.io/docs/plugins-list 看到 babel 中支持的大多数插件。
一般来讲,每个插件点击进去会包含该插件对应的说明信息,一般包含这些内容:
- 该插件的说明
- 插件编译前后代码的区别
- 该插件的使用方法
- 该插件的配置选项
使用预设
预设的基本使用
首先第一步仍然是先要安装对应的预设
pnpm add --save-dev @babel/preset-env
安装完成后,在配置文件中进行配置:
{
"presets": ["@babel/preset-env"]
}
预设对应的值是一个数组,说明也是能够配置多个预设的,但是一定要注意顺序
{
"presets": ["@babel/preset-env", "@babel/preset-react"]
}
上面的配置中,会先运行 @babel/preset-react 预设里面的插件,然后运行 @babel/preset-env 这套预设里面的插件。
运行的顺序刚好和插件是相反的,从后往前运行。
官方预设
官方提供了 4 套预设:
- @babel/preset-env 用于编译 ES2015 及以上版本的语法
- @babel/preset-typescript 用于 TypeScript
- @babel/preset-react 用于 React
- @babel/preset-flow 用于 Flow
stage-x 预设
在 babel v7.0.0 之前,支持一种叫做 state-x 的预设特性。
JavaScript 的新特性是由 TC39 的小组提出并且通过一系列的阶段来推动的。一般来讲,这个阶段分为从 0 到 4,每个阶段对应了新特性的不同状态:
- Stage 0 - Strawman:只是一个想法或者提案,还没有任何实现。
- Stage 1 - Proposal:这是一个正式的提案,包含 API 的描述,但可能还没有完全实现。
- Stage 2 - Draft:初步版本,已经有了初步的规范文本,并且大部分细节都已经确定。
- Stage 3 - Candidate:候选阶段,规范已经完成,并且已经完成了浏览器的初步实现,这个阶段主要是为了获取反馈和评估。
- Stage 4 - Finished:完成阶段,已经在多个浏览器中实现并通过了实际使用的测试,可以被添加到 ECMAScript 标准中。
在早期的时候(babel v7.x.x 之前),可以安装对应阶段的预设
npm install --save-dev @babel/preset-stage-2
这个预设对应了 stage2 阶段的新特性的编译
{
"presets": ["@babel/preset-stage-2"]
}
之后你在做开发的时候,就可以只用 stage 2 阶段的新语法了。
但是上面的 stage-x 的预设从 v7.0.0 版本开始就已经废弃了。
As of Babel 7, we've decided to deprecate the Stage-X presets and stop publishing them. Because these proposals are inherently subject to change, it seems better to ask users to specify individual proposals as plugins vs. a catch all preset that you would need to check up on anyway.
目前官方推荐的做法是要使用哪个新特性,直接安装对应的插件即可。
@babel/preset-env
这里我们主要看一下这一套插件对应的 options
{
"presets": [
[
"@babel/preset-env",
{
"useBuiltIns": "entry",
"corejs": "3.22",
"modules": false
}
]
]
}
在上面的配置中,我们就使用了 @babel/preset-env 预设,并且对这套预设做了一些配置。
- targets:指定浏览器需要支持的版本范围
{
"presets": [
["@babel/preset-env", {
"targets": "> 0.25%, not dead"
}]
]
}
useBuiltIns:让你决定如何使用 polyfills
- entry:该选项值会根据项目中 browserslist 对应的浏览器版本范围来添加 polyfills,这个选项不会管你源码中是否用到缺失的特性,只要对应的浏览器版本是缺失的,那么就会添加对应的特性。而且在使用这个选项值的时候,还需要在源码的入口文件中手动引入 core-js
- usage:根据你的源码中是否使用了缺失的特性,如果使用到了缺失的特性,那么才添加对应的 polyfills
- false:这个是默认值,关闭自动引入 polyfills。
corejs:指定你的 corejs 版本,polyfills 实际上就是通过 corejs 来实现的。该配置项一般就和 useBuiltIns 一起使用
{
"presets": [
["@babel/preset-env", {
"useBuiltIns": "usage",
"corejs": 3
}]
]
}
corejs 支持的配置项有 2、3 还有 false:
"2": 使用 core-js 的版本 2。这是旧版本的 core-js,它包含 ES5、ES6 和 ES7 的特性。在 Babel 7.4.0 之前,这是默认值。
"3": 使用 core-js 的版本 3。这是新版本的 core-js,它包含 ES5、ES6、ES7、ES8 和更高版本的特性。在 Babel 7.4.0 及更高版本,这是推荐的值。
false: 不使用 core-js。如果你不想让 Babel 添加任何 polyfill,你可以将 corejs 设置为 false。
modules:设置模块的类型
- amd
- umd
- systemjs
- commonjs
- cjs
- auto
- false
默认值为 auto,根据你的环境和代码自动来决定使用的模块版本。
- include:允许你显式的指定要包含的插件(这个插件是本身在预设里面,但是因为 targets 的设置,可能会被排除掉)
{
"presets": [
["@babel/preset-env", {
"targets": "> 0.25%, not dead",
"include": ["@babel/plugin-proposal-optional-chaining"]
}]
]
}
假设 preset-env 里面有 pluginA、pluginB、pluginC,假设我现在指定了浏览器范围,所指定的这些浏览器范围已经实现了特性 A 和 特性 B,那么这里就只会用到 pluginC。那么 include 配置项就可以强行指定要包含的插件
APIs
关于 babel 里面的 APIs 主要位于 @babel/core 这个依赖里面,你可以在官网左下角的 Tooling Packages 分类下找到这个依赖包。
这里顺便介绍一下每一种依赖包的作用:
- @babel/parser: 是 Babel 的解析器,用于将源代码转换为 AST。
- @babel/core: Babel 的核心包,它提供了 Babel 的核心编译功能。这个包是使用 Babel 必须安装的。
- @babel/generator: 是 Babel 的代码生成器,它接收一个 AST 并将其转换为代码和源码映射(sourcemap)。
- @babel/code-frame: 提供了一种用于生成 Babel 错误消息的方法,可以在代码帧中高亮显示错误。
- @babel/runtime: 提供了 Babel 运行时所需要的辅助函数和 polyfills,以避免在每个文件中都重复这些代码。
- @babel/template: 提供了一种编写带有占位符的 Babel AST 模板的方法。
- @babel/traverse: 是 Babel 的 AST 遍历器,它包含了一些用于处理 AST 的工具。
- @babel/types: 提供了一种用于 AST 节点的 Lodash-esque 实用程序库。
在第一节课的时候,我们安装了三个依赖:core、cli、preset,但是我们使用 babel 进行编译的时候发现最终是生成了编译后的代码的,而从 AST 生成编译后代码是 generator 的工作,实际上当你安装 core 的时候,就会间接的安装 generator、traverse 等需要用到的依赖包。
通过对官方 API 的观察,我们发现 babel/core 的 API 主要分为三大类:
- transformXXX
- parseXXX
- loadXXX
transformXXX
这一组方法一看就是做和编译相关的操作,之所以有这么多,其实就是同步或者异步、编译代码或者文件的区别,每个方法的具体含义如下:
- transform(code: string, options: Object): 这是一个异步函数,用于将源代码字符串转换为 Babel 的结果对象。结果对象包含了转换后的代码,源码映射,以及 AST。
- transformSync(code: string, options: Object): 这个函数和 transform 函数功能相同,但它是同步执行的。
- transformAsync(code: string, options: Object): 这个函数和 transform 函数功能相同,它返回一个 Promise,这个 Promise 会在转换完成后解析为结果对象。
- transformFile(filename: string, options: Object, callback: Function): 这个函数会读取并转换指定的文件。转换完成后,会调用提供的回调函数,并将结果对象传递给回调函数。
- transformFileSync(filename: string, options: Object): 这个函数和 transformFile 函数功能相同,但它是同步执行的。
- transformFileAsync(filename: string, options: Object): 这个函数和 transformFile 函数功能相同,它返回一个 Promise,这个 Promise 会在转换完成后解析为结果对象。
- transformFromAst(ast: Object, code: string, options: Object): 这个函数接受一个 AST 对象,然后将这个 AST 转换为 Babel 的结果对象。这个函数可以用于在已经有 AST 的情况下避免重新解析代码。
- transformFromAstSync(ast: Object, code: string, options: Object): 这个函数和 transformFromAst 函数功能相同,但它是同步执行的。
- transformFromAstAsync(ast: Object, code: string, options: Object): 这个函数和 transformFromAst 函数功能相同,它返回一个 Promise,这个 Promise 会在转换完成后解析为结果对象。
上面这些方法中,只要搞懂一个,其他的也就搞懂了。
parseXXX
该系列方法主要负责将源码转为抽象语法树(AST),之后就不管了。
parse(code: string, options: Object): 这是一个异步函数,用于解析源代码字符串并返回一个 AST。你可以通过选项对象来配置解析过程,例如是否包含注释,是否包含 location 信息等。
parseSync(code: string, options: Object): 这个函数和 parse 函数功能相同,但它是同步执行的。
parseAsync(code: string, options: Object): 这个函数和 parse 函数功能相同,它返回一个 Promise,这个 Promise 会在解析完成后解析为 AST。
loadXXX
这一系列方法主要是做配置文件的加载工作的
loadOptions(options: Object): 这个函数接受一个选项对象,然后返回一个完整的、已解析的 Babel 配置对象。这个配置对象包括了所有的预设,插件,和其他配置选项。如果提供的选项对象中没有指定配置,那么这个函数会尝试从 .babelrc 文件或 babel.config.js 文件中加载配置。
例如:
const babel = require('@babel/core'); const options = { filename: './src/myFile.js', }; const config = babel.loadOptions(options); console.log(config);
在这个例子中,我们首先导入了 @babel/core,然后定义了一个选项对象。这个对象中,filename 属性指定了我们正在处理的文件的路径。然后我们使用 @babel/core 的 loadOptions 方法来加载 Babel 的配置。
loadOptions 方法返回一个配置对象,这个对象包括了所有的预设,插件,和其他配置选项。在这个例子中,我们将这个配置对象打印到控制台。
loadPartialConfig(options: Object): 这个函数和 loadOptions 函数类似,但是返回的配置对象可能是部分的,也就是说,它可能没有包括所有的预设和插件。这个函数主要用于在构建工具中,当你需要对 Babel 配置进行更精细的控制时。
自定义插件 part1
关于 babel 中如何创建自定义插件,官方是有一个 handbook:https://github.com/jamiebuilds/babel-handbook/blob/master/translations/en/plugin-handbook.md
- AST
- Babel 处理代码流程
- 遍历
AST
开发者所书写的源码文件里面的代码,最终会被表现为一颗树结构
function square(n) {
return n * n;
}
最终上面的代码,就会被转为如下的树结构:
- FunctionDeclaration:
- id:
- Identifier:
- name: square
- params [1]
- Identifier
- name: n
- body:
- BlockStatement
- body [1]
- ReturnStatement
- argument
- BinaryExpression
- operator: *
- left
- Identifier
- name: n
- right
- Identifier
- name: n
上面的树结构如果使用 JS 来表示,结构如下:
{
type: "FunctionDeclaration",
id: {
type: "Identifier",
name: "square"
},
params: [{
type: "Identifier",
name: "n"
}],
body: {
type: "BlockStatement",
body: [{
type: "ReturnStatement",
argument: {
type: "BinaryExpression",
operator: "*",
left: {
type: "Identifier",
name: "n"
},
right: {
type: "Identifier",
name: "n"
}
}
}]
}
}
你可以在 https://astexplorer.net/ 看到一段源码转换为的 AST
在上面的 JS 对象中,我们会发现每一层有一些相同的结构:
{
type: "FunctionDeclaration",
id: {...},
params: [...],
body: {...}
}
{
type: "Identifier",
name: ...
}
{
type: "BinaryExpression",
operator: ...,
left: {...},
right: {...}
}
每一个拥有 type 属性的对象,我们可以将其称之为一个节点,那么一颗 AST 树实际上就是由成百上千个节点构成的。不同的节点有不同的类型,通过 type 来表示当前节点的类型。
除了 type 以外,还会有一些额外的属性,这些属性就提供了该节点额外的一些信息。
{
type: ...,
start: 0,
end: 38,
loc: {
start: {
line: 1,
column: 0
},
end: {
line: 3,
column: 1
}
},
...
}
Babel 处理代码流程
Babel 对代码进行处理的时候,核心的流程就分为三步:
- 解析(parse)
- 转换(transform)
- 生成(generate)
解析(parse)
将接收到的源代码转为抽象语法树,这个步骤又分为两个小阶段:
- 词法分析
- 语法分析
所谓词法分析,就是将源码转为 token
let i = 'Hello';
let、i、=、 "Hello"
转为 token 时,每一个 token 会包含一些额外的信息:
n * n;
会形成如下的 token:
[
{ type: { ... }, value: "n", start: 0, end: 1, loc: { ... } },
{ type: { ... }, value: "*", start: 2, end: 3, loc: { ... } },
{ type: { ... }, value: "n", start: 4, end: 5, loc: { ... } },
]
每一个 token 里面专门有一个 type 属性来描述这个 token:
{
type: {
label: 'name',
keyword: undefined,
beforeExpr: false,
startsExpr: true,
rightAssociative: false,
isLoop: false,
isAssign: false,
prefix: false,
postfix: false,
binop: null,
updateContext: null
},
...
}
形成一个一个 token 之后,接下来就会进入到语法分析阶段,该阶段就是将所得到的 token 转为 AST 树结构,便于后续的操作。
转换(transform)
目前我们已经得到了一颗 AST 树结构,接下来对这棵树进行一个遍历操作,在遍历的时候,就可以对树里面的节点进行一些添加、删除、更新等操作,这个其实就是 babel 转换代码的核心。
例如我们的一些插件,就是在转换阶段介入并进行工作的。
生成(generate)
经历过转换之后,你现在得到的树结构已经和之前不一样,接下来我们要做的事情,就是将这颗 AST 重新转为代码(字符串)
遍历
在对 AST 进行遍历的时候,采用的是深度优先遍历,例如:
{
type: "FunctionDeclaration",
id: {
type: "Identifier",
name: "square"
},
params: [{
type: "Identifier",
name: "n"
}],
body: {
type: "BlockStatement",
body: [{
type: "ReturnStatement",
argument: {
type: "BinaryExpression",
operator: "*",
left: {
type: "Identifier",
name: "n"
},
right: {
type: "Identifier",
name: "n"
}
}
}]
}
}
- 于是我们从 FunctionDeclaration 开始并且我们知道它的内部属性(即:id,params,body),所以我们依次访问每一个属性及它们的子节点。
- 接着我们来到 id,它是一个 Identifier。Identifier 没有任何子节点属性,所以我们继续。
- 之后是 params,由于它是一个数组节点所以我们访问其中的每一个,它们都是 Identifier 类型的单一节点,然后我们继续。
- 此时我们来到了 body,这是一个 BlockStatement 并且也有一个 body 节点,而且也是一个数组节点,我们继续访问其中的每一个。
- 这里唯一的一个属性是 ReturnStatement 节点,它有一个 argument,我们访问 argument 就找到了 *BinaryExpression**(二元表达式)。
- BinaryExpression 有一个 operator,一个 left,和一个 right。Operator 不是一个节点,它只是一个值因此我们不用继续向内遍历,我们只需要访问 left 和 right。
访问者
所谓访问者其实就是一个对象,该对象上面会有一些特殊的方法,这些特殊的方法会在你到达特定的节点的时候触发。
const MyVisitor = {
Identifier() {
console.log('Called!');
},
};
该访问者对象会在遍历这颗树的时候,当遇见 Identifier 节点的时候就会被调用。
例如上面的那颗 AST 树,我们只表示 type,表示出来的形式如下:
-FunctionDeclaration -
Identifier(id) -
Identifier(params[0]) -
BlockStatement(body) -
ReturnStatement(body) -
BinaryExpression(argument) -
Identifier(left) -
Identifier(right);
因此在遍历上面这颗树的时候,Identifier 方法就会被调用四次。
有些时候我们可以针对特定的节点定义进入时要调用的方法,退出时要调用的方法
const MyVisitor = {
Identifier: {
enter() {
console.log('Entered!');
},
exit() {
console.log('Exited!');
},
},
};
这里还是以上面的抽象语法树为例,整体的进入节点和退出节点的流程如下:
进入 FunctionDeclaration
进入 Identifier (id)
走到尽头
退出 Identifier (id)
进入 Identifier (params[0])
走到尽头
退出 Identifier (params[0])
进入 BlockStatement (body)
进入 ReturnStatement (body)
进入 BinaryExpression (argument)
进入 Identifier (left)
走到尽头
退出 Identifier (left)
进入 Identifier (right)
走到尽头
退出 Identifier (right)
退出 BinaryExpression (argument)
退出 ReturnStatement (body)
退出 BlockStatement (body)
退出 FunctionDeclaration
现在你可能比较好奇的是访问者对象除了 Identifier 方法,还能够有哪些方法?
一般来讲,不同的节点类型就有节点 type 所对应的方法,例如:
- Identifier(path, state): 这个方法在遍历到标识符节点时会被调用。
- FunctionDeclaration(path, state): 这个方法在遍历到函数声明节点时会被调用。
至于节点究竟有哪些类型,可以参阅 estree:https://github.com/estree/estree/blob/master/es5.md
路径
AST 是由一个一个的节点组成的,但是这些节点之间并非孤立的,而是彼此之间有一些联系的。因此有一个 path 对象,该对象主要就是记录节点和节点之间的一些关系。path 对象里面不仅仅包含了节点本身的信息,还包含了节点和父节点、子节点、兄弟节点之间的关系。
这样做的好处在于我们使用了一个相对简单的对象来表示节点之间复杂关系,不需要在每个节点里面来保存节点之间关系的信息。
在实际编写插件的时候,我们经常就会利用 path 对象来获取节点的相关信息:
const babel = require('@babel/core');
const traverse = require('@babel/traverse').default;
const code = `function square(n) {
return n * n;
}`;
const ast = babel.parse(code);
// traverse 接收两个参数
// 第一个参数就是抽象语法树
// 第二个参数就是访问者对象
traverse(ast, {
enter(path) {
console.log(path.node.type);
},
});
状态
在遍历和修改抽象语法树的时候,应该尽量避免全局状态的问题
例如,现在我们有一个需求,重命名一个函数的参数。
let paramName; // 存储函数参数名
const MyVisitor = {
FunctionDeclaration(path) {
const param = path.node.params[0]; // 同 path 对象拿到当前节点的参数
paramName = param.name; // 将参数的名称存储到 paramName 里面(全局变量)
param.name = 'x';
},
Identifier(path) {
// 之后,进入到每一个 Identifier 类型的节点的时候
// 判断当前节点的名称是否等于 paramName(之前的函数参数名称)
if (path.node.name === paramName) {
// 进行修改
path.node.name = 'x';
}
},
};
上面的代码看上去没有什么问题,但是上面的代码可能在某些情况下不能够正常的工作。
例如在我们要转换的源码文件中就存在 paramName 这个变量,那么这段代码就会出现问题
为了解决这样的问题,我们需要避免全局状态,我们可以在一个访问者对象里面再定义一个访问者对象专门拿来存储状态。
const updateParamNameVisitor = {
Identifier(path) {
if (path.node.name === this.paramName) {
path.node.name = 'x';
}
},
};
const MyVisitor = {
FunctionDeclaration(path) {
const param = path.node.params[0];
const paramName = param.name;
param.name = 'x';
path.traverse(updateParamNameVisitor, { paramName });
},
};
path.traverse(MyVisitor);
自定义插件 part2
要自定义 babel 的插件,实际上有一个固定的格式:
module.exports = function (babel) {
// 该函数会自动传入 babel 对象
// types 也是一个对象,该对象上面有很多的方法,方便我们对 AST 的节点进行操作
const { types } = babel;
return {
name: '插件的名字',
visitor: {
// ...
// 这里书写不同类别的方法,不同的方法会被进入不同类别的节点触发
},
};
};
示例一
创建一个自定义插件,该插件能够把 ES6 里面的 ** 转换为 Math.pow
在编写自定义插件的时候,会使用到 types 对象的一些方法:
t.callExpression(callee, arguments):这个函数用于创建一个表示函数调用的 AST 节点。callee 参数是一个表示被调用的函数的表达式节点,arguments 参数是一个数组,包含了所有的参数表达式节点。
t.memberExpression(object, property, computed = false):这个函数用于创建一个表示属性访问的 AST 节点。object 参数是一个表示对象的表达式节点,property 参数是一个表示属性名的标识符或表达式节点。computed 参数是一个布尔值,表示属性名是否是动态计算的。
t.identifier( ): 创建 AST 节点,只不过创建的是 identifier 类型的 AST 节点。
插件的核心,其实就是创建一些新的 AST 节点,去替换旧的 AST 节点。
插件的代码如下:
// 该插件负责将 ** --> Math.pow
// 例如 2 ** 3 ---> Math.pow(2, 3)
module.exports = function (babel) {
const { types: t } = babel;
return {
name: 'transform-to-mathpow',
visitor: {
// 当你遍历 AST 节点的时候
// 遍历到二元表达式的时候会自动执行该方法
BinaryExpression(path) {
// 二元表达式比较多
// 5 + 3
// 1 / 2
// 检查当前的节点的运算符是否是 **
// 如果不是,直接返回
if (path.node.operator !== '**') {
return;
}
// 说明当前是 ** 我们要做一个替换操作
// 首先需要生成新的 AST 节点,因为替换使用新的 AST 节点来替换的旧的 AST 节点
// t.identifier("Math") // ---> Math
// t.identifier("pow") // ---> pow
// pow 需要作为 Math 的一个属性
// Math.pow
// t.memberExpress(t.identifier("Math"), t.identifier("pow"));
const mathpowAstNode = t.callExpression(
t.memberExpression(t.identifier('Math'), t.identifier('pow')),
[path.node.left, path.node.right]
);
// 用新的 AST 节点替换旧的 AST 节点
path.replaceWith(mathpowAstNode);
},
},
};
};
在上面的代码中,我们就创建了一个自定义的插件,该插件首先对外暴露一个函数,该函数需要返回一个对象,对象里面就有访问器对象,访问器对象里面会有一些特定的方法,这些方法会在进入到特定的节点的时候被调用。
插件内部做的核心的事情:创建新的 AST 节点,然后去替换旧的 AST 节点。
示例二
编写一个自定义插件,该插件能够将箭头函数转为普通的函数。
// a => {...}
// function(a){...}
module.exports = function (babel) {
const { types: t } = babel;
return {
name: 'transform-arrow-to-function',
visitor: {
// 当你的节点类型为箭头函数表达式的时候
// 执行特定的方法
ArrowFunctionExpression(path) {
let body; // 存储函数体
if (path.node.body.type !== 'BlockStatement') {
// 进入此 if,说明箭头函数是一个表达式,需要将 body 部分转为返回语句
// a => b
// function(a){return b}
body = t.blockStatement([t.returnStatement(path.node.body)]);
} else {
// 可以直接使用箭头函数的方法体
body = path.node.body;
}
// 该方法创建一个普通函数表达式的 AST 节点( function(){} )
const functionExpression = t.functionExpression(
null, // 函数名
path.node.params, // 函数参数,和箭头函数的参数是一致的
body, // 函数方法体
false, // 不是一个生成器函数
path.node.async // 是否是异步函数,和箭头函数是一致的
);
path.replaceWith(functionExpression);
},
},
};
};