深入浅出 Babel 上篇:架构和原理 + 实战(1)

}

})

复制代码

深入浅出 Babel 上篇:架构和原理 + 实战

查看代码执行结果

enter Program(program)

enter FunctionDeclaration(0)

enter Identifier(id)

exit Identifier(id)

enter Identifier(0)

exit Identifier(0)

enter BlockStatement(body)

enter ExpressionStatement(0)

enter CallExpression(expression)

enter MemberExpression(callee)

enter Identifier(object)

exit Identifier(object)

enter Identifier(property)

exit Identifier(property)

exit MemberExpression(callee)

enter BinaryExpression(0)

enter BinaryExpression(left)

enter StringLiteral(left)

exit StringLiteral(left)

enter Identifier(right)

exit Identifier(right)

exit BinaryExpression(left)

enter StringLiteral(right)

exit StringLiteral(right)

exit BinaryExpression(0)

exit CallExpression(expression)

exit ExpressionStatement(0)

exit BlockStatement(body)

exit FunctionDeclaration(0)

exit Program(program)

复制代码

深入浅出 Babel 上篇:架构和原理 + 实战

当访问者进入一个节点时就会调用 enter(进入) 方法,反之离开该节点时会调用 exit(离开) 方法。 一般情况下,插件不会直接使用enter方法,只会关注少数几个节点类型,所以具体访问者也可以这样声明访问方法:

traverse(ast, {

// 访问标识符

Idenfifier(path) {

console.log(`enter Identifier`)

},

// 访问调用表达式

CallExpression(path) {

console.log(`enter CallExpression`)

},

// 上面是enter的简写,如果要处理exit,也可以这样

// 二元操作符

BinaryExpression: {

enter(path) {},

exit(path) {},

},

// 更高级的, 使用同一个方法访问多种类型的节点

“ExportNamedDeclaration|Flow”(path) {}

})

复制代码

深入浅出 Babel 上篇:架构和原理 + 实战

那么 Babel 插件是怎么被应用的呢?

Babel 会按照插件定义的顺序来应用访问方法,比如你注册了多个插件,babel-core 最后传递给访问器的数据结构大概长这样:

{

Identifier: {

enter: [plugin-xx, plugin-yy,] // 数组形式

}

}

复制代码

深入浅出 Babel 上篇:架构和原理 + 实战

当进入一个节点时,这些插件会按照注册的顺序被执行。大部分插件是不需要开发者关心定义的顺序的,有少数的情况需要稍微注意以下,例如plugin-proposal-decorators:

{

“plugins”: [

“@babel/plugin-proposal-decorators”, // 必须在plugin-proposal-class-properties之前

“@babel/plugin-proposal-class-properties”

]

}

复制代码

深入浅出 Babel 上篇:架构和原理 + 实战

所有插件定义的顺序,按照惯例,应该是新的或者说实验性的插件在前面,老的插件定义在后面。因为可能需要新的插件将 AST 转换后,老的插件才能识别语法(向后兼容)。下面是官方配置例子, 为了确保先后兼容,stage-*阶段的插件先执行:

{

“presets”: [“es2015”, “react”, “stage-2”]

}

复制代码

深入浅出 Babel 上篇:架构和原理 + 实战

注意Preset的执行顺序相反,详见官方文档

节点的上下文

访问者在访问一个节点时, 会无差别地调用 enter 方法,我们怎么知道这个节点在什么位置以及和其他节点的关联关系呢?

通过上面的代码,读者应该可以猜出几分,每个visit方法都接收一个 Path 对象, 你可以将它当做一个‘上下文’对象,类似于JQuery的 JQuery(const $el = $(‘.el’)) 对象,这里面包含了很多信息:

  • 当前节点信息

  • 节点的关联信息。父节点、子节点、兄弟节点等等

  • 作用域信息

  • 上下文信息

  • 节点操作方法。节点增删查改

  • 断言方法。isXXX, assertXXX

下面是它的主要结构:

export class NodePath<T = Node> {

constructor(hub: Hub, parent: Node);

parent: Node;

hub: Hub;

contexts: TraversalContext[];

data: object;

shouldSkip: boolean;

shouldStop: boolean;

removed: boolean;

state: any;

opts: object;

skipKeys: object;

parentPath: NodePath;

context: TraversalContext;

container: object | object[];

listKey: string; // 如果节点在一个数组中,这个就是节点数组的键

inList: boolean;

parentKey: string;

key: string | number; // 节点所在的键或索引

node: T; // 当前节点

scope: Scope; // 当前节点所在的作用域

type: T extends undefined | null ? string | null : string; // 节点类型

typeAnnotation: object;

// … 还有很多方法,实现增删查改

}

复制代码

深入浅出 Babel 上篇:架构和原理 + 实战

你可以通过这个手册来学习怎么通过 Path 来转换 AST. 后面也会有代码示例,这里就不展开细节了

副作用的处理

实际上访问者的工作比我们想象的要复杂的多,上面示范的是静态 AST 的遍历过程。而 AST 转换本身是有副作用的,比如插件将旧的节点替换了,那么访问者就没有必要再向下访问旧节点了,而是继续访问新的节点, 代码如下。

traverse(ast, {

ExpressionStatement(path) {

// 将 `console.log(‘hello’ + v + ‘!’)` 替换为 `return ‘hello’ + v`

const rtn = t.returnStatement(t.binaryExpression(‘+’, t.stringLiteral(‘hello’), t.identifier(‘v’)))

path.replaceWith(rtn)

},

}

复制代码

深入浅出 Babel 上篇:架构和原理 + 实战

上面的代码, 将console.log(‘hello’ + v + ‘!’)语句替换为return “hello” + v;, 下图是遍历的过程:

深入浅出 Babel 上篇:架构和原理 + 实战

深入浅出 Babel 上篇:架构和原理 + 实战

我们可以对 AST 进行任意的操作,比如删除父节点的兄弟节点、删除第一个子节点、新增兄弟节点… 当这些操作’污染’了 AST 树后,访问者需要记录这些状态,响应式(Reactive)更新 Path 对象的关联关系, 保证正确的遍历顺序,从而获得正确的转译结果

作用域的处理

访问者可以确保正确地遍历和修改节点,但是对于转换器来说,另一个比较棘手的是对作用域的处理,这个责任落在了插件开发者的头上。插件开发者必须非常谨慎地处理作用域,不能破坏现有代码的执行逻辑。

const a = 1, b = 2

function add(foo, bar) {

console.log(a, b)

return foo + bar

}

复制代码

深入浅出 Babel 上篇:架构和原理 + 实战

比如你要将 add 函数的第一个参数 foo 标识符修改为a, 你就需要递归遍历子树,查出foo标识符的所有引用, 然后替换它:

traverse(ast, {

// 将第一个参数名转换为a

FunctionDeclaration(path) {

const firstParams = path.get(‘params.0’)

if (firstParams == null) {

return

}

const name = firstParams.node.name

// 递归遍历,这是插件常用的模式。这样可以避免影响到外部作用域

path.traverse({

Identifier(path) {

if (path.node.name === name) {

path.replaceWith(t.identifier(‘a’))

}

}

})

},

})

console.log(generate(ast).code)

// function add(a, bar) {

// console.log(a, b);

// return a + bar;

// }

复制代码

深入浅出 Babel 上篇:架构和原理 + 实战

慢着,好像没那么简单,替换成 a 之后, console.log(a, b) 的行为就被破坏了。所以这里不能用 a,得换个标识符, 譬如c.

这就是转换器需要考虑的作用域问题,AST 转换的前提是保证程序的正确性。 我们在添加和修改引用时,需要确保与现有的所有引用不冲突。Babel本身不能检测这类异常,只能依靠插件开发者谨慎处理。

Javascript采用的是词法作用域, 也就是根据源代码的词法结构来确定作用域:

深入浅出 Babel 上篇:架构和原理 + 实战

深入浅出 Babel 上篇:架构和原理 + 实战

词法区块(block)中,由于新建变量、函数、类、函数参数等创建的标识符,都属于这个区块作用域. 这些标识符也称为绑定(Binding),而对这些绑定的使用称为引用(Reference)

在Babel中,使用Scope对象来表示作用域。 我们可以通过Path对象的scope字段来获取当前节点的Scope对象。它的结构如下:

{

path: NodePath;

block: Node; // 所属的词法区块节点, 例如函数节点、条件语句节点

parentBlock: Node; // 所属的父级词法区块节点

parent: Scope; // ⚛️指向父作用域

bindings: { [name: string]: Binding; }; // ⚛️ 该作用域下面的所有绑定(即该作用域创建的标识符)

}

复制代码

深入浅出 Babel 上篇:架构和原理 + 实战

Scope 对象和 Path 对象差不多,它包含了作用域之间的关联关系(通过parent指向父作用域),收集了作用域下面的所有绑定(bindings), 另外还提供了丰富的方法来对作用域仅限操作

我们可以通过bindings属性获取当前作用域下的所有绑定(即标识符),每个绑定由Binding类来表示:

export class Binding {

identifier: t.Identifier;

scope: Scope;

path: NodePath;

kind: “var” | “let” | “const” | “module”;

referenced: boolean;

references: number; // 被引用的数量

referencePaths: NodePath[]; // ⚛️获取所有应用该标识符的节点路径

constant: boolean; // 是否是常量

constantViolations: NodePath[];

}

复制代码

深入浅出 Babel 上篇:架构和原理 + 实战

通过Binding对象我们可以确定标识符被引用的情况

Ok,有了 Scope 和 Binding, 现在有能力实现安全的变量重命名转换了。 为了更好地展示作用域交互,在上面代码的基础上,我们再增加一下难度:

const a = 1, b = 2

function add(foo, bar) {

console.log(a, b)

return () => {

const a = ‘1’ // 新增了一个变量声明

return a + (foo + bar)

}

}

复制代码

深入浅出 Babel 上篇:架构和原理 + 实战

现在你要重命名函数参数 foo, 不仅要考虑外部的作用域, 也要考虑下级作用域的绑定情况,确保这两者都不冲突。

上面的代码作用域和标识符引用情况如下图所示:

深入浅出 Babel 上篇:架构和原理 + 实战

深入浅出 Babel 上篇:架构和原理 + 实战

来吧,接受挑战,试着将函数的第一个参数重新命名为更短的标识符:

// 用于获取唯一的标识符

const getUid = () => {

let uid = 0

return () => `_${(uid++) || ‘’}`

}

const ast = babel.parseSync(code)

traverse(ast, {

FunctionDeclaration(path) {

// 获取第一个参数

const firstParam = path.get(‘params.0’)

if (firstParam == null) {

return

}

const currentName = firstParam.node.name

const currentBinding = path.scope.getBinding(currentName)

const gid = getUid()

let sname

// 循环找出没有被占用的变量名

while(true) {

sname = gid()

// 1️⃣首先看一下父作用域是否已定义了该变量

if (path.scope.parentHasBinding(sname)) {

continue

}

// 2️⃣ 检查当前作用域是否定义了变量

if (path.scope.hasOwnBinding(sname)) {

// 已占用

continue

}

// 再检查第一个参数的当前的引用情况,

// 如果它所在的作用域定义了同名的变量,我们也得放弃

if (currentBinding.references > 0) {

let findIt = false

for (const refNode of currentBinding.referencePaths) {

if (refNode.scope !== path.scope && refNode.scope.hasBinding(sname)) {

findIt = true

break

}

}

if (findIt) {

continue

}

}

break

}

// 开始替换掉

const i = t.identifier(sname)

currentBinding.referencePaths.forEach(p => p.replaceWith(i))

firstParam.replaceWith(i)

},

})

console.log(generate(ast).code)

// const a = 1,

// b = 2;

// function add(_, bar) {

// console.log(a, b);

// return () => {

// const a = ‘1’; // 新增了一个变量声明

// return a + (_ + bar);

// };

// }

复制代码

深入浅出 Babel 上篇:架构和原理 + 实战

上面的例子虽然没有什么实用性,而且还有Bug(没考虑label),但是正好可以揭示了作用域处理的复杂性。

Babel的 Scope 对象其实提供了一个generateUid方法来生成唯一的、不冲突的标识符。我们利用这个方法再简化一下我们的代码:

traverse(ast, {

FunctionDeclaration(path) {

const firstParam = path.get(‘params.0’)

if (firstParam == null) {

return

}

let i = path.scope.generateUidIdentifier(‘_’) // 也可以使用generateUid

const currentBinding = path.scope.getBinding(firstParam.node.name)

currentBinding.referencePaths.forEach(p => p.replaceWith(i))

firstParam.replaceWith(i)

},

})

复制代码

深入浅出 Babel 上篇:架构和原理 + 实战

能不能再短点!

traverse(ast, {

FunctionDeclaration(path) {

const firstParam = path.get(‘params.0’)

if (firstParam == null) {

return

}

let i = path.scope.generateUid(‘_’) // 也可以使用generateUid

path.scope.rename(firstParam.node.name, i)

},

})

复制代码

深入浅出 Babel 上篇:架构和原理 + 实战

查看generateUid的实现代码

generateUid(name: string = “temp”) {

name = t

.toIdentifier(name)

.replace(/^_+/, “”)

.replace(/[0-9]+$/g, “”);

let uid;

let i = 0;

do {

uid = this._generateUid(name, i);

i++;

} while (

this.hasLabel(uid) ||

this.hasBinding(uid) ||

this.hasGlobal(uid) ||

this.hasReference(uid)

);

const program = this.getProgramParent();

program.references[uid] = true;

program.uids[uid] = true;

return uid;

}

复制代码

深入浅出 Babel 上篇:架构和原理 + 实战

非常简洁哈?作用域操作最典型的场景是代码压缩,代码压缩会对变量名、函数名等进行压缩… 然而实际上很少的插件场景需要跟作用域进行复杂的交互,所以关于作用域这一块就先讲到这里。

搞一个插件呗

等等别走,还没完呢,这才到2/3。学了上面得了知识,总得写一个玩具插件试试水吧?

现在打算模仿babel-plugin-import, 写一个极简版插件,来实现模块的按需导入. 在这个插件中,我们会将类似这样的导入语句:

import {A, B, C as D} from ‘foo’

复制代码

深入浅出 Babel 上篇:架构和原理 + 实战

转换为:

import A from ‘foo/A’

import ‘foo/A/style.css’

import B from ‘foo/B’

import ‘foo/B/style.css’

import D from ‘foo/C’

import ‘foo/C/style.css’

复制代码

深入浅出 Babel 上篇:架构和原理 + 实战

首先通过 AST Explorer 看一下导入语句的 AST 节点结构:

深入浅出 Babel 上篇:架构和原理 + 实战

深入浅出 Babel 上篇:架构和原理 + 实战

通过上面展示的结果,我们需要处理 ImportDeclaration 节点类型,将它的specifiers拿出来遍历处理一下。另外如果用户使用了默认导入语句,我们将抛出错误,提醒用户不能使用默认导入.

最后总结

ActiveMQ+Kafka+RabbitMQ学习笔记PDF

image.png

  • RabbitMQ实战指南

image.png

  • 手写RocketMQ笔记

image.png

  • 手写“Kafka笔记”

image

关于分布式,限流+缓存+缓存,这三大技术(包含:ZooKeeper+Nginx+MongoDB+memcached+Redis+ActiveMQ+Kafka+RabbitMQ)等等。这些相关的面试也好,还有手写以及学习的笔记PDF,都是啃透分布式技术必不可少的宝藏。以上的每一个专题每一个小分类都有相关的介绍,并且小编也已经将其整理成PDF啦
rocess=image/format,png)

首先通过 AST Explorer 看一下导入语句的 AST 节点结构:

深入浅出 Babel 上篇:架构和原理 + 实战

深入浅出 Babel 上篇:架构和原理 + 实战

通过上面展示的结果,我们需要处理 ImportDeclaration 节点类型,将它的specifiers拿出来遍历处理一下。另外如果用户使用了默认导入语句,我们将抛出错误,提醒用户不能使用默认导入.

最后总结

ActiveMQ+Kafka+RabbitMQ学习笔记PDF

[外链图片转存中…(img-cy8yudZZ-1720117556814)]

  • RabbitMQ实战指南

[外链图片转存中…(img-MjW7yEC0-1720117556815)]

  • 手写RocketMQ笔记

[外链图片转存中…(img-lxmDy3sa-1720117556816)]

  • 手写“Kafka笔记”

[外链图片转存中…(img-WPP2YFl2-1720117556816)]

关于分布式,限流+缓存+缓存,这三大技术(包含:ZooKeeper+Nginx+MongoDB+memcached+Redis+ActiveMQ+Kafka+RabbitMQ)等等。这些相关的面试也好,还有手写以及学习的笔记PDF,都是啃透分布式技术必不可少的宝藏。以上的每一个专题每一个小分类都有相关的介绍,并且小编也已经将其整理成PDF啦

  • 15
    点赞
  • 29
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值