在处理我的 Webflow/React transpiler 时,在我脑海中突然出现写下这篇文章的想法。我想做的就是获取一个 JS 代码字符串并以我想要的方式对其进行转换:如果全局变量已经定义,则不会重新定义:
/* In */
foo = 'foo'
/* Out */
if (typeof window.foo === 'undefined') window.foo = 'foo'
起初我以为我可以通过正则表达的帮助来做到这一点,但我错了。
正则表达式是不够的,因为它完全忽略了作用域变量的概念,并且对像它是纯文本一样的字符串起作用。要确定一个全局变量,我们需要问自己的是:这个变量已经在当前作用域或父级作用域之一中声明过吗?
解决这个问题的方法是将代码分解为节点,其中每个节点代表我们代码中的一部分,并且所有节点以关系方式相互连接。整个节点的形成称为AST - 抽象语法树,可用于轻松查找作用域和变量以及与我们的代码相关的其他元素。
AST 示例可能如下所示:
function foo(x) {
if (x > 10) {
var a = 2;
return a * x;
}
return x + 10;
}
示例取自于Lachezar Nickolov’s article关于 JS ASTs 的文章。
显然,将代码分解为节点并不像在公园散步一样简单。幸运的是,我们有一个名为 Babel 的工具已经做到了
Babel 的营救
Babel 最初是一个将最新的 es20XX 语法转换为 es5 语法的项目,以实现更好的浏览器兼容性。由于 Ecmascript 委员会不断更新 Ecmascript 语言的标准,插件提供了一个优秀且可维护的解决方案,可以轻松更新 Babel 编译器的行为。
Babel 由众多组件组成,这些组件协同工作以将最新的 Ecmascript 语法变为现实。具体来说,代码转换流程是组件以下关系一起工作:
- 解析器使用 babel-parser 将代码字符串解析为名为AST(抽象语法树)的数据表示结构。
- AST 由预定义的插件操纵,插件使用 babel-traverse。
- 使用 babel-generator 将 AST 转换回浏览器可以执行的代码。
现在你对 Babel 有了更好的理解,你可以真正理解在构建插件时发生了什么。说到哪,我们怎么做?
创建并且使用一个 Babel 插件
首先,我希望我们了解 Babel 生成的 AST,因为这对于构建插件至关重要,因为插件会操纵 AST,因此我们需要了解它。如果你去astexplorer.net,你会发现一个令人惊奇的编译器,它将代码转换为 AST。我们以代码 foo =“foo” 为例。生成的 AST应如下所示:
如你所见,树中的每个节点都代表了代码的一部分,并且它是递归的。赋值表达式 foo =“foo”
使用 operator =
,左边的操作数是名为 foo
的标识符,右边的操作数是值为“foo”
的文字。这就是它的方式,代码的每个部分都可以表示为一个由其他节点组成的节点,每个节点都有一个类型和基于其类型的附加属性。
现在让我们来做一个操作,将值 “foo”
更改为 “bar”
,假设我们将要做的就是获取相应的文字节点并将其值从“foo”
更改为“bar”
。让我们把这个简单的例子变成一个插件。
我准备了一个快速模板项目,你可以使用它来快速编写插件并通过转换它们来测试它们。可以通过克隆 babel-plugin-tester 来下载该项目。该项目包含以下文件:
-
in.js
- 包含我们想要转换的代码。 -
out.js
- 包括我们刚刚转换输出的代码。 -
transform.js
- 获取in.js
中的代码,转换它并将新代码写入out.js
。 -
plugin.js
- 将在整个转换过程中应用的转换插件。
请复制以下内容并将其粘贴到in.js
文件中以实现我们的插件,:
foo = "foo"
然后将以下内容写入 transform.js
文件中:
odule.exports = () => {
return {
visitor: {
AssignmentExpression(path) {
if (
path.node.left.type === 'Identifier' &&
path.node.left.name === 'foo' &&
path.node.right.type === 'StringLiteral' &&
path.node.right.value === 'foo'
) {
path.node.right.value = 'bar'
}
}
}
}
}
启动转换,运行 $ node transform.js
。现在打开 out.js
文件,应该看到以下内容:
foo = "bar"
访问者属性是对 AST 进行实际操作的地方。它遍历树并为每个指定的节点类型运行处理程序。在我们的例子中,每当访问者遇到类型为 AssignmentExpression
节点的节点时,如果我们将 “bar”
赋值给 “foo”
,它将用替换右操作数 foo
。我们可以为我们想要的任何节点类型添加一个操作处理程序,它可以是AssignmentExpression
,Identifier
,Literal
,甚至是 Program
,它是 AST 的根节点。
回到我们收集的主要目的,我将首先提供一个提醒:
/* In */
foo = 'foo'
/* Out */
if (typeof window.foo === 'undefined') window.foo = 'foo'
我们将首先完成所有全局任务并将其转换为 window
的成员分配表达式,以防止混淆和潜在的误解。我想首先探索所需的 AST 输出:
然后编写相应的插件:
module.exports = ({ types: t }) => {
return {
visitor: {
AssignmentExpression(path) {
if (
path.node.left.type === 'Identifier' &&
!path.scope.hasBinding(path.node.left.name)
) {
path.node.left = t.memberExpression(t.identifier('window'), t.identifier(path.node.left.name))
}
}
}
}
}
现在将介绍我之前未提及的但上面插件正在使用的两个新概念:
types
对象是 AST 节点的Lodash-esque
实用程序库。它包含用于构建,验证和转换 AST 节点的方法。它通过精心设计的实用方法对于理清 AST 逻辑非常有用。它的方法应该都等同于驼峰式节点类型。所有类型都在 babel-types 中定义,而且,我建议在构建插件时查看源代码,以便定义所需的节点创建者签名,因为大多数类型都没有记录。有types
更多信息,请查看文档。就像
types
对象一样,scope
对象包含与当前节点范围相关的实用程序。它可以检查是否定义了变量,生成唯一变量 ID 或重命名变量。在上面的插件中,我们使用hasBinding()
方法通过爬上 AST 来检查标识符是否具有相应的声明变量。有关的scope
更多信息,请查看文档。
现在我们将把失踪的和平添加到谜题中,即将赋值表达式转换为条件赋值表达式。所以我们想转这个代码:
window.foo = 'foo'
转换成:
if (typeof window.foo === 'undefined') window.foo = 'foo'
如果查看该代码的 AST,你将看到我们正在处理3种新节点类型:
- UnaryExpression —
typeof window.foo
- BinaryExpression —
... === 'undefined'
- IfStatement —
if (...)
注意每个节点是如何由它上面的节点组成的。因此,我们将更新我们的插件。我们将保留旧逻辑,我们将全局变量转换为 window
成员,最重要的是,我们将使用 IfStatement
使其成为条件:
module.exports = ({ types: t }) => {
return {
visitor: {
AssignmentExpression(path) {
if (
path.node.left.type === 'Identifier' &&
!path.scope.hasBinding(path.node.left.name)
) {
path.node.left = t.memberExpression(t.identifier('window'), t.identifier(path.node.left.name))
}
if (
path.node.left.type == 'MemberExpression' &&
path.node.left.object.name == 'window'
) {
const typeofNode = t.unaryExpression('typeof', path.node.left)
const isNodeUndefined = t.binaryExpression('===', typeofNode, t.stringLiteral('undefined'))
const ifNodeUndefined = t.ifStatement(isNodeUndefined, t.expressionStatement(path.node))
path.replaceWith(ifNodeUndefined)
path.skip()
}
}
}
}
}
所以基本上我们在这里做的是检查我们是否处理 window
成员赋值表达式,如果是这样,我们将创建条件语句并将其替换为当前节点,说明:
- 根据 AST 的说法,我没有对
explenation
感兴趣,而是在IfStatement
中创建了一个嵌套的ExpressionStatement
,因为这是我所想要的。 - 我已经使用
replaceWith
方法用新创建的节点替换当前节点。有关像replace
这样的操作方法的更多信息,请查看文档。 - 通常应该再次调用
AssignmentExpression
处理程序,因为我在调用replaceWith
方法时创建了该类型的新节点,但由于我不想为新创建的节点运行另一次遍历,我调用了skip
方法,否则我会有一个无限的递归。有关像skip
这样的访问方法的更多信息,请查看文档。
现在插件应该是完整的。它不是最复杂的插件,但它绝对是这个介绍的一个很好的例子,它将为你提供进一步插件的良好基础。
回顾一下,每当你因任何原因忘记了插件的工作原理时,请阅读本文。当你处理插件本身时,请在 astexplorer.net 上调查所需的 AST 结果,对于API 文档,我建议您使用这本精彩的手册。
PS
原文为 This is how I build Babel plug-ins,感觉比较形象的解释的 babel 的原理,更好的理解插件的实现。Babel 通过 babylon 解析器将 JS 转换成 AST。使用其他的解析器也可以将 HTML、CSS 转换成 AST, 相关可以阅读 从零开始写一个wepy转VUE的工具。