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 变量报错的时间点,

通过检查调用栈 checkExpressionWorker,src/compiler/checker.ts#L17575,
我们发现 node.escapedText 为 Symbol,不是我们的变量 x。

原来
checkSourceFile,src/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#L18085,Cannot_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)局部变量
resolveName,src/compiler/checker.ts#L1430,会调用 resolveNameHelper,

然后跑到一个很长的带
loop 标签的 while 循环中,src/compiler/checker.ts#L1463,整个
resolveNameHelper 有 407 行,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 属性的父节点,函数声明 FunctionDeclaration,pos: 0,end: 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 语言内置的一些变量进行合并,例如 Array,Date 这些。
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.getGlobalDiagnostics,src/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