[Node] 随遇而安 TypeScript(一):查找符号
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