logo

鱼肚的博客

Don't Repeat Yourself

前端AST处理实践指南(基于ts-morph)

背景

最近在工作中遇到了一些重复性的任务,为了提升效率,使用了AST进行了批量处理。

ts-morph是一个适用于Javascript、Typescript的AST处理工具库,基于typescript实现。

虽然之前就有用过ts-morph,并进行过介绍:使用ts-morph批量修改代码,但是再次使用的时候,还是遇到了不少的坑。

为了备忘并对读者有一些借鉴作用,特总结了实践指南如下:

安装和配置 ts-morph

安装

ts-morph 发布在 npm 镜像仓库上,直接使用 npm 命令就可以安装。

1npm install --save-dev ts-morph

在安装之前,需要先考虑下安装的位置,即独立成工具项目,或集成在业务项目中。

鉴于ts-morph的体积比较大(1.53 MB),且使用频次不频繁,不建议随项目安装。独立成一个工具项目既不会占用业务项目的node_modules体积,也可以较容易地应用于多个项目。

配置

关于实际使用案例,可以参考我之前的一个小工具项目:transformer,之前在改造 tsterser 项目时曾大量应用过。

这里给出一些关键的文件:

package.json

依赖配置

1"dependencies": {
2  "ts-morph": "^11.0.0",
3  "ts-node": "^10.0.0",
4  "typescript": "^4.3.5",
5  "yargs": "^15.4.1"
6}

utils

根据tsconfig.json 文件,生成包含整个项目的 Project ,或按给定文件生成只包含指定文件的 Project

1export function getProject (): Project {
2  // if tsconfig.json specified, use it
3  if ((argv.config ?? '') !== '') {
4    return new Project({
5      tsConfigFilePath: argv.c
6    })
7  }
8  // otherwise use arguments in argv as source files
9  const project = new Project({ })
10  argv._.forEach(srcPath => project.addSourceFileAtPath(srcPath))
11  return project
12}

遍历节点

1export function walk (node: Node, callback: (node: Node) => boolean): boolean {
2  return _walk(node, callback, new Set())
3}
4
5function _walk (node: Node, callback: (node: Node) => boolean, parsed: Set<Node>): boolean {
6  try {
7    if (parsed.has(node) || node.compilerNode === null) {
8      return false
9    }
10    parsed.add(node)
11  } catch (error) {
12    return false
13  }
14
15  const children = node.getChildren()
16
17  // visit root node
18  if (!callback(node)) {
19    return false
20  }
21
22  // visit children, depth-first-search
23  for (let i = 0; i < children.length; i++) {
24    if (!_walk(children[i], callback, parsed)) {
25      return false
26    }
27  }
28  return true
29}

更多代码,请移步 transformer 中查看。

应用指南

在安装和配置好ts-morph之后,就进入到实战的环节中了。

我将实战环节分为如下的几个过程:

  • 方案选择

    当前面临的工作,是否适合用AST解决。

  • AST结构分析

    待处理的文件的语法树长什么样?

  • 脚本编写&运行

    编写适合的脚本

  • 常见的坑

    理解和绕过常见的问题

方案选择

很多批量的任务,可以通过批量搜索替换,或者编写Shell脚本完成,这些任务需要满足以下两个条件之一:

  1. 规则简单,如批量替换。在大多数的IDE中可完成,如VSCode支持正则表达式替换。
  2. 新代码,也可说成是代码生成器。即使规则很复杂,也可以通过脚本处理,因为它不需要与原有代码做处理,本质上还是一个模板生成的过程。

而那些既规则复杂(难以用正则表达式描述),又是修改原有代码的任务,就适合用AST处理了。

在没有脚本积累的时候(即第一次使用时),使用AST处理批量任务会是一个比较耗时的过程(留出一天左右的时间),之后就会快很多。所以也可以考虑先使用AST处理一些简单任务,积累经验。

另外还有一些任务是比较推荐用AST来实现的:

  1. 准确度要求高:批量替换虽然简单,但是容易替换出bug,在数据比较多的时候,可能会注意不到bug的产生。因此在webpack-loadereslint-pluginbabel-plugin等应用场景中,都应该优先考虑使用AST。
  2. 分析报表:类似于一些代码质量检查工具等,需要出分析报告的场景,用AST可以给出精确的报告,且当报表规则变化时,调整也方便。
  3. 自动化任务:批量替换等方式,因为可靠度不够高,往往需要人的二次甄别,因此不太适合处理自动化任务。

AST结构分析

批量处理的脚本,从原理上来说,是先定位到相应的节点位置(类似于CSS选择器),然后再执行一些相应的操作(如增减属性、替换文本)。

因此,有一个可视化的结构分析工具是比较重要的,实现类似于浏览器devtools的效果。

对于TS、JS相关的代码,可以使用一个在线的AST分析网站:https://ts-ast-viewer.com/。

举例来说,对于如下的一段TS代码:

1const arr: number[] = [1, 2, 3, 4];
2const obj = { hello: 1, world: 2 };
3const func = () => {
4  console.log('func');
5  return arr.length;
6}

可以看到类似这样的 AST 结构:

ast-preview

然后,在代码中可以使用类似于如下的选择器,选择到相应的节点:

1// 寻找文件中的第一个类型为 VariableDeclaration 的节点
2const arrNode = file.getFirstDescendantByKind(SyntaxKind.VariableDeclaration)

脚本编写&运行

编写

首先看一个简单的脚本示意代码:

1import { getProject } from '../utils';
2import { SyntaxKind } from '@ts-morph/common'
3
4(async () => {
5  const project = getProject()
6  const files = project.getSourceFiles()
7  for (const file of files) {
8    const actions: Array<() => void> = []
9    // 各种节点获取逻辑和判断逻辑
10    const arrNode = file.getFirstDescendantByKind(SyntaxKind.VariableDeclaration)
11    
12    // 先把待处理事项存起来
13    actions.unshift(() => {
14      arrNode.replaceWithText('{ hello: 1, world: 2 }')
15    })
16    
17    // 最后一并处理
18    actions.forEach((act) => {
19      act()
20    })
21  }
22
23  await project.save()
24})().catch(console.error)

上面的代码的逻辑比较简单,主要分为以下几个部分:

  1. getProject() 获取工程,上面有给出getProject函数的代码供参考

  2. project.getSourceFiles()获取文件列表,这里可以过滤掉不需要处理的文件

  3. file.getFirstDescendantByKind定位节点,getFirstDescendantByKind是一种选择器,类似的选择器有很多,举例来说,对于获取子元素,有如下的常见方式:

    名称作用
    getChildren返回所有子元素
    getChildAtIndex返回第index个子元素
    getChildCount返回子元素数量
    getChildAtPos返回指定位置的子元素(pos代替在文件流中的配置)
    getChildrenOfKind获取指定类型的子元素列表
    getDescendants获取后代(子、孙、重孙等所有层级)列表
    getDescendantsOfKind获取指定类型的所有后代列表

    类似的方法比较多,这里就不一一介绍了。在实际使用过程中,Typescript的提示会很有帮助。如果想要得到一个比较全的列表,也可以参考ts-morph的类型文件

  4. replaceWithText 替换节点内容为新的文本。

    这是一种常见的修改方式,先通过getText()获取到其文本内容,在处理之后,再调用replaceText将原有内容替换成新的内容。

    这是一个比较万金油型的替换方式,除了这种方式以外,还有一些根据节点类型而定的方法,比如 addStatements、addProperty、insertProperty等,各种有其适用的场景。

  5. await project.save 执行保存动作,此时会保存所有变更到磁盘中。

运行

在代码编写完成后,使用ts-node工具执行脚本,并给出目标工程的tsconfig.json文件路径,或待处理的单个文件路径,即可运行。

如对于 transformer 中的脚本,可以通过如下的命令来运行。

1ts-node src/scripts/xxxxx.ts -c /path/to/project/tsconfig.json

这里的 -c 参数是提供tsconfig.json文件路径用的,工程内使用yargsgetProject函数处理。

常见的坑

  • 修改操作(如replaceText)导致原有节点失效
  • 修改操作(如replaceText)提示语法错误

原有节点失效

getProject操作之后,ts-morph根据涉及到的文件的内容,构建出了AST模型,可以用来遍历查询。

但是当在文件中执行了替换操作(如replaceText)之后,ts-morph建立的模型会被改变,继而会导致之前查询的节点不再可用。

此时会出现这样的错误:

InvalidOperationError: Attempted to get information from a node that was removed or forgotten.

要避免这个问题,就要注意将查询阶段与修改阶段分开,如上面给出的例子,先将所有待操作的指令存储在一个actions数组中,在每个文件遍历完成后,再统一执行。

同时为了避免修改文件上方的代码,影响到下方代码的锚点,应该先改下面的,再改上面的。

1actions.unshift(() => { // 使用 unshift 来倒序
2  // 具体操作
3})
4
5actions.forEach(act => act()) // 遍历执行

语法错误

replaceText 操作有时会提示语法错误,因为ts-morph认为新插入节点的类型和原有类型不一致,可能是个bug,常见于注释代码的场景。

对于确认没有语法错误的情况,可以先调用removeText删除原有节点,再使用insertText插入新节点。

总结

以上就是我总结的前端AST处理工具实践指南的内容,主要基于 ts-morph 进行操作,并使用了https://ts-ast-viewer.com/ 这个网站查看语法树。

想要参考具体代码的,可以移步到我之前写的一个Git仓库transformer

希望对大家有所帮助!