Node.js

[Node] 随遇而安 TypeScript(九):多文件处理

2020-06-06  本文已影响0人  何幻

背景

到目前为止,前面文章提到的内容,都是仅涉及单文件的,
可实际大多数项目,都是多文件的,比如,我们有以下两个文件,

import x from './other';
x
const a = 1;
export default a;

index.ts 引用了 other.ts 中的变量。

我们把它们放在同一个文件夹下,用 VSCode 打开这个文件夹,
然后在 index.ts 的变量 x 上使用快捷键 F12,或者右键选择 Go to Definition

就会发现光标会从 index.ts 文件,跳转到 other.ts 文件中,并且位于 x 引用的 a 变量上了。


这种跨文件的定义跳转是怎样实现的呢?
要了解具体细节,还要从多文件的解析方式开始。

1. 调试环境

为了理解 Go to Definition 的实现逻辑,我们需要同时对 vscodetypescript 进行调试。
调试环境的配置方式,这里就不再赘述了,第七篇中我们用了很长的篇幅进行介绍。

跟之前介绍唯一不同的是,我们在启动 TypeScript 插件(typescript-language-features)时,
要指定上文 index.ts 和 other.ts 所在的文件夹作为启动目录。

具体的改法是,修改 vscode 项目的 .vscode/launch.json 配置,
第七篇中,我们已经在 .vscode/launch.json 中增加了这样一个配置项,

{
  ...,
  "configurations": [
    {
      "type": "extensionHost",
      "request": "launch",
      "name": "Debug TypeScript Extension",
      "runtimeExecutable": "${execPath}",
      "args": [
        "${workspaceFolder}",
        "--extensionDevelopmentPath=${workspaceFolder}/extensions/typescript-language-features",
      ],
      "outFiles": [
        "${workspaceFolder}/extensions/typescript-language-features/out/**/*.js"
      ],
      "env": {
        "TSS_DEBUG": "9003",
      }
    },
    ...,
  ]
}

args 数组的第一个元素 "${workspaceFolder}",就是 TypeScript 插件(typescript-language-features)的启动目录,
我们将它改成上文 index.ts 和 other.ts 所在的文件夹绝对路径,

{
  ...,
  "configurations": [
    {
      "type": "extensionHost",
      "request": "launch",
      "name": "Debug TypeScript Extension",
      "runtimeExecutable": "${execPath}",
      "args": [
        "/User/.../test/",
        "--extensionDevelopmentPath=${workspaceFolder}/extensions/typescript-language-features",
      ],
      "outFiles": [
        "${workspaceFolder}/extensions/typescript-language-features/out/**/*.js"
      ],
      "env": {
        "TSS_DEBUG": "9003",
      }
    },
    ...,
  ]
}

在 vscode 源码仓库中,选中这一项 Debug TypeScript Extension 启动调试,
会在弹出的新窗口 [Extension Development Host] 中打开了 index.ts 和 other.ts 所在的目录。

2. 查找定义

Go to Definition 是 TypeScript 插件(typescript-language-features) 与 tsserver 通信实现的。
我们在 vscode 源码位置 vscode/extensions/typescript-language-features/src/features/definitions.ts#L31 打个断点,

const response = await this.client.execute('definitionAndBoundSpan', args, token);

TypeScript 插件(typescript-language-features) 向 tsserver 发送了名为 definitionAndBoundSpan 的消息。
args 指明了 Go to Definition 发生在 index.ts 文件,第 2 行第 2 列。

然后我们跑到已经 attach 到 tsserver 的 typescript 项目,
typescript/src/services/goToDefinition.ts#L166 位置打个断点,

注意 vscode 和 typescript 是用两个 VSCode 实例进行调试的,全景图如下,


那么 tsserver 到底是怎么根据 index.ts 中的 x 查找到 other.ts 中的 a 的呢?
这都是 tsserver 在解析文件的时候做的,所以用的时候却比较简单了,
位于 typescript/src/services/goToDefinition.ts#L204 getSymbol 函数中。

function getSymbol(node: Node, checker: TypeChecker): Symbol | undefined {
  const symbol = checker.getSymbolAtLocation(node);
  // If this is an alias, and the request came at the declaration location
  // get the aliased symbol instead. This allows for goto def on an import e.g.
  //   import {A, B} from "mod";
  // to jump to the implementation directly.
  if (symbol && symbol.flags & SymbolFlags.Alias && shouldSkipAlias(node, symbol.declarations[0])) {
    const aliased = checker.getAliasedSymbol(symbol);
    if (aliased.declarations) {
      return aliased;
    }
  }
  return symbol;
}

getSymbol 先是通过 checker.getSymbolAtLocation 得到了 ast 节点(是一个 expression)位置的符号 x
然后通过 checker.getAliasedSymbol 获取 other.ts 中定义的符号 a

3. 多文件解析

使用 VSCode 打开多个文件时,到底发生了什么呢?
事实上,它是先加载当前打开的文件,然后再递归的加载它依赖的后代文件。

加载文件也是 TypeScript 插件(typescript-language-features) 与 tsserver 通信实现的,
发送了名为 updateOpen 这样的消息。

3.1 attach 到刚启动的 tsserver

为了能劫持到这个消息,在调试当前 vscode 版本(v1.45.1)时会比较费事,
这是因为 vscode v1.45.1 没有提供 --inspect-brk 调试方式,只提供了 --inspect
后者启动进程后不会卡住,而是会直接向下执行。

不过我们仍然可以在 tsserver 刚启动时,就进行 attach,
这样就能劫持到它接收的 updateOpen 消息了。

我们在 TypeScript 插件(typescript-language-features)中加个条件断点,
位于 vscode/extensions/typescript-language-features/src/tsServer/spawner.ts#L99

kind === 'semantic'

这是 tsserver 进程启动的地方。

断点停到这里之后,typescript 项目就可以 attach 了。


3.2 发送消息

然后回到 TypeScript 插件(typescript-language-features)中,在发送 updateOpen 消息的地方打个条件断点,
vscode/extensions/typescript-language-features/src/typescriptServiceClient.ts#L669
(其实直接在这里打断点,然后再 attach 也行,上文只是阐述了 tsserver 的启动位置)

在 typescript 项目 typescript/src/server/session.ts#L2187 打个断点。

然后发送消息,typescript 这边就可以接收到了。


3.3 入口文件和库文件

到了 tsserver 之后,调试就变得简单些了,完整的调用链路,我已经整理好了如下,

CommandNames.UpdateOpen
  applyChangesInOpenFiles
    assignProjectToOpenedScriptInfo
      assignOrphanScriptInfoToInferredProject
        updateGraph
          updateGraphWorker
            getProgram
              synchronizeHostData
                createProgram

                  # 逐个处理入口文件
                  forEach
                    processRootFile
                      processSourceFile
                        getSourceFileFromReferenceWorker
                          getSourceFile
                            findSourceFile                       <- index.ts
                              host.getSourceFile                 <- 解析
                              processImportedModules             <- 处理 import 的文件
                                for
                                  findSourceFile                 <- other.ts
                                    host.getSourceFile           <- 解析

                  # 处理内置库
                  getDefaultLibraryFileName
                  processRootFile
                    processSourceFile
                      getSourceFileFromReferenceWorker
                        getSourceFile
                          findSourceFile                         <- lib.es2016.full.d.ts
                            host.getSourceFile                   <- 解析库文件
                            processLibReferenceDirectives        <- 处理库文件的引用指令
                              forEach
                                processRootFile
                                  processSourceFile
                                    getSourceFileFromReferenceWorker
                                      getSourceFile
                                        findSourceFile            <- es2016, dom, ...
                                          host.getSourceFile      <- 解析库文件的引用

tsserver 先是处理了入口文件 index.ts,然后再处理它 import 的文件 other.ts。
other.ts 没有 import 其他文件了,所以这个处理过程就停止了。

然后,tsserver 开始处理内置库,入口是 lib.es2016.full.d.ts 这个文件,
这个文件的完整路径位于 typescript 项目中 built/local/lib.es2016.full.d.ts

去掉注释后,这个文件内容是这样的,

/// <reference no-default-lib="true"/>

/// <reference lib="es2016" />
/// <reference lib="dom" />
/// <reference lib="webworker.importscripts" />
/// <reference lib="scripthost" />
/// <reference lib="dom.iterable" />

它有引入了 es2016domwebworker.importscripts 等 5 个文件,
增加前缀和后缀,这些文件的路径是这样的 built/local/lib.{文件名}.d.ts

tsserver 会根据相似的逻辑,先处理内置库的入口文件 lib.es2016.full.d.ts,
然后再递归的处理它的后代依赖。

以上就是 tsserver 处理多文件的过程了。

4. 总结

本文介绍了 VSCode 实现 Go to Definition 的技术细节,
总而言之,是通过向 tsserver 发送名为 definitionAndBoundSpan 的消息来完成的。
tsserver 这边收到消息后,通过 getAliasedSymbol 方法,得到了符号的定义位置,因此才可完成跳转。

要想能够拿到其他文件中的符号定义,多文件处理是必须的,
于是,本文后半部分介绍了 typescript 解析多文件的过程。
概括而言就是,先加载入口文件,然后再加载 import 的文件(都加载完后还有内置库文件的加载)。

最后,我在探索的过程中,发现有两件事值得一提。

4.1 空的配置工程

我发现 tsserver 加载多文件,在接收到 updateOpen 消息后,
会创建两个 program,第一次创建的 program 没有解析任何文件,
第二次创建的 program 才会进行解析,不清楚这块逻辑的话,调试起来会感觉很乱。

CommandNames.UpdateOpen
  applyChangesInOpenFiles
    assignProjectToOpenedScriptInfo

      # 一个空的工程(program)
      createLoadAndUpdateConfiguredProject
        createAndLoadConfiguredProject
          createConfiguredProject
            new ConfiguredProject
              rootFilesMap                <- 初始化,new Map
        updateGraph
          updateGraphWorker
            getProgram
              synchronizeHostData
                new HostCache
                  fileNameToEntry         <- 初始化,new Map
                  getScriptFileNames
                    rootFilesMap.forEach  <- 空的
                getRootFileName
                  fileNameToEntry.forEach <- 空的
                createProgram

      # 包含文件解析的工程(program)
      assignOrphanScriptInfoToInferredProject
        getOrCreateInferredProjectForProjectRootPathIfEnabled
          createInferredProject
            new InferredProject
              rootFilesMap                <- 初始化,new Map
        addRoot
          rootFilesMap.set                <- 添加
        updateGraph
          updateGraphWorker
            getProgram
              synchronizeHostData
                new HostCache
                  fileNameToEntry         <- 初始化,new Map
                  getScriptFileNames
                    rootFilesMap.forEach  <- 有值
                  createEntry
                    fileNameToEntry.set   <- 有值
                getRootFileName
                  fileNameToEntry.forEach <- 有值
                createProgram             <- 这里才开始有文件解析了
                  # 逐个处理入口文件
                  # 处理内置库

4.2 js 文件的定义跳转

我们将 index.ts 和 other.ts 改成 .js 文件,并使用 CommonJS 模块,

const x = require('./other');
x
const a = 1;
module.exports = a;

然后,重复上面的操作,在 index.js 中 x 位置跳转到定义,

发现它只跳到了当前文件中,


这是因为 tsserver 无法处理 .js 文件吗?
后来仔细观察发现,这是 typescript 版本的问题,v3.7.3 版本对 .js 文件的跳转支持有限,
到了当前最新的 v3.9.3 版本就已经可以了。

我们看到 v3.7.3 版本,对于 .js 文件来说,checker.getAliasedSymbol 并没有执行到,
因此,无法获取跨文件的符号定义,

而到了 v3.9.3 版本,这里的代码变成了这样的,


判断是否 .js 文件,如果是的话,就调用 checker.resolveExternalModuleSymbol 来拿到外部定义的符号。
最后当然是跳转成功了,


参考

vscode v1.45.1
typescript v3.7.3
typescript v3.9.3

上一篇下一篇

猜你喜欢

热点阅读