TypeScript 之模組

冴羽發表於2021-12-16

TypeScript 的官方文件早已更新,但我能找到的中文文件都還停留在比較老的版本。所以對其中新增以及修訂較多的一些章節進行了翻譯整理。

本篇翻譯整理自 TypeScript Handbook 中 「Module」 章節。

本文並不嚴格按照原文翻譯,對部分內容也做了解釋補充。

模組(Module)

JavaScript 有一個很長的處理模組化程式碼的歷史,TypeScript 從 2012 年開始跟進,現在已經實現支援了很多格式。不過隨著時間流逝,社群和 JavaScript 規範已經收斂為名為 ES 模組(或者 ES6 模組)的格式,這也就是我們所知的 import/export 語法。

ES 模組在 2015 年被新增到 JavaScript 規範中,到 2020 年,大部分的 web 瀏覽器和 JavaScript 執行環境都已經廣泛支援。

本章將覆蓋講解 ES 模組和和它之前流行的前身 CommonJS module.exports = 語法,你可以在 Modules 章節找到其他的模組模式。

JavaScript 模組是如何被定義的(How JavaScript Modules are Defined)

在 TypeScript 中,就像在 ECMAScript 2015 中 ,任何包含了一個頂層 import 或者 export 的檔案會被認為是一個模組。​

相對應的,一個沒有頂層匯入和匯出宣告的檔案會被認為是一個指令碼,它的內容會在全域性範圍內可用。

模組會在它自己的作用域,而不是在全域性作用域裡執行。這意味著,在一個模組中宣告的變數、函式、類等,對於模組之外的程式碼都是不可見的,除非你顯示的匯出這些值。

相對應的,要消費一個從另一個的模組匯出的值、函式、類、介面等,也需要使用匯入的格式先被匯入。

非模組(Non-modules)

在我們開始之前,我們需要先理解 TypeScript 認為什麼是一個模組。JavaScript 規範宣告任何沒有 export 或者頂層 await 的 JavaScript 檔案都應該被認為是一個指令碼,而非一個模組。

在一個指令碼檔案中,變數和型別會被宣告在共享的全域性作用域,它會被假定你或者使用 outFile 編譯選項,將多個輸入檔案合併成一個輸出檔案,或者在 HTML使用多個 <script> 標籤載入這些檔案。

如果你有一個檔案,現在沒有任何 import 或者 export,但是你希望它被作為模組處理,新增這行程式碼:

export {};

這會把檔案改成一個沒有匯出任何內容的模組,這個語法可以生效,無論你的模組目標是什麼。

TypeScript 中的模組(Modules in TypeScript)

在 TypeScript 中,當寫一個基於模組的程式碼時,有三個主要的事情需要考慮:

  • 語法:我想匯出或者匯入該用什麼語法?
  • 模組解析:模組名字(或路徑)和硬碟檔案之間的關係是什麼樣的?
  • 模組匯出目標:匯出的 JavaScript 模組長什麼樣?

ES 模組語法(ES Module Syntax)

一個檔案可以通過 export default 宣告一個主要的匯出:

// @filename: hello.ts
export default function helloWorld() {
  console.log("Hello, world!");
}

然後用這種方式匯入:

import hello from "./hello.js";
hello();

除了預設匯出,你可以通過省略 defaultexport 語法匯出不止一個變數和函式:

// @filename: maths.ts
export var pi = 3.14;
export let squareTwo = 1.41;
export const phi = 1.61;
 
export class RandomNumberGenerator {}
 
export function absolute(num: number) {
  if (num < 0) return num * -1;
  return num;
}

這些可以在其他的檔案通過 import 語法引入:

import { pi, phi, absolute } from "./maths.js";
 
console.log(pi);
const absPhi = absolute(phi);
// const absPhi: number

附加匯入語法(Additional Import Syntax)

一個匯入也可以使用類似於 import {old as new} 的格式被重新命名:

import { pi as π } from "./maths.js";
 
console.log(π);
// (alias) var π: number
// import π

你可以混合使用上面的語法,寫成一個單獨的 import

// @filename: maths.ts
export const pi = 3.14;
export default class RandomNumberGenerator {}
 
// @filename: app.ts
import RNGen, { pi as π } from "./maths.js";
 
RNGen;
 
(alias) class RNGen
import RNGen
 
console.log(π);
// (alias) const π: 3.14
// import π

你可以接受所有的匯出物件,然後使用 * as name 把它們放入一個單獨的名稱空間:

// @filename: app.ts
import * as math from "./maths.js";
 
console.log(math.pi);
const positivePhi = math.absolute(math.phi);

// const positivePhi: number

你可以通過 import "./file" 匯入一個檔案,這不會引用任何變數到你當前模組:

// @filename: app.ts
import "./maths.js";
 
console.log("3.14");

在這個例子中, import 什麼也沒幹,然而,math.ts 的所有程式碼都會執行,觸發一些影響其他物件的副作用(side-effects)。

TypeScript 具體的 ES 模組語法(TypeScript Specific ES Module Syntax)

型別可以像 JavaScript 值那樣,使用相同的語法被匯出和匯入:

// @filename: animal.ts
export type Cat = { breed: string; yearOfBirth: number };
 
export interface Dog {
  breeds: string[];
  yearOfBirth: number;
}
 
// @filename: app.ts
import { Cat, Dog } from "./animal.js";
type Animals = Cat | Dog;

TypeScript 已經在兩個方面擴充了 import 語法,方便型別匯入:

匯入型別(import type)

// @filename: animal.ts
export type Cat = { breed: string; yearOfBirth: number };
// 'createCatName' cannot be used as a value because it was imported using 'import type'.
export type Dog = { breeds: string[]; yearOfBirth: number };
export const createCatName = () => "fluffy";
 
// @filename: valid.ts
import type { Cat, Dog } from "./animal.js";
export type Animals = Cat | Dog;
 
// @filename: app.ts
import type { createCatName } from "./animal.js";
const name = createCatName();

內建型別匯入(Inline type imports)

TypeScript 4.5 也允許單獨的匯入,你需要使用 type 字首 ,表明被匯入的是一個型別:

// @filename: app.ts
import { createCatName, type Cat, type Dog } from "./animal.js";
 
export type Animals = Cat | Dog;
const name = createCatName();

這些可以讓一個非 TypeScript 編譯器比如 Babel、swc 或者 esbuild 知道什麼樣的匯入可以被安全移除。

匯入型別和內建型別匯入的區別在於一個是匯入語法,一個是僅僅匯入型別。

有 CommonJS 行為的 ES 模組語法(ES Module Syntax with CommonJS Behavior)

TypeScript 之所以有 ES 模組語法跟 CommonJS 和 AMD 的 required 有很大的關係。使用 ES 模組語法的匯入跟 require 一樣都可以處理絕大部分的情況,但是這個語法能確保你在有 CommonJS 輸出的 TypeScript 檔案裡,有一個 1 對 1 的匹配:

import fs = require("fs");
const code = fs.readFileSync("hello.ts", "utf8");

你可以在模組引用頁面瞭解到關於這個語法更多的資訊。

CommonJS 語法(CommonJS Syntax)

CommonJS 是 npm 大部分模組的格式。即使你正在寫 ES 模組語法,瞭解一下 CommonJS 語法的工作原理也會幫助你除錯更容易。

匯出(Exporting)

通過設定全域性 moduleexports 屬性,匯出識別符號。

function absolute(num: number) {
  if (num < 0) return num * -1;
  return num;
}
 
module.exports = {
  pi: 3.14,
  squareTwo: 1.41,
  phi: 1.61,
  absolute,
};

這些檔案可以通過一個 require 語句匯入:

const maths = require("maths");
maths.pi;
// any

你可以使用 JavaScript 的解構語法簡化一點程式碼:

const { squareTwo } = require("maths");
squareTwo;
// const squareTwo: any

CommonJS 和 ES 模組互操作(CommonJS and ES Modules interop)

因為預設匯出和模組宣告空間物件匯出的差異,CommonJS 和 ES 模組不是很合適一起使用。TypeScript 有一個 esModuleInterop 編譯選項可以減少兩種規範之間的衝突。

TypeScript 模組解析選項(TypeScript’s Module Resolution Options)

模組解析是從 import 或者 require 語句中取出字串,然後決定字元指向的是哪個檔案的過程。

TypeScript 包含兩個解析策略:Classic 和 Node。Classic,當編譯選項 module 不是 commonjs 時的預設選擇,包含了向後相容。Node 策略則複製了 CommonJS 模式下 Nodejs 的執行方式,會對 .ts.d.ts 有額外的檢查。

這裡有很多 TSConfig 標誌可以影響 TypeScript 的模組策略:moduleResolution, baseUrl, paths, rootDirs

關於這些策略工作的完整細節,你可以參考 Module Resolution

TypeScript 模組輸出選項(TypeScript’s Module Output Options)

有兩個選項可以影響 JavaScript 輸出的檔案:

  • target 決定了哪些 JS 特性會被降級(被轉換成可以在更老的 JavaScript 執行環境使用),哪些則完整保留。
  • module 決定了轉換後程式碼採用的模組規範

你使用哪個 target 取決於你期望程式碼執行的環境。這些可以是:你需要支援的最老的瀏覽器,你期望程式碼執行的最老的 Nodejs 版本,或者一些獨特的執行環境比如 Electron 等。

編譯選項 module 決定了模組之間通訊使用哪一種規範。在執行時,模組載入器會在執行模組之前,查詢並執行這個模組所有的依賴。

舉個例子,這是一個使用 ES Module 語法的 TypeScript 檔案,展示了 module 選項不同導致的編譯結果:

import { valueOfPi } from "./constants.js";
 
export const twoPi = valueOfPi * 2;

ES2020

import { valueOfPi } from "./constants.js";
export const twoPi = valueOfPi * 2;

CommonJS

"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.twoPi = void 0;
const constants_js_1 = require("./constants.js");
exports.twoPi = constants_js_1.valueOfPi * 2;

UMD

(function (factory) {
    if (typeof module === "object" && typeof module.exports === "object") {
        var v = factory(require, exports);
        if (v !== undefined) module.exports = v;
    }
    else if (typeof define === "function" && define.amd) {
        define(["require", "exports", "./constants.js"], factory);
    }
})(function (require, exports) {
    "use strict";
    Object.defineProperty(exports, "__esModule", { value: true });
    exports.twoPi = void 0;
    const constants_js_1 = require("./constants.js");
    exports.twoPi = constants_js_1.valueOfPi * 2;
});
注意 ES2020 已經跟原始的 index.ts 檔案相同了。

你可以在 TSConfig 模組頁面看到所有可用的選項和它們對應編譯後的 JavaScript 程式碼長什麼樣。

TypeScript 名稱空間(TypeScript namespaces)

TypeScript 有它自己的模組格式,名為 namespaces 。它在 ES 模組標準之前出現。這個語法有一系列的特性,可以用來建立複雜的定義檔案,現在依然可以在 DefinitelyTyped 看到。當沒有被廢棄的時候,名稱空間主要的特性都還存在於 ES 模組,我們推薦你對齊 JavaScript 方向使用。你可以在名稱空間頁面瞭解更多。

TypeScript 系列

TypeScript 系列文章由官方文件翻譯、重難點解析、實戰技巧三個部分組成,涵蓋入門、進階、實戰,旨在為你提供一個系統學習 TS 的教程,全系列預計 40 篇左右。點此瀏覽全系列文章,並建議順便收藏站點。

微信:「mqyqingfeng」,加我進冴羽唯一的讀者群。

如果有錯誤或者不嚴謹的地方,請務必給予指正,十分感謝。如果喜歡或者有所啟發,歡迎 star,對作者也是一種鼓勵。

相關文章