本文 AI 產出,尚未審核

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. 為什麼需要 pathsbaseUrl

在多專案環境下,相對路徑往往會變得冗長且易出錯。透過 baseUrl + paths 可以設定 別名,讓引用更直觀:

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@core/*": ["../core/dist/*"],
      "@utils/*": ["../utils/dist/*"]
    }
  }
}

小技巧:在 IDE(如 VSCode)中,同時設定 jsconfig.jsontsconfig.json,即可即時取得別名的自動完成與跳轉功能。

4. 增量編譯(Incremental Build)

  • compositetrue 時,編譯器會在 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 放在同層 outDirsrc 同層,產出的檔案會與原始碼混雜,導致循環引用。 確保 outDir 為獨立資料夾(如 dist)。
相對路徑錯誤 多層參照時,path 必須相對於參照專案的 tsconfig.json,不是根目錄。 使用 ../ 正確定位,或在根目錄建立總參照 tsconfig.json
未同步 tsconfig 變更 改動 compilerOptions(如 target)後,舊的 .tsbuildinfo 仍會使用舊設定。 在變更後執行 tsc -b --clean,或手動刪除 *.tsbuildinfo
循環參照 兩個專案互相引用會造成編譯死循環。 重新設計依賴層級,或將共同程式抽成第三個「共享」專案。

最佳實踐

  1. 最小化依賴層級:盡量保持 單向依賴(A → B → C),避免循環。
  2. 使用 references 替代 npm link:在 monorepo 中,Project References 比 npm link 更可靠且支援型別檢查。
  3. CI 中加入 --incremental:在 CI/CD pipeline 中加入 tsc -b --incremental,可利用快取加速建置。
  4. 分離測試專案:若有大型測試套件,建議為測試建立獨立的 tsconfig.test.json,並參照主要專案。

實際應用場景

1. Monorepo 前端應用

大型企業常使用 NxLernaTurboRepo 管理多套前端應用與共用 UI 元件庫。透過 Project References,核心 UI 元件 (ui-lib) 只需編譯一次,即可被所有子應用即時引用,且型別安全不會因版本不一致而出錯。

2. Node.js 微服務

每個微服務都有自己的入口 src/index.ts,但共用的資料模型、驗證函式庫則抽成 shared/models。使用 Composite Projects,部署腳本 只需要 tsc -b 針對變更的微服務編譯,其他服務保持快取,縮短部署窗口。

3. 多語系編譯(ESM 與 CJS)

在同一 repo 中同時產出 ESMCommonJS 兩套輸出:

// 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 能以 階層式單向 的方式互相參照,避免手動排序與重複編譯。
  • 正確設定 compositedeclarationoutDir 等選項,搭配 paths/baseUrl 別名,可讓大型 monorepo 或微服務架構的開發與 CI 流程更順暢。
  • 常見的陷阱(忘記產生宣告檔、循環參照、舊快取)只要留意 編譯設定依賴層級,即可輕鬆避免。

透過本文的概念與範例,你已經可以在自己的專案中建立、維護與優化 TypeScript 的組合專案,讓開發體驗更快、更安全,也為未來的擴充奠定堅實基礎。祝你編譯順利、程式碼永遠保持乾淨!