簡介
TypeScript 中的一些獨特概念用於在型別層面描述 JavaScript 物件的形態。其中一個對 TypeScript 而言非常獨特的概念是“宣告合併”。理解這個概念將在處理現有 JavaScript 程式碼時為你提供優勢,同時它也為你接觸更高階的抽象概念打開了大門。
就本文而言,“宣告合併”是指編譯器將兩個使用相同名稱宣告的獨立宣告合併為一個單一的定義。這個合併後的定義同時具備了原先兩個宣告的特性。任意數量的宣告都可以合併,不僅限於兩個宣告。
基本概念
在 TypeScript 中,宣告至少會建立以下三類實體中的一種:名稱空間(namespace)、型別(type)或值(value)。建立名稱空間的宣告會生成一個名稱空間,其中包含使用點號表示法訪問的名稱。建立型別的宣告顧名思義:它們建立一個可見的、具有宣告形態並繫結到給定名稱的型別。最後,建立值的宣告會生成在輸出的 JavaScript 中可見的值。
| 宣告型別 | 名稱空間 | 型別 | 值 |
|---|---|---|---|
| 名稱空間 | X | X | |
| 類 | X | X | |
| 列舉 | X | X | |
| 介面 | X | ||
| 類型別名 | X | ||
| 函式 | X | ||
| 變數 | X |
理解每種宣告所建立的內容,將有助於你理解執行宣告合併時究竟合併了什麼。
合併介面
最簡單且可能也是最常見的宣告合併型別是介面合併。從最基本的層面來看,這種合併機制會將兩個宣告的成員連線到一個同名的單一介面中。
tsinterface Box {height: number;width: number;}interface Box {scale: number;}let box: Box = { height: 5, width: 6, scale: 10 };
介面中的非函式成員必須是唯一的。如果它們不唯一,則它們必須具有相同的型別。如果兩個介面聲明瞭相同名稱但型別不同的非函式成員,編譯器將會報錯。
對於函式成員,每個同名的函式成員都被視為對同一個函式的過載描述。另外值得注意的是,當介面 A 與後來的介面 A 合併時,第二個介面將具有比第一個更高的優先順序。
也就是說,在示例中
tsinterface Cloner {clone(animal: Animal): Animal;}interface Cloner {clone(animal: Sheep): Sheep;}interface Cloner {clone(animal: Dog): Dog;clone(animal: Cat): Cat;}
這三個介面將合併為一個單一的宣告,如下所示
tsinterface Cloner {clone(animal: Dog): Dog;clone(animal: Cat): Cat;clone(animal: Sheep): Sheep;clone(animal: Animal): Animal;}
注意,每一組中的元素保持相同的順序,但組本身是合併的,後出現的過載集排在前面。
此規則的一個例外是特殊化簽名。如果一個簽名的引數型別是單一字串字面量型別(例如,不是字串字面量的聯合型別),那麼它將被提升到其合併後的過載列表的最前面。
例如,以下介面將合併在一起
tsinterface 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 合併後的結果宣告將是以下內容
tsinterface Document {createElement(tagName: "canvas"): HTMLCanvasElement;createElement(tagName: "div"): HTMLDivElement;createElement(tagName: "span"): HTMLSpanElement;createElement(tagName: string): HTMLElement;createElement(tagName: any): Element;}
合併名稱空間
與介面類似,同名的名稱空間也會合並它們的成員。由於名稱空間同時建立名稱空間和值,我們需要了解兩者是如何合併的。
在合併名稱空間時,每個名稱空間中宣告的匯出介面的型別定義會進行合併,形成一個包含合併後接口定義的單一名稱空間。
在合併名稱空間的值時,在每個宣告位置,如果已經存在同名的名稱空間,則透過獲取現有的名稱空間並將第二個名稱空間的匯出成員新增到第一個名稱空間中,來對其進行擴充套件。
在此示例中 Animals 的宣告合併
tsnamespace Animals {export class Zebra {}}namespace Animals {export interface Legged {numberOfLegs: number;}export class Dog {}}
等同於
tsnamespace Animals {export interface Legged {numberOfLegs: number;}export class Zebra {}export class Dog {}}
這種名稱空間合併模型是一個很好的起點,但我們還需要了解未匯出成員會發生什麼。未匯出的成員僅在原始(未合併)的名稱空間內可見。這意味著合併後,來自其他宣告的合併成員無法看到未匯出的成員。
我們可以在這個示例中更清楚地看到這一點
tsnamespace 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 以及其他程式語言中的某些模式。
合併名稱空間與類
這為使用者提供了一種描述內部類的方法。
tsclass Album {label: Album.AlbumLabel;}namespace Album {export class AlbumLabel {}}
合併成員的可見性規則與 合併名稱空間 一節中描述的相同,因此我們必須匯出 AlbumLabel 類,以便合併後的類能夠看到它。最終結果是一個在另一個類中管理的類。你也可以使用名稱空間向現有類新增更多靜態成員。
除了內部類模式外,你可能還熟悉 JavaScript 中建立函式,然後透過向函式新增屬性來進一步擴充套件它的做法。TypeScript 使用宣告合併以型別安全的方式構建這類定義。
tsfunction buildLabel(name: string): string {return buildLabel.prefix + name + buildLabel.suffix;}namespace buildLabel {export let suffix = "";export let prefix = "Hello, ";}console.log(buildLabel("Sam Smith"));
同樣,名稱空間可用於向列舉新增靜態成員
tsenum 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 中的 Mixins 一節。
模組擴充套件(Module Augmentation)
雖然 JavaScript 模組不支援合併,但你可以透過匯入現有物件並進行更新來對其進行“修補”。讓我們看一個簡單的 Observable 示例
ts// observable.tsexport class Observable<T> {// ... implementation left as an exercise for the reader ...}// map.tsimport { Observable } from "./observable";Observable.prototype.map = function (f) {// ... another exercise for the reader};
這在 TypeScript 中也可以正常工作,但編譯器不知道 Observable.prototype.map。你可以使用模組擴充套件來告知編譯器
ts// observable.tsexport class Observable<T> {// ... implementation left as an exercise for the reader ...}// map.tsimport { 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.tsimport { Observable } from "./observable";import "./map";let o: Observable<number>;o.map((x) => x.toFixed());
模組名稱的解析方式與 import/export 中的模組說明符相同。更多資訊請參閱 模組。然後,擴充套件中的宣告將像在原始檔案中宣告一樣進行合併。
然而,需要記住兩個限制
- 你不能在擴充套件中宣告新的頂層宣告 — 只能對現有宣告進行修補。
- 預設匯出(default exports)也不能被擴充套件,只能擴充套件具名匯出(因為你需要按匯出的名稱來擴充套件匯出,而
default是一個保留字 - 詳細資訊請參閱 #14080)
全域性擴充套件
你還可以從模組內部向全域性作用域新增宣告
ts// observable.tsexport class Observable<T> {// ... still no implementation ...}declare global {interface Array<T> {toObservable(): Observable<T>;}}Array.prototype.toObservable = function () {// ...};
全域性擴充套件具有與模組擴充套件相同的行為和限制。