在上一篇文章的最後,我提到了為了要把 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,上面是簡化過的版本,詳細內容可參考原始碼。
因為輸出檔案的副檔名被改寫了,為了讓 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
。
// Inputimport "./file";import "./dir";// Outputimport "./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}`);}
詳細內容可參考原始碼。
一開始 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;};
詳細內容可參考原始碼。