babel 背景介绍
babel 是一个前端的代码转换工具,目的是为了让开发者使用ECMA最新的标准甚至一些在stage阶段的提案功能,而不用过多考虑运行环境的兼容性。
近些年得益于js社区的活跃,ES版本从15年开始每年都会发布一个新的版本,截止目前最新的版本是ECMAScript 2019,已经是第10个版本了,但是目前最新的chrome尚未完全支持ES6的所有功能,比如模块方面的功能,而babel的出现可以让前端开发工程师们用最新的语法去coding,由它来保证在浏览器中代码的转换,换句话说它可以让我们用最新的标准写代码,而不用考虑浏览器的兼容~
如果想了解更多关于babel的内容,请移步这里,还有中文版文档
上回分析的基础配置篇在这里
babel 原理
babel 可以转化ES6的语法到ES5,如下
// Babel 输入: ES2015 箭头函数
[1, 2, 3].map((n) => n + 1);
// Babel 输出: ES5 语法实现的同等功能
[1, 2, 3].map(function(n) {
return n + 1;
});
所有被babel处理的js内容都会经历3个大的流程:
- 词法分析,解析为tokens数组
- 语法分析
- 转化tokens到对象的带有语法信息的ast[抽象语法树]
- 根据ast转化为对应的需要的新的ast
- 生成,将新的ast生成为可执行的代码,并输出
实现一个自己的的babel
本文实现的babel fork 自 the-super-tiny-compiler,内容基本一样,重在希望可以对没有时间阅读代码的同学起个快速理解的作用,后续如果有时间,会写一个和js相关的转换代码
步骤
- input -> tokens [tokenizer]
- tokens -> ast [parser]
- ast -> newAst [transformer]
- newAst -> output [codeGenerator]
例子
const code = '(add 2 (add 4 2))'; // 初始的code
const expectCode = 'add(2, add(4,2))'; // 期望转换结果
预期过程
- tokenizer result [词法分析结果]
const tokens = [
{ type: 'paren', value: '(' },
{ type: 'name', value: 'add' },
{ type: 'number', value: '2' },
{ type: 'paren', value: '(' },
{ type: 'name', value: 'add' },
{ type: 'number', value: '4' },
{ type: 'number', value: '2' },
{ type: 'paren', value: ')' },
{ type: 'paren', value: ')' }
]
- parser result [语法分析结果,构建ast]
const ast = {
"type": "Program",
"body": [
{
"type": "CallExpression",
"name": "add",
"params": [
{ "type": "NumericLiteral", "value": "2" },
{
"type": "CallExpression",
"name": "add",
"params": [{ "type": "NumericLiteral", "value": "4" }, { "type": "NumericLiteral", "value": "2" }]
}
]
}
]
}
- transformer result [转换原始ast到新的目标ast格式,语法转化结果]
const newAst = {
"type": "Program",
"body": [
{
"type": "ExpressionStatement",
"expression": {
"type": "CallExpression",
"callee": { "type": "Identifier", "name": "add" },
"arguments": [
{ "type": "NumberLiteral", "value": "2" },
{
"type": "CallExpression",
"callee": { "type": "Identifier", "name": "add" },
"arguments": [
{ "type": "NumberLiteral", "value": "4" },
{ "type": "NumberLiteral", "value": "2" }
]
}
]
}
}
]
}
- output result [生成预期code]
const output = 'add(2, add(4, 2));'
实现
词法分析阶段[tokenizer]
解析源代码,生成tokens
const tokenizer = input => {
let current = 0; // 记录起始位置
const tokens = []; // 存放tokens
while (current < input.length) {
let char = input[current];
if (char === '(' || char === ')') {
// 遇到括号,转为type是paren的token
tokens.push({
type: 'paren',
value: char
});
current++; // 递增一位
continue;
}
if (/\s/.test(char)) {
// 空格不管,直接跳过 【空格在这里对我们没有实际的意义,但是需要current下标跳过】
current++;
continue;
}
if (/[0-9]/.test(char)) {
// 如果是数字,用while来遍历到下一位不是数字的位置
let value = ''; // 用来放这个数字
while (/[0-9]/.test(char)) {
value += char;
char = input[++current];
}
tokens.push({
type: 'number',
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;
}
const LETTERS = /[a-z]/i; // 处理name
if (LETTERS.test(char)) {
// 处理连续的字母,变量的名称,也因此把type定义为name
let value = '';
while (LETTERS.test(char)) {
value += char;
char = input[++current];
}
tokens.push({
type: 'name',
value
});
continue;
}
// 如果都匹配不到,我们就认为无法解析~
throw new TypeError('I dont know what this character is: ' + char);
}
// 返回处理好的tokens
return tokens;
};
语法分析阶段[parser]
构建原始ast
解析第一步生成的tokens为对应的ast抽象语法树
const parser = tokens => {
let current = 0;
// walk 函数 用来 parse token到对应的 【语法】 类型
function walk() {
let token = tokens[current];
if (token.type === 'number') {
current++;
return {
type: 'NumericLiteral',
value: token.value
};
}
if (token.type === 'string') {
current++;
return {
type: 'StringLiteral',
value: token.value
};
}
// 遇到括号的时候,我们认为开始进行方法调用的转换了
if (token.type === 'paren' && token.value === '(') {
token = tokens[++current]; // 跳过 '(' 本身这个token,它只是个标记,在这里没有实际作用
let node = {
// 定义一个node节点,类型就是 CallExpression
type: 'CallExpression',
name: token.value, // 我们认为括号接下来的token 放的就是这个方法的 name
params: [] // 默认方法的参数是一个空数组
};
token = tokens[++current]; // 跳过name这个token
// 遍历name之后的token,一定要遇到方法调用的结束符号 '(' 为止
while (token.type !== 'paren' || (token.type === 'paren' && token.value !== ')')) {
node.params.push(walk()); // 在'('和')' 中间所有的token,我们都认为是当前方法调用时候的参数,因此递归解析就可以了~
token = tokens[current]; // 这里每次重新获取一下当前的token,为了抛出错误的时候用到
}
current++; // 和前面一样,处理完当前current 加一位到下一个
return node; // 返回当前构建好的node
}
throw new TypeError(token.type);
}
// 定义原始的ast结构
const ast = {
type: 'Program', // 入口开始我们称为 Program
body: []
};
// 遍历tokens
while (current < tokens.length) {
ast.body.push(walk()); // 递归tokens,填充ast
}
return ast; // 返回填充结束的ast~
};
生成新的ast
根据原始ast转化为新的ast
// 拿到ast后,就可以进行下一步的“转化”流程了,但是在这之前我们需要先设计一个traverser方法,这个方法会对生成好的ast进行深度优先的递归遍历,同时还可以提供一个visitor参数用来“访问”每一个语法类型的遍历过程,有了visitor就可以在“访问”到当前type类型的node的时候,对当前的node进行一些额外的处理了,这也是babel插件的工作原理,其实还是在对ast的转化过程中进行的处理~
const traverser = (ast, visitor) => {
// traverseArray 会遍历array,对每一个item进行traverseNode转化
function traverseArray(array, parent) {
array.forEach(el => {
traverseNode(el, parent);
});
}
// 转化过程,接受2个参数 当前转化的node 和 父节点parent
function traverseNode(node, parent) {
// 先看下提供的visitor中有没有当前node.type类型的method
// 从babel的官网中可以看到method可以直接通过 [type](){}这种function的类型进行定义,也可以通过一个config,包含enter 和 exit的形式来定义~
// 树的遍历过程中,我们对每一个节点会有2次访问的机会,第一次是进入,第二次是退出,对应这里的 enter 和 exit
const method = visitor[node.type];
if (typeof method === 'function') {
// 如果method是方法,直接调用,并送入node和parent
method(node, parent);
} else if (method && method.enter) {
// 如果是enter,则调用enter~
method.enter(node, parent);
}
switch (
node.type // 对每个type进行转化
) {
case 'Program':
traverseArray(node.body, node); // 如果是Program,我们知道他下面的子节点都在body中,因此我们通过traverseArray把子节点的数组和当前节点送进去
break;
case 'CallExpression':
traverseArray(node.params, node); // 如果是CallExpression,我们知道CallExpression的子节点声名在params这个数组中,同样的对子节点进行递归转化
break;
case 'NumericLiteral': // 当遇到 NumericLiteral 和 StringLiteral 的时候,它们都没有子节点了,因此直接break就可以了
case 'StringLiteral':
break;
default:
throw new TypeError(node.type); // 同样,找不到就丢出去一个错误
}
if (typeof method === 'function') {
// 每个节点在遍历结束后,我们需要调用visitor的exit方法,类似于enter的调用
method(node, parent);
} else if (method && method.exit) {
method.exit(node, parent);
}
}
traverseNode(ast, null); // 递归的启动,我们的第一个节点就是ast,它是没有父节点的,因此第二个参数给null~
};
// 接下来是对ast进行默认的转化的过程,参数就是ast
const transformer = ast => {
const newAst = {
// 转化后的结果会保存到一个新的newAst中去
type: 'Program',
body: []
};
// 这里是一个hack,真正的实现比这个要复杂,我们为了实例简单,通过_context来传递新老ast之间的上下文的对应关系
// 因为虽然我们遍历的是旧的ast,但是需要转化的结果保存到新的ast中,因此存在一个节点之间的对应关系
// 我们给旧的ast加一个_context属性,并且让它指向实际的新的ast的body
// 这里指向body是因为最外层的type类型是Program 并且他们子节点都是body,因此我只要后面修改parent的_context,就会更新到新的ast的body中
ast._context = newAst.body;
traverser(ast, {
// 利用 traverser 开始遍历
NumericLiteral: {
enter(node, parent) {
// 遇到 NumericLiteral 类型,转化为新的带有语法意义的node,并通过 parent._context push 到新的ast对应的节点中
// 请注意这里的用词 parent._context 是ast对应的新节点,不会一直是第一层的body节点
// ----- 分割线 ----- 第一次看到这里请继续往下看代码,忽略掉下面紧挨着的【第一次先忽略】的注释
// [第一次先忽略]
// ok,这里我们继续遍历到 NumericLiteral 的时候,可能已经遍历到了 CallExpression 下的子节点 params 中,但是这个时候,请注意:
// parent._context 指向的是 expression.arguments 这个节点哦,这就解释了这里是如何通过 parent._context hack了新老ast的对应关系的指向
parent._context.push({
type: 'NumberLiteral',
value: node.value
});
}
},
StringLiteral: {
// StringLiteral 同上
enter(node, parent) {
parent._context.push({
type: 'StringLiteral',
value: node.value
});
}
},
CallExpression: {
// 这里要注意,遇到方法调用表达式的时候,我们要转为新的表达式类型
enter(node, parent) {
let expression = {
// 定义新的调用方法的语法node格式
type: 'CallExpression',
callee: {
// 存放方法名称
type: 'Identifier',
name: node.name
},
arguments: [] // 存放调用参数
};
// 因为 Program 类型下的子节点都在body中,我们用 _context 来指向新的body,这里的原理是一样的
// 类型 CallExpression 的所有子节点在 expression.arguments 因此我们把当前node的 _context 指向 expression.arguments
// 请回到上面的 【第一次先忽略】 节点所在的注释部分
node._context = expression.arguments;
if (parent.type !== 'CallExpression') {
// 当父节点的类型不在是 CallExpression 的时候,我们知道这个表达式部分已经结束了
expression = {
type: 'ExpressionStatement',
expression: expression
};
}
parent._context.push(expression); // 把结果通过 parent._context 保存到新ast的对应节点中去
}
}
});
return newAst; // 递归结束后 返回新的ast
};
生成最后的output代码
根据新的ast,生成我们处理过后的代码
const codeGenerator = node => {
// 区分不同的类型,进行不同的 generator
switch (node.type) {
case 'Program':
return node.body.map(codeGenerator).join('\n'); // 对于 Program 只需要加上换行符
case 'ExpressionStatement':
return codeGenerator(node.expression) + ';'; // 对于表达式,递归表达式的expression,然后加上分号 保证代码允许的正确性
case 'CallExpression':
// 对于方法调用要注意下,我们的初始的name token被放到callee属性中,同时 params被放到arguments中,因此需要递归这2个属性
// 同时对于 arguments 要先加括号,再用 , 隔开每一项 以便于生成(x,y,z)这样的调用格式
return codeGenerator(node.callee) + '(' + node.arguments.map(codeGenerator).join(', ') + ')';
case 'Identifier':
return node.name; // 对于变量直接返回name就可以
case 'NumberLiteral':
return node.value; // 对于数字返回value
case 'StringLiteral':
return '"' + node.value + '"'; // 对于字符串需要加上双引号,切记我们在转码,不是在运行~
default:
throw new Error(node.type); // 无法解析的 抛出错误
}
};
到此,我们的迷你版babel就实现好了,来测试一下:
const code = '(add 2 (add 4 2))'; // 初始的code
const ast = parser(tokenizer(code));
const newAst = transformer(ast);
const output = codeGenerator(newAst);
console.log(output); // 'add(2, add(4,2))'
完美,真正的babel源码中会处理很多细节的问题,但是从原理来讲,大家都是相通的~,有兴趣的话可以去翻翻babel的源码~
总结
- babel的思想还是要掌握,比如最近很火的各种小程序框架,大家都会基于微信的代码去做转换,如果你不理解babel,可能对转换的过程就不是很懂~
- 对js来说万物皆对象,但是,对babel来说,万物都是字符,我们的转化过程中,input 和 output 都是字符,转换只是将input串修改成期望的output串~
如果对你有点帮助,希望顺手给个赞,您的赞是我坚持输出的动力,谢谢~