前端中的编译原理:从零打造一个实用的 Babel 插

来源:http://www.chinese-glasses.com 作者:Web前端 人气:54 发布时间:2020-04-15
摘要:领域特定语言 一个Visitor一般来说是这样的: 可以看到想要实现整个功能其实是有以下几个难点的: 如果想要了解更多,可以阅读和尝试: Resolve a chain of sourcemaps back to the original sour

领域特定语言

一个Visitor一般来说是这样的:

可以看到想要实现整个功能其实是有以下几个难点的:

如果想要了解更多,可以阅读和尝试:

Resolve a chain of sourcemaps back to the original source, like magic.

我们可以看到AST中有很多相似的元素,它们都有一个type属性,这样的元素被称作节点。一个节点通常含有若干属性,可以用于描述AST的部分信息。

如何减少 Babel 对 AST 的遍历以及操作次数,从而减少 Babel 插件的运行时间。

一个小例子:

最后让我们来看看在编写 Babel 插件时该如何避免不必要的遍历以及对 AST 的操作,进而减少插件的运行时间。

表示这是一个标识符。

作者:RetroAstro原文地址:-blog

函数体:函数主体是一个BinaryExpression(二项式),一个标准的二项式分为三部分:

使用单例,优化嵌套的访问者对象。

词法分析阶段可以看成是对代码进行“分词”,它接收一段源代码,然后执行一段tokenize函数,把代码分割成被称为Tokens的东西。Tokens是一个数组,由一些代码的碎片组成,比如数字、标点符号、运算符号等等等等,例如这样:

// 正确if (shouldVisit(path, this.path)) { returnStatementTransform(path, this.query)}

// 正确if (isEmptyFunction(path) || isTraversalFunction(path)) { return}

当然,Babel中的Visitor模式远远比这复杂...

将原始代码转换为能够让 Babel 操纵的 AST 。

看上去好像很容易啊,就是把一句完整的代码拆成一个个独立个体就好了。但是,我们得让机器知道怎么拆~

因此,这段代码的大概意思就是每当遇到一个函数,首先判断这个函数是否为空或者是像mapforEachreduce这样的遍历函数,如果满足以上条件就直接跳过,不插入打点代码。然后我们会创建_tid变量(后文会讲到)以及uid作为该函数的唯一标识符。之后我们会判断该函数是同步函数还是异步函数,进而执行不同的转换 ( transform ) 操作。最后就是处理函数中特有的return语句,在这里我们通过returnStatementVisitor来访问该函数下的所有return语句,但只会对相同函数作用域下的return语句进行转换操作。

一个AST的根节点始终都是Program,上面的例子我们从declarations开始往下读:

在讲了这么多概念之后,相信读者很容易就可以理解什么是 Babel 插件。从上文中我们可以知道 Babel 其实就是一个转译器,它会将 ES6+ 语法的代码解析为 AST ,通过对 AST 中节点的增加、删除和更改将其转换为符合 ES5 规范的 AST ,最终再将转换后的 AST 翻译为 ES5 代码。下图展示了这个过程:

{ type: 'Identifier', name: 'add'}
// 错误path.traverse({ Identifier(path) { // ... }})path.traverse({ BinaryExpression(path) { // ... }})

// 正确path.traverse({ Identifier(path) { // ... }, BinaryExpression(path) { // ... }})
// es2015 的 const 和 arrow functionconst add = (a, b) = a + b;// Babel 转译后var add = function add(a, b) { return a + b;};

转译器

参考链接BabelBabel是如何读懂JS代码的the super tiny compilerBabel 插件手册理解 Babel 插件

接下来就让我们开始讲解函数的转换操作,对于普通函数,我们会对 AST 进行如下转换:

var visitor = { ArrowFunction() { console.log('我是箭头函数'); }, IfStatement() { console.log('我是一个if语句'); }, CallExpression() {}};

如果想要了解一个简单的编译器是如何实现的,可以看看The Super Tiny Compiler

一般来说,Parse阶段可以细分为两个阶段:词法分析(Lexical Analysis, LA)和语法分析(Syntactic Analysis, SA)。

Ruby 之父松本行弘在《代码的未来》一书中对领域特定语言 ( Domain Specific Language ) 有着这样的解释:

当然,仅仅是Babel是不够的,还需要polyfill等等等等,这里就先不说了。

不难理解,这段代码会在同步函数的头部插入开始计时的打点函数,如果函数中没有return语句,则会在函数结尾插入结束计时的打点函数。

进入Identifier(params[0])走到尽头退出Identifier(params[0])进入Identifier(params[1])走到尽头退出Identifier(params[1])

在遍历 AST 时,对于不满足要求的节点应该直接返回,这样既防止了我们生成错误代码也在一定程度上缩短了遍历时间。

对细节感兴趣的可以翻翻源码-traverse。

如果想要具体了解 DSL 是什么,可以看看这篇文章

我们可以通过一棵“树”来更为直观地展示这句代码的AST(从第二层的declarations开始):

接下来让我们看看该如何对return语句进行转换:

Parse(解析):将源代码转换成更加抽象的表示方法(例如抽象语法树)Transform(转换):对(抽象语法树)做一些特殊处理,让它符合编译器的期望Generate(代码生成):将第二步经过转换过的(抽象语法树)生成新的代码

应尽量避免遍历 AST,及时合并访问者对象。

[ { "type": "Keyword", "value": "const" }, { "type": "Identifier", "value": "add" }, { "type": "Punctuator", "value": "=" }, { "type": "Punctuator", "value": "(" }, { "type": "Identifier", "value": "a" }, { "type": "Punctuator", "value": "," }, { "type": "Identifier", "value": "b" }, { "type": "Punctuator", "value": ")" }, { "type": "Punctuator", "value": "=" }, { "type": "Identifier", "value": "a" }, { "type": "Punctuator", "value": "+" }, { "type": "Identifier", "value": "b" }]

如何定义相关的数据格式使得最终收集到的函数数据正确。

分析AST:规范: 工作过程

在开始讲解该 Babel 插件的实现之前,请读者确保已经对 Babel 下的 AST 规范十分熟悉,并且已经通读过 Babel Plugin Handbook 。完成这两个步骤后,就让我们来直接看代码吧。

我们还是拿上面的例子来说明const add = (a, b) = a + b;,这样一句简单的代码,我们来看看它生成的AST会是怎样的:

function syncTransform(path, query) { path.get('body').unshiftContainer('body', startExpression(query)) if (!hasReturnStatement(path)) { path.get('body').pushContainer('body', endExpression(query)) }}

type是ArrowFunctionExpression,表示这是一个箭头函数表达式params是这个箭头函数的入参,其中每一个参数都是一个Identifier类型的节点;body属性是这个箭头函数的主体,这是一个BinaryExpression二项式:left、operator、right,分别表示二项式的左边变量、运算符以及右边变量。

可以看到,我们以时间戳作为整个数据对象的 key ,每当调用time.start()就会记录当前函数开始执行的时间点,之后我们将这个时间戳返回,并在函数内部新建一个变量来接收它,在函数执行结束时我们会调用time.end(),此时再将该变量传回对象内部,这样就能通过startTime这个 key 将结束时的时间戳放到正确的位置。值得注意的是,由于一般时间戳的精度不足以计算同步函数执行的时间差,所以我们使用的是精确到毫秒的performance.now()Web API 。打点函数中的uid指的是原始代码中每个函数的唯一标识符,它是在 Babel 遍历每个有效函数时由我们生成的,在上文代码中也有提到。

params(函数入参):a和b

在深入讲解函数的转换操作之前,我们先来看看插入的打点代码是如何实现的:

时间: 2019-06-25阅读: 252标签: Babel 前言

@babel/generator

left(左边):aoperator(运算符):加号 +right(右边):b

既然讲到了编译器 ( compiler ) ,就不得不提与它概念十分相近的转译器 ( transpiler ) 。转译器其实是一种特殊的编译器,它用于将一种语言编写的源代码转换为另一种具有相同抽象层次的语言。例如,能够将 TypeScript 转换为 JavaScript 的 tsc 转译器以及能够将 ES6+ 转换为 ES5 的 Babel 转译器。从这里我们也可以看出编译器与转译器最大的区别就在于编译器是将高级语言转换为低级语言(例如汇编语言、机器语言),转译器则是相同抽象层次间的语言转换。

我们可以在尝试一下。

从上面的代码不难看出,编写 Babel 插件的入口其实就是一个返回访问者对象的函数,该函数为我们提供了@babel/types中的 types 对象,这对操纵 AST 十分有用。通过访问器模式和迭代器模式,Babel 能够遍历每个特定类型的 AST 节点以及相应的路径,开发者只需在 Babel 暴露的函数中编写操纵特定 AST 节点的代码即可。

大多数编译器的工作过程可以分为三部分:

@babel/types

已经9102了,我们已经能够熟练地使用es2015+的语法。但是对于浏览器来说,可能和它们还不够熟悉,我们得让浏览器理解它们,这就需要Babel。

转换 ( transform ) 让我们可以在同一种语言下操纵 AST ,也可以将它翻译为另一种语言的 AST 。

简单地说,Babel能够转译ECMAScript 2015+的代码,使它在旧的浏览器或者环境中也能够运行。

理解 Babel 插件机制

嗯... 既然Babel是一个编译器,当然它的工作过程也是这样的。我们来仔细看看这三步分别做了什么事。当然,还是拿上面的例子来说明const add = (a, b) = a + b,看看它是如何经过Babel变成:

@babel/parser

Parse(解析)

解析 ( parse ) 是将原始代码转换为更为抽象的表达形式,在这个阶段编译器会对原始代码进行词法分析、语法分析、语义分析并最终生成抽象语法树 ( AST )。例如,ESTree 就是 JavaScript 的 AST 规范。

/** * 词法分析 tokenize * @param {string} code JavaScript 代码 * @return {Array} token */function tokenize(code) { if (!code || code.length === 0) { return []; } var current = 0; // 记录位置 var tokens = []; // 定义一个空的 token 数组 var LETTERS = /[a-zA-Z$_]/i; var KEYWORDS = /const/; // 模拟一下判断是不是关键字 var WHITESPACE = /s/; var PARENS = /(|)/; var NUMBERS = /[0-9]/; var OPERATORS = /[+*/-]/; var PUNCTUATORS = /[~!@#$%^*()/|,.?"';:_+-=[]{}]/; // 从第一个字符开始遍历 while (current  code.length) { var char = code[current]; // 判断空格 if (WHITESPACE.test(char)) { current++; continue; } // 判断连续字符 if (LETTERS.test(char)) { var value = ''; var type = 'Identifier'; while (char  LETTERS.test(char)) { value += char; char = code[++current]; } // 判断是否是关键字 if (KEYWORDS.test(value)) { type = 'Keyword' } tokens.push({ type: type, value: value }); continue; } // 判断小括号 if (PARENS.test(char)) { tokens.push({ type: 'Paren', value: char }); current++; continue; } // 判断连续数字 if (NUMBERS.test(char)) { var value = ''; while (char  NUMBERS.test(char)) { value += char; char = code[++current]; } tokens.push({ type: 'Number', value: value }); continue; } // 判断运算符 if (OPERATORS.test(char)) { tokens.push({ type: 'Operator', value: char }); current++; continue; } // 判断箭头函数 if (PUNCTUATORS.test(char)) { var value = char; var type = 'Punctuator'; var temp = code[++current]; if (temp === '') { type = 'ArrowFunction'; value += temp; current ++; } tokens.push({ type: type, value: value }); continue; } tokens.push({ type: 'Identifier', value: char }); current++; } return tokens;}

想要编写一个可用的 Babel 插件,是需要很多前置知识的。首先我们得理解基于 ESTree 的 AST 语法规范,通过 AST Explorer 我们可以实时查看某段代码生成的 AST ,对不同类型的节点对象有更加深刻的认识。在理解 AST 其实就是用来描述代码的一种抽象形式后,我们还需要学习如何对 Babel 生成的 AST 进行增加、删除和更改。在这里推荐 Babel Plugin Handbook ,里面完整地讲解了如何去写一个 Babel 插件,细读两遍之后写一个简单的 Babel 插件基本不在话下。在编写 Babel 插件时,我们常常会用到以下几个 npm 包:

AST是什么这里就不细说了,想要了解更多信息可以查看Abstract syntax tree - Wikipedia。

是一个用于 AST 节点的 Lodash 式工具库,它包含了构造、验证以及变换 AST 节点的方法,对编写处理 AST 逻辑非常有用。

首先得要先了解一个概念:抽象语法树(Abstract Syntax Tree, AST),Babel本质上就是在操作AST来完成代码的转译。

将转换后的 AST 翻译为目标代码。

10bet,了解了AST是什么样的,就可以开始研究Babel的工作过程了。

var ast = parser.parse(code)function isStartExpression(path) { var result = path.node.object.name === 'time'  path.node.property.name === 'start'  path.parentPath.node.type === 'CallExpression' return result}function getFunctionInfo(path) { var funcPath = path.getFunctionParent() var parentNode = funcPath.parentPath.node var info = {} function generateInfo(name, location) { info = { name, location } } if (parentNode.type === 'AssignmentExpression') { generateInfo(parentNode.left.property.name, parentNode.left.property.loc.start) } else if (parentNode.type === 'VariableDeclarator') { generateInfo(parentNode.id.name, parentNode.id.loc.start) } else { funcPath.node.id ? generateInfo(funcPath.node.id.name, funcPath.node.id.loc.start) : generateInfo('anonymous', funcPath.node.loc.start) } return info}var data = {}// 对代码进行二次语法树分析,收集函数名以及对应的行列号。traverse(ast, { MemberExpression(path) { if (isStartExpression(path)) { var uid = path.parentPath.node.arguments[0].value data[uid] = getFunctionInfo(path) } }})

一个VariableDeclaration(变量声明):声明了一个name为add的ArrowFunctionExpression(箭头函数):

在找到正确的 await 表达式后,我们该如何插入打点代码来获得正确的函数耗时数据呢?我们知道在 async 函数中,当遇到 await 表达式时会立刻暂停当前函数的执行,然后去执行 await 表达式后面紧跟的函数,而恢复函数执行的条件则是等待 await 表达式后面的函数执行完毕或者返回的 Promise 决议完成。从这里我们也可以看出,在 async 函数遇到 await 表达式停止执行到恢复执行的时间段并不属于当前函数的耗时。因此我们的打点代码其实可以这样插入:

还是上面的:

通过 sorcery.js 我们可以 flatten 多个 sourcemap 并最终生成能够直接映射到原始代码的 sourcemap ,这为我们调试代码提供了极大的帮助。之后我们通过@babel/traverse对压缩后的最终文件进行二次语法树分析,此时收集到的函数名与函数行列号,在有了正确的 sourcemap 后便显得尤为重要。下面是进行二次语法树分析的代码实现:

上面这个tokenize函数只是自己实现以下,与实际上Babel的实现方式还是差不少的,如果感兴趣可以看看-parser/src/tokenizer

能够遍历 AST ,维护着整棵树的状态,并且负责替换、移除和添加节点。

来自:

var data = {}var time = { start(uid) { var startTime = performance.now() data[startTime] = { uid, startTime } return startTime }, end(uid, tid) { if (data[tid]) { var endTime = performance.now() data[tid] = { ...data[tid], endTime } } }}

说来惭愧,这里没有想到很好的思路来实现一个parse函数。如果哪天想到了,再补充上来。

在这里我们限制了 await 表达式能够进行转换的条件,只有当该表达式之前没有被转换过并且与函数位于同一作用域时,才能进行转换。那什么叫同一作用域呢?我们来举个例子:

比如我们拿这个visitor来遍历这样一个AST:

生成 ( generate ) 阶段则是将转换后的 AST 翻译为目标代码的过程。

import * as t from "@babel/types";var visitor = { ArrowFunction(path) { path.replaceWith(t.FunctionDeclaration(id, params, body)); }};

开始编写 Babel 插件

What:什么是BabelBabel is a toolchain that is mainly used to convert ECMAScript 2015+ code into a backwards compatible version of JavaScript in current and older browsers or environments.

本文由10bet发布于Web前端,转载请注明出处:前端中的编译原理:从零打造一个实用的 Babel 插

关键词:

频道精选

最火资讯