2023-08-15
前端工程化
00
请注意,本文编写于 466 天前,最后修改于 466 天前,其中某些信息可能已经过时。

目录

何为AST
为什么会有AST?
AST如何生成的?
AST语法
1.Literal
2. Identifier
3. Statement
4. Declaration
5. Expression
AST的公共属性
实现一个mini编译器
Babel插件学习
babel的处理过程
babel工具库
写一个babel插件
console打印日之前输出代码行好列号
参数path有哪些内容
babel插件Demo console.log增加参数名

本文将介绍AST背景,基本语法,然后再从一个全网最小编译器聊起带你理解AST如何转换代码,理解什么是访问者模式,最后再带你手写一个babel插件。

何为AST

AST概念
在计算机科学中,抽象语法树(Abstract Syntax Tree, AST),或简称语法树,是源代码语法结构的一种抽象表示
以树状的形式表现编程语言的语法结构树上的每个节点都表示源代码中的一种结构
之所以说语法是“抽象”的,是因为这里的语法并不会表示出真实语法中出现的每个细节
比如,嵌套括号被隐含在树的结构中,并没有以节点的形式呈现;而类似 if-condition-then这样的条件跳转语句,可以使用带有三个分支节点的语句表示

  • AST 技术是现代化前端基建和工程化建设的基石
  • 现在的开发框架尤其是构建工具中,离不开AST相关技术
    • 构建工具上 Webpack Babel Postcss Eslint TypeScript
    • 开发框架上 Vue的模版编译,Reactjsx解析

为什么会有AST?

首先JS语言在目标浏览器上运行需要降级,将高级的语法特性转换成目标浏览器上可以运行的语法,对语法的解析转换需要AST。

有人可能会问为什么不采用字符串读取或正则替换,这岂不更简单?如果直接对字符串操作,具有很大局限性,代码耦合难懂,不利于迭代维护扩展等。

AST就是提供一种通用的数据结构描述程序, 通过对树结构的修改,达到修改源代码的能力。这种树形结构的语法确实增加了学习成本, 通过AST这种语法树,解决了程序耦合问题,为团队协作插件化机制提供了可能,它是对复杂问题提供通用化解决方案的必然结果

那么AST是谁定义的?
AST也是有标准的, JS parser的AST 大多是 estree 标准,Babel的编译也是遵循这个标准来的。

AST与编译之间的关系
首先编译,就是从一种高级语言,转换成另一种低级语言。

  • 高级语言: JavaScriptJavaC 有很多用于描述逻辑的语言特性,比如 分支、循环、函数、OOP、判断,接近人的思维,可以让开发人员快速的掌握。
  • 低级语言: 汇编语言机器语言 这些是和硬件和执行细节有关的,会操作寄存器、内存。需要开发者理解熟悉计算机的工作原理。

其次前端的编译,通常是指 ES6+ES5 的转换,AST 是中间产物,当让将TS转换为JSJSX转换到JS也是编译.

AST如何生成的?

以babel为例,

  • 读取源代码得到字符串
  • tokenizer解析(词法解析)
  • 得到令牌数组 [{token}, {token}, {token}]
  • 语法解析 得到AST
  • 转换 遍历AST修改结构 得到新的AST
  • 将AST转换成字符串(目标代码),写入文件

AST语法

以babel为例先熟悉下AST的树形结构。 点这里 ast在线预览 注意使用@babel/parser解析器

1.Literal

字面量的意思

js
const a = 'zhangsan'; // StringLiteral const b = `lisi`; // TemplateLiteral const c = 123; // NumericLiteral const reg = /^[a-z]+/; // RegExpLiteral const d = true; // BooleanLiteral const e = null; // NullLiteral

2. Identifier

标识符: 变量名、属性名、参数名等各种声明和引用的名字

js
const name = 'ww'; function foo(name) { console.log(name); }
  • 上述代码共有6个标识符 name foo name console log name

3. Statement

  • 语句,它是可以独立执行的单位。
  • break continue debugger return if while for
  • 每一个可以独立运行的代码都是语句,一般末尾会有分号;
js
// BreakStatement // ContinueStatement // ReturnStatement // DebuggerStatement // ThrowStatement {} // BlockStatement try{}catch(e){}finally{} // TryStatement // ForinStatement // ForStatement // WhileStatement // DoWhileStatement // ...

4. Declaration

  • 声明语句
js
const a = 1; // VariableDeclaration function b() {} // FunctionDeclaration class c {} // ClassDeclaration import E from 'e'; // ImportDeclaration export // ExportDefautDeclaration // ExportDeclaration // ExportAllDeclaration

5. Expression

  • 表达式与语句的区别是,表达式有返回值
js
a=1; 1+2 [1, 2, 3]

AST的公共属性

  • start end loc {line, column}

实现一个mini编译器

image.png

关于ast这一块,要多动手敲敲代码,多调试调试,推荐 全网最小编译器 这个案例多写几遍, 体会一下访问者模式,

这里简单记一下实现思路

  • src/test.ts
ts
/** * 1. string --> tokens * 2. tokens --> ast * 3. ast --> newAst * 4. newAst --> code * // Literal Identifier Statement Expression */ import { tokenizer, parse, traverser, codeGenerator } from './ast'; const input = `(add 2 (substract 4 2))`; // 1. string --> tokens /* [ {type: 'paren', value: '('}, {type: 'name', value: 'add'}, {type: 'number', value: '2'}, {type: 'paren', value: '('}, {type: 'name', value: 'substract'}, {type: 'number', value: '4'}, {type: 'number', value: '2'}, {type: 'paren', value: ')'}, {type: 'paren', value: ')'}, ] */ const tokens = tokenizer(input); // 2. tokens --> ast /* { type: 'Program', body: [ { type: 'CallExpression', name: 'add', params: [ {type: 'NumberLiteral', value: '2'}, { type: 'CallExpression', name: 'substract', params: [ {type: 'NumberLiteral', value: '4'}, {type: 'NumberLiteral', value: '2'}, ] } ] } ] } */ const ast = parse(tokens); // 3. ast -> newAst /* var myNewAst = { type: 'Program', body: [ { type: 'ExpressionStatement', expression: { type: 'CallExpression', callee: { type: 'Identifier', name: 'add', }, arguments:[ {type: 'NumberLiteral', value: '2'}, { type: 'CallExpression', callee: { type: 'Identifier', name: 'substract', }, arguments: [ {type: 'NumberLiteral', value: '4'}, {type: 'NumberLiteral', value: '2'}, ] } ] } } ] } */ const newAst = { type: 'Program', body: [] } ast._context = newAst.body; traverser(ast, { CallExpression: { entry(node: any, parent: any){ // ... } }, NumberLiteral: { entry(node: any, parent: any) { // ... 同上 } } }/* visiter */, ); // 4. newAst --> code // add(2, substract(4, 2)); const output = codeGenerator(newAst); console.log('output', output);
  • src/ast.ts
ts
export function tokenizer(input: string) { const tokens: any[] = []; // ... return tokens; } export function parse(tokens: any[]): any { const ast = { type: 'Program', body: [] } // ... return ast; } export function traverser(ast: any, visitor: any) { // ... return ast; } export function codeGenerator(newAst: any): string { let output = ''; // ... return output; }

上述案例采用 ts编写,可以借助 ts-nodetypescript这两个npm包运行

  • npx ts-node src/test.ts --files
    • --files 表示 让ts-node读取tsconfig.json
  • ts-node同样可以在VSCode进行断点调试, 调试方法与js调试完全一致

步骤简要描述

  1. 将原代码读取为字符串,转换成token数组
    1. 逐个字符读取,直到结束
  2. 将token数组转换成 ast
    1. Array 转 Tree操作
    2. 遇到左括号就开始递归,遇到右括号 结束递归
  3. 对AST进行转换(这里是将LISP的AST转换成JS的AST, 额外补充一句:Babel的ast转换是将JS从高版本转译到低版本,本质上都是编译)
    1. 首先我们先了解一下引用类型的背景知识,有两个tree数据 astnewAst两个对象存在纠葛,表现在 asttree内部有多次newAst内部的结构,这样修改了ast newAst也会发生变更。
    2. 比如对ast操作,原有的节点我们不会进行编辑操作的,但会在对象本身增加属性(这里是_context),通过对该属性的增加修改操作,再利用引用类型的纠葛,达到修改newAst的效果
    3. 其次 我们在了解一个设计模式: 访问者模式,将数据与对数据的操作进行分离,这里要提供一个访问者对象vistor 和 对AST遍历的方法
      1. visitor 它根据AST的每个Type类型提供进入节点和离开节点的方法(非必需),进入和离开节点的方法都遵循指责单一原则,即仅用于生成和维护新ast的节点。
      2. traverser 方法,它也是指责单一, 只提供遍历ast的run方法(注意:遍历ast的每个节点前后分别调用了visitor对象中的进入和退出方法)。深度优先遍历,遍历完成,也就生成了完整的newAst

image.png

  1. 将新的AST转换成目标代码字符串
    1. 深度优先遍历

完整代码: 点击这里

Babel插件学习

在学习babel插件编写前 建议先上前面的全网最小编译器练习几遍,调试下,否则阅读官方插件文档,云里雾里的,比较晦涩难懂。

官方插件教程

babel的处理过程

  • 解析
    • 又分两个过程 词法解析、语法解析
  • 转换
    • 对AST进行增删改
    • 如果转换? 进行递归的树形遍历
    • 访问者模式: 将对数据操作与遍历进行分离的一种设计模式。
      • 这里的数据 即 AstAstNode
      • 对数据操作 即 Visitor 形如 image.png
      • 遍历即 traverser(ast: Ast, visitor: Visitor)Ast 进行深度优先遍历,遍历的同时执行Visitor对应的钩子方法
    • Paths(路径)
      • AST通常会有许多节点,那么节点之间是如何关联的呢?
        • 可以使用一个可操作和访问的巨大可变对象表示节点之间的关联关系
        • 或者也可以用路径来简化这件事情
      • 路径不仅包含了很多元数据,还包含添加、更新、移动和删除节点有关的其他很多方法
      • 路径是一个节点在树中的位置及关于该节点各种信息的响应式表示,当你调用一个修改树的方法后,路径信息也被更新(Babel为你管理这一切,从而使得节点操作简单。尽可能做到无状态)
    • State(状态)
      • 状态是抽象语法树AST转换的敌人,通过绑定标记递归遍历的方式隔离状态的范围
    • 作用域
      • 当编写一个转换时,必须小心作用域。确保在改变代码的各个部分时不会破坏已经存在的代码。
    • Bindings(绑定)
      • 所有引用属于特定的作用域,引用和作用域的关系称作为:绑定
  • 生成
    • 将最终的AST转换成字符串形式的代码同时还会创建源码映射
    • 代码生成其实很简单,深度优先遍历整个AST,然后构建可以转换后代码的字符串

babel工具库

  • @babel/parser 之前称为 babylon,它是Babel的解析器。最初是从Acorn项目fork出来的。Acorn非常快,易于使用,并且针对非标准特性(以及那些未来的标准特性)设计了一个基于插件的架构
    作用 将源代码转换为AST

  • @babel/traverse 可以遍历 ast,并调用 visitor 函数修改 ast。对于 ast节点的判断、创建、修改等,可以用 @babel/types 包,当需要批量创建 AST 的时候可以用 @babel/template 简化逻辑。

  • @babel/generate 会把 ast 打印为目标代码字符串,同时生成 sourcemap

  • @babel/code-frame 用于中途遇到错误想打印代码位置

经过上述三个工具,就可以完成代码转换的过程,以下是一个精简案例

javascript
import * as babylon from "babylon"; import traverse from "babel-traverse"; import generate from "babel-generator"; const code = `function square(n) { return n * n; }`; const ast = babylon.parse(code); traverse.default(ast, { enter(path) { if ( path.node.type === "Identifier" && path.node.name === "n" ) { path.node.name = "x"; } } }); console.log(generate.default(ast, {}, code))

其他工具

  • babel-types
    • 它是一个用于AST节点的Lodash式的工具库,他包含了构造、验证及变换AST节点的方法。
  • babel-template
    • 是一个虽然小但很实用的模块。它能让你编写字符串形式且带有占位符的代码,来代替手工编写,尤其是生成的大规模AST的时候。在计算机科学中称之为标准引用

写一个babel插件

console打印日之前输出代码行好列号

console.log(123) ---> console.log('第5行第8列', 123); console.warn(1, 2) ---> console.warn('第5行第8列', 1, 2);

js
// log.mjs // 测试 node log.mjs import { parse } from "@babel/parser"; import traverse from '@babel/traverse'; import generate from '@babel/generator'; import types from '@babel/types' const sourceCode = `function add(a, b) { console.log(a, b); return a+b; }`; // https://astexplorer.net/ 查看ast树结构 // 对照着 方便编写Visiter对象方法 const ast = parse(sourceCode, { sourceType: 'script' }); // 要修改的节点 // ast.program.body[0].body.body[0] // CallExpression traverse.default(ast, { CallExpression(path, state) { // path.node // 这个就是真实的ast节点, 可以参考 https://astexplorer.net const calleeName = generate.default(path.node.callee).code; // console.log if (calleeName === 'console.log') { const { line, column } = path.node.loc.start; path.node.arguments.unshift( types.stringLiteral(`第${line}行第${column}列`); ); } } }) // console.log 参数已经改变 // console.log(ast.program.body[0].body.body[0].expression.arguments); // ast 代码转目标代码 const { code, map } = generate.default(ast); console.log(code);

参数path有哪些内容

获取节点信息

  • path.node 当前节点
  • path.parent 父节点
  • path.parentPath 父节点的 path
  • path.scope javaScript 中的作用域链信息
  • path.hub
    • path.hub.file 拿到最外层 File 对象
    • path.hub.getScope 拿到最外层作用域,
    • path.hub.getCode 拿到源码字符串

判断 AST 类型

  • path.isFunctionDeclaration()
  • path.isTemplateLiteral()
  • ...

增删改 AST 类型

  • 插入节点:path.insertBeforepath.insertAfter
  • 替换节点:path.replaceWithpath.replaceWithMultiplereplaceWithSourceString
  • 删除节点:path.remove

在开发时我们可以通过 debugger 或者直接查看 babel 源码测试用例了解更多的操作 api

image.png

state(状态)

可以从 state 中获取插件的配置项 opts 以及 file 对象:

babel插件Demo console.log增加参数名

image.png

  • 创建文件夹 npm init -y
  • pnpm add @babel/cli @babel/core @babel/generator @babel/parser
js
// babel.config.js module.exports = { "presets": [], plugins: [ "./babel-plugin-log.js", ] } // babel-plugin-log.js const generate = require('@babel/generator') ; module.exports = function ({ types: t }) { return { visitor: { CallExpression (path) { const calleeName = generate.default(path.node.callee).code; if (calleeName !== 'console.log') return path.shouldSkip = true const args = path.node.arguments const newArgs = [] args.forEach((arg, index) => { const label = getExpressionLiteral(arg, path) newArgs.push(t.stringLiteral(label), arg) }) path.node.arguments = newArgs } } } } function getExpressionLiteral (expression, path) { const { start, end } = expression return path.hub.file.code.slice(start, end) } // input.js console.log(a); console.log(a, b, c); console.log(a + b);
  • 测试 npx babel input.js --out-file output.js

本段主要参考内容

本文作者:郭敬文

本文链接:

版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!