首页 > temp > JavaScript教程 >
-
面试官: 聊一聊Babel
前言
Babel 是现代 JavaScript 语法转换器,几乎在任何现代前端项目中都能看到他的身影,其背后的原理对于大部分开发者还属于黑盒,不过 Babel 作为一个工具真的有了解背后原理的必要吗?
如果只是 Babel 可能真没有必要,问题是其背后的原理在我们开发中应用过于广泛了,包括不限于: eslint jshint stylelint css-in-js prettier jsx vue-template uglify-js postcss less 等等等等,从模板到代码检测,从混淆压缩到代码转换,甚至编辑器的代码高亮都与之息息相关.
如果有兴趣就可以搞一些黑魔法: 前端工程师可以用编译原理做什么?
前置
Babel 大概分为三大部分:
- 解析: 将代码(其实就是字符串)转换成 AST( 抽象语法树)
- 转换: 访问 AST 的节点进行变换操作生成新的 AST
- 生成: 以新的 AST 为基础生成代码
我们主要通过打造一个微型 babel 来了解 babel 的基本原理,这个微型 babel 的功能很单一也很鸡肋,但是依然有400行代码,其实现细节与 babel 并不相同,因为我们省去了很多额外的验证和信息解析,因为单单一个兼容现代 JavaScript 语法的 parser 就需要5000行代码,并不利于我们快速了解 babel 的基本实现,所以这个微型 babel可以说比较鸡肋(因为除了展示之外没啥用处),但是比较完整展示了 babel 的基本原理,你可以以此作为入门,在入门之后如果仍有兴趣,可以阅读:
- estree规范
- acorn: 轻量级现代 JavaScript 解析器, babel 最初就是基于此项目
代码解析
parser 概念
代码解析,也就是我们常说的 Parser, 用于将一段代码(文本)解析成一个数据结构.
例如这段 es6的代码
const add = (a, b) => a + b
我们用 babel 解析后便是这种形式:
{
"type": "File",
"start": 0,
"end": 27,
"loc": {
"start": {
"line": 1,
"column": 0
},
"end": {
"line": 1,
"column": 27
}
},
"program": {
"type": "Program",
"start": 0,
"end": 27,
"loc": {
"start": {
"line": 1,
"column": 0
},
"end": {
"line": 1,
"column": 27
}
},
"sourceType": "module",
"body": [
{
"type": "VariableDeclaration",
"start": 0,
"end": 27,
"loc": {
"start": {
"line": 1,
"column": 0
},
"end": {
"line": 1,
"column": 27
}
},
"declarations": [
{
"type": "VariableDeclarator",
"start": 6,
"end": 27,
"loc": {
"start": {
"line": 1,
"column": 6
},
"end": {
"line": 1,
"column": 27
}
},
"id": {
"type": "Identifier",
"start": 6,
"end": 9,
"loc": {
"start": {
"line": 1,
"column": 6
},
"end": {
"line": 1,
"column": 9
},
"identifierName": "add"
},
"name": "add"
},
"init": {
"type": "ArrowFunctionExpression",
"start": 12,
"end": 27,
"loc": {
"start": {
"line": 1,
"column": 12
},
"end": {
"line": 1,
"column": 27
}
},
"id": null,
"generator": false,
"expression": true,
"async": false,
"params": [
{
"type": "Identifier",
"start": 13,
"end": 14,
"loc": {
"start": {
"line": 1,
"column": 13
},
"end": {
"line": 1,
"column": 14
},
"identifierName": "a"
},
"name": "a"
},
{
"type": "Identifier",
"start": 16,
"end": 17,
"loc": {
"start": {
"line": 1,
"column": 16
},
"end": {
"line": 1,
"column": 17
},
"identifierName": "b"
},
"name": "b"
}
],
"body": {
"type": "BinaryExpression",
"start": 22,
"end": 27,
"loc": {
"start": {
"line": 1,
"column": 22
},
"end": {
"line": 1,
"column": 27
}
},
"left": {
"type": "Identifier",
"start": 22,
"end": 23,
"loc": {
"start": {
"line": 1,
"column": 22
},
"end": {
"line": 1,
"column": 23
},
"identifierName": "a"
},
"name": "a"
},
"operator": "+",
"right": {
"type": "Identifier",
"start": 26,
"end": 27,
"loc": {
"start": {
"line": 1,
"column": 26
},
"end": {
"line": 1,
"column": 27
},
"identifierName": "b"
},
"name": "b"
}
}
}
}
],
"kind": "const"
}
],
"directives": []
}
}
我们以解析上面的 es6箭头函数为目标,来写一个简单的 parser.
文本 ---> AST 的过程中有两个关键步骤:
- 词法分析: 将代码(字符串)分割为token流,即语法单元成的数组
- 语法分析: 分析token流(上面生成的数组)并生成 AST
词法分析(Tokenizer -- 词法分析器)
要做词法分析,首先我们需要明白在 JavaScript 中哪些属于语法单元
- 数字:JavaScript 中的科学记数法以及普通数组都属于语法单元.
- 括号:『(』『)』只要出现,不管任何意义都算是语法单元
- 标识符:连续字符,常见的有变量,常量(例如: null true),关键字(if break)等等
- 运算符:+、-、*、/等等
- 当然还有注释,中括号等
在我们 parser 的过程中,应该换一个角度看待代码,我们平时工作用的代码.本质是就是字符串或者一段文本,它没有任何意义,是 JavaScript 引擎赋予了它意义,所以我们在解析过程中代码只是一段字符串.
仍然以下面代码为例
const add = (a, b) => a + b
我们期望的结果是类似这样的
[
{ type: "identifier", value: "const" },
{ type: "whitespace", value: " " },
...
]
那么我们现在开始打造一个Tokenizer(词法分析器)
// 词法分析器,接收字符串返回token数组
export const tokenizer = (code) => {
// 储存 token 的数组
const tokens = [];
// 指针
let current = 0;
while (current < code.length) {
// 获取指针指向的字符
const char = code[current];
// 我们先处理单字符的语法单元 类似于`;` `(` `)`等等这种
if (char === '(' || char === ')') {
tokens.push({
type: 'parens',
value: char,
});
current ++;
continue;
}
// 我们接着处理标识符,标识符一般为以字母、_、$开头的连续字符
if (/[a-zA-Z\$\_]/.test(char)) {
let value = '';
value += char;
current ++;
// 如果是连续字那么将其拼接在一起,随后指针后移
while (/[a-zA-Z0-9\$\_]/.test(code[current]) && current < code.length) {
value += code[current];
current ++;
}
tokens.push({
type: 'identifier',
value,
});
continue;
}
// 处理空白字符
if (/\s/.test(char)) {
let value = '';
value += char;
current ++;
//道理同上
while (/\s]/.test(code[current]) && current < code.length) {
value += code[current];
current ++;
}
tokens.push({
type: 'whitespace',
value,
});
continue;
}
// 处理逗号分隔符
if (/,/.test(char)) {
tokens.push({
type: ',',
value: ',',
});
current ++;
continue;
}
// 处理运算符
if (/=|\+|>/.test(char)) {
let value = '';
value += char;
current ++;
while (/=|\+|>/.test(code[current])) {
value += code[current];
current ++;
}
// 当 = 后面有 > 时为箭头函数而非运算符
if (value === '=>') {
tokens.push({
type: 'ArrowFunctionExpression',
value,
});
continue;
}
tokens.push({
type: 'operator',
value,
});
continue;
}
// 如果碰到我们词法分析器以外的字符,则报错
throw new TypeError('I dont know what this character is: ' + char);
}
return tokens;
};
那么我们基本的词法分析器就打造完成,因为只针对这一个es6函数,所以没有做额外的工作(额外的工作量会非常庞大).
const result = tokenizer('const add = (a, b) => a + b')
console.log(result);
/**
[ { type: 'identifier', value: 'const' },
{ type: 'whitespace', value: ' ' },
{ type: 'identifier', value: 'add' },
{ type: 'whitespace', value: ' ' },
{ type: 'operator', value: '=' },
{ type: 'whitespace', value: ' ' },
{ type: 'parens', value: '(' },
{ type: 'identifier', value: 'a' },
{ type: ',', value: ',' },
{ type: 'whitespace', value: ' ' },
{ type: 'identifier', value: 'b' },
{ type: 'parens', value: ')' },
{ type: 'whitespace', value: ' ' },
{ type: 'ArrowFunctionExpression', value: '=>' },
{ type: 'whitespace', value: ' ' },
{ type: 'identifier', value: 'a' },
{ type: 'whitespace', value: ' ' },
{ type: 'operator', value: '+' },
{ type: 'whitespace', value: ' ' },
{ type: 'identifier', value: 'b' } ]
**/
1.3 语法分析
语法分析要比词法分析复杂得多,因为我们接下来的是示意代码,所以做了很多“武断”的判断来省略代码,即使这样也是整个微型 babel 中代码量最多的.
语法分析之所以复杂,是因为要分析各种语法的可能性,需要开发者根据token流(上一节我们生成的 token 数组)提供的信息来分析出代码之间的逻辑关系,只有经过词法分析 token 流才能成为有结构的抽象语法树.
做语法分析最好依照标准,大多数 JavaScript Parser 都遵循estree规范
由于标准内容很多,感兴趣的可以去阅读,我们目前只介绍几个比较重要的标准:
语句(Statements): 语句是 JavaScript 中非常常见的语法,我们常见的循环、if 判断、异常处理语句、with 语句等等都属于语句
// 典型的for 循环语句
for (var i = 0; i < 7; i++) {
console.log(i);
}
表达式(Expressions): 表达式是一组代码的集合,它返回一个值,表达式是另一个十分常见的语法,函数表达式就是一种典型的表达式,如果你不理解什么是表达式, MDN上有很详细的解释.
// 函数表达式
var add = function(a, b) {
return a + b
}
声明(Declarations): 声明分为变量声明和函数声明,表达式(Expressions)中的函数表达式的例子用声明的写法就是下面这样.
// 函数声明
function add(a, b) {
return a + b
}
你可能有点糊涂,为了理清其中的关系,我们就下面的代码为例来解读
// 函数表达式
var add = function(a, b) {
return a + b
}
首先这段代码的整体本质是是一个变量声明(VariableDeclarator):
而变量被声明为一个函数表达式(FunctionExpression):
函数表达式中的大括号在内的为块状语句(BlockStatement):
块状语句内 return 的部分是返回语句(ReturnStatement):
而 return 的其实是一个二元运算符或者叫二元表达式(BinaryExpression):
上面提到的这些有些属于表达式,有些属于声明也有些属于语句,当然还有更多我们没提到的,它们被语法分析之后被叫做AST(抽象语法树).
我们做语法分析的时候思路也是类似的,要分析哪一层的 token 到底属于表达式或者说语句,如果是语句那么是块状语句(BlockStatement)还是Loops,如果是 Loops 那么属于while 循环(WhileStatement)还是for 循环(ForStatement)等等,其中甚至难免要考虑作用域的问题,因此语法分析的复杂也体现在此.
const parser = tokens => {
// 声明一个全时指针,它会一直存在
let current = -1;
// 声明一个暂存栈,用于存放临时指针
const tem = [];
// 指针指向的当前token
let token = tokens[current];
const parseDeclarations = () => {
// 暂存当前指针
setTem();
// 指针后移
next();
// 如果字符为'const'可见是一个声明
if (token.type === 'identifier' && token.value === 'const') {
const declarations = {
type: 'VariableDeclaration',
kind: token.value
};
next();
// const 后面要跟变量的,如果不是则报错
if (token.type !== 'identifier') {
throw new Error('Expected Variable after const');
}
// 我们获取到了变量名称
declarations.identifierName = token.value;
next();
// 如果跟着 '=' 那么后面应该是个表达式或者常量之类的,额外判断的代码就忽略了,直接解析函数表达式
if (token.type === 'operator' && token.value === '=') {
declarations.init = parseFunctionExpression();
}
return declarations;
}
};
const parseFunctionExpression = () => {
next();
let init;
// 如果 '=' 后面跟着括号或者字符那基本判断是一个表达式
if (
(token.type === 'parens' && token.value === '(') ||
token.type === 'identifier'
) {
setTem();
next();
while (token.type === 'identifier' || token.type === ',') {
next();
}
// 如果括号后跟着箭头,那么判断是箭头函数表达式
if (token.type === 'parens' && token.value === ')') {
next();
if (token.type === 'ArrowFunctionExpression') {
init = {
type: 'ArrowFunctionExpression',
params: [],
body: {}
};
backTem();
// 解析箭头函数的参数
init.params = parseParams();
// 解析箭头函数的函数主体
init.body = parseExpression();
} else {
backTem();
}
}
}
return init;
};
const parseParams = () => {
const params = [];
if (token.type === 'parens' && token.value === '(') {
next();
while (token.type !== 'parens' && token.value !== ')') {
if (token.type === 'identifier') {
params.push({
type: token.type,
identifierName: token.value
});
}
next();
}
}
return params;
};
const parseExpression = () => {
next();
let body;
while (token.type === 'ArrowFunctionExpression') {
next();
}
// 如果以(开头或者变量开头说明不是 BlockStatement,我们以二元表达式来解析
if (token.type === 'identifier') {
body = {
type: 'BinaryExpression',
left: {
type: 'identifier',
identifierName: token.value
},
operator: '',
right: {
type: '',
identifierName: ''
}
};
next();
if (token.type === 'operator') {
body.operator = token.value;
}
next();
if (token.type === 'identifier') {
body.right = {
type: 'identifier',
identifierName: token.value
};
}
}
return body;
};
// 指针后移的函数
const next = () => {
do {
++current;
token = tokens[current]
? tokens[current]
: { type: 'eof', value: '' };
} while (token.type === 'whitespace');
};
// 指针暂存的函数
const setTem = () => {
tem.push(current);
};
// 指针回退的函数
const backTem = () => {
current = tem.pop();
token = tokens[current];
};
const ast = {
type: 'Program',
body: []
};
while (current < tokens.length) {
const statement = parseDeclarations();
if (!statement) {
break;
}
ast.body.push(statement);
}
return ast;
};
至此我们暴力 parser 了token 流,最终得到了简陋的抽象语法树:
{
"type": "Program",
"body": [
{
"type": "VariableDeclaration",
"identifierName": "add",
"init": {
"type": "ArrowFunctionExpression",
"params": [
{
"type": "identifier",
"identifierName": "a"
},
{
"type": "identifier",
"identifierName": "b"
}
],
"body": {
"type": "BinaryExpression",
"left": {
"type": "identifier",
"identifierName": "a"
},
"operator": "+",
"right": {
"type": "identifier",
"identifierName": "b"
}
}
}
}
]
}
代码转换
如何转换代码?
在 Babel 中我们使用者最常使用的地方就是代码转换,大家常用的 Babel 插件就是定义代码转换规则而生的,而代码解析和生成这一头一尾都主要是 Babel 负责。
比如我们要用 babel 做一个React 转小程序的转换器,babel工作流程的粗略情况是这样的:
- babel 将 React 代码解析为抽象语法树
- 开发者利用 babel 插件定义转换规则,根据原本的抽象语法树生成一个符合小程序规则的新抽象语法树
- babel 则根据新的抽象语法树生成代码,此时的代码就是符合小程序规则的新代码
例如 Taro就是用 babel 完成的小程序语法转换.
到这里大家就明白了,我们转换代码的关键就是根据当前的抽象语法树,以我们定义的规则生成新的抽象语法树,转换的过程就是生成新抽象语法树的过程.
遍历抽象语法树(实现遍历器traverser)
抽象语法树是一个树状数据结构,我们要生成新语法树,那么一定需要访问 AST 上的节点,因此我们需要一个工具来遍历抽象语法树的节点.
const traverser = (ast, visitor) => {
// 如果节点是数组那么遍历数组
const traverseArray = (array, parent) => {
array.forEach((child) => {
traverseNode(child, parent);
});
};
// 遍历 ast 节点
const traverseNode = (node, parent) => {
const method = visitor[node.type];
if (method) {
method(node, parent);
}
switch (node.type) {
case 'Program':
traverseArray(node.body, node);
break;
case 'VariableDeclaration':
traverseArray(node.init.params, node.init);
break;
case 'identifier':
break;
default:
throw new TypeError(node.type);
}
};
traverseNode(ast, null);
};
转换代码(实现转换器transformer)
我们要转换的代码const add = (a, b) => a + b
其实是个变量声明,按理来讲我们要转换为es5的代码也应该是个变量声明,比如这种:
var add = function(a, b) {
return a + b
}
当然也可以不按规则,直接生成一个函数声明,像这样:
function add(a, b) {
return a + b
}
这次我们把代码转换为一个es5的函数声明
我们之前的遍历器traverser