[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 代码生成以及写文件的过程,
其中一个关键函数是 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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,219评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,363评论 1 293
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,933评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,020评论 0 206
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,400评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,640评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,896评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,597评论 0 199
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,327评论 1 244
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,581评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,072评论 1 261
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,399评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,054评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,083评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,849评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,672评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,585评论 2 270