[Node] 随遇而安 TypeScript(四):增量编译
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 代码生成以及写文件的过程,
其中一个关键函数是 emitSourceFile
,lib/typescript.js#L92567。
而它是由枢纽函数 pipelineEmitWithHint
,lib/typescript.js#L90565 调用的,
pipelineEmitWithHint
会根据 AST 各节点的类型,递归的调用各节点相应的 emitXXX
函数,
最终拼凑起来,得到整个 SourceFile
的转译结果。
因此,对于探索增量编译而言,我们只需要跟进到 emitSourceFile
即可。
再次之前,我们先跑一遍增量编译,看看主流程。
(1)Starting compilation in watch mode...
参考 github: debug-typescript,TypeScript 源码环境已经配置好之后,
我们在 debug-watch/index.js
第 13
行 reportWatchStatus
中打个断点,
![](https://img.haomeiwen.com/i1023733/680e0721b179d6a0.png)
然后执行调试,让断点停在这里。
![](https://img.haomeiwen.com/i1023733/c842f556e7daa4f4.png)
此时还没有进行编译。
(2)Found 0 errors. Watching for file changes.
然后继续调试,断点再次来到了 reportWatchStatus
中,
此时 debug/index.js
js 目标文件已经生成了。
![](https://img.haomeiwen.com/i1023733/bef8b8c50bde9c17.png)
(3)File change detected. Starting incremental compilation...
按 F5
让程序继续运行,进程挂住监听源码的变更,此时我们修改一下 debug/index.ts
内容并保存,
保存后,程序会自动停在 reportWatchStatus
中,
![](https://img.haomeiwen.com/i1023733/5d652ba2eb7a7ee6.png)
这时候,编译产物
debug/index.js
js 文件还没有被改写。
(4)Found 0 errors. Watching for file changes.
再次跑完当前代码,源码新的变更已经写入目标文件中了。
![](https://img.haomeiwen.com/i1023733/6e4f31602165e3c9.png)
综上所述,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. 对比
我们在 emitSourceFile
,lib/typescript.js#L92567 函数中打个断点,
然后对比一下首次 emit 与增量编译 emit 的调用栈。
(1)首次编译
![](https://img.haomeiwen.com/i1023733/31f0aef52579bd05.png)
注意 emitFilesAndReportErrors
lib/typescript.js#L99929 会触发两次 emitSourceFile
,
第一次是语义分析 program.getSemanticDiagnostics
lib/typescript.js#L99942 时触发的,
第二次才是写文件 program.emit
lib/typescript.js#L99949 触发的。
(2)增量编译
![](https://img.haomeiwen.com/i1023733/285b15f4fcff15d0.png)
这里 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
有何不同就行了。
![](https://img.haomeiwen.com/i1023733/ada48969966bae10.png)
左边是首次编译,右边是增量编译。
我们发现,program
的值是不同的,
首次编译时,这两个值都是 undefined
,而在增量编译时,它们都是有值的。
问题出在 createNewProgram
lib/typescript.js#L100381 函数中。
function synchronizeProgram() {
...
var program = getCurrentBuilderProgram();
...
if (...) {
...
}
else {
createNewProgram(hasInvalidatedResolution);
}
...
return builderProgram;
}
接着 createNewProgram
经过几步又调用了 createProgram
,lib/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;
...
}
首次编译由于旧的 program
为 undefined
,结果 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。
不妨在这里打个断点,对比一下调用栈。
![](https://img.haomeiwen.com/i1023733/1be496f0e790d9c3.png)
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;
}
...
}
![](https://img.haomeiwen.com/i1023733/dffde45f3f0df42e.png)
首次编译时,
hostSourceFile
为 undefined
,因此,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 位置打个断点,
![](https://img.haomeiwen.com/i1023733/0fa181e17167200e.png)
我们看到,TypeScript 对源文件又进行了一遍解析,并不是根据修改位置对 AST 的影响范围来增量解析的。
5. 总结
本文探索了 TypeScript 的增量编译过程,首次编译和增量编译,
TypeScript 都会通过 synchronizeProgram
调用 createProgram
,
createProgram
中根据旧的 program
是否已存在,判断 tryReuseStructureFromOldProgram
是否提前返回。
首次编译时,tryReuseStructureFromOldProgram
提前返回,TypeScript 开始处理 processRootFile
每个源文件。
增量编译时,tryReuseStructureFromOldProgram
通过 host.getSourceFileByPath
调用 getVersionedSourceFileByPath
,
创建了新的 sourceFile
。
值得一提的是,TypeScript 创建 sourceFile
时,会重新解析源代码,得到一棵新的 AST。