1. 回顾

上一篇我们探索了 TypeScript 的符号查找过程,在判断一个符号 x 是否已定义时,
TypeScript 会沿着 ast 当前节点,通过 parent 属性往上找,
途中某些祖先节点,可能具有 locals 值,它保存了当前作用域中定义的所有变量。

如此这样,我们可以找到所有局部定义的词法变量。
全局变量也在 locals 中,位于 ast 的根节点中。
只不过,除了用户定义的全局变量之外,还包含 TypeScript 语言内置的一些全局变量。
随遇而安 TypeScript(二):符号表 - 图1

然而,上一篇篇尾也提到,
通过 ts.createProgram 创建的 program,是没有 locals 属性的,
各 ast 节点也没有 parent 属性相连,必须执行一次,program.getGlobalDiagnostics();

program.getGlobalDiagnostics 到底做了哪些工作呢?
本文我们就探索一下其中的秘密。

2. 直击本质

在开始之前,请先按 github: debug-typescript 准备好调试环境。

为了简单起见,我们写入待编译的 debug/index.ts 的内容如下,

  1. let _x;

然后在 src/compiler/binder.ts#L454 declareSymbol 函数中,

  1. function declareSymbol(...) {
  2. ...
  3. if (name === undefined) {
  4. ...
  5. }
  6. else {
  7. ...
  8. if (!symbol) {
  9. symbolTable.set(name, symbol = createSymbol(SymbolFlags.None, name));
  10. ...
  11. }
  12. ...
  13. }
  14. ...
  15. }

打一个条件断点,

  1. name === '_x'
  1. ![](https://cdn.nlark.com/yuque/0/2020/png/110185/1581073144018-7b2753f3-a68d-4a94-abf9-1c420fa92f80.png)


启动调试,
随遇而安 TypeScript(二):符号表 - 图2

  1. symbolTable.set(name, symbol = createSymbol(SymbolFlags.None, name));

这一步就是在符号表 symbolTable 中写入映射关系,key 为 name,值是一个 symbol

在当前这个例子中,symbolTable 就是 ast 根节点 SourceFilelocals 值。
我们来看看 declareSymbol 是怎么被调用的,symbolTable 是怎么传进来的,
随遇而安 TypeScript(二):符号表 - 图3
这个 blockScopeContainer 正是 ast 的根节点 SourceFile

想必是 TypeScript 从根节点开始,往下搜索子节点,
遇到变量定义之后,就在表示变量生效范围的 container 上,添加一条符号映射关系。

  1. let _x;

我们定义的是全局变量,
这个变量在整个 SourceFile 内部生效,或者说,作用域为 SourceFile
因此,会在 SourceFile.locals 中添加一条符号映射关系。

2. 链路

(1)全局变量

那么 SourceFile.locals 是什么时候创建的呢?
TypeScript 又是怎样向下搜索子节点的呢?

这得从调用链路说起。
随遇而安 TypeScript(二):符号表 - 图4

我们仔细看左侧的调用栈,会发现 bind 函数重复出现了多次,它被间接的递归调用了。
bind 的作用就是处理当前节点,可是在处理的过程中,bindEachChild 又调用了 bind 自身。
这其实是一个 ast 的遍历过程。

bind 会遍历 ast 的所有节点,一旦发现变量定义,就在它的 container 中写入符号映射关系。
第一个 bind 调用了 bindContainer,正是在这里 TypeScript 为 container 添加了 locals 属性。
随遇而安 TypeScript(二):符号表 - 图5

初始时它是一个空的符号表,createSymbolTablesrc/compiler/utilities.ts#L46

  1. export function createSymbolTable(symbols?: readonly Symbol[]): SymbolTable {
  2. const result = createMap<Symbol>() as SymbolTable;
  3. if (symbols) {
  4. for (const symbol of symbols) {
  5. result.set(symbol.escapedName, symbol);
  6. }
  7. }
  8. return result;
  9. }

我们示例中的 _x 是全局变量,因此写到了 SourceFile 中,
下面我们来看一下局部变量的 bind 链路。

(2)局部变量

修改 debug/index.ts 的内容如下,

  1. function f() {
  2. function g() {
  3. let _x;
  4. }
  5. }

保持 declareSymbol 函数中 src/compiler/binder.ts#L454 位置的条件断点不变,

  1. name === '_x'

再次启动调试,调用链路明显变长了。
随遇而安 TypeScript(二):符号表 - 图6
blockScopeContainer 不再是 SourceFile 节点了,而是 FunctionDeclaration 了。
节点范围如下,pos: 14end: 47

我们可以调用 blockScopeContainer.__debugGetText() 获取节点的内容,
随遇而安 TypeScript(二):符号表 - 图7

或者,使用 slice(14, 47) 截取一下源代码,

  1. `function f() {
  2. function g() {
  3. let _x;
  4. }
  5. }`.slice(14, 47)
  6. 执行结果:
  7. "
  8. function g() {
  9. let _x;
  10. }"

这正是 _x 是词法作用域范围,它在函数 g 中可见,而在函数 f 中( g 外的地方)不可见。
所以,与 _x 相关的符号映射关系,放在了 glocals 中。

3. 后记

(1)parent 属性

我们知道原始的 ast 节点,除了没有 locals 信息之外,也没有 parent 属性,
实际上 parent 属性正是在 bind 函数开头 src/compiler/binder.ts#L2235 中设置的。

  1. function bind(node: Node | undefined): void {
  2. if (!node) {
  3. return;
  4. }
  5. node.parent = parent;
  6. ...
  7. ...
  8. if (...) {
  9. ...
  10. if (...) {
  11. bindChildren(node);
  12. }
  13. else {
  14. bindContainer(node, containerFlags);
  15. }
  16. ...
  17. }
  18. else if (...) {
  19. ...
  20. }
  21. ...
  22. }
node.parent = parent;

后面那个 parent 是外层函数 createBinder 中的变量,
这个 createBinder 函数有 3053 行,src/compiler/binder.ts#L183-L3235
随遇而安 TypeScript(二):符号表 - 图8

(2)ts.bindSourceFile

上一篇末尾我们知道,原始的 ast 是不包含符号表信息的,
这样得到 sourceFilelocals 属性为 undefined

const ts = require('typescript');

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

  const program = ts.createProgram(rootNames, options);
  const sourceFile = program.getSourceFile(filePath);
  const { locals } = sourceFile;

  locals;  // undefined
};

后来意外的发现,添加 program.getGlobalDiagnostics(); 之后就有符号表了。

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;  // 有值了
};

当时我们并不知道其中发生了什么。

如今,通过本文的分析,我们已经知道 program.getGlobalDiagnostics(); 过程中发生了什么。
调用链路如下,

program.getGlobalDiagnostics
getDiagnosticsProducingTypeChecker
createTypeChecker
intializeTypeChecker
bindSourceFile
...
bind
...
bind
...
declareSymbol

其中,bindSourceFile src/compiler/binder.ts#L174 是一个关键函数,
正是它递归调用了一系列 bind,计算每个节点的符号表信息。

export function bindSourceFile(file: SourceFile, options: CompilerOptions) {
  performance.mark("beforeBind");
  perfLogger.logStartBindFile("" + file.fileName);
  binder(file, options);
  perfLogger.logStopBindFile();
  performance.mark("afterBind");
  performance.measure("Bind", "beforeBind", "afterBind");
}

除此之外,值得高兴的是,

这个 bindSourceFile 被 TypeScript 导出了,外部也可以通过 ts.bindSourceFile 调用它。
因此,我们就可以把 program.getGlobalDiagnostics(); 换成更精确的写法了,

const ts = require('typescript');

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

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

  ts.bindSourceFile(sourceFile, options);  // 计算符号表信息

  const { locals } = sourceFile;

  locals;  // 有值
};

参考

github: debug-typescript
TypeScipt v3.7.3