本文首发于政采云前端团队博客:前端工程师需要了解的Babel 知识 https://www.zoo.team/article/babel
在前端圈子里,对于 Babel,大家肯定都比较熟悉了。如果哪天少了它,对于前端工程师来说肯定是个噩梦。Babel 的工作原理是怎样的可能了解的人就不太多了。
本文将主要介绍 Babel 的工作原理以及怎么写一个 Babel 插件。
Babel
是一个 JavaScript
编译器。
注意很重要的一点就是,Babel
只是转译新标准引入的语法,比如:
哪些在 Babel 范围外?对于新标准引入的全局变量、部分原生对象新增的原型链上的方法,Babel 表示超纲了。
对于上面的这些 API,Babel
是不会转译的,需要引入 polyfill
来解决。
Babel 的编译过程和大多数其他语言的编译器相似,可以分为三个阶段:
为了理解 Babel
,我们从最简单一句 console
命令下手
Babel
拿到源代码会把代码抽象出来,变成 AST
(抽象语法树),学过编译原理的同学应该都听过这个词,全称是 Abstract Syntax Tree。
抽象语法树是源代码的抽象语法结构的树状表示,树上的每个节点都表示源代码中的一种结构,只所以说是抽象的,是因为抽象语法树并不会表示出真实语法出现的每一个细节,比如说,嵌套括号被隐含在树的结构中,并没有以节点的形式呈现,它们主要用于源代码的简单转换。
console.log('zcy');
的 AST 长这样:
{
"type": "Program",
"body": [
{
"type": "ExpressionStatement",
"expression": {
"type": "CallExpression",
"callee": {
"type": "MemberExpression",
"computed": false,
"object": {
"type": "Identifier",
"name": "console"
},
"property": {
"type": "Identifier",
"name": "log"
}
},
"arguments": [
{
"type": "Literal",
"value": "zcy",
"raw": "'zcy'"
}
]
}
}
],
"sourceType": "script"
}
上面的 AST
描述了源代码的每个部分以及它们之间的关系。
整个解析过程分为两个步骤:
分词
语法单元通俗点说就是代码中的最小单元,不能再被分割,就像原子是化学变化中的最小粒子一样。
Javascript
代码中的语法单元主要包括以下这么几种:
const
、 let
、 var
等其实分词说白了就是简单粗暴地对字符串一个个遍历。为了模拟分词的过程,写了一个简单的 Demo,仅仅适用于和上面一样的简单代码。Babel 的实现比这要复杂得多,但是思路大体上是相同的。对于一些好奇心比较强的同学,可以看下具体是怎么实现的,链接在文章底部。
function tokenizer(input) {
const tokens = [];
const punctuators = [',', '.', '(', ')', '=', ';'];
let current = 0;
while (current < input.length) {
let char = input[current];
if (punctuators.indexOf(char) !== -1) {
tokens.push({
type: 'Punctuator',
value: char,
});
current++;
continue;
}
// 检查空格,连续的空格放到一起
let WHITESPACE = /\s/;
if (WHITESPACE.test(char)) {
current++;
continue;
}
// 标识符是字母、$、_开始的
if (/[a-zA-Z\$\_]/.test(char)) {
let value = '';
while(/[a-zA-Z0-9\$\_]/.test(char)) {
value += char;
char = input[++current];
}
tokens.push({ type: 'Identifier', value });
continue;
}
// 数字从0-9开始,不止一位
const NUMBERS = /[0-9]/;
if (NUMBERS.test(char)) {
let value = '';
while (NUMBERS.test(char)) {
value += char;
char = input[++current];
}
tokens.push({ type: 'Numeric', value });
continue;
}
// 处理字符串
if (char === '"') {
let value = '';
char = input[++current];
while (char !== '"') {
value += char;
char = input[++current];
}
char = input[++current];
tokens.push({ type: 'String', value });
continue;
}
// 最后遇到不认识到字符就抛个异常出来
throw new TypeError('Unexpected charactor: ' + char);
}
return tokens;
}
const input = `console.log("zcy");`
console.log(tokenizer(input));
结果如下:
[
{
"type" : "Identifier" ,
"value" : "console"
},
{
"type" : "Punctuator" ,
"value" : "."
},
{
"type" : "Identifier" ,
"value" : "log"
},
{
"type" : "Punctuator" ,
"value" : "("
},
{
"type" : "String" ,
"value" : "'zcy'"
},
{
"type" : "Punctuator" ,
"value" : ")"
},
{
"type" : "Punctuator" ,
"value" : ";"
}
]
语法分析
语义分析则是将得到的词汇进行一个立体的组合,确定词语之间的关系。考虑到编程语言的各种从属关系的复杂性,语义分析的过程又是在遍历得到的语法单元组,相对而言就会变得更复杂。
简单来说语法分析是对语句和表达式识别,这是个递归过程,在解析中,Babel
会在解析每个语句和表达式的过程中设置一个暂存器,用来暂存当前读取到的语法单元,如果解析失败,就会返回之前的暂存点,再按照另一种方式进行解析,如果解析成功,则将暂存点销毁,不断重复以上操作,直到最后生成对应的语法树。
插件应用于 babel
的转译过程,尤其是第二个阶段 Transformation
,如果这个阶段不使用任何插件,那么 babel
会原样输出代码。
Babel
官方帮我们做了一些预设的插件集,称之为 Preset
,这样我们只需要使用对应的 Preset 就可以了。每年每个 Preset
只编译当年批准的内容。而 babel-preset-env
相当于 ES2015 ,ES2016 ,ES2017 及最新版本。
如果 Plugin 是通过 npm 安装,可以传入 Plugin 名字给 Babel,Babel 将检查它是否安装在 node_modules
中。
"plugins": ["babel-plugin-myPlugin"]
也可以指定你的 Plugin/Preset 的相对或绝对路径。
"plugins": ["./node_modules/asdf/plugin"]
如果两次转译都访问相同的节点,则转译将按照 Plugin 或 Preset 的规则进行排序然后执行。
例如:
{
"plugins": [
"transform-decorators-legacy",
"transform-class-properties"
]
}
将先执行 transform-decorators-legacy
再执行 transform-class-properties
但 preset 是反向的
{
"presets": [
"es2015",
"react",
"stage-2"
]
}
会按以下顺序运行: stage-2
, react
, 最后 es2015
。
那么问题来了,如果 presets
和 plugins
同时存在,那执行顺序又是怎样的呢?答案是先执行 plugins
的配置,再执行 presets
的配置。
所以以下代码的执行顺序为
// .babelrc 文件
{
"presets": [
[
"@babel/preset-env"
]
],
"plugins": [
["@babel/plugin-proposal-decorators", { "legacy": true }],
["@babel/plugin-proposal-class-properties", { "loose": true }],
"@babel/plugin-transform-runtime",
]
}
用 babel-generator
通过 AST 树生成 ES5 代码。
基础的东西讲了些,下面说下具体如何写插件,只做简单的介绍,感兴趣的同学可以看 Babel
官方的介绍。
先从一个接收了当前 Babel
对象作为参数的 Function
开始。
export default function(babel) {
// plugin contents
}
我们经常会这样写
export default function({ types: t }) {
//
}
接着返回一个对象,其 visitor
属性是这个插件的主要访问者。
export default function({ types: t }) {
return {
visitor: {
// visitor contents
}
};
};
visitor
中的每个函数接收 2 个参数:path
和 state
export default function({ types: t }) {
return {
visitor: {
CallExpression(path, state) {}
}
};
};
我们先写一个简单的插件,把所有定义变量名为 a
的换成 b
,先看下 var a = 1
的 AST
{
"type": "Program",
"start": 0,
"end": 10,
"body": [
{
"type": "VariableDeclaration",
"start": 0,
"end": 9,
"declarations": [
{
"type": "VariableDeclarator",
"start": 4,
"end": 9,
"id": {
"type": "Identifier",
"start": 4,
"end": 5,
"name": "a"
},
"init": {
"type": "Literal",
"start": 8,
"end": 9,
"value": 1,
"raw": "1"
}
}
],
"kind": "var"
}
],
"sourceType": "module"
}
从这里看,要找的节点类型就是 VariableDeclarator
,下面开始撸代码
export default function({ types: t }) {
return {
visitor: {
VariableDeclarator(path, state) {
if (path.node.id.name == 'a') {
path.node.id = t.identifier('b')
}
}
}
}
}
我们要把 id
属性是 a 的替换成 b 就好了。但是这里不能直接 path.node.id.name = 'b'
。如果操作的是Object,就没问题,但是这里是 AST 语法树,所以想改变某个值,就是用对应的 AST 来替换,现在我们用新的标识符来替换这个属性。
最后测试一下
import * as babel from '@babel/core';
const c = `var a = 1`;
const { code } = babel.transform(c, {
plugins: [
function({ types: t }) {
return {
visitor: {
VariableDeclarator(path, state) {
if (path.node.id.name == 'a') {
path.node.id = t.identifier('b')
}
}
}
}
}
]
})
console.log(code); // var b = 1
例如我们要实现把 import { Button } from 'antd'
转成 import Button from 'antd/lib/button'
通过对比 AST 发现,specifiers
里的 type
和 source
不同。
// import { Button } from 'antd'
"specifiers": [
{
"type": "ImportSpecifier",
...
}
]
// import Button from 'antd/lib/button'
"specifiers": [
{
"type": "ImportDefaultSpecifier",
...
}
]
import * as babel from '@babel/core';
const c = `import { Button } from 'antd'`;
const { code } = babel.transform(c, {
plugins: [
function({ types: t }) {
return {
visitor: {
ImportDeclaration(path) {
const { node: { specifiers, source } } = path;
if (!t.isImportDefaultSpecifier(specifiers[0])) { // 对 specifiers 进行判断,是否默认倒入
const newImport = specifiers.map(specifier => (
t.importDeclaration(
[t.ImportDefaultSpecifier(specifier.local)],
t.stringLiteral(`${source.value}/lib/${specifier.local.name}`)
)
))
path.replaceWithMultiple(newImport)
}
}
}
}
}
]
})
console.log(code); // import Button from "antd/lib/Button";
当然 babel-plugin-import
这个插件是有配置项的,我们可以对代码做以下更改。
export default function({ types: t }) {
return {
visitor: {
ImportDeclaration(path, { opts }) {
const { node: { specifiers, source } } = path;
if (source.value === opts.libraryName) {
// ...
}
}
}
}
}
至此,这个插件我们就编写完成了。
Babel
的编译器,核心 API 都在这里面,比如常见的 transform
、parse
。
cli
是命令行工具, 安装了 @babel/cli
就能够在命令行中使用 babel
命令来编译文件。当然我们一般不会用到,打包工具已经帮我们做好了。
直接在 node
环境中,运行 ES6 的代码。
Babel
的解析器。
用于对 AST 的遍历,维护了整棵树的状态,并且负责替换、移除和添加节点。
用于 AST 节点的 Lodash 式工具库, 它包含了构造、验证以及变换 AST 节点的方法,对编写处理 AST 逻辑非常有用。
Babel 的代码生成器,它读取 AST 并将其转换为代码和源码映射(sourcemaps)。
文章主要介绍 Babel
编译代码的过程和原理以及简单编写了一个 babel
插件,欢迎大家对内容进行指正和讨论。
招人,前端,隶属政采云前端大团队(ZooTeam),50 余个小伙伴正等你加入一起浪~ 如果你想改变一直被事折腾,希望开始能折腾事;如果你想改变一直被告诫需要多些想法,却无从破局;如果你想改变你有能力去做成那个结果,却不需要你;如果你想改变你想做成的事需要一个团队去支撑,但没你带人的位置;如果你想改变既定的节奏,将会是“5年工作时间3年工作经验”;如果你想改变本来悟性不错,但总是有那一层窗户纸的模糊… 如果你相信相信的力量,相信平凡人能成就非凡事,相信能遇到更好的自己。如果你希望参与到随着业务腾飞的过程,亲手参与一个有着深入的业务理解、完善的技术体系、技术创造价值、影响力外溢的前端团队的成长历程,我觉得我们该聊聊。任何时间,等着你写点什么,发给 ZooTeam@cai-inc.com