TypeScript 與 JavaScript 整合(JS Interop)
主題:混合專案(.js + .ts)
簡介
在現代前端開發中,TypeScript 以靜態型別、編譯期錯誤檢查等優勢,逐漸成為大型專案的首選語言。然而,許多既有的程式碼庫仍然是純 JavaScript,或是團隊在逐步遷移時不想一次全部改寫。這時 混合專案(同時包含 .js 與 .ts 檔案)就成了最實用的過渡方案。
透過正確的設定與技巧,我們可以在同一個專案裡自由呼叫 JavaScript 函式、使用第三方 JS 套件,同時享受 TypeScript 帶來的型別安全與開發體驗。本文將從概念說明、實作範例、常見陷阱與最佳實踐,帶領讀者完整掌握「.js + .ts」混合專案的核心要點,讓你在真實專案中順利推動 TypeScript 化。
核心概念
1. TypeScript 專案的基本設定
在混合專案中,tsconfig.json 必須告訴編譯器要處理哪些檔案、如何解析 JavaScript。以下是一個常見的設定範例:
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"strict": true,
"allowJs": true, // 允許編譯 .js 檔案
"checkJs": false, // 若想要檢查 .js,設為 true
"noEmit": false,
"outDir": "./dist",
"esModuleInterop": true,
"resolveJsonModule": true
},
"include": ["src/**/*"], // 包含 .ts 與 .js
"exclude": ["node_modules"]
}
allowJs:允許 TypeScript 編譯器處理.js檔案,使兩種語言可以共存。checkJs:若開啟,編譯器會對 JavaScript 檔案執行型別檢查(需搭配// @ts-check註解)。esModuleInterop:讓 CommonJS 模組可以以import方式使用,減少相容性問題。
小技巧:在遷移過程中,先把
checkJs關掉,等到 JavaScript 部分逐步補上型別註解後,再開啟以獲得更完整的檢查。
2. 引入 JavaScript 模組的方式
2.1 ES Module (import)
如果 JavaScript 檔案本身使用 export,可以直接以 ES Module 方式匯入:
// src/util.js
export function add(a, b) {
return a + b;
}
// src/main.ts
import { add } from "./util.js"; // 必須加 .js 擴展名 (Node/Esm)
const sum = add(3, 5);
console.log(sum); // 8
注意:在 TypeScript 中匯入
.js檔案時,必須保留副檔名(.js),否則編譯器會找不到對應檔案。
2.2 CommonJS (require)
對於舊有的 module.exports/require 風格,esModuleInterop 讓我們可以這樣寫:
// src/legacy.js
module.exports = {
multiply: (x, y) => x * y,
};
// src/main.ts
import legacy from "./legacy.js"; // 透過 default import 取得整個模組
const product = legacy.multiply(4, 6);
console.log(product); // 24
如果不想使用 esModuleInterop,也可以直接使用 require:
// src/main.ts
const legacy = require("./legacy.js");
const product = legacy.multiply(4, 6);
3. 為 JavaScript 加上型別宣告
純 JavaScript 本身沒有型別資訊,為了在 TypeScript 中取得自動完成與錯誤檢查,我們可以:
3.1 使用 JSDoc
在 .js 檔案內加入 JSDoc 註解,編譯器會根據註解推斷型別:
/**
* 計算兩個數字的平均值
* @param {number} a
* @param {number} b
* @returns {number}
*/
export function average(a, b) {
return (a + b) / 2;
}
3.2 宣告檔 (.d.ts)
若不想改動原始 JavaScript,亦可在 src/types 目錄下建立對應的宣告檔:
// src/types/legacy.d.ts
declare module "./legacy.js" {
export function multiply(x: number, y: number): number;
}
這樣在 main.ts 中使用 legacy.multiply 時,就會得到正確的型別提示。
4. 編譯與執行流程
| 步驟 | 說明 |
|---|---|
1️⃣ tsc |
依 tsconfig.json 把 .ts 與 .js 皆編譯至 dist/(.js 會直接拷貝,.ts 會轉譯) |
2️⃣ node dist/main.js |
執行編譯後的入口檔案,所有模組路徑已指向 dist/ 中的檔案 |
3️⃣ (Optional) ts-node |
在開發階段直接執行 TypeScript 檔案,ts-node 會自動處理混合檔案 |
建議:在 CI/CD 流程中使用
npm run build && node dist/main.js,確保所有 JavaScript 都已正確搬移。
程式碼範例
以下示範一個 混合專案 的完整結構與核心功能。
目錄結構
my-mixed-project/
├─ src/
│ ├─ util.js // 純 JavaScript 函式庫
│ ├─ legacy.js // CommonJS 模組
│ ├─ math.ts // TypeScript 模組
│ └─ index.ts // 入口檔案
├─ tsconfig.json
└─ package.json
1️⃣ util.js(ES Module + JSDoc)
// src/util.js
/**
* 將字串轉為大寫
* @param {string} str
* @returns {string}
*/
export function shout(str) {
return str.toUpperCase() + "!";
}
/**
* 判斷是否為偶數
* @param {number} n
* @returns {boolean}
*/
export function isEven(n) {
return n % 2 === 0;
}
2️⃣ legacy.js(CommonJS)
// src/legacy.js
module.exports = {
/** 取得隨機整數 */
randomInt: (max) => Math.floor(Math.random() * max),
};
3️⃣ math.ts(TypeScript)
// src/math.ts
export const PI = 3.14159;
/**
* 計算圓形面積
* @param radius 半徑
*/
export function circleArea(radius: number): number {
return PI * radius * radius;
}
4️⃣ index.ts(混合使用)
// src/index.ts
import { shout, isEven } from "./util.js";
import legacy from "./legacy.js"; // 透過 esModuleInterop
import { circleArea } from "./math";
// 直接使用 JavaScript 函式
console.log(shout("hello world")); // HELLO WORLD!
// 使用 CommonJS 模組
const rand = legacy.randomInt(100);
console.log(`Random number < 100: ${rand}`);
// 結合 TypeScript 與 JavaScript
if (isEven(rand)) {
console.log(`偶數 ${rand} 的圓形面積是 ${circleArea(rand)}`);
} else {
console.log(`奇數 ${rand},不計算面積`);
}
5️⃣ tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"strict": true,
"allowJs": true,
"esModuleInterop": true,
"outDir": "./dist",
"skipLibCheck": true
},
"include": ["src/**/*"]
}
6️⃣ package.json(簡易腳本)
{
"name": "my-mixed-project",
"type": "module",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "ts-node src/index.ts"
},
"devDependencies": {
"typescript": "^5.4.0",
"ts-node": "^10.9.0"
}
}
執行 npm run build && npm start,即可看到混合專案的完整運作。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方案 |
|---|---|---|
忘記在 import 時加 .js 副檔名 |
ES Module 在 Node/Esm 中必須保留副檔名,否則會跑不出錯誤。 | 始終寫成 import … from "./file.js"。 |
allowJs 開啟卻忘記 checkJs |
會導致 JavaScript 部分完全沒有型別檢查,錯誤被忽略。 | 在過渡期先使用 // @ts-check 單檔啟用檢查,或逐步開啟 checkJs。 |
CommonJS 與 ES Module 混用產生的 default/named 匯入衝突 |
例如 module.exports = fn 與 export default fn 行為不同。 |
使用 esModuleInterop,或手動寫 import * as mod from "./cjs.js"。 |
| 宣告檔與實際實作不一致 | .d.ts 中型別與 JS 實作不匹配,會產生錯誤提示。 |
保持宣告檔與程式碼同步,使用 npm run lint 或 tsc --noEmit 檢查。 |
| 編譯輸出路徑錯誤導致找不到模組 | outDir 與 rootDir 配置不當,執行時找不到 .js。 |
確認 tsconfig.json 中 outDir、rootDir 與 package.json 中的 type 設定一致。 |
最佳實踐
- 逐檔遷移:先從最關鍵的模組開始加上
.ts,其餘保持.js,減少一次性改動的風險。 - 使用 JSDoc:在不想改檔名的情況下,給予 JavaScript 函式完整的型別註解,提升 IDE 補全與錯誤偵測。
- 建立統一的型別宣告目錄:例如
src/types/,集中管理所有第三方或 legacy 模組的.d.ts,便於維護。 - CI 中加入
tsc --noEmit:確保每次提交的程式碼都能通過型別檢查,即使是純 JavaScript。 - 避免在同一檔案內混用
export default與module.exports:保持模組風格一致,降低相容性問題。
實際應用場景
| 場景 | 為何適合混合專案 | 範例說明 |
|---|---|---|
| 大型遺留系統 | 完全改寫成本高,且業務邏輯穩定。 | 先在新功能模組使用 .ts,舊的計算引擎保留 .js,透過 declare module 讓新程式碼安全呼叫。 |
| 逐步導入 TypeScript | 團隊成員對 TypeScript 熟悉度不同。 | 先在 UI 組件使用 .tsx,而服務層仍以 .js 為主,隨時可把服務層搬到 .ts。 |
| 混合第三方套件 | 某些套件只提供 JavaScript 檔案且缺少型別宣告。 | 用 npm i --save-dev @types/xxxx(若有)或自行寫 *.d.ts,讓 TypeScript 能正確辨識。 |
| Node.js + ESM 與 CJS 共存 | 有些工具只能以 CommonJS 方式匯入(例如 webpack 插件)。 |
透過 esModuleInterop 同時支援 import … from 與 require()。 |
| 前端微前端架構 | 各子應用可能使用不同語言或編譯設定。 | 主殼程式以 TypeScript 撰寫,子應用保留原本的 JavaScript,透過全域事件或共享模組進行互動。 |
總結
混合專案(.js + .ts)提供了一條 低風險、可漸進 的 TypeScript 轉型路徑。透過正確的 tsconfig 設定、合理的模組匯入方式、以及適當的型別補強(JSDoc、.d.ts),開發者可以在不犧牲既有功能的前提下,逐步享受到 TypeScript 帶來的 型別安全、開發工具支援與可維護性提升。
在實務上,記得:
- 保留
.js副檔名、開啟allowJs,讓編譯器能同時處理兩種檔案。 - 使用 JSDoc 或宣告檔 為 JavaScript 補上型別資訊,提升 IDE 體驗。
- 遵守最佳實踐(逐檔遷移、CI 型別檢查、統一型別目錄),避免常見的相容性陷阱。
只要掌握上述概念與技巧,無論是遺留系統還是新創專案,都能在 混合專案 的框架下,平滑且有效率地推動 TypeScript 化,為團隊與產品的長期健康奠定堅實基礎。祝開發順利,玩得開心!