TypeScript 編譯與設定:Composite Projects 與 Project References
簡介
在大型前端或 Node.js 專案中,程式碼分割、模組化與增量編譯是提升開發效率與維護性的關鍵。傳統的 tsc 單一入口編譯只能一次性產出所有檔案,當專案規模成長時,編譯時間會急速拉長,且模組之間的依賴關係也不易追蹤。
TypeScript 從 3.0 版開始引入 Composite Projects(組合專案)與 Project References(專案參照)機制,讓多個 tsconfig.json 可以相互參照、分段編譯,同時保留完整的型別檢查與 IntelliSense。這不僅縮短 CI/CD 的建置時間,也讓團隊可以在 微服務、單元測試套件、共用函式庫等情境下,靈活管理編譯流程。
本文將從概念說明、實作範例、常見陷阱與最佳實踐,帶你一步一步建立可擴充、可維護的 TypeScript 組合專案。
核心概念
1. 什麼是 Composite Project?
- Composite 是指在
tsconfig.json中加入"composite": true,告訴 TypeScript 此專案會被其他專案引用。 - 啟用後,編譯器會產生
.tsbuildinfo檔案,記錄上一次編譯的狀態,用於增量編譯(incremental build)。 - Composite 專案必須 輸出宣告檔 (
.d.ts),因此declaration必須為true,且outDir必須明確指定。
// core/tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"declaration": true, // 必須產生 .d.ts
"declarationMap": true,
"composite": true, // 啟用 Composite
"outDir": "./dist"
},
"include": ["src"]
}
2. Project References 的運作方式
- Project Reference 讓一個
tsconfig.json直接參照另一個已編譯好的專案。 - 參照的專案會先被編譯(如果尚未產生
.d.ts),之後才會編譯當前專案。 - 這樣的階層式編譯可以 自動推斷依賴順序,避免手動指定編譯順序。
// app/tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"outDir": "./dist",
"baseUrl": ".",
"paths": {
"*": ["node_modules/*"]
}
},
"references": [
{ "path": "../core" }, // 參照 core 專案
{ "path": "../utils" } // 參照 utils 專案
],
"include": ["src"]
}
3. 為什麼需要 paths 與 baseUrl?
在多專案環境下,相對路徑往往會變得冗長且易出錯。透過 baseUrl + paths 可以設定 別名,讓引用更直觀:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@core/*": ["../core/dist/*"],
"@utils/*": ["../utils/dist/*"]
}
}
}
小技巧:在 IDE(如 VSCode)中,同時設定
jsconfig.json或tsconfig.json,即可即時取得別名的自動完成與跳轉功能。
4. 增量編譯(Incremental Build)
- 當
composite為true時,編譯器會在outDir旁生成*.tsbuildinfo檔。 - 再次執行
tsc -b時,編譯器只會重新編譯 變更過的檔案 或其相依的檔案,大幅縮短編譯時間。
# 初次編譯所有專案
$ tsc -b
# 只編譯 core 專案(假設 utils 沒有變動)
$ tsc -b core
5. 完整範例:三層專案結構
/repo
├─ core/
│ ├─ src/
│ │ └─ math.ts
│ └─ tsconfig.json
├─ utils/
│ ├─ src/
│ │ └─ logger.ts
│ └─ tsconfig.json
└─ app/
├─ src/
│ └─ index.ts
└─ tsconfig.json
5.1 core/src/math.ts
// core/src/math.ts
export function add(a: number, b: number): number {
return a + b;
}
/**
* 取得兩數的最大公因數
*/
export function gcd(a: number, b: number): number {
while (b !== 0) {
const t = b;
b = a % b;
a = t;
}
return a;
}
5.2 utils/src/logger.ts
// utils/src/logger.ts
export enum LogLevel {
Debug = "debug",
Info = "info",
Warn = "warn",
Error = "error",
}
/** 簡易的 console 包裝 */
export function log(level: LogLevel, message: string): void {
console[level](`[${level}] ${message}`);
}
5.3 app/src/index.ts
// app/src/index.ts
import { add, gcd } from "@core/math";
import { log, LogLevel } from "@utils/logger";
const a = 42;
const b = 56;
log(LogLevel.Info, `add(${a}, ${b}) = ${add(a, b)}`);
log(LogLevel.Info, `gcd(${a}, ${b}) = ${gcd(a, b)}`);
5.4 core/tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"declaration": true,
"declarationMap": true,
"composite": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true
},
"include": ["src"]
}
5.5 utils/tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"declaration": true,
"declarationMap": true,
"composite": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true
},
"include": ["src"]
}
5.6 app/tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"baseUrl": ".",
"paths": {
"@core/*": ["../core/dist/*"],
"@utils/*": ["../utils/dist/*"]
}
},
"references": [
{ "path": "../core" },
{ "path": "../utils" }
],
"include": ["src"]
}
5.7 一鍵編譯指令
在 repo 根目錄建立 tsconfig.json(僅作為根參照):
{
"files": [],
"references": [
{ "path": "./core" },
{ "path": "./utils" },
{ "path": "./app" }
]
}
然後執行:
# 只要跑一次,所有子專案會自動依序編譯
$ tsc -b
若只修改 core,執行 tsc -b core 即可,app 只會在需要時重新編譯。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方式 |
|---|---|---|
忘記 declaration |
Composite 必須產生 .d.ts,否則 tsc 會報錯 Project ... cannot be referenced because it does not have a declaration file。 |
在 tsconfig.json 中加入 "declaration": true。 |
outDir 放在同層 |
若 outDir 與 src 同層,產出的檔案會與原始碼混雜,導致循環引用。 |
確保 outDir 為獨立資料夾(如 dist)。 |
| 相對路徑錯誤 | 多層參照時,path 必須相對於參照專案的 tsconfig.json,不是根目錄。 |
使用 ../ 正確定位,或在根目錄建立總參照 tsconfig.json。 |
未同步 tsconfig 變更 |
改動 compilerOptions(如 target)後,舊的 .tsbuildinfo 仍會使用舊設定。 |
在變更後執行 tsc -b --clean,或手動刪除 *.tsbuildinfo。 |
| 循環參照 | 兩個專案互相引用會造成編譯死循環。 | 重新設計依賴層級,或將共同程式抽成第三個「共享」專案。 |
最佳實踐
- 最小化依賴層級:盡量保持 單向依賴(A → B → C),避免循環。
- 使用
references替代npm link:在 monorepo 中,Project References 比npm link更可靠且支援型別檢查。 - CI 中加入
--incremental:在 CI/CD pipeline 中加入tsc -b --incremental,可利用快取加速建置。 - 分離測試專案:若有大型測試套件,建議為測試建立獨立的
tsconfig.test.json,並參照主要專案。
實際應用場景
1. Monorepo 前端應用
大型企業常使用 Nx、Lerna 或 TurboRepo 管理多套前端應用與共用 UI 元件庫。透過 Project References,核心 UI 元件 (ui-lib) 只需編譯一次,即可被所有子應用即時引用,且型別安全不會因版本不一致而出錯。
2. Node.js 微服務
每個微服務都有自己的入口 src/index.ts,但共用的資料模型、驗證函式庫則抽成 shared/models。使用 Composite Projects,部署腳本 只需要 tsc -b 針對變更的微服務編譯,其他服務保持快取,縮短部署窗口。
3. 多語系編譯(ESM 與 CJS)
在同一 repo 中同時產出 ESM 與 CommonJS 兩套輸出:
// core/tsconfig.esm.json
{
"extends": "./tsconfig.json",
"compilerOptions": { "module": "ESNext", "outDir": "./dist/esm" }
}
// core/tsconfig.cjs.json
{
"extends": "./tsconfig.json",
"compilerOptions": { "module": "CommonJS", "outDir": "./dist/cjs" }
}
兩個 config 都是 Composite,app 只要在 references 中指向其中一個即可,讓不同環境的消費者自由選擇。
總結
- Composite Projects 為 TypeScript 提供了 增量編譯、跨專案型別檢查與 可視化依賴圖 的能力。
- Project References 讓多個
tsconfig.json能以 階層式、單向 的方式互相參照,避免手動排序與重複編譯。 - 正確設定
composite、declaration、outDir等選項,搭配paths/baseUrl別名,可讓大型 monorepo 或微服務架構的開發與 CI 流程更順暢。 - 常見的陷阱(忘記產生宣告檔、循環參照、舊快取)只要留意 編譯設定 與 依賴層級,即可輕鬆避免。
透過本文的概念與範例,你已經可以在自己的專案中建立、維護與優化 TypeScript 的組合專案,讓開發體驗更快、更安全,也為未來的擴充奠定堅實基礎。祝你編譯順利、程式碼永遠保持乾淨!