Babel 是一个强大的 js 编译器,也是前端工程化的基石。有了 Babel, 我们可以放肆的使用 js 的新特性,而不用考虑浏览器兼容性问题。不仅如此,基于 babel 体系,我们可以通过插件的方法修改一些语法,优化一些语法,甚至创建新的语法。
那么,如此强大且灵活的特性是如何实现的?我们从头开始,了解下 Babel 的编译流程。
环境搭建
简单的搭建一个实例,用于演示 Babel 的编译过程。
安装 @babel/cli,这样我们就能过以命令行的方式调用 babel 的功能。babel src -d lib 的意思就是将 src 目录下的文件进行编译,将结果放在 lib 目录下。
package.json
{
"name": "my-project",
"version": "1.0.0",
"scripts": {
"build": "babel src -d lib"
},
"devDependencies": {
"@babel/cli": "^7.0.0",
"@babel/core": "^7.0.0",
"@babel/preset-env": "^7.11.5"
}
}
Debug 源码的时候将 build 脚本改成如下形式,这样可以在 node js 代码上打断点,看源码的效率更高。
"scripts": {
"build": "node --inspect-brk node_modules/@babel/cli/bin/babel src -d lib"
},
如果我们什么都不配置的话,打包后的文件不会有任何变化,需要在 babelrc 文件中对 babel 做如下配置。然后打包。我们后续会分析该配置作用的机制。
.babelrc
{
"presets": ["@babel/preset-env"]
}
src 下的 index 文件中使用了箭头函数,是 ES6 的语法,经过 babel 编译后,箭头函数被替换为了ES5 函数的写法。
src\index.js
[1, 2, 3].map((n) => n + 1);
lib\index.js
"use strict";
[1, 2, 3].map(function (n) {
return n + 1;
});
@babel/cli
从 babel src -d lib 命令开始看起,该命令位于 node_modules 目录下的 .bin 目录,该命令会指向 @babel/cli/bin/babel.js。由于这里的代码都是编译后的代码,我们直接下载源码。
如下是 @babel/cli 的入口文件,首先会解析参数,babel 使用 CommanderJs 来处理命令行,具体的处理方式略过,最终得到的 opts 是一个 json 对象,该对象中包含了命令行中的参数。
babel-main\packages\babel-cli\src\babel\index.js
#!/usr/bin/env node
import parseArgv from "./options";
import dirCommand from "./dir";
import fileCommand from "./file";
const opts = parseArgv(process.argv);
if (opts) {
const fn = opts.cliOptions.outDir ? dirCommand : fileCommand;
fn(opts).catch(err => {
console.error(err);
process.exitCode = 1;
});
} else {
process.exitCode = 2;
}
如下是解析命令行得到的 json 对象。
{
babelOptions: {}
cliOptions: {
filenames: ['src'],
outDir: 'lib'
}
}
接下来就是根据 cliOptions 中的配置,创建目录,遍历文件列表,依次读入每个文件进行编译。我们看下编译的核心代码。调用了 util.compile 方法。
// 处理下文件后缀(省略)
const dest = getDest(relative, base);
const res = await util.compile(
src,
defaults(
{
sourceFileName: slash(path.relative(dest + "/..", src)),
},
babelOptions,
),
);
// 将 SourceMap 链接加入到目标文件中(省略)
util.compile 方法调用 babel.transformFile 方法。
import * as babel from "@babel/core";
export function compile(
filename: string,
opts: Object | Function,
): Promise<Object> {
opts = {
...opts,
caller: CALLER,
};
return new Promise((resolve, reject) => {
babel.transformFile(filename, opts, (err, result) => {
if (err) reject(err);
else resolve(result);
});
});
}
不难看出,@babel/cli 的使命就是处理解析 babel 的命令,根据命令行中的参数做一些非核心的工作,比如删除旧的构建文件,检查目标目录等功能。核心功能是由 @babel/core 实现的,@babel/cli 中调用了 @babel/core 进行编译,然后经编译后的文件写到目标路径下。
@babel/cli 只是个命令行工具,本身并不涉及便以流程,事实上,我们可以直接调用 @babel/core 中的代码进行编译工作。如下代码中,我们调用 transform 方法直接生成目标代码,sourceMap 以及 AST。
import * as babel from "@babel/core";
babel.transform(code, options, function(err, result) {
result; // => { code, map, ast }
});
@babel/core
@babel/core 是整个 babel 的核心,它负责调度 babel 的各个组件来进行代码编译,是整个行为的组织者和调度者。
transform 方法会调用 transformFileRunner 进行文件编译,首先就是 loadConfig 方法生成完整的配置。然后读取文件中的代码,根据这个配置进行编译。
const transformFileRunner = gensync<[string, ?InputOptions], FileResult | null>(
function* (filename, opts) {
const options = { ...opts, filename };
const config: ResolvedConfig | null = yield* loadConfig(options);
if (config === null) return null;
const code = yield* fs.readFile(filename, "utf8");
return yield* run(config, code);
},
);
babel 生成配置
@babel/cli 解析命令行,但是仅有命令行中的参数的话,babel 是无法进行编译工作的,还缺少一些关键性的参数,也就是配置在 .babelrc 文件中的插件信息。
@babel/core 在执行 transformFile 操作之前,第一步就是读取 .babelrc 文件中的配置。
流程是这样的,babel 首先会判断命令行中有没有指定配置文件(-config-file),有就解析,没有的话 babel 会在当前根目录下寻找默认的配置文件。默认文件名称定义如下。优先级从上到下。
babel-main\packages\babel-core\src\config\files\configuration.js
const RELATIVE_CONFIG_FILENAMES = [
".babelrc",
".babelrc.js",
".babelrc.cjs",
".babelrc.mjs",
".babelrc.json",
];
.babelrc 文件中,我们经常配置的是 plugins 和 presets,plugin 是 babel 中真正干活的,代码的转化全靠它,但是随着 plugin 的增多,如何管理好这些 plugin 也是一个挑战。于是,babel 将一些 plugin 放在一起,称之为 preset。
对于 babelrc 中的 plugins 和 presets,babel 将每一项都转化为一个 ConfigItem。presets 是一个 ConfigItem 数组,plugins 也是一个 ConfigItem 数组。
假设有如下的 .babelrc 文件,会生成这样的 json 配置。
{
"presets": ["@babel/preset-env"],
"plugins": ["@babel/plugin-proposal-class-properties"]
}
plugins: [
ConfigItem {
value: [Function],
options: undefined,
dirname: 'babel\\babel-demo',
name: undefined,
file: {
request: '@babel/plugin-proposal-class-properties',
resolved: 'babel\\babel-demo\\node_modules\\@babel\\plugin-proposal-class-properties\\lib\\index.js'
}
}
],
presets: [
ConfigItem {
value: [Function],
options: undefined,
dirname: 'babel\\babel-demo',
name: undefined,
file: {
request: '@babel/preset-env',
resolved: 'babel\\babel-demo\\node_modules\\@babel\\preset-env\\lib\\index.js'
}
}
]
对于 plugins,babel 会依序加载其中的内容,解析出插件中定义的 pre,visitor 等对象。由于 presets 中会包含对个 plugin,甚至会包括新的 preset,所以 babel 需要解析 preset 的内容,将其中包含的 plugin 解析出来。以 @babel/preset-env 为例,babel 会将其中的 40 个 plugin 解析到,之后会重新解析 presets 中的插件。
这里有一个很有意思的点,就是对于解析出的插件列表,处理的方式是使用 unshift 插入到一个列表的头部。
if (plugins.length > 0) {
pass.unshift(...plugins);
}
这其实是因为 presets 加载顺序和一般理解不一样 ,比如 presets 写成 ["es2015", "stage-0"],由于 stage-x 是 Javascript 语法的一些提案,那这部分可能依赖了ES6 的语法,解析的时候需要先将新的语法解析成 ES6,在把 ES6 解析成 ES5。这也就是使用 unshift 的原因。新的 preset 中的插件会被优先执行。
当然,不管 presets 的顺序是怎样的,我们定义的 plugins 中的插件永远是最高优先级。原因是 plugins 中的插件是在 presets 处理完毕后使用 unshift 插入对列头部。
最终生成的配置包含 options 和 passes 两块,大部分情况下,options 中的 presets 是个空数组,plugins 中存放着插件集合,passes 中的内容和 options.plugins 是一致的。
{
options: {
babelrc: false,
caller: {name: "@babel/cli"},
cloneInputAst: true,
configFile: false,
envName: "development",
filename: "babel-demo\src\index.js",
plugins: Array(41),
presets: []
}
passes: [Array(41)]
}
babel 执行编译
获得配置后,先读取需要编译的文件,获得编译前代码。
const code = yield* fs.readFile(filename, "utf8");
接下来,就是正式的编译流程了。如下,我们列出了 run 的主要代码,首先是执行 normalizeFile 方法,该方法的作用就是将 code 转化为抽象语法树(AST)。接着执行 transformFile 方法,该方法入参有我们的插件列表,这一步做的就是根据插件修改 AST 的内容,最后执行 generateCode 方法,将修改后的 AST 转换成代码。
export function* run(
config: ResolvedConfig,
code: string,
ast: ?(BabelNodeFile | BabelNodeProgram),
): Handler<FileResult> {
const file = yield* normalizeFile(
config.passes,
normalizeOptions(config),
code,
ast,
);
const opts = file.opts;
try {
yield* transformFile(file, config.passes);
} catch (e) {
...
}
let outputCode, outputMap;
try {
if (opts.code !== false) {
({ outputCode, outputMap } = generateCode(config.passes, file));
}
} catch (e) {
...
}
return {
metadata: file.metadata,
options: opts,
ast: opts.ast === true ? file.ast : null,
code: outputCode === undefined ? null : outputCode,
map: outputMap === undefined ? null : outputMap,
sourceType: file.ast.program.sourceType,
};
}
整个编译过程还是挺清晰的,简单来说就是解析(parse),转换(transform),生成(generate)。我们详细看下每个过程。
解析(parse)
了解解析过程之前,要先了解抽象语法树(AST),它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。不同的语言生成 AST 规则不同,在 JS 中,AST 就是一个用于描述代码的 JSON 串。
举例简单的例子,对于一个简单的常量申明,生成的 AST 代码是这样的。
const a = 1
{
"type": "Program",
"start": 0,
"end": 11,
"body": [
{
"type": "VariableDeclaration",
"start": 0,
"end": 11,
"declarations": [
{
"type": "VariableDeclarator",
"start": 6,
"end": 11,
"id": {
"type": "Identifier",
"start": 6,
"end": 7,
"name": "a"
},
"init": {
"type": "Literal",
"start": 10,
"end": 11,
"value": 1,
"raw": "1"
}
}
],
"kind": "const"
}
],
"sourceType": "module"
}
回到 normalizeFile 方法,该方法中调用了 parser 方法。
export default function* normalizeFile(
pluginPasses: PluginPasses,
options: Object,
code: string,
ast: ?(BabelNodeFile | BabelNodeProgram),
): Handler<File> {
...
ast = yield* parser(pluginPasses, options, code);
...
}
parser 会遍历所有的插件,看哪个插件中定义了 parserOverride 方法。为了方便理解,我们先跳过这部分,先看 parse 方法,parse 方法是 @babel/parser 提供的一个方法,用于将 JS 代码装化为 AST。
正常情况下, @babel/parser 中的规则是可以很好的完成 AST 转换的,但如果我们需要自定义语法,或者是修改/扩展这些规则的时候,@babel/parser 就不够用了。babel 想了个方法,就是你可以自己写一个 parser,然后通过插件的方式,指定这个 parser 作为 babel 的编译器。
import { parse } from "@babel/parser";
export default function* parser(
pluginPasses: PluginPasses,
{ parserOpts, highlightCode = true, filename = "unknown" }: Object,
code: string,
): Handler<ParseResult> {
try {
const results = [];
for (const plugins of pluginPasses) {
for (const plugin of plugins) {
const { parserOverride } = plugin;
if (parserOverride) {
const ast = parserOverride(code, parserOpts, parse);
if (ast !== undefined) results.push(ast);
}
}
}
if (results.length === 0) {
return parse(code, parserOpts);
} else if (results.length === 1) {
yield* []; // If we want to allow async parsers
...
return results[0];
}
throw new Error("More than one plugin attempted to override parsing.");
} catch (err) {
...
}
}
现在回过头来看前面的循环就很好理解了,遍历插件,插件中如果定义了 parserOverride 方法,就认为用户指定了自定义的编译器。从代码中得知,插件定义的编译器最多只能是一个,否则 babel 会不知道执行哪个编译器。
如下是一个自定义编译器插件的例子。
const parse = require("custom-fork-of-babel-parser-on-npm-here");
module.exports = {
plugins: [{
parserOverride(code, opts) {
return parse(code, opts);
},
}]
}
JS 转换为 AST 的过程依赖于 @babel/parser,用户已可以通过插件的方式自己写一个 parser 来覆盖默认的。@babel/parser 的过程还是挺复杂的,后续我们单独分析它,这里只要知道它是将 JS 代码转换成 AST 就可以了。
转换(transform)
AST 需要根据插件内容做一些变换,我们先大概的看下一个插件长什么样子。如下所示,Babel 插件返回一个 function ,入参为 babel 对象,返回 Object。其中 pre, post 分别在进入/离开 AST 的时候触发,所以一般分别用来做初始化/删除对象的操作。visitor(访问者)定义了用于在一个树状结构中获取具体节点的方法。
module.exports = (babel) => {
return {
pre(path) {
this.runtimeData = {}
},
visitor: {},
post(path) {
delete this.runtimeData
}
}
}
理解了插件的结构之后,再看 transformFile 方法就比较简单了。首先 babel 为插件集合增加了一个 loadBlockHoistPlugin 的插件,用于排序的,无需深究。然后就是执行插件的 pre 方法,等待所有插件的 pre 方法都执行完毕后,执行 visitor 中的方法(并不是简单的执行方法,而是根据访问者模式在遇到相应的节点或属性的时候执行,具体规则见Babel 插件手册),为了优化,babel 将多个 visitor 合并成一个,使用 traverse 遍历 AST 节点,在遍历过程中执行插件。最后执行插件的 post 方法。
import traverse from "@babel/traverse";
function* transformFile(file: File, pluginPasses: PluginPasses): Handler<void> {
for (const pluginPairs of pluginPasses) {
const passPairs = [];
const passes = [];
const visitors = [];
for (const plugin of pluginPairs.concat([loadBlockHoistPlugin()])) {
const pass = new PluginPass(file, plugin.key, plugin.options);
passPairs.push([plugin, pass]);
passes.push(pass);
visitors.push(plugin.visitor);
}
for (const [plugin, pass] of passPairs) {
const fn = plugin.pre;
if (fn) {
const result = fn.call(pass, file);
yield* [];
...
}
}
// merge all plugin visitors into a single visitor
const visitor = traverse.visitors.merge(
visitors,
passes,
file.opts.wrapPluginVisitorMethod,
);
traverse(file.ast, visitor, file.scope);
for (const [plugin, pass] of passPairs) {
const fn = plugin.post;
if (fn) {
const result = fn.call(pass, file);
yield* [];
...
}
}
}
}
该阶段的核心是插件,插件使用 visitor 访问者模式定义了遇到特定的节点后如何进行操作。babel 将对AST 树的遍历和对节点的增删改等方法放在了 @babel/traverse 包中。
生成(generate)
AST 转换完毕后,需要将 AST 重新生成 code。
@babel/generator 提供了默认的 generate 方法,如果需要定制的话,可以通过插件的 generatorOverride 方法自定义一个。这个方法和第一个阶段的 parserOverride 是相对应的。生成目标代码后,还会同时生成 sourceMap 相关的代码。
import generate from "@babel/generator";
export default function generateCode(
pluginPasses: PluginPasses,
file: File,
): {
outputCode: string,
outputMap: SourceMap | null,
} {
const { opts, ast, code, inputMap } = file;
const results = [];
for (const plugins of pluginPasses) {
for (const plugin of plugins) {
const { generatorOverride } = plugin;
if (generatorOverride) {
const result = generatorOverride(
ast,
opts.generatorOpts,
code,
generate,
);
if (result !== undefined) results.push(result);
}
}
}
let result;
if (results.length === 0) {
result = generate(ast, opts.generatorOpts, code);
} else if (results.length === 1) {
result = results[0];
...
} else {
throw new Error("More than one plugin attempted to override codegen.");
}
let { code: outputCode, map: outputMap } = result;
if (outputMap && inputMap) {
outputMap = mergeSourceMap(inputMap.toObject(), outputMap);
}
if (opts.sourceMaps === "inline" || opts.sourceMaps === "both") {
outputCode += "\n" + convertSourceMap.fromObject(outputMap).toComment();
}
if (opts.sourceMaps === "inline") {
outputMap = null;
}
return { outputCode, outputMap };
}
总结
Babel 最核心的包我们基本都认识了,@babel/cli 负责解析 babel 的命令,根据命令行中的参数做一些非核心的工作。,@babel/core 负责串起整个编译流程,包括生成配置,读取文件,解析为 AST,AST 转换,AST 生成代码。其中,@babel/parser 提供默认的 parse 方法用于解析,@babel/traverse 封装了对 AST 树的遍历和节点的增删改查操作。@babel/generator 提供给默认的 generate 方法用于代码生成。
总的来说,编译的流程本身是比较清晰的,Babel 只负责串起整个流程,具体的编译工作由 Babel 插件完成,甚至核心的编译和生成流程也能通过插件的方式实现自定义,这样做的好处也是显而易见的,Babel 能非常快速的响应语言的变化。
后续会继续分析 AST 的生成过程,遍历方式,插件的设计模式,作用机制等等。
如果您觉得有所收获,就请点个赞吧!