Node.js

[Node] 随遇而安 TypeScript(一):查找符号

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

0. 前言

在《淡如止水 TypeScript》中,我们研究了 TypeScript 源码的一些基本概念,
例如,TypeScript 是如何进行词法分析、语法分析的,如何进行类型检查的,
如何进行代码转换,以及 tsserver 是如何作为独立的进程提供语言服务的。

本系列文章,我们将继续深入探索,
TypeScript 源码量比较大,短时间内通读一遍也不太现实,更无必要,
因此,打算以专题的形式,从问题出发总结成文。

TypeScript 的调试方式,我已经整理到了 github: debug-typescript 中。
上一个系列的文章中已详细介绍过了,本系列文章直接使用它。
TypeScript 源码的版本,我用的是 TypeScipt v3.7.3

1. 问题

参考 github: debug-typescript 的使用说明,
安装完毕后,它会在 TypeScript 源码目录新建一个 debug/index.ts 文件。
这是我们用来试验 TypeScript 各项功能的源代码文件。

我们修改 debug/index.ts 的内容如下,并用 VSCode 打开这个文件,

function f() {
  x
}

鼠标移动到 x 上,我们会看到 VSCode 会提示诊断信息(Diagnostics),

Cannot find name 'x'. ts(2304)

VSCode 是如何知道 x 是未定义的呢?
这还要从 TypeScript 诊断过程中的 resolveName 说起。

2. 启动调试

Cannot find name 'x'. ts(2304)

我们已经知道 VSCode 是通过与 tsserver 通信,实现 TypeScript 的各项语言支持的,
那么,以上诊断信息,应该是由 TypeScript 源码中反馈回来的。

本来我们需要 debug tsserver 来找到结果,但其实 tsc 命令也会返回错误信息。

$ tsc debug/index.ts
debug/index.ts:2:3 - error TS2304: Cannot find name 'x'.

2   x
    ~


Found 1 error.

tsc 相较于 tsserver 调试起来会简单一些,所以下文我们用 tsc 来进行调试。
因为 x 是代码相关的,肯定是一个占位符,所以我们只搜索 Cannot find name


全局搜索后,第一个结果中,我们看到了 2304 错误码,
还看到了 Cannot find name '{0}'. 模板形式的字符串,{0} 应该是占位符了,最后被替换为 x

接着搜索 Cannot_find_name_0

位于 src/compiler/checker.ts#L18085,在这里打个断点,
然后按照 github: debug-typescript 介绍的方式,按 F5 进行调试。


注意这里在调试的时候,要先 Step Into 进入到 src/compiler/core.ts#L1 代码中,再按 Continue
否则可能会出现 VSCode 无法跳转到 TypeScript 源码的情形。

再按 Continue,果然来到了断点处。

3. 预加载的 .d.ts 文件

然而,不幸的是,这并不是我们示例代码中 x 变量报错的时间点,

通过检查调用栈 checkExpressionWorkersrc/compiler/checker.ts#L17575
我们发现 node.escapedTextSymbol,不是我们的变量 x


原来 checkSourceFilesrc/compiler/checker.ts#L33009
所检查的并非我们的源码文件 debug/index.ts,而是这个文件,
/Users/.../Microsoft/TypeScript/built/local/lib.es5.d.ts

其中,/Users/.../Microsoft/TypeScript 是我本地 TypeScript 源码仓库地址,
我们来看一下这个文件的内容,

它是一个 TypeScript 的声明文件,用于声明 es5 中内置对象的类型,
TypeScript 会预加载很多内置的声明文件。

我们可以从这里 src/compiler/program.ts#L1653 获取 TypeScript 总共预先加载了哪些文件,

program.getSourceFiles().map(({fileName})=>fileName).length
> 114

包括 debug/index.ts 在内,总共有 114 个,除了 built/local/ 目录下的,还有 node_modules/ 中的。

4. 条件断点

为了能定位到 debug/index.ts 中的 x 变量的报错信息,我们需要使用条件断点(Conditional Breakpoint),

src/compiler/checker.ts#L27575 行打断点的位置,右键添加条件断点。

然后输入条件,回车,

node.escapedText === 'x'

再把最初 src/compiler/checker.ts#L18085Cannot_find_name_0 报错位置的断点去掉,按 F5 继续调试。

这是不是我们的 debug/index.ts 文件中的 x 呢?


查看调用栈信息,发现很幸运刚好是,其他预加载的文件中,没有 x

然后我们再到 src/compiler/checker.ts#L27575 把断点再打上,应该会跑到这里,

5. 跟踪

(1)查找符号

现在我们来分析 TypeScript 是怎么 x 未定义的,这才是问题的关键。
以下我从上到下,列举了调用栈中几个主要的函数,

executeCommandLine                     # 执行 tsc
performCompilation                     # 开始编译
getSemanticDiagnostics                 # 语义分析
checkSourceFile                        # 检查加载的各个文件
checkSourceElement                     # 从 ast 的根元素开始检查
checkIdentifier                        # 检查标识符 x
getResolvedSymbol                      # 从符号表中获取与 x 相关的信息
resolveName                            # 查找 x
getCannotFindNameDiagnosticForName     # 获取 “无法找到名字” 的诊断文案

resolveName,是由 getResolvedSymbol 调用的,src/compiler/checker.ts#L18094

function getResolvedSymbol(node: Identifier): Symbol {
  const links = getNodeLinks(node);
  if (!links.resolvedSymbol) {
    links.resolvedSymbol = !nodeIsMissing(node) &&
      resolveName(
        node,
        node.escapedText,
        SymbolFlags.Value | SymbolFlags.ExportValue,
        getCannotFindNameDiagnosticForName(node),
        node,
        !isWriteOnlyAccess(node),
                        /*excludeGlobals*/ false,
        Diagnostics.Cannot_find_name_0_Did_you_mean_1) || unknownSymbol;
  }
  return links.resolvedSymbol;
}

可见,不论是否能找到 x,都会先调用 getCannotFindNameDiagnosticForName 获取报错文案。

(2)局部变量

resolveNamesrc/compiler/checker.ts#L1430,会调用 resolveNameHelper


然后跑到一个很长的带 loop 标签的 while 循环中,src/compiler/checker.ts#L1463
整个 resolveNameHelper407 行,src/compiler/checker.ts#L1442,结构如下,
function resolveNameHelper(
  ...
): ... {
  ...
  loop: while (location) {
    // Locals of a source file are not in scope (because they get merged into the global symbol table)
    if (location.locals && !isGlobalSourceFile(location)) {
      if (result = lookup(location.locals, name, meaning)) {
        ...
      }
    }
    ...
    switch (location.kind) {
      ...
    }
    ...
    lastLocation = location;
    location = location.parent;
  }
  ...

  if (!result) {
    ...
    if (!excludeGlobals) {
      result = lookup(globals, name, meaning);
    }
  }
  if (!result) {
    ...
  }
  if (!result) {
    ...
  }
  ...
  if (nameNotFoundMessage) {
    ...
  }
  return result;
}

while 循环做的主要事情就是,从 x 节点开始不断的向父节点搜索,
检查祖先节点的 locals 属性,其中保存了这个祖先节点作用域内的词法变量。
location 指的是当前正在查找的节点。

src/compiler/checker.ts#L1466 打个断点,


发现了第一个具有 locals 属性的父节点,函数声明 FunctionDeclarationpos: 0end: 19
function f(){
  x
}

函数没有形参,因此函数声明创建的词法作用域中没有符号,locals 为空 Map

如果我们修改一下 debug/index.ts,给 f 加上形参 y,在进行调试,

function f(y){
  x
}

发现这里的 locals 已经不再为空了,Map 中有与 y 相关的信息。

(2)全局变量

局部变量保存在了 FunctionDeclaration 节点的 locals 属性中,
全局变量也是一样,也在祖先节点的 locals 属性中,


位于 FunctionDeclaration 节点的父节点的 locals 中。

只是 TypeScript 中,在不同的源码位置对全局变量进行查找,
位于 src/compiler/checker.ts#L1752

result = lookup(globals, name, meaning);

为什么要区分开来呢?
这是因为,声明在最外层的全局变量,要与 TypeScript 语言内置的一些变量进行合并,例如 ArrayDate 这些。

resolveNameHelper 局部变量 lookup 前的注释进行了说明,

Locals of a source file are not in scope (because they get merged into the global symbol table)

局部变量与全局变量,lookup 调用位置关系如下,

function resolveNameHelper(
  ...
): ... {
  ...
  loop: while (location) {
    // Locals of a source file are not in scope (because they get merged into the global symbol table)
    if (location.locals && !isGlobalSourceFile(location)) {
      if (result = lookup(location.locals, name, meaning)) {
        ...
      }
    }
    ...
    lastLocation = location;
    location = location.parent;
  }
  ...

  if (!result) {
    ...
    if (!excludeGlobals) {
      result = lookup(globals, name, meaning);
    }
  }
  ...
  return result;
}

现在我们在全局变量查找位置 src/compiler/checker.ts#L1752 打个条件断点,按 F5 执行,

name === 'x'

我们看到全局范围内有 1812 个名字,包含函数 f,不包含变量 x

6. 后记

上文我们研究了 TypeScript 变量是否定义的诊断过程,从报错文案出发,
顺藤摸瓜的跟踪了,局部变量和全局变量的查找过程。

一个意外的发现是,ast 节点中可能包含了 locals 属性,其中保存了相关词法作用域中定义的全部变量。
因此我们就可以静态分析出,源码的作用域层次结构了。

然而,从 program 中直接得到的 ast 中是不包含 locals 信息的,

const ts = require('typescript');

const main = filePath => {
  const rootNames = [filePath];
  const options = {};

  const program = ts.createProgram(rootNames, options);

  // program.getGlobalDiagnostics();
  const sourceFile = program.getSourceFile(filePath);
  const { locals } = sourceFile;


  locals;
};

其中,filePath 是待编译源码的绝对地址,我们传入 debug/index.ts 文件地址,
文件内容如下,

function f(){
  x
}

通过对比 tsc 的执行过程,
我们发现是因为 tsc 在编译的时候执行了 program.getGlobalDiagnosticssrc/compiler/watch.ts#L165
位于语义分析 program.getSemanticDiagnostics 之前。

上述代码,我们把注释解除,在获取 sourceFile 之前先执行,

program.getGlobalDiagnostics();

果然 locals 属性有值了,正是我们全局声明的函数 f

事实上,不执行 program.getGlobalDiagnostics 的话,
节点的 parent 属性也是没有的,我们无法通过叶子节点,向上追溯到 ast 根节点。

至于 program.getGlobalDiagnostics 是怎样为每个节点添加 parent 属性,
又怎样为部分节点计算出 locals 的,等到必要时遇到阻碍时,再详细探究吧。

参考

github: debug-typescript
TypeScipt v3.7.3
TypeScript Compiler API

上一篇下一篇

猜你喜欢

热点阅读