TypeScript 與 JavaScript 整合(JS Interop)
主題:與既有 JS 程式共存
簡介
在現代前端開發中,TypeScript 已成為提升程式碼可讀性與安全性的首選語言。可是,大多數專案在導入 TypeScript 前,都已經有一大批 JavaScript 程式碼、第三方函式庫或是舊有的業務邏輯。直接把整個專案一次性改寫成 TypeScript,既耗時又容易產生錯誤。
因此,讓 TypeScript 與既有的 JavaScript 共存,成為了實務上最常見、也是最實用的需求。透過正確的設定與技巧,我們可以在不破壞現有功能的前提下,逐步將程式碼遷移到 TypeScript,享受到型別檢查、IDE 智慧提示等好處,同時保留 JavaScript 的彈性。
本文將從概念、實作、常見陷阱與最佳實踐,逐步說明如何在同一個專案裡安全、順暢地混合使用 TypeScript 與 JavaScript。
核心概念
1. 專案結構與編譯設定
| 目標 | 做法 |
|---|---|
| 允許 .js 檔案被編譯 | 在 tsconfig.json 中加入 allowJs: true |
| 保留原有的 JavaScript | 設定 checkJs: false(除非想要在 .js 中也啟用型別檢查) |
| 分離輸出 | 使用 outDir 把編譯後的檔案放到 dist/,避免覆蓋原始 .js 檔案 |
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"strict": true,
"allowJs": true, // 允許編譯 .js 檔案
"checkJs": false, // 不對 .js 執行型別檢查
"outDir": "./dist",
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*"]
}
小技巧:若想在部分 JavaScript 檔案中啟用型別檢查,只要在檔案開頭加上
// @ts-check,即可讓 TypeScript 嚴格檢查該檔案。
2. 宣告檔 (*.d.ts) 的角色
當 TypeScript 需要使用純 JavaScript 函式或物件時,型別資訊 必須由宣告檔提供。
- 手寫宣告檔:適用於自家寫的工具函式或尚未有官方型別套件的第三方庫。
declare module:用於告訴編譯器「這是一個什麼樣的模組,但我不提供實作」。
// utils.d.ts
declare module "legacy-utils" {
export function formatDate(date: Date, pattern?: string): string;
export const VERSION: string;
}
這樣在 TypeScript 檔案裡 import { formatDate } from "legacy-utils" 時,就能得到完整的型別提示與錯誤檢查。
3. any、unknown 與型別斷言
在與不熟悉的 JavaScript 程式碼互動時,暫時使用 any 可以避免編譯錯誤,但會失去型別安全。
any:直接告訴編譯器「我什麼都不知道」。unknown:比any更安全,必須先做型別檢查才能使用。
// 假設從全域變數取得第三方庫
declare const legacyLib: any;
// 正確做法:先檢查再斷言
if (typeof legacyLib?.doSomething === "function") {
(legacyLib.doSomething as (arg: string) => void)("hello");
}
4. 混合模組系統:CommonJS vs ES Module
舊有的 JavaScript 常使用 module.exports(CommonJS),而 TypeScript 預設編譯成 ES Module。
- 使用
esModuleInterop: true能讓import foo from "foo"正常對應module.exports = foo。 - 若需要保留 CommonJS 風格,可在
tsconfig.json設定module: "CommonJS",或在 TypeScript 檔案裡使用import * as foo from "foo"。
// legacy-lib.js (CommonJS)
module.exports = {
greet(name) {
return `Hi, ${name}!`;
}
};
// ts 使用 ES Module 方式匯入
import legacyLib from "./legacy-lib";
console.log(legacyLib.greet("Tom"));
5. 漸進式遷移的策略
- 先從入口檔案改寫:把
main.js改成main.ts,並在裡面逐步import其他.js。 - 寫型別宣告:對常用的 JavaScript 函式或物件寫
.d.ts,讓後續的 TypeScript 檔案能安全使用。 - 逐檔轉換:挑選風險較低、單元測試覆蓋率高的檔案改寫成
.ts,同時保留原始.js作為備援。
程式碼範例
範例 1:從純 JavaScript 直接引用 TypeScript 函式
// src/util.js (純 JS)
function add(a, b) {
return a + b;
}
module.exports = { add };
// src/util.ts (TS)
export function multiply(a: number, b: number): number {
return a * b;
}
// src/main.js
const { add } = require("./util"); // 引入 JS
import { multiply } from "./util"; // 同時引入 TS (需要 Babel/TS-node)
console.log(add(2, 3)); // 5
console.log(multiply(2, 3)); // 6
說明:透過
esModuleInterop,Node.js 可以同時支援require與import,讓舊有的 JavaScript 檔案無縫呼叫新寫的 TypeScript 函式。
範例 2:使用 declare 為第三方無型別庫提供型別
// src/global.d.ts
declare const _ : {
map<T, U>(arr: T[], fn: (item: T) => U): U[];
filter<T>(arr: T[], predicate: (item: T) => boolean): T[];
};
// src/legacy.js
// 假設全域載入了 lodash 的舊版
function processData(data) {
return _.map(data, d => d * 2);
}
module.exports = { processData };
// src/processor.ts
import { processData } from "./legacy";
const raw = [1, 2, 3];
const result = processData(raw); // TypeScript 會根據 declare 的型別推斷 result 為 number[]
console.log(result);
重點:只要在
global.d.ts中宣告全域變數_,TypeScript 就能在編譯期間提供正確的型別資訊,避免使用any。
範例 3:使用 unknown 與型別保護 (type guard)
// src/api.ts
export async function fetchData(url: string): Promise<unknown> {
const res = await fetch(url);
return res.json(); // 回傳 unknown,呼叫端必須自行驗證
}
// src/main.ts
import { fetchData } from "./api";
function isUser(obj: any): obj is { id: number; name: string } {
return (
typeof obj === "object" &&
obj !== null &&
typeof obj.id === "number" &&
typeof obj.name === "string"
);
}
async function showUser() {
const data = await fetchData("/api/user");
if (isUser(data)) {
console.log(`User: ${data.name} (ID: ${data.id})`);
} else {
console.error("取得的資料格式不符合預期");
}
}
showUser();
說明:
unknown讓 API 函式保持彈性,同時透過自訂的型別保護 (isUser) 在使用端取得完整的型別安全。
範例 4:混用 CommonJS 與 ES Module
// legacy/math.js (CommonJS)
module.exports = {
pi: 3.14159,
square(x) {
return x * x;
}
};
// src/math.ts (ES Module)
export const e = Math.E;
export function cube(x: number): number {
return x * x * x;
}
// src/index.ts
import math from "./legacy/math"; // 透過 esModuleInterop 取得 default
import { e, cube } from "./math";
console.log(`π = ${math.pi}, e = ${e}`);
console.log(`square(4) = ${math.square(4)}`);
console.log(`cube(3) = ${cube(3)}`);
關鍵:
esModuleInterop: true讓import math from "./legacy/math"直接對應到module.exports,避免了import * as math的冗長寫法。
範例 5:逐步遷移 – 先在 JavaScript 加上 // @ts-check
// src/helpers.js
// @ts-check
/**
* @param {number[]} arr
* @returns {number}
*/
function sum(arr) {
return arr.reduce((a, b) => a + b, 0);
}
module.exports = { sum };
// src/app.ts
import { sum } from "./helpers";
const total = sum([1, 2, 3]); // TypeScript 會根據 JSDoc 推斷參數型別
console.log(total);
優點:不必立刻改寫成
.ts,只要在 JS 檔案加入 JSDoc 或// @ts-check,就能享受到型別提示,降低遷移成本。
常見陷阱與最佳實踐
| 陷阱 | 可能的問題 | 解法 / 最佳實踐 |
|---|---|---|
忘記 allowJs |
編譯時會忽略 .js,導致找不到模組 |
確認 tsconfig.json 中已啟用 allowJs |
過度使用 any |
失去型別安全,錯誤只能在執行時才被發現 | 儘量使用 unknown + 型別保護或自行撰寫 .d.ts |
| 全域變數未宣告 | 編譯時出現 Cannot find name 錯誤 |
在 global.d.ts 中宣告全域變數或使用 declare const |
| 模組解析衝突 (CommonJS vs ES) | import 失敗或得到 default 為 {} |
開啟 esModuleInterop,或使用 import * as 方式 |
| 編譯輸出覆蓋原始 JS | 原有程式被覆寫,導致部署失敗 | 設定 outDir,並在 package.json 中使用 node dist/index.js 入口 |
| 忽略測試 | 遷移過程中不易發現行為差異 | 在每次把 .js 改成 .ts 前後,都執行單元測試,確保行為一致 |
最佳實踐
- 先寫型別宣告:對常用的 JavaScript 函式寫
.d.ts,即使只包含少量的export,也能大幅提升開發體驗。 - 使用 JSDoc:在仍保留
.js的檔案裡加入 JSDoc,讓 TypeScript 能自動推斷型別,降低遷移阻力。 - 分層編譯:把 TypeScript 的輸出放在
dist/,讓原始的.js保持不變,方便在開發與生產環境切換。 - 持續執行 lint & type-check:在 CI 中加入
tsc --noEmit與eslint,提前捕捉型別錯誤。 - 漸進式遷移:不要期望一次改完所有檔案,先從「純工具函式」或「不涉及 UI」的模組開始,逐步擴散。
實際應用場景
| 場景 | 為何需要 JS/TS 共存 | 具體做法 |
|---|---|---|
| 大型遺留系統(如舊版管理介面) | 完全重寫成本過高,且業務需求頻繁 | 先在入口 index.js 改寫為 index.ts,逐步把子模組遷移,同時使用 declare 為舊函式提供型別。 |
| 第三方未提供型別的庫(如老舊的 UI 套件) | 想要在 TypeScript 中使用卻缺少 @types |
手動建立 *.d.ts,或在使用處加上 // @ts-ignore 作為最後手段。 |
| 混合前端框架(React + jQuery) | 部分 UI 仍依賴 jQuery,其他部分使用 React+TS | 在 tsconfig.json 開啟 allowJs,把 jQuery 相關檔案保留 .js,新寫的 React 元件使用 .tsx。 |
| Node.js 後端微服務 | 部分服務仍是舊的 Express 版,想逐步導入 TypeScript | 在服務入口 server.js 改寫為 server.ts,其餘路由檔案先保留 .js,同時在 src/types/express.d.ts 補足缺失的型別。 |
| 跨平台共用程式庫(Web + React Native) | 同一套工具函式要在瀏覽器與行動端共享,部分程式碼只能以純 JS 實作 | 把共用程式庫寫成 .ts,針對平台特有的實作保留 .js,並使用 declare module 讓 TypeScript 能正確辨識。 |
總結
在實務開發中,TypeScript 與 JavaScript 的共存不僅是可能的,更是提升專案品質、降低長期維護成本的關鍵策略。透過以下幾點,你可以順利完成漸進式遷移:
- 啟用
allowJs,讓編譯器接受.js檔案。 - 使用宣告檔 (
*.d.ts) 為現有程式碼提供型別資訊。 - 善用
any、unknown與型別斷言,在保留彈性的同時逐步收緊型別。 - 處理模組系統差異,
esModuleInterop讓 CommonJS 與 ES Module 可以互相呼叫。 - 採取漸進式遷移策略,從入口檔案、工具函式開始,配合測試與 lint,確保功能不被破壞。
只要遵守上述最佳實踐,開發者就能在不犧牲既有功能的前提下,逐步享受到 TypeScript 帶來的靜態型別、IDE 補完與更可靠的程式碼基礎。未來,當所有核心模組都完成遷移,專案的可維護性、可擴充性將會大幅提升,開發效率與團隊協作也會因此受益。
祝你在 TypeScript 與 JavaScript 的共存之路上順利前行! 🎉