本文 AI 產出,尚未審核

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.jsonincludetypeRoots 路徑中,才能被編譯器發現。

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。

最佳實踐

  1. 盡量使用型別斷言一次搞定as typeof import('./module'),簡潔且不影響執行效能。
  2. 為第三方非 TS 套件建立型別宣告,避免在每次使用時重複斷言。
  3. 封裝通用的動態匯入函式(如 dynImport<T>()),提升程式碼可讀性與維護性。
  4. tsconfig.json 中啟用 esModuleInteropallowSyntheticDefaultImports,減少 import 語法的摩擦。
  5. 使用 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 專案將兼具 效能安全性,在大型應用程式開發中更顯得游刃有餘。祝你寫程式快樂、寫得安心!