宣告合併

簡介

TypeScript 中的一些獨特概念描述了 JavaScript 物件在類型層級的形狀。一個特別獨特於 TypeScript 的範例是「宣告合併」的概念。了解這個概念將有助於你在使用現有 JavaScript 時獲得優勢。它也開啟了更進階抽象概念的大門。

就本文而言,「宣告合併」表示編譯器將兩個以相同名稱宣告的個別宣告合併成單一定義。這個合併的定義具有兩個原始宣告的功能。任何數量的宣告都可以合併;它不限於僅兩個宣告。

基本概念

在 TypeScript 中,宣告會在至少三個群組中的其中一個建立實體:命名空間、類型或值。建立命名空間的宣告會建立一個命名空間,其中包含使用點號表示法存取的名稱。建立類型的宣告會執行此動作:它們會建立一個類型,該類型可見於宣告的形狀,並繫結到指定的類型。最後,建立值的宣告會建立在輸出 JavaScript 中可見的值。

宣告類型 命名空間 類型
命名空間 X X
類別 X X
列舉 X X
介面 X
類型別名 X
函式 X
變數 X

了解每個宣告建立的內容將有助於您了解在執行宣告合併時合併的內容。

合併介面

最簡單也可能是最常見的宣告合併類型是介面合併。在最基本的層級,合併會機械性地將兩個宣告的成員合併成單一介面,且名稱相同。

ts
interface Box {
height: number;
width: number;
}
interface Box {
scale: number;
}
let box: Box = { height: 5, width: 6, scale: 10 };

介面的非函式成員應是唯一的。如果它們不是唯一的,它們必須是相同的類型。如果介面都宣告同名的非函式成員,但類型不同,編譯器會發出錯誤。

對於函式成員,每個同名的函式成員都被視為說明同一個函式的重載。另外要注意的是,在介面 A 與後來的介面 A 合併的情況下,第二個介面的優先順序會高於第一個介面。

也就是說,在範例中

ts
interface Cloner {
clone(animal: Animal): Animal;
}
interface Cloner {
clone(animal: Sheep): Sheep;
}
interface Cloner {
clone(animal: Dog): Dog;
clone(animal: Cat): Cat;
}

三個介面會合併以建立單一宣告,如下所示

ts
interface Cloner {
clone(animal: Dog): Dog;
clone(animal: Cat): Cat;
clone(animal: Sheep): Sheep;
clone(animal: Animal): Animal;
}

請注意,每個群組的元素會維持相同的順序,但群組本身會合併,且後來的重載組會先排序。

這個規則的一個例外是特殊簽章。如果簽章有一個參數的類型是單一字串文字類型(例如,不是字串文字的聯集),則它會浮到合併重載清單的頂端。

例如,下列介面會合併在一起

ts
interface Document {
createElement(tagName: any): Element;
}
interface Document {
createElement(tagName: "div"): HTMLDivElement;
createElement(tagName: "span"): HTMLSpanElement;
}
interface Document {
createElement(tagName: string): HTMLElement;
createElement(tagName: "canvas"): HTMLCanvasElement;
}

合併後的 Document 宣告結果如下

ts
interface Document {
createElement(tagName: "canvas"): HTMLCanvasElement;
createElement(tagName: "div"): HTMLDivElement;
createElement(tagName: "span"): HTMLSpanElement;
createElement(tagName: string): HTMLElement;
createElement(tagName: any): Element;
}

合併命名空間

與介面類似,同名的命名空間也會合併其成員。由於命名空間會同時建立命名空間和值,我們需要了解兩者是如何合併的。

若要合併命名空間,每個命名空間中宣告的已匯出介面的類型定義會合併,形成一個單一命名空間,其中包含已合併的介面定義。

若要合併命名空間值,在每個宣告位置,如果已存在具有指定名稱的命名空間,則會透過取得現有命名空間並將第二個命名空間的已匯出成員新增到第一個命名空間來進一步延伸它。

此範例中 Animals 的宣告合併

ts
namespace Animals {
export class Zebra {}
}
namespace Animals {
export interface Legged {
numberOfLegs: number;
}
export class Dog {}
}

等於

ts
namespace Animals {
export interface Legged {
numberOfLegs: number;
}
export class Zebra {}
export class Dog {}
}

這個命名空間合併模型是一個有用的起點,但我們也需要了解非已匯出成員會發生什麼事。非已匯出成員只會在原始(未合併)命名空間中可見。這表示在合併後,來自其他宣告的已合併成員無法看到非已匯出成員。

我們可以在這個範例中更清楚地看到

ts
namespace Animal {
let haveMuscles = true;
export function animalsHaveMuscles() {
return haveMuscles;
}
}
namespace Animal {
export function doAnimalsHaveMuscles() {
return haveMuscles; // Error, because haveMuscles is not accessible here
}
}

由於 haveMuscles 未匯出,只有與其共用未合併命名空間的 animalsHaveMuscles 函式可以看到符號。doAnimalsHaveMuscles 函式即使是已合併 Animal 命名空間的一部分,也無法看到這個非已匯出成員。

合併命名空間與類別、函式和列舉

命名空間夠靈活,也可以與其他類型的宣告合併。若要執行此動作,命名空間宣告必須遵循它將合併的宣告。產生的宣告具有兩種宣告類型的屬性。TypeScript 使用此功能來建模 JavaScript 中的一些模式以及其他程式語言。

合併命名空間與類別

這為使用者提供描述內部類別的方法。

ts
class Album {
label: Album.AlbumLabel;
}
namespace Album {
export class AlbumLabel {}
}

合併成員的可見性規則與合併命名空間區段中所述相同,因此我們必須匯出 AlbumLabel 類別,讓合併的類別可以看到它。最終結果是在另一個類別內管理的類別。您也可以使用命名空間來新增更多靜態成員到現有類別。

除了內部類別的模式之外,您可能也熟悉建立函式,然後透過新增屬性到函式來進一步延伸函式的 JavaScript 實務。TypeScript 使用宣告合併來以類型安全的方式建立此類定義。

ts
function buildLabel(name: string): string {
return buildLabel.prefix + name + buildLabel.suffix;
}
namespace buildLabel {
export let suffix = "";
export let prefix = "Hello, ";
}
console.log(buildLabel("Sam Smith"));

類似地,命名空間可用於使用靜態成員來延伸列舉。

ts
enum Color {
red = 1,
green = 2,
blue = 4,
}
namespace Color {
export function mixColor(colorName: string) {
if (colorName == "yellow") {
return Color.red + Color.green;
} else if (colorName == "white") {
return Color.red + Color.green + Color.blue;
} else if (colorName == "magenta") {
return Color.red + Color.blue;
} else if (colorName == "cyan") {
return Color.green + Color.blue;
}
}
}

不允許合併

並非所有合併在 TypeScript 中都允許。目前,類別無法與其他類別或變數合併。有關模擬類別合併的資訊,請參閱TypeScript 中的 Mixin區段。

模組擴充

儘管 JavaScript 模組不支援合併,但您可以透過匯入然後更新現有物件來修補它們。讓我們來看一個玩具 Observable 範例

ts
// observable.ts
export class Observable<T> {
// ... implementation left as an exercise for the reader ...
}
// map.ts
import { Observable } from "./observable";
Observable.prototype.map = function (f) {
// ... another exercise for the reader
};

這在 TypeScript 中也運作良好,但編譯器不知道 Observable.prototype.map。您可以使用模組擴充來告訴編譯器有關它的資訊

ts
// observable.ts
export class Observable<T> {
// ... implementation left as an exercise for the reader ...
}
// map.ts
import { Observable } from "./observable";
declare module "./observable" {
interface Observable<T> {
map<U>(f: (x: T) => U): Observable<U>;
}
}
Observable.prototype.map = function (f) {
// ... another exercise for the reader
};
// consumer.ts
import { Observable } from "./observable";
import "./map";
let o: Observable<number>;
o.map((x) => x.toFixed());

模組名稱的解析方式與 import/export 中的模組指定符相同。有關更多資訊,請參閱 模組。然後,擴充中的宣告會合併,就好像它們宣告在與原始檔案相同的檔案中一樣。

不過,有兩個限制事項需要記住

  1. 您無法在擴充中宣告新的頂層宣告,只能修補現有的宣告。
  2. 預設匯出也無法擴充,只能擴充命名匯出(因為您需要透過其匯出名稱來擴充匯出,而 default 是保留字詞 - 有關詳細資訊,請參閱 #14080

全域擴充

您也可以從模組內部將宣告新增到全域範圍

ts
// observable.ts
export class Observable<T> {
// ... still no implementation ...
}
declare global {
interface Array<T> {
toObservable(): Observable<T>;
}
}
Array.prototype.toObservable = function () {
// ...
};

全域擴充與模組擴充具有相同的行為和限制事項。

TypeScript 文件是一個開放原始碼專案。協助我們改善這些頁面 透過傳送 Pull Request

此頁面的貢獻者
RCRyan Cavanaugh (53)
DRDaniel Rosenwasser (20)
OTOrta Therox (16)
NSNathan Shively-Sanders (10)
MFMartin Fischer (1)
15+

上次更新:2024 年 3 月 21 日