前端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脚本完成,这些任务需要满足以下两个条件之一:
- 规则简单,如批量替换。在大多数的IDE中可完成,如VSCode支持正则表达式替换。
- 新代码,也可说成是代码生成器。即使规则很复杂,也可以通过脚本处理,因为它不需要与原有代码做处理,本质上还是一个模板生成的过程。
而那些既规则复杂(难以用正则表达式描述),又是修改原有代码的任务,就适合用AST处理了。
在没有脚本积累的时候(即第一次使用时),使用AST处理批量任务会是一个比较耗时的过程(留出一天左右的时间),之后就会快很多。所以也可以考虑先使用AST处理一些简单任务,积累经验。
另外还有一些任务是比较推荐用AST来实现的:
- 准确度要求高:批量替换虽然简单,但是容易替换出bug,在数据比较多的时候,可能会注意不到bug的产生。因此在
webpack-loader
、eslint-plugin
、babel-plugin
等应用场景中,都应该优先考虑使用AST。 - 分析报表:类似于一些代码质量检查工具等,需要出分析报告的场景,用AST可以给出精确的报告,且当报表规则变化时,调整也方便。
- 自动化任务:批量替换等方式,因为可靠度不够高,往往需要人的二次甄别,因此不太适合处理自动化任务。
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 结构:
然后,在代码中可以使用类似于如下的选择器,选择到相应的节点:
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)
上面的代码的逻辑比较简单,主要分为以下几个部分:
-
getProject() 获取工程,上面有给出
getProject
函数的代码供参考 -
project.getSourceFiles()
获取文件列表,这里可以过滤掉不需要处理的文件 -
file.getFirstDescendantByKind
定位节点,getFirstDescendantByKind
是一种选择器,类似的选择器有很多,举例来说,对于获取子元素,有如下的常见方式:名称 作用 getChildren 返回所有子元素 getChildAtIndex 返回第index个子元素 getChildCount 返回子元素数量 getChildAtPos 返回指定位置的子元素(pos代替在文件流中的配置) getChildrenOfKind 获取指定类型的子元素列表 getDescendants 获取后代(子、孙、重孙等所有层级)列表 getDescendantsOfKind 获取指定类型的所有后代列表 类似的方法比较多,这里就不一一介绍了。在实际使用过程中,Typescript的提示会很有帮助。如果想要得到一个比较全的列表,也可以参考ts-morph的类型文件。
-
replaceWithText
替换节点内容为新的文本。这是一种常见的修改方式,先通过
getText()
获取到其文本内容,在处理之后,再调用replaceText
将原有内容替换成新的内容。这是一个比较万金油型的替换方式,除了这种方式以外,还有一些根据节点类型而定的方法,比如
addStatements、addProperty、insertProperty
等,各种有其适用的场景。 -
await project.save
执行保存动作,此时会保存所有变更到磁盘中。
运行
在代码编写完成后,使用ts-node
工具执行脚本,并给出目标工程的tsconfig.json
文件路径,或待处理的单个文件路径,即可运行。
如对于 transformer 中的脚本,可以通过如下的命令来运行。
1ts-node src/scripts/xxxxx.ts -c /path/to/project/tsconfig.json
这里的 -c 参数是提供tsconfig.json文件路径用的,工程内使用
yargs
和getProject
函数处理。
常见的坑
- 修改操作(如
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。
希望对大家有所帮助!