美文网首页Node.js
[Node] 随遇而安 TypeScript(一):查找符号

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

作者: 何幻 | 来源:发表于2020-02-06 14:13 被阅读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

    相关文章

      网友评论

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

        本文链接:https://www.haomeiwen.com/subject/igyyxhtx.html