使用ts-morph自动重写了大量的TypeScript代码
有一款名为ts-morph的工具,可以用于分析和重写TypeScript代码。
最近在工作中需要对大量的TypeScript文件进行重写,于是尝试使用了这个工具。它非常方便,并且学习使用方法也很简单,因此我打算结合实例来介绍一下。
我做了什么样的改写?
因为要根据实例进行介绍,所以首先简要介绍一下本次要进行的改写的内容。
本次进行的是与GraphQL代码生成库的迁移工作(apollo-tooling → GraphQL Code Generator)相关的改写工作。
由于代码生成库的更改,导入文件发生了变化,导入的名称也有一些改变,需要对其进行相应的修改。
具体而言,这样的代码可以这样写:
import FetchDataQuery from '~/graphql/generated/someQueryByQueryName.gql'
import { FetchData, FetchDataVariable, FetchData_items } from '~/graphql/generated/types/someQueryByQueryName'
export const SomeComponent = () => {
const { data, refetch } = useQuery<FetchData, FetchDataVariables>(
FetchDataQuery,
)
const items: FetchData_items[] = data.items
// ...
)
请将以下内容用中文进行改述,仅需要一个选项:
↓
import { FetchData, FetchDataDocument } from '~/graphql/generated/some-query-by-file-name'
export const SomeComponent = () => {
const { data, refetch } = useQuery(
FetchDataDocument,
)
const items: FetchData_items[] = data.items
// ...
)
type FetchData_items = FetchData['items'][number]
在Qiita(一个网站)上有大约100个这样的文件需要修改,所以用手工方式全部重新写入是非常繁琐的。
我在这里使用的是 ts-morph。
ts-morph 能做些什么?
目的
TypeScript AST的设置、导航和操作可能是一个挑战。这个库封装了TypeScript编译器API,使其变得简单。
https://ts-morph.com/
ts-morph是一个用于操作TypeScript文件的库,它封装了TypeScript编译器的功能,以便于简单操作。
对于使用VSCode或TypeScript LSP的人来说,它提供了大部分可通过ts-morph来使用的重构功能(重命名、添加导入等)。
使用ts-morph进行代码重写
只要阅读 ts-morph 的官方文档 (ts-morph – Documentation),并试着操作一下,就能大致理解它。下面我将介绍一些使用 ts-morph 的实际案例。
使用 ts-morph 进行文件更改的流程
使用 ts-morph 操作文件的基本流程如下:获取每个文件(sourceFile),执行操作,并保存。
import { Project } from 'ts-morph'
const project = new Project({ tsConfigFilePath: './tsconfig.json' })
project.getSourceFiles().forEach(sourceFile => {
sourceFile.getClasses() // sourceFile 内の class 定義をすべて取得する
sourceFile.getImportDeclarations((importDeclaration) => { // sourceFile 内の各 import を取得する
const specifier = importDeclaration.getModuleSpecifierValue() // import ファイル指定先
const newSpecifier = specifier.replace(specifier, `modified/${specifier}`)
importDeclaration.setModuleSpecifier(newSpecifier) // import ファイル指定先を変更する
})
const importDeclaration = sourceFile.addImportDeclaration({
defaultImport: "MyClass",
moduleSpecifier: "./file",
}) // import MyClass from "./file" を追加する
sourceFile.save() // 行った操作をファイルに反映させる
})
修改导入的名称
如果不仅仅是对其进行简单的改写,而且还要更改变量名,您可以一并在使用它的地方进行更改(类似于 LSP 提供的 rename 功能)。
import { FetchDataQuery } from '~/graphql/generated/types/some-query-by-file-name'
const { data, refetch } = useQuery(FetchDataQuery)
点击这里
import { FetchDataDocument } from '~/graphql/generated/types/some-query-by-file-name'
const { data, refetch } = useQuery(FetchDataDocument)
你可以使用以下代码一次性修改导入的名称以及使用该名称的地方。
const namedImports = importDeclaration.getNamedImports()
namedImports.forEach(namedImport => {
const currentName = namedImport.getText()
if (/Query$/.test(currentName)) {
const newName = currentName.replace(/Query$/, 'Document')
const aliasName = namedImport.getAliasNode()?.getText() // import { FetchDataQuery as Query } from ... という形式の場合は Query が alias
if (aliasName) {
// Alias がある場合は一度 Alias を削除してから、import する名前を変更して Alias を再設定する
namedImport.removeAlias()
namedImport.setName(newName)
namedImport.setAlias(aliasName)
} else {
// Alias がない場合はそのまま名前を変更する。この書き方だと利用箇所も一緒に変更される
namedImport.getNameNode().rename(newName)
}
}
})
添加定义
由于生成的代码中有一些名称不再导出,所以为了与其相对应,需要添加类型定义。
import { FetchData_items } from '~/graphql/generated/types/some-query-by-file-name'
↓
请将以下内容用中文进行本地化改写,只需提供一个选项:
↓
请使用中文将以下内容重新表达,只需提供一个选项:
import { FetchData } from '~/graphql/generated/some-query-by-file-name'
type FetchData_items = FetchData['items'][number]
由于ts-morph提供了一个用于添加Type定义的函数,因此可以利用它来添加上述的类型定义。
const namedImports = importDeclaration.getNamedImports()
namedImports.forEach(namedImport => {
const currentName = namedImport.getNameNode().getText()
if (/_/.test(currentName)) {
const [basePart, ...props] = currentName
.split('_') as string[]
// FetchData_items -> FetchData['items'][number] という形式に変換する
const aliasType = props.reduce((accType, prop) => {
switch (prop) {
case 'items':
return `${accType}[${prop}][number]`
default:
return `${accType}[${prop}]`
}
}, basePart)
// type 定義を追加する
sourceFile.addTypeAlias({
name: currentName,
type: aliasType,
})
// import する名前を FetchData -> FetchData_items に変更する
namedImport.setName(basePart)
}
})
整理 import 定义,在 organize imports 中执行
当以机械方式进行代码重写时,可能会导致未使用的导入模块保留下来,添加的导入模块顺序混乱等,使得代码看起来不美观。
import FetchDataQuery from '~/graphql/generated/someQueryByQueryName.gql'
import { FetchData_items } from '~/graphql/generated/types/someQueryByQueryName'
import XXX from 'other-library'
import { FetchData } from '~/graphql/generated/some-query-by-file-name'
请将以下内容用中文进行本土化改写,只需要一种选择:
↓
import { FetchData } from '~/graphql/generated/some-query-by-file-name'
import XXX from 'other-library'
在 ts-morph 中,有一个称为“整理导入”的功能,可以删除未使用的导入项,调整导入的顺序。
project.getSourceFiles().forEach(sourceFile => {
sourceFile.organizeImports() // organize imports を実行する
sourceFile.save() // 行った操作をファイルに反映させる
})
写代码时的实际修改技巧
经此一事,我们发现了 ts-morph 可以进行必要的重写操作。
在团队开发中,使用这个工具进行实际的重写是很常见的,与其他成员发生讨论也是常有的情况。下面将介绍在团队开发中进行重写工作时有用的方法。
提示1:将一系列的改写全部编码化,以便能够幂等地执行。
將以下內容以中文進行本地化改寫,只需提供一種選項:
對於改寫作業,基本上建議先將其編碼化,同時進行回滾操作,以確保腳本的冪等性。
# 書き換え実施前にコードの状態を戻す
git restore --source=$BASE_COMMIT_ID --worktree -- javascripts/
# 一連の書き換え作業を行う
ts-node ./rewriter-with-ts-morph.ts
yarn run format || true
很好的原因是,因为这样做有以下优点。
-
- 書き換え作業では、バグや考慮漏れの対処など、試行錯誤が必要になるので、リトライしやすくしておくと効率が良い
-
- 他のメンバーの変更への追従が容易になる
-
- 作業を全てスクリプト化することで、意図しない作業の混入を防げる
-
- レビューのタイミングでのフィードバックなど、後から出てきた要望にも対応しやすい
作業を始める前に、ある程度方向性などを握っておくのももちろん重要ですが、実物を見てコメントが来ることもあるので、それに対応しやすいというメリットは大きいです。
特别是,我认为能够容易地应对后来产生的细微反馈是非常重要的。
当然,在开始工作之前阶段的时候,与审核者进行某种程度的沟通也是很重要的,但是能够在查看更改后的代码时进行细致的讨论,能够高效地推进讨论,这是一件非常好的事情。
提示2:建议同时使用代码检查工具(如Linter)的自动修正功能和格式化工具。
这种代码重写工具经常会出现的情况是,重写或添加的代码往往不符合项目的缩进等写法。
由于重写工具很难根据项目的编写方式添加代码,所以基本上最好同时使用 Linter 的自动修复和格式化工具。