在上一篇文章的最後,我提到了為了要把 TypeScript 檔案同時輸出成 CommonJS 和 ECMAScript modules (ESM),所以開發了 tsc-multi。
tsc-multi 的運作方式其實非常簡單,就是同時執行多個 TypeScript compiler 平行運作而已;除此之外,我還對 compiler 動了一點手腳。
修改 ts.System
ts.System
是 TypeScript 用來和作業系統互動的 interface,其中包含了跟檔案系統 (file system) 有關的 method。因為 TypeScript 預設的輸出檔案的副檔名是 .js
,為了要變更輸出檔案的副檔名,我修改了 ts.System
裡跟讀寫檔案有關的 method。
function rewritePath(path) {
if (path.endsWith(".js") return path.replace(/\.js$/, ".mjs");
return path;
}
const sys: ts.System = {
...ts.sys,
fileExists(path) {
return ts.sys.fileExists(rewritePath(path)) || ts.sys.fileExists(path);
},
readFile(path, encoding) {
return ts.sys.readFile(rewritePath(path), encoding) ??
ts.sys.readFile(path, encoding);
},
writeFile(path, data, writeBOM) {
ts.sys.writeFile(rewritePath(path), data, writeBOM);
},
deleteFile(path) {
ts.sys.deleteFile(rewritePath(path));
}
};
我修改了 fileExists
, readFile
, writeFile
, deleteFile
這四個 method,上面是簡化過的版本,詳細內容可參考原始碼。
改寫 Import 路徑
因為輸出檔案的副檔名被改寫了,為了讓 CommonJS 和 ESM 能夠 import 到正確的檔案,必須在 import 路徑加上副檔名。
這個部份我用 transformer 的形式來實作,在 transformer 內,可以把 TypeScript AST 替換成任意的程式碼。以這次的案例來說,我們需要替換的 node 有以下四種。
// ESM import (ImportDeclaration)
import foo from "./foo";
// ESM export (ExportDeclaration)
export foo from "./foo";
// ESM dynamic import (CallExpression)
import("./foo");
// CommonJS require (CallExpression)
require("./foo");
從上面這四種 node 可以取得 import 路徑,如果是相對路徑的話(開頭是 ./
或 ../
),就是需要修改的路徑。
在 Node.js 裡,import 路徑可能會是檔案或資料夾,但是 ESM 的 import 路徑一定要加上副檔名,所以必須要把資料夾的 import 路徑加上 /index.js
。
// Input
import "./file";
import "./dir";
// Output
import "./file.js";
import "./dir/index.js";
總結來說,可以把修改 import 路徑的部分統整成以下程式碼。
function updateModuleSpecifier(sourceFile: ts.SourceFile, node: ts.Expression) {
if (!ts.isStringLiteral(node) || !isRelativePath(node.text)) return node;
if (isDirectory(sourceFile, node.text)) {
return ts.factory.createStringLiteral(
`${node.text}/index${options.extname}`
);
}
const ext = extname(node.text);
const base = ext === ".js" ? trimSuffix(node.text, ".js") : node.text;
return ts.factory.createStringLiteral(`${base}${options.extname}`);
}
詳細內容可參考原始碼。
避免 Race Condition
一開始 tsc-multi 在小規模的專案(例如 Kosko 和 kubernetes-models)使用時,都沒有任何異常。可是一旦在 Dcard 這種大規模的 monorepo 使用時,就很容易發生問題。
主要原因是 TypeScript 在使用 Project References 功能時,為了要加快未來編譯的速度,會寫入 .tsbuildinfo
檔案,內容包含了目前的 build state,檔案大小大約會是幾百 KB。
因為 tsc-multi 會同時執行多個 TypeScript compiler,在寫入 TS build info 時,其他 compiler 可能就會剛好讀取寫入到一半的檔案,這種問題在一般的電腦上通常不會發生,但是在 CI 等資源有限的環境下偶爾會觸發。
我的解決方法是變更 tsconfig.json
的 tsBuildInfoFile
設定,讓 TypeScript compiler 不會同時寫入到同一個路徑。
const host = ts.createSolutionBuilderHost();
host.getParsedCommandLine = (path: string) => {
const config = ts.getParsedCommandLineOfConfigFile(path, {}, ts.sys);
config.options.tsBuildInfoFile = `${basePath}${data.extname}.tsbuildinfo`;
return config;
};
詳細內容可參考原始碼。