# 一种编辑 TypeScript 代码的方式
## 前言
众所周知,TypeScript 在 [#13940](https://github.com/Microsoft/TypeScript/pull/13940) 中开放了 `transformer` 的API,在此之后, 这些 API 已经成为在 TypeScript 生态中编写代码生成器(`codegen`),代码转换(`transformer`)的普遍方式。同时,目前这些 API 也有它的一些缺点。
本文将分享与分析作者在开发 [ts-upgrade](https://github.com/HearTao/ts-upgrade) (将旧版本 TypeScript 代码转换为新版本语法的工具)过程中遇到的问题,思考以及解决方案。
## 途径
#### 传统方式:transformer API
TypeScript 目前提供了将代码解析为 AST, 并且进行转换的 API。 让我们通过一个简单的例子了解它。
##### 目标:将 `foo` 标识符 转换为 `bar` 标识符
首先,我们要将代码解析为 AST,以 ` let foo = 42` 为例:
```ts
import * as ts from 'typescript'
const code = "let foo = 1;" // 要转换的代码
const ast = ts.createSourceFile(
/* filename */'dummy.ts',
/* sourceText */ code,
ts.ScriptTarget.Latest
) // 解析后的 AST
```
可以看到,调用 TypeScript 编译器解析的过程非常简单。接下来,我们创建一个非常简单的 `transformer`:
```ts
const transformerFactory: ts.TransformerFactory<ts.Node> = (context: ts.TransformationContext) => {
return visitor
function visitor(node: ts.Node): ts.Node {
if (ts.isIdentifier(node) && node.text === "foo") { // 判断是否是 foo 标识符
return ts.createIdentifier("bar") // 如果是则替换为 bar 标识符
}
return ts.visitEachChild(node, visitor, context) // 否则继续遍历其他节点
}
};
```
在开发 transformer 的过程中,可能涉及许多 TypeScript 的数据结构,我们可以使用 [ts-ast-viewer](https://ts-ast-viewer.com) 查看 AST 的结构并且获取生成对应代码的工厂函数。 然后我们尝试转换并生成代码:
```ts
const result = ts.transform(ast, [transformerFactory]) // 转换 AST
const node = result.transformed[0]
// 由 AST 生成代码
const printer = ts.createPrinter()
const codeAfterTransform = printer.printNode(ts.EmitHint.Unspecified, node, ast);
console.log(codeAfterTransform) // let bar = 1;
```
可以看到,最后生成的代码已经如我们所预期的,将标识符 `foo` 改为了 `bar`。为了验证,我们再尝试一下:
```ts
const code = "console.log(foo)"
// ...
console.log(codeAfterTransform) // console.log(bar);
```
以上就是一个简单的 `transformer`。
##### 扩展
显而易见,我们可以由 `transformer` 实现基于 AST 节点的宏,即判断某些节点,并替换为其他内容。但是由于 `transformer` 在解析后才被执行,所以不可以像其他语言的宏一样修改/扩展语法,这也是它的限制。
##### 问题
`transformer` API 非常简洁好用,但是它同样也有一些缺憾:
1. 对 JSDoc 以及注释支持欠佳。在某些特殊情况下会丢失注释,并且在 TypeScript 4.0 之前的版本,JSDoc 相关的 API 并没有公开,并且处理 JSDoc/注释也是非常繁琐的工作。
2. 对代码风格有很大的破坏。可以认为`transformer` API 解析代码后,重新根据 AST 生成了新的代码。由于在这一过程中,丢失了很多信息,例如:空行,缩进等,因此通过 `transformer` API 转换后的代码相比原有的代码会有很多的改动。如果我们的目的是修改已有的 codebase,那么这是不可接受的。
3. 对跨节点的操作支持不够友好。如果需要修改同一级的两个节点,往往需要在访问公共的父节点时进行操作,这会让 `transformer` 的编写非常痛苦。
针对这些问题,作者经过一些尝试,找到了另外一种“新”方法。
#### 新的选择:textChanges API
##### 什么是 textChanges API
并且 TypeScript 在编译器之外,还提供了 `language server` 的功能,它为众多编辑器提供了例如“快速修复”,“重构” 等功能。这些功能会增删改代码,并且几乎不会修改原本的格式,而这些操作则是由被称为`textChanges`的 API 所提供。所以可以说,`textChangs` 并不 ”新“,只是很少暴露于视野中。
我们可以看到,TypeScript 在解析代码时,记录了每一个节点的位置:
```ts
export interface TextRange {
pos: number;
end: number;
}
export interface Node extends TextRange {
// ...
}
```
在获取到这些信息后,可以通过字符串操作更新代码。而`textChanges` 就是基于这些信息进行代码编辑的 API。
##### 为什么选择 textChanges API
由于 `textChanges` 基于字符串操作进行,不会涉及其他代码,因此不会有上文中代码格式变动方面的问题。由于同样的原因,它并不要求在访问公共父节点时操作,提升了编辑时的灵活性。
并且我们在编辑代码时,并不只是简单的文本替换,还需要考虑到例如代码格式方面(例如逗号)的问题。自行处理的话需要花费非常大的精力,而 `textChanges` 作为 TypeScript 用来提供代码编辑的接口,本身处理了大量的 edge case。本着不重复造轮子的原则,我们选择其作为编辑代码的另一种选择。
## 实践
下面作者简单分享一些在`ts-upgrade` 中使用 `textChanges` API 的实践。
##### 使用内部 API
不幸的是 `textChanges` 目前仅被内部使用,并没有开放 API。但是由于我们最终运行的是 `JavaScript`,而这些 API 仅仅是没有提供类型定义,所以我们可以手动为这些内部 API 添加类型定义。例如:
```ts
// typescript.d.ts
import { /* ... */ } from 'typescript';
declare module 'typescript' {
// ...
export namespace textChanges {
// ...
export function applyChanges(
text: string,
changes: readonly TextChange[]
): string; // 应用 changes
export class ChangeTracker {
public static with(
context: TextChangesContext,
cb: (tracker: ChangeTracker) => void
): FileTextChanges[]; // 记录 textChange
public replaceNode(
sourceFile: SourceFile,
oldNode: Node,
newNode: Node,
options?: ChangeNodeOptions
): void; // 替换节点
}
// ...
}
}
```
这些类型定义都可以从 TypeScript 仓库中提取,可以访问 [类型定义](https://github.com/HearTao/ts-upgrade/blob/master/src/typing/typescript.d.ts) 查看完整的 API。
##### textChanges 的依赖
使用 `textChanges` 需要构造 `LanguageServiceHost`,其中有一些涉及 TypeScript Compiler Host 与 VFS 的细节在此不再赘述,可以查看 [hosts.ts](https://github.com/HearTao/ts-upgrade/blob/master/src/host.ts) 与 [upgrade.ts](https://github.com/HearTao/ts-upgrade/blob/master/src/upgrade.ts#L43)了解更多。
##### 实例
下面我们重新使用 `textChanges` API 改写上面的例子
```ts
// ...
declare const context: ts.textChanges.TextChangesContext; // 构造 context 的过程省略
const changes = ts.textChanges.ChangeTracker.with(context, tracker => {
function changesVisitor (node: ts.Node) {
if (ts.isIdentifier(node) && node.text === "foo") {
tracker.replaceNode(ast, node, ts.createIdentifier("bar")) // 替换节点
}
ts.forEachChild(node, changesVisitor)
}
ts.forEachChild(ast, changesVisitor)
})
const result = ts.textChanges.applyChanges(ast.text, changes.map(change => change.textChanges).flat()) // 获取所有的 changes
console.log(result) // console.log(bar)
```
可以看到,在遍历 AST 时,不再需要返回更改后的节点,而是由 `changeTracker` 记录。在遍历结束后统一应用。这样在处理同级的节点时,只需通过各种手段(例如 `Symbol`, 可以查看 [NamespaceExport ](https://github.com/HearTao/ts-upgrade/blob/master/src/visitor.ts#L87)了解更多)获取到相应的节点即可。
##### 一些问题
正如 ”没有银弹“ 所说的那样,`textChanges` 也不是全无缺点:
1. Hack 使用 内部 API 有一定风险,可能随时失效。
2. 依赖较多,上手难度比较高,需要对 TypeScript 生态有一定了解。
3. 不支持重叠的代码编辑。而重叠的代码编辑是很常见的需求。
##### 解决方案
针对不支持重叠编辑的问题,作者进行了一层抽象,即进行多次遍历/编辑操作,若编辑操作有重叠,则仅会记录首次操作,其余操作将被忽略。而如果有操作被忽略则会进行另一次遍历,直到没有重叠为止。可以查看 [changes.ts](https://github.com/HearTao/ts-upgrade/blob/master/src/changes.ts) 了解更多。
## 结语
`transformer` 与 `textChanges` 都是进行代码编辑/转换的接口,在实际开发中,应根据自己不同场景和实际的需求,选择最适合自己的工具。
感谢阅读。