TypeScript 與 JavaScript 整合(JS Interop)
主題:dynamic import 的型別
簡介
在大型前端專案中,**程式碼切割(code‑splitting)**已成為提升效能的關鍵手段。ES2020 引入的 import()(又稱 dynamic import)讓開發者可以在程式執行時才載入模組,配合 webpack、Vite 等 bundler,能有效降低首屏載入時間。
然而,當我們在 TypeScript 專案裡使用 import() 時,型別資訊往往會遺失,導致編譯器無法提供完整的 IntelliSense、錯誤檢查與 refactor 支援。本文將深入探討 dynamic import 的型別機制,說明如何正確取得模組的型別、避免常見陷阱,並提供實務上可直接套用的範例。
核心概念
1. import() 回傳的是 Promise<any>(預設行為)
// 直接使用 import(),TypeScript 只能推斷出 any
const mod = await import('./utils'); // 型別為 Promise<any>
mod.doSomething(); // ← 沒有型別提示,容易出錯
如果不加以說明,編譯器會把結果視為 any,失去 TypeScript 的好處。解決方式是 告訴編譯器模組的具體型別,常見做法有:
- 使用
import()的 type‑only 形式 - 為模組建立 declare module
- 透過
typeof import("./module")取得型別
下面分別示範。
2. import() 的 type‑only 語法
ES2020 支援的 import() 也可以在型別位置使用:
// utils.ts
export function add(a: number, b: number): number {
return a + b;
}
// main.ts
async function loadUtils() {
const { add } = await import('./utils') as typeof import('./utils');
// 此時 add 的型別已正確推斷為 (a: number, b: number) => number
console.log(add(3, 4));
}
loadUtils();
技巧:
as typeof import('./utils')只在型別層面起作用,編譯後的 JavaScript 不會產生額外的import語句。
3. 建立 declare module 供型別使用
當模組是 第三方套件 或 非 TypeScript 檔案(如 .js、.json)時,我們可以手動為它寫型別宣告:
// typings/utils.d.ts
declare module './utils' {
export function add(a: number, b: number): number;
export function mul(a: number, b: number): number;
}
// app.ts
async function calc() {
const utils = await import('./utils'); // 仍是 Promise<any>
// 由於已有 declare,TypeScript 能自動補全型別
console.log(utils.add(2, 5));
}
calc();
注意:
declare module必須放在tsconfig.json的include或typeRoots路徑中,才能被編譯器發現。
4. 使用泛型工具型別 Awaited<T>
從 TypeScript 4.5 起,內建的 Awaited<T> 可將 Promise<T> 轉換成 T,配合條件型別可寫出更通用的函式:
// genericDynamicImport.ts
export async function dynImport<T extends object>(path: string): Promise<Awaited<T>> {
const mod = await import(path);
return mod as Awaited<T>;
}
// 使用範例
interface Utils {
add(a: number, b: number): number;
}
(async () => {
const utils = await dynImport<Utils>('./utils');
console.log(utils.add(1, 2));
})();
此寫法的好處是 一次宣告即可重複使用,不必在每次 import() 時寫 as typeof import(...)。
5. 動態匯入 JSON 並保留型別
在 tsconfig.json 中開啟 "resolveJsonModule": true 後,我們可以直接匯入 JSON,型別仍需要明確指定:
// data.json
{
"name": "Alice",
"age": 30
}
// loadJson.ts
interface Person {
name: string;
age: number;
}
async function loadPerson(): Promise<Person> {
const data = await import('./data.json') as unknown as Person;
// 或使用 typeof import
// const data = await import('./data.json') as typeof import('./data.json');
return data;
}
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方案 |
|---|---|---|
返回 any |
直接 await import() 會失去型別資訊。 |
使用 as typeof import(...)、declare module 或自訂泛型函式。 |
| 重複載入同一模組 | 不同路徑字串(相對路徑 vs 絕對路徑)會被視為不同模組,造成多次下載。 | 統一使用 相對或別名,並在 tsconfig.json 設定 paths。 |
| 錯誤的路徑在編譯期無法偵測 | import() 的參數是字串,若拼寫錯誤,只有執行時才會拋錯。 |
使用 字面量類型或 enum 來限制可接受的路徑。 |
| JSON 型別不自動推斷 | 即使開啟 resolveJsonModule,仍需要手動斷言。 |
建立 .d.ts 定義或使用 as const 讓 JSON 變成字面量型別。 |
未處理 Promise |
忘記 await 或 .then,導致程式碼在 Promise 上執行操作。 |
嚴格模式 ("strict": true) 會警告未處理的 Promise。 |
最佳實踐:
- 盡量使用型別斷言一次搞定:
as typeof import('./module'),簡潔且不影響執行效能。 - 為第三方非 TS 套件建立型別宣告,避免在每次使用時重複斷言。
- 封裝通用的動態匯入函式(如
dynImport<T>()),提升程式碼可讀性與維護性。 - 在
tsconfig.json中啟用esModuleInterop、allowSyntheticDefaultImports,減少 import 語法的摩擦。 - 使用 lint 規則(如
@typescript-eslint/no-floating-promises)確保每個 Promise 都被正確處理。
實際應用場景
1. 按需載入大型圖表庫
// chartLoader.ts
export async function loadChartLib() {
const { Chart } = await import('chart.js') as typeof import('chart.js');
// 之後即可安全使用 Chart
return Chart;
}
只在使用圖表的頁面才下載 chart.js,減少主包體積。
2. 多語系資源的動態匯入
// i18n.ts
type Locale = 'en' | 'zh-TW' | 'ja';
export async function loadLocale(lang: Locale) {
const messages = await import(`./locales/${lang}.json`) as typeof import(`./locales/${lang}.json`);
return messages.default; // JSON 模組的預設匯出
}
利用字面量類型 Locale 防止拼寫錯誤,同時保留 JSON 的型別。
3. 插件機制(Plugin Architecture)
// pluginManager.ts
export interface Plugin {
name: string;
init(): void;
}
export async function loadPlugin(path: string): Promise<Plugin> {
const mod = await import(path) as { default: Plugin };
return mod.default;
}
// 使用
(async () => {
const plugin = await loadPlugin('./plugins/logger');
plugin.init();
})();
插件開發者只需匯出符合 Plugin 介面的預設物件,主程式透過型別斷言確保安全載入。
總結
- dynamic import 為前端效能優化提供了強大的工具,但在 TypeScript 中若不處理型別,會失去靜態檢查的好處。
- 透過
as typeof import(...)、declare module、自訂泛型函式 等方式,我們可以在保持程式碼分割的同時,讓編譯器完整理解模組的型別。 - 注意路徑一致性、Promise 處理以及第三方模組的型別宣告,才能避免常見的執行時錯誤。
- 在實務上,按需載入圖表庫、國際化資源、插件系統等情境都能直接套用本篇提供的範例與最佳實踐。
掌握了 dynamic import 的型別技巧後,你的 TypeScript 專案將兼具 效能 與 安全性,在大型應用程式開發中更顯得游刃有餘。祝你寫程式快樂、寫得安心!