回顾

上文我们介绍了 VSCode 进行代码重构的大体逻辑,
内置的 TypeScript 插件(typescript-language-features)响应快捷键后,发消息给 tsserver,
tsserver 计算重构后的结果并返回,最后展示在编辑器中。

颇费篇幅的是 vscodetypescript 的调试配置。
包括如何调试 VSCode 内置插件,如何 attach 到 tsserver 进程,
如何让 VSCode 调用指定版本的 TypeScript 源码(需要 source map)。

代码调通后,剩下的工作就会变得简单许多了。
本文重点研究 tsserver 的重构过程,看看它是怎样计算得到重构结果的。

1. 找到相关的 refactor

接上一篇文章,我们打开了两个 VSCode 实例,
一个用于启动 TypeScript 插件(typescript-language-features),
另一个用于 attach 到 tsserver。

启动 TypeScript 插件(typescript-language-features)后,
VSCode 会弹出一个新的名为 [Extension Development Host] 的窗口,
在这个窗口中,打开一个 .ts 文件,对它执行重构。

选中 x, y,按 ⌘ + .,选择 Extract to function in global scope

  1. const f = x => {
  2. x, y
  3. };

重构结果为,

  1. const f = x => {
  2. newFunction(x);
  3. };
  4. function newFunction(x: any) {
  5. x, y;
  6. }

下图为 tsserver 执行重构前的断点位置,
随遇而安 TypeScript(八):TSServer Refactor - 图1
位于 getEditsForRefactor src/services/refactorProvider.ts#L35 函数中,

  1. export function getEditsForRefactor(context: RefactorContext, refactorName: string, actionName: string): RefactorEditInfo | undefined {
  2. const refactor = refactors.get(refactorName);
  3. return refactor && refactor.getEditsForAction(context, actionName);
  4. }

它首先根据 refactorName 拿到了相关的 refactor
然后再调用这个 refactorgetEditsForAction 方法,得到重构结果。
随遇而安 TypeScript(八):TSServer Refactor - 图2
我们选择的是 Extract to function in global scope 重构方式,对应的 refactorNameExtract Symbol

单步调试,进入到 refactor.getEditsForAction 函数中,位于 src/services/refactors/extractSymbol.ts#L89
随遇而安 TypeScript(八):TSServer Refactor - 图3

这就进到了 extractSymbol 这个 refactor 中,

仔细观察一下,src/services/refactors/ 这个文件夹包含了 8 个 refactor。
每个 refactor 的代码结构都是类似的,

  1. /* @internal */
  2. namespace ts.refactor.xxx {
  3. const refactorName = "xxx";
  4. registerRefactor(refactorName, { getAvailableActions, getEditsForAction });
  5. function getAvailableActions(context: RefactorContext): readonly ApplicableRefactorInfo[] { }
  6. function getEditsForAction(context: RefactorContext, actionName: string): RefactorEditInfo | undefined { }
  7. }

都是调用了 ts.refactor namespace 中的 registerRefactor 进行注册。

传入了 getAvailableActions(有哪些重构方式) 和 getEditsForAction(计算特定的重构结果) 两个方法。

其中,registerRefactor,位于 src/services/refactorProvider.ts#L26

  1. namespace ts {
  2. ...
  3. export namespace refactor {
  4. ...
  5. export function registerRefactor(name: string, refactor: Refactor) {
  6. refactors.set(name, refactor);
  7. }
  8. ...
  9. }
  10. ...
  11. }

2. extractSymbol refactor 全流程

知道了 refactor 的代码结构之后,我们言归正传,来看当前用到的 extractSymbol 这个 refactor。
随遇而安 TypeScript(八):TSServer Refactor - 图4
仔细阅读代码之后,我直接将断点停在了重构结果返回的位置,

位于 extractFunctionInScope 函数中 src/services/refactors/extractSymbol.ts#L978,有 282 行。

过程中重点调用的函数总结如下,

  1. refactor.getEditsForAction
  2. getFunctionExtractionAtIndex
  3. # 分析上下文信息
  4. getPossibleExtractionsWorker
  5. # 计算作用域信息
  6. collectEnclosingScopes
  7. # 计算 usage 信息,以确定参数列表
  8. collectReadsAndWrites
  9. # 提取函数
  10. extractFunctionInScope
  11. # 在全局作用域创建一个函数声明
  12. createIdentifier
  13. createParameter
  14. transformFunctionBody
  15. createFunctionDeclaration
  16. changeTracker.insertNodeAtEndOfScope
  17. # 在原位置创建一个函数调用
  18. createCall
  19. createStatement
  20. changeTracker.replaceNodeRangeWithNodes
  21. # 获取所有的修改
  22. changeTracker.getChanges

我们发现这个流程还是挺复杂的,并且没有采用拼字符串的方式生成代码,

而是使用了创建 ast 节点的工厂方法(createXXX),
这些工厂方法都集中放在了 src/compiler/factory.ts 文件中,有 5547 行。

值得一提的是,changeTracker.getChanges 的返回值 edits 有一个坑。

从 VSCode 的表现来看,Extract to function in global scope 会产生两个 changes,
一个是在全局作用域创建函数声明,另一个是在原位置创建一个函数调用。
它们都应该反映在 changeTracker.getChanges 的返回值 edits 中。
随遇而安 TypeScript(八):TSServer Refactor - 图5
edits[0].textChanges[1].newText 其实是多行文本,
但由于 VSCode 调试面板只能展示第一行,就看起来这个 newText 只是一个空字符串了。
我们在 DEBUG CONSOLE 中展示一下 edits 的内容,就看到换行符了。

随遇而安 TypeScript(八):TSServer Refactor - 图6

  1. [
  2. {
  3. "fileName": "/Users/.../index.ts",
  4. "textChanges": [
  5. {
  6. "span": {
  7. "start": 19,
  8. "length": 4
  9. },
  10. "newText": "newFunction(x);"
  11. },
  12. {
  13. "span": {
  14. "start": 27,
  15. "length": 0
  16. },
  17. "newText": "\nfunction newFunction(x: any) {\n x, y;\n}\n"
  18. }
  19. ]
  20. }
  21. ]

最后,回顾整个重构过程,getPossibleExtractionsWorker 对上下文进行分析,

得到了作用域信息,usage 信息,我觉得反而是最值得研究的环节,
只有拿到了这些信息,提取函数才有据可依。

3. 详解:上下文分析

getPossibleExtractionsWorker 位于 src/services/refactors/extractSymbol.ts#L644

  1. function getPossibleExtractionsWorker(...): ... {
  2. ...
  3. const scopes = collectEnclosingScopes(targetRange);
  4. ...
  5. const readsAndWrites = collectReadsAndWrites(...);
  6. return { scopes, readsAndWrites };
  7. }

它返回了两个变量 scopesreadsAndWrites
随遇而安 TypeScript(八):TSServer Refactor - 图7

3.1 collectEnclosingScopes

我们对着代码来说,

  1. const f = x => {
  2. x, y
  3. };

scopes 是一个数组,包含了两个节点,
第一个元素是函数 f 的定义,第二个元素是 sourceFile
也就是从选中的待提取为函数的代码 x, y 来看,它包含在这样两个作用域(ast 节点)中。

它是怎么知道是这两个节点呢?
这还要看 collectEnclosingScopes 的代码 src/services/refactors/extractSymbol.ts#L528
随遇而安 TypeScript(八):TSServer Refactor - 图8

  1. function collectEnclosingScopes(range: TargetRange): Scope[] {
  2. ...
  3. const scopes: Scope[] = [];
  4. while (true) {
  5. current = current.parent;
  6. ...
  7. if (isScope(current)) {
  8. scopes.push(current);
  9. if (current.kind === SyntaxKind.SourceFile) {
  10. return scopes;
  11. }
  12. }
  13. }
  14. }

它会从当前节点位置,循环往上查找父节点 current.parent,识别每个是作用域边界(isScope )的节点,

isScopesrc/services/refactors/extractSymbol.ts#L519

  1. function isScope(node: Node): node is Scope {
  2. return isFunctionLikeDeclaration(node) || isSourceFile(node) || isModuleBlock(node) || isClassLike(node);
  3. }

3.2 collectReadsAndWrites

usage 信息就略微复杂一些了,
collectReadsAndWrites 位于 src/services/refactors/extractSymbol.ts#L1451,有 367 行。

它不止返回了 usage 信息,从返回类型上,我们看到还包含这些信息,
ReadsAndWritessrc/services/refactors/extractSymbol.ts#L1444

  1. interface ReadsAndWrites {
  2. readonly target: Expression | Block;
  3. readonly usagesPerScope: readonly ScopeUsages[];
  4. readonly functionErrorsPerScope: readonly (readonly Diagnostic[])[];
  5. readonly constantErrorsPerScope: readonly (readonly Diagnostic[])[];
  6. readonly exposedVariableDeclarations: readonly VariableDeclaration[];
  7. }

我们只看 usagesPerScope
随遇而安 TypeScript(八):TSServer Refactor - 图9
collectReadsAndWrites 创建了一个临时节点 target,然后调用 collectUsages 来分析 usage 情况。

我们看到 target.statements[0].expression.lefttarget.statements[0].expression.right
刚好是我们选中的代码 x, y(逗号表达式)的逗号分隔的两个部分 xy
随遇而安 TypeScript(八):TSServer Refactor - 图10

接着我们来看 collectUsages 函数,src/services/refactors/extractSymbol.ts#L1629

它会遍历临时创建的那个节点 target,然后计算 recordUsage 每个标识符的 usage 信息。
随遇而安 TypeScript(八):TSServer Refactor - 图11
recordUsage 位于 src/services/refactors/extractSymbol.ts#L1668

  1. function recordUsage(n: Identifier, usage: Usage, isTypeNode: boolean) {
  2. const symbolId = recordUsagebySymbol(n, usage, isTypeNode);
  3. ...
  4. }

正是第一行的 recordUsagebySymbol 函数 src/services/refactors/extractSymbol.ts#L1681 计算了 usage 信息。
随遇而安 TypeScript(八):TSServer Refactor - 图12

结合重构前的代码来看,

  1. const f = x => {
  2. x, y
  3. };

recordUsagebySymbol 函数中,有几个地方值得注意,

  • getSymbolReferencedByIdentifier:获取与标识符(ast 节点)对应的 symbol 对象。
  • symbol.getDeclarations:获取 symbol 的定义节点,例如 x 这个符号是函数的形参,所以定义位置就是形参节点(ast 节点)。
  • checker.resolveName:在函数 f 作用域下,找一下变量名 x,能找到就会返回 symbol。

对于 y 来说,getSymbolReferencedByIdentifier 直接返回 undefined
自然就不会加入 usage 了。

最终得到的 ussage 信息如下,x 是有 usage 的,它引用了函数 f 形参定义的符号,
y 则没有 usage 信息。
随遇而安 TypeScript(八):TSServer Refactor - 图13

4. 总结

本文分析了 tsserver 计算重构结果的过程,主要包含两个步骤,

  • getPossibleExtractionsWorker:分析上下文信息(获得 scope 和 usage 信息)
  • extractFunctionInScope:提取函数(利用工厂函数创造 ast 节点)

其中,第一步需要对 ast 进行语义分析,第二步需要对工厂函数较为熟悉才可以。
因此 tsserver 重构完全是基于 ast 和语义的,能更好的理解上下文。

代码重构这些内容,值得投入些时间来学习。


参考

vscode v1.45.1
typescript v3.7.3