Node.js

[Node] 随遇而安 TypeScript(四):增量编译

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

1. 回顾

上文中我们探索了 TypeScript watch 文件变更的过程,
基本原理是用了 Node.js 标准库中的 fs.watchFile 方法,

const fs = require('fs');

fs.watchFile(
  fileName,            // 要监控的文件路径
  {                    // (可选)
    persistent: true,  // 程序执行完后,当前进程是否挂住,默认为 true(挂住)
    interval: 250,     // 每个多久检测一次,默认值 5000 ms
  }, 
  fileChanged,         // 文件变更时的回调
);

TypeScript 监控到文件变更之后,就会触发回调,进行增量编译。
那么,具体是怎样进行增量编译的呢?
本文来一探究竟。

2. 主流程

还记得上一系列的文章中,我们曾介绍过 TypeScript 代码生成以及写文件的过程,
其中一个关键函数是 emitSourceFilelib/typescript.js#L92567

而它是由枢纽函数 pipelineEmitWithHintlib/typescript.js#L90565 调用的,
pipelineEmitWithHint 会根据 AST 各节点的类型,递归的调用各节点相应的 emitXXX 函数,
最终拼凑起来,得到整个 SourceFile 的转译结果。

因此,对于探索增量编译而言,我们只需要跟进到 emitSourceFile 即可。
再次之前,我们先跑一遍增量编译,看看主流程。

(1)Starting compilation in watch mode...

参考 github: debug-typescript,TypeScript 源码环境已经配置好之后,
我们在 debug-watch/index.js13reportWatchStatus 中打个断点,

然后执行调试,让断点停在这里。



此时还没有进行编译。

(2)Found 0 errors. Watching for file changes.

然后继续调试,断点再次来到了 reportWatchStatus 中,
此时 debug/index.js js 目标文件已经生成了。

(3)File change detected. Starting incremental compilation...

F5 让程序继续运行,进程挂住监听源码的变更,此时我们修改一下 debug/index.ts 内容并保存,
保存后,程序会自动停在 reportWatchStatus 中,


这时候,编译产物 debug/index.js js 文件还没有被改写。

(4)Found 0 errors. Watching for file changes.

再次跑完当前代码,源码新的变更已经写入目标文件中了。


综上所述,reportWatchStatus 总共会被触发 4 次。

Starting compilation in watch mode...
Found 0 errors. Watching for file changes.
File change detected. Starting incremental compilation...
Found 0 errors. Watching for file changes.

我们关心的是,文件变更后 TypeScript 是如何进行编译,并改写目标文件的。

3. 对比

我们在 emitSourceFilelib/typescript.js#L92567 函数中打个断点,
然后对比一下首次 emit 与增量编译 emit 的调用栈。

(1)首次编译

注意 emitFilesAndReportErrors lib/typescript.js#L99929 会触发两次 emitSourceFile
第一次是语义分析 program.getSemanticDiagnostics lib/typescript.js#L99942 时触发的,
第二次才是写文件 program.emit lib/typescript.js#L99949 触发的。

(2)增量编译

这里 emitFilesAndReportErrors lib/typescript.js#L99929 也会触发两次 emitSourceFile
我们看到 program.emit lib/typescript.js#L99949 的调用栈是一模一样的。

首次编译,

createWatchProgram
synchronizeProgram
result.afterProgramCreate
emitFilesAndReportErrors
program.emit
...

增量编译

updateProgram
synchronizeProgram
result.afterProgramCreate
emitFilesAndReportErrors
program.emit
...

因此,我们只需观察两次 synchronizeProgram 有何不同就行了。


左边是首次编译,右边是增量编译。

我们发现,program 的值是不同的,
首次编译时,这两个值都是 undefined,而在增量编译时,它们都是有值的。
问题出在 createNewProgram lib/typescript.js#L100381 函数中。

function synchronizeProgram() {
  ...
  var program = getCurrentBuilderProgram();
  ...
  if (...) {
    ...
  }
  else {
    createNewProgram(hasInvalidatedResolution);
  }
  ...
  return builderProgram;
}

接着 createNewProgram 经过几步又调用了 createProgramlib/typescript.js#L95005
这个 createProgram 我们是熟悉的,tsc 命令行编译的时候,也会调用它,ts 源码位置位于 src/compiler/program.ts#L713
ts 源码有 2665 行,编译之后有 2288 行。

我们感兴趣的部分结构如下,

function createProgram(...) {
  ...
  structuralIsReused = tryReuseStructureFromOldProgram();
  if (structuralIsReused !== 2) {
    ...
    ts.forEach(rootNames, function (name) { return processRootFile(name, false, false); });
    ...
  }
  ...
  var program = {
    ...
  };
  ...
  return program;
  ...
}

首次编译由于旧的 programundefined,结果 tryReuseStructureFromOldProgram lib/typescript.js#L95463 会直接返回 0
返回 0 就会走到 ts.forEach,对于每个源文件调用 processRootFile

而增量编译则不然,tryReuseStructureFromOldProgram lib/typescript.js#L95463 并不会直接返回,而是做了一些额外的事情。
我们知道 TypeScript 语法分析是在 parseSourceFileWorker 中处理的,
ts 源码位置在 src/compiler/parser.ts#L843,编译后的 js 代码中,位于 lib/typescript.js#L18546

不妨在这里打个断点,对比一下调用栈。


4. 新的 sourceFile

上文我们看到,TypeScript 首次编译和增量编译,都会调用 createProgram lib/typescript.js#L95005
只不过增量编译会调用 tryReuseStructureFromOldProgram lib/typescript.js#L95463,复用首次编译的一些中间产物。

这个函数有 220 行,主要结构如下,

function tryReuseStructureFromOldProgram() {
  if (!oldProgram) {
    return 0;
  }
  ...
  var oldSourceFiles = oldProgram.getSourceFiles();
  ...
  for (var _i = 0, oldSourceFiles_2 = oldSourceFiles; _i < oldSourceFiles_2.length; _i++) {
    ...
    var newSourceFile = host.getSourceFileByPath
      ? host.getSourceFileByPath(oldSourceFile.fileName, oldSourceFile.resolvedPath, options.target, undefined, shouldCreateNewSourceFile)
      : ...
    ...
    var fileChanged = void 0;
    if (oldSourceFile.redirectInfo) {
      ...
    }
    else if (oldProgram.redirectTargetsMap.has(oldSourceFile.path)) {
      ...
    }
    else {
      fileChanged = newSourceFile !== oldSourceFile;
    }
    ...
    if (fileChanged) {
      ...
      modifiedSourceFiles.push({ oldFile: oldSourceFile, newFile: newSourceFile });
    }
    else if (hasInvalidatedResolution(oldSourceFile.path)) {
      ...
    }
    ...
    newSourceFiles.push(newSourceFile);
  }
  ...
  return oldProgram.structureIsReused = 2;
}

它调用了 host.getSourceFileByPath 进行语法分析,并创建返回了新的 SourceFile 对象。
注意 host.getSourceFileByPath 的值其实是 getVersionedSourceFileByPath,在 lib/typescript.js#L100278 被重新赋值,

compilerHost.getSourceFileByPath = getVersionedSourceFileByPath;

所以,后面我们要看的是 getVersionedSourceFileByPath lib/typescript.js#L100278 的源码,

function getVersionedSourceFileByPath(fileName, path, languageVersion, onError, shouldCreateNewSourceFile) {
  var hostSourceFile = sourceFilesCache.get(path);
  ...
  if (hostSourceFile === undefined || shouldCreateNewSourceFile || isFilePresenceUnknownOnHost(hostSourceFile)) {
    var sourceFile = getNewSourceFile(fileName, languageVersion, onError);
    if (hostSourceFile) {
      ...
    }
    else {
      ...
    }
    return sourceFile;
  }
  ...
}

首次编译时,hostSourceFileundefined,因此,if 条件为 true,调用 getNewSourceFile 返回一个 sourceFile
增量编译时,hostSourceFile 虽然不为 undefined,但是 isFilePresenceUnknownOnHost 返回了 true
也会调用 getNewSourceFile 创建新的 sourceFile

此外,我们知道 TypeScript 在创建 sourceFile 的过程中,会进行语法分析,
那么 TypeScript 增量编译过程中,进行语法分析时,是否使用了增量解析算法(incremental parsing)呢?
其实是没有的。

我们修改 debug/index.ts 文件的内容如下,

const i: number = 1;

然后,在 parseInitializer lib/typescript.js#L20804 位置打个断点,


我们看到,TypeScript 对源文件又进行了一遍解析,并不是根据修改位置对 AST 的影响范围来增量解析的。

5. 总结

本文探索了 TypeScript 的增量编译过程,首次编译和增量编译,
TypeScript 都会通过 synchronizeProgram 调用 createProgram
createProgram 中根据旧的 program 是否已存在,判断 tryReuseStructureFromOldProgram 是否提前返回。

首次编译时,tryReuseStructureFromOldProgram 提前返回,TypeScript 开始处理 processRootFile 每个源文件。
增量编译时,tryReuseStructureFromOldProgram 通过 host.getSourceFileByPath 调用 getVersionedSourceFileByPath
创建了新的 sourceFile

值得一提的是,TypeScript 创建 sourceFile 时,会重新解析源代码,得到一棵新的 AST。

参考

github: debug-typescript
TypeScipt v3.7.3

上一篇 下一篇

猜你喜欢

热点阅读