JavaScript AST 学习笔记

几乎所有编程语言都有 AST,这是编译器/解释器的基础。 Babel 的 visitor 模式有几个让人别扭的地方: Facebook 出品,用于批量自动化修改代码,典型场景是库升级迁移。 先用 AST Explorer 验证节点类型:在写任何遍历逻辑之前,先把目标代码粘进 https://astexplorer.net,选对解析器(babel/parser 或 acorn),点击你关心的代码位置查看节点类型和字段名。手写遍历时节点名打错是最常见的低级错误。 用 path.node 读,用 path.replaceWith 写:path.node 是当前

分享

官方文档:https://babeljs.io/docs/babel-parser

一、AST 解析工具生态

JS 专用解析器

工具 说明
Babel 最常用,支持最新语法和 JSX,生态丰富
acorn 轻量、标准兼容,Babel/ESLint 底层都用过
espree ESLint 官方解析器,基于 acorn
@typescript-eslint/parser 专门处理 TypeScript AST
oxc-parser Rust 实现,极速,新兴选择
swc Rust 实现,比 Babel 快几十倍

遍历/操作工具

工具 说明
recast 修改 AST 后能保留原始格式,做 codemod 常用
jscodeshift Facebook 出品,基于 recast,批量 codemod 工具
esquery 类 CSS 选择器语法查询 AST 节点
ast-types recast 底层,提供路径遍历和节点类型系统

选型参考

场景 推荐
通用代码分析/lint acorn / espree
批量 codemod jscodeshift + recast
TypeScript 项目 ts-morph
追求极致性能 swc 或 oxc
需要保留原始格式输出 recast
类选择器查询 esquery

二、多语言 AST

几乎所有编程语言都有 AST,这是编译器/解释器的基础。

语言 工具
Python 标准库 astlibcst(保留格式)、astroid
Rust syn(proc-macro 必备)
Go 标准库 go/ast + go/parser
Java/Kotlin JavaParser、IntelliJ PSI
C/C++ libclang(LLVM 官方)
Ruby parser gem(RuboCop 底层)
PHP nikic/PHP-Parser
通用多语言 tree-sitter(50+ 种语言)

tree-sitter 特点

  • 核心用 C 编写,极速
  • 增量解析,只重新解析变化部分,适合编辑器实时场景
  • 生成 CST(具体语法树),保留所有原始信息含空白和注释
  • 有错误恢复能力,代码不完整也能解析
  • Neovim 语法高亮底层就是 tree-sitter

三、tree-sitter 使用

安装

pip install tree-sitter tree-sitter-languages

基本解析

from tree_sitter_languages import get_parser

parser = get_parser("python")  # 支持 javascript、rust、go 等

code = b"""
def hello(name: str) -> str:
    return f"hello {name}"
"""

tree = parser.parse(code)
root = tree.root_node

print(root.type)        # module
print(root.child_count)

Query 语法(S 表达式,类 CSS 选择器)

query = get_language("python").query("""
(function_definition
  name: (identifier) @func_name)
""")

captures = query.captures(root)
for node, name in captures:
    print(node.text.decode())

tree-sitter vs Babel 对比

tree-sitter Babel
语言支持 50+ 种 主要 JS/TS
速度 极快(C 实现) 较慢
树类型 CST(保留所有) AST(语义化)
代码修改 不方便 方便
查询能力 强(Query DSL) 需手动遍历
主要用途 分析、高亮、搜索 转译、修改代码

tree-sitter 更适合,Babel 更适合


四、Babel 插件的痛点

Babel 的 visitor 模式有几个让人别扭的地方:

// 结构臃肿,注入对象来源不直观
export default function({ types: t }) {
  return {
    visitor: {
      CallExpression(path, state) {
        // path 和 node 傻傻分不清
        // t、path、state、scope 关系不直观
      }
    }
  }
}

更简洁的写法(不用插件形式)

import { parse } from "@babel/parser"
import traverse from "@babel/traverse"
import generate from "@babel/generator"
import * as t from "@babel/types"

const ast = parse(`console.log("hello")`)

traverse(ast, {
  CallExpression(path) {
    console.log(path.node.callee)
  }
})

const { code } = generate(ast)

五、esquery —— 类 CSS 选择器查询

安装

npm install esquery @babel/parser

基本用法

import { parse } from "@babel/parser"
import esquery from "esquery"

const ast = parse(`
  foo(1, 2)
  console.log("hello")
`)

esquery.query(ast, "CallExpression")

选择器语法速查

// 节点类型
"CallExpression"

// 属性等于某值
"Identifier[name='foo']"

// 嵌套属性访问
"CallExpression[callee.name='foo']"
"CallExpression[callee.object.name='console']"

// 后代(任意层级)
"FunctionDeclaration Identifier"

// 直接子节点
"FunctionDeclaration > Identifier"

// :has 包含
"CallExpression:has([name='foo'])"

// :not 排除
"Identifier:not([name='undefined'])"

// 多个选择器
"FunctionDeclaration, ArrowFunctionExpression"

// 通配
"*[name='foo']"

配合修改

import generate from "@babel/generator"

const nodes = esquery.query(ast, "CallExpression[callee.name='foo']")
nodes.forEach(node => {
  node.callee.name = "bar"  // 直接改节点属性
})

const { code } = generate(ast)

六、jscodeshift —— 批量 codemod

Facebook 出品,用于批量自动化修改代码,典型场景是库升级迁移。

// transform.js
export default function(fileInfo, api) {
  const j = api.jscodeshift
  const root = j(fileInfo.source)

  root.find(j.CallExpression, {
    callee: { name: 'foo' }
  })
  .forEach(path => {
    path.node.callee.name = 'bar'
  })

  return root.toSource()
}
jscodeshift -t transform.js src/**/*.js
jscodeshift Babel transform
定位 批量改文件 编译时转换
格式保留 ✅ recast 保留原始风格 ❌ 重新生成
运行方式 命令行跑一次 每次构建都跑

七、ESTree 节点类型参考

ESTree 是 JS AST 的通用规范,Babel/ESLint/acorn 等工具都遵循此格式。

基础节点

Identifier          标识符(变量名、函数名等)
  .name             "foo"

Literal             字面量(字符串、数字、布尔、null、正则)
  .value            实际值
  .raw              原始文本 '"hello"'

表达式 Expression

CallExpression          函数调用  foo(a, b)
  .callee               被调用对象(Identifier 或 MemberExpression)
  .arguments[]          参数列表

MemberExpression        成员访问  obj.prop 或 obj[key]
  .object / .property / .computed

AssignmentExpression    赋值  a = b
  .operator  .left  .right

BinaryExpression        二元运算  a + b
  .operator  .left  .right

UnaryExpression         一元运算  !x  typeof x
  .operator  .argument

LogicalExpression       逻辑运算  a && b
  .operator  .left  .right

ConditionalExpression   三元  a ? b : c
  .test  .consequent  .alternate

ArrowFunctionExpression 箭头函数  (x) => x + 1
  .params[]  .body  .async

FunctionExpression      函数表达式  function() {}
  .id  .params[]  .body

NewExpression           new 调用  new Foo()
  .callee  .arguments[]

TemplateLiteral         模板字符串  `hello ${name}`
  .quasis[](静态部分)  .expressions[](动态插值)

ObjectExpression        对象字面量  { a: 1 }
  .properties[]

ArrayExpression         数组字面量  [1, 2, 3]
  .elements[]

ThisExpression          this

语句 Statement

ExpressionStatement     表达式语句  foo()
  .expression

BlockStatement          块  { ... }
  .body[]

ReturnStatement         return x
  .argument

IfStatement             if/else
  .test  .consequent  .alternate

ForStatement            for (init; test; update) {}
ForInStatement          for (x in obj) {}
ForOfStatement          for (x of iter) {}
WhileStatement          while (test) {}

SwitchStatement         switch
  .discriminant  .cases[]

ThrowStatement          throw err
TryStatement            try/catch/finally

声明 Declaration

VariableDeclaration     var/let/const
  .kind  .declarations[]

VariableDeclarator
  .id(Identifier 或解构)  .init(初始值)

FunctionDeclaration     function foo() {}
  .id  .params[]  .body  .async  .generator

ClassDeclaration        class Foo {}
  .id  .superClass  .body

模块 Module

ImportDeclaration       import { a } from 'b'
  .specifiers[]  .source

ExportNamedDeclaration  export { a }
ExportDefaultDeclaration export default foo
ExportAllDeclaration    export * from 'b'

常用 esquery 选择器示例

"CallExpression"                                          // 所有函数调用
"CallExpression[callee.name='foo']"                       // foo()
"CallExpression[callee.type='MemberExpression']"          // obj.method()
"CallExpression[callee.object.name='console'][callee.property.name='log']"  // console.log()
"VariableDeclaration[kind='const']"                       // const 声明
"Literal[typeof value='string']"                          // 字符串字面量
"ArrowFunctionExpression"                                 // 箭头函数
"FunctionDeclaration[async=true]"                         // async 函数
"FunctionDeclaration ReturnStatement"                     // 函数内的 return

八、工具资源

资源 地址
AST Explorer(Felix Kling 作品) https://astexplorer.net
ESTree 规范 https://github.com/estree/estree
esquery https://github.com/estools/esquery
jscodeshift https://github.com/facebook/jscodeshift
tree-sitter https://tree-sitter.github.io

最佳实践

先用 AST Explorer 验证节点类型:在写任何遍历逻辑之前,先把目标代码粘进 https://astexplorer.net,选对解析器(babel/parser 或 acorn),点击你关心的代码位置查看节点类型和字段名。手写遍历时节点名打错是最常见的低级错误。

path.node 读,用 path.replaceWithpath.node 是当前节点引用,修改它的属性会直接改 AST;path.replaceWith(newNode) 整体替换节点,会触发访问器重新执行。不要混用。

// 正确:替换整个节点
path.replaceWith(t.stringLiteral('replaced'));

// 错误:直接赋值给 path 无效,path 是包装对象不是节点本身
path = t.stringLiteral('replaced');

遍历顺序:先 enter 后 exitenter(默认)在进入节点时触发,exit 在离开时触发。处理嵌套结构时(如函数内的函数),用 exit 可以保证内层先处理完。

// 处理嵌套函数时,exit 保证由内而外
traverse(ast, {
    FunctionExpression: {
        exit(path) { /* 先处理内层,再处理外层 */ }
    }
});

使用 path.scope 避免变量名冲突:生成新标识符前调用 path.scope.generateUid('name') 而不是直接写死名字,Babel 会自动在当前作用域内保证唯一性。

const uid = path.scope.generateUid('temp'); // 返回 "_temp", "_temp2" 等

批量替换用 path.replaceWithMultiple,不要手动操作 body:直接 push 到 body 数组会跳过路径追踪,导致后续 visitor 看不到新节点。

// 正确:让 Babel 接管插入
path.replaceWithMultiple([stmt1, stmt2, stmt3]);

// 错误:直接修改数组
path.node.body.push(stmt3);

常见陷阱

陷阱:visitor 里修改节点导致无限循环

现象: 遍历卡死,CPU 100%,或报 "Maximum call stack size exceeded"。

原因:enter 中替换节点后,Babel 会重新访问新节点,而新节点满足同一条件,触发再次替换,陷入死循环。

解决: 替换后立即调用 path.skip() 跳过对新节点的访问,或者在处理前加标记检查。

traverse(ast, {
    StringLiteral(path) {
        if (path.node._processed) return; // 已处理则跳过
        const newNode = t.stringLiteral(decode(path.node.value));
        newNode._processed = true;
        path.replaceWith(newNode);
    }
});

陷阱:t.identifier 和字符串混用

现象: 生成的代码出现 "undefined" 字符串,或变量名被加上引号变成字符串字面量。

原因: t.memberExpression(obj, t.identifier('key'))t.memberExpression(obj, t.stringLiteral('key')) 生成的代码不同:前者是 obj.key,后者是 obj["key"]。误用 StringLiteral 做属性名就会出现 obj["undefined"] 这类错误。

解决: 点号属性用 t.identifier,方括号属性用 t.stringLiteral 并设 computed: true

t.memberExpression(obj, t.identifier('name'))         // → obj.name
t.memberExpression(obj, t.stringLiteral('name'), true) // → obj["name"]

陷阱:path.remove() 后继续访问同一 path

现象: 报错 NodePath has been removed,或删除节点后周边逻辑出错。

原因: path.remove() 从 AST 中移除节点,但不会停止当前 visitor 的执行,后续对 path.node 的访问会报错。

解决: path.remove() 之后立刻 return,不要再访问该 path 的任何属性。

traverse(ast, {
    EmptyStatement(path) {
        path.remove();
        return; // 必须立刻 return
    }
});

参见

阅读更多

Web 安全基础

1. HTML 转义(服务端渲染必须): 2. CSP(Content Security Policy): 3. HttpOnly Cookie:防止 JS 读取会话 Cookie: 4. 前端框架防护: 攻击者在第三方网站构造一个表单,诱导已登录用户提交,浏览器会自动携带目标站的 Cookie。 触发条件: 1. 用户已登录目标网站(Cookie 有效) 2. 目标 API 仅凭 Cookie 识别用户身份 3. 请求来源未验证 1. CSRF Token(推荐): 2. SameSite Cookie: 3. 验证 Origin/Referer 头:

By yellowdog

HTTP 协议深度指南

HTTP(HyperText Transfer Protocol)是 Web 的基础传输协议,基于 TCP/IP,采用请求/响应模型。 相关文档:Web安全基础(/web-an-quan-ji-chu/) FastAPI完全指南(/fastapi-wan-quan-zhi-nan/) Nginx完全指南(/nginx-wan-quan-zhi-nan/) 幂等性:多次执行相同请求,服务器状态结果相同。PUT /users/1 多次执行结果一致;POST /users 每次创建新资源,非幂等。 浏览器直接从本地缓存读取,不向服务器发送请求。 缓存命中时,状

By yellowdog

系统设计基础

SLA 对照表: 选择建议:无状态服务(Web 层、API 层)优先水平扩展;数据库初期垂直扩展,达到瓶颈后考虑分库分表或读写分离。 缓存穿透(查询不存在的 key,每次都打到 DB): 缓存击穿(热点 key 过期,瞬间大量请求打到 DB): 缓存雪崩(大量 key 同时过期,或缓存服务宕机): 令牌桶 Python 实现: Redis 实现分布式限流(滑动窗口): URL 命名规则: Cursor 分页响应格式: 雪花算法结构(64 bit): 定义:分布式系统不能同时满足以下三个特性: 在分布式环境中 P 是必须保证的,所以实际是 CP vs AP

By yellowdog

算法思路与模板

二分查找要求序列有序,每次将搜索范围缩减一半,时间复杂度 O(log n)。 两个指针从两端向中间收缩,常用于有序数组。 滑动窗口维护一个满足条件的区间 left, right,right 不断向右扩张,条件不满足时收缩 left。 滑动窗口通用框架: 1. 确定"子问题":原问题可以分解为哪些规模更小的同类问题 2. 定义 dpi 或 dpij 的含义,要足够清晰 3. 推导状态转移方程 4. 确定初始状态(边界条件) 5. 确定计算顺序(确保依赖的子问题先计算) 每件物品最多选一次。dpj = 容量为 j 时的最大价值,逆序遍历容量防止重复选取。 每

By yellowdog