一。簡介
類似於 CSS 裡的聲明合併:
.box {
background: red;
}
.box {
color: white;
}
/* 等價於 */
.box {
background: red;
color: white;
}
TypeScript 也有這樣的機制:
interface IPerson {
name: string;
}
interface IPerson {
age: number;
}
// 等價於
interface IPerson {
name: string;
age: number;
}
簡言之,多條描述同一個東西的聲明會被合併成一條
二。基本概念
TypeScript 裡,一條聲明可能會創建命名空間、類型或值,比如聲明 Class 時會同時創建類型和值:
class Greeter {
static standardGreeting = "Hello, there";
greeting: string;
constructor(message: string) {
this.greeting = message;
}
greet() {
return "Hello, " + this.greeting;
}
}
let greeter: Greeter; // Greeter 類型
greeter = new Greeter("world"); // Greeter 值
(摘自 [類與類型](/articles/類-typescript 筆記 4/#articleHeader8))
因此,可以把聲明分為 3 類:
-
會創建命名空間的聲明:創建一個用點號(
.)來訪問的命名空間名 -
會創建類型的聲明:創建一個指定「形狀」的類型,並以給定的名稱命名
-
會創建值的聲明:創建一個值,在輸出的 JavaScript 中也存在
具體的,在 TypeScript 的 7 種聲明中,命名空間具有命名空間和值含義,類與枚舉同時具有類型和值含義,接口與類型別名只有類型含義,函數與變量只有值含義:
| Declaration Type | Namespace | Type | Value |
|---|---|---|---|
| Namespace | X | X | |
| Class | X | X | |
| Enum | X | X | |
| Interface | X | ||
| Type Alias | X | ||
| Function | X | ||
| Variable | X |
三。合併接口
最簡單,也最常見的聲明合併就是接口合併,基本規則是把同名接口的成員放到一起:
interface Box {
height: number;
width: number;
}
interface Box {
scale: number;
}
// 等價於
interface MergedBox {
height: number;
width: number;
scale: number;
}
let box: Box = {height: 5, width: 6, scale: 10};
let b: MergedBox = box;
要求非函數成員唯一,如果不唯一的話,類型相同的函數成員會被忽略掉,類型不同的則拋出編譯錯誤:
interface Box {
color: string
}
// 錯誤 Subsequent property declarations must have the same type.
interface Box {
color: number
}
對於函數成員,同名的看作 [函數重載](/articles/函數-typescript 筆記 5/#articleHeader9):
class Animal { }
class Sheep { }
class Dog { }
class Cat { }
interface Cloner {
clone(animal: Animal): Animal;
}
interface Cloner {
clone(animal: Sheep): Sheep;
}
interface Cloner {
clone(animal: Dog): Dog;
clone(animal: Cat): Cat;
}
會被合併成:
interface Cloner {
clone(animal: Dog): Dog;
clone(animal: Cat): Cat;
clone(animal: Sheep): Sheep;
clone(animal: Animal): Animal;
}
同一聲明內的合併後仍保持聲明順序,不同聲明間後聲明的優先(也就是說,靠後的接口聲明語句中定義的函數成員在合併結果中靠前),而非函數成員合併後會按字典序排列
特殊的,如果函數簽名含有一個字符串字面量類型的參數,就會在合併後的重載列表中置頂:
interface IDocument {
createElement(tagName: any): Element;
}
interface IDocument {
createElement(tagName: "div"): HTMLDivElement;
createElement(tagName: "span"): HTMLSpanElement;
}
interface IDocument {
createElement(tagName: string): HTMLElement;
createElement(tagName: "canvas"): HTMLCanvasElement;
}
合併結果為:
interface IDocument {
// 特殊簽名置頂
createElement(tagName: "div"): HTMLDivElement;
createElement(tagName: "span"): HTMLSpanElement;
createElement(tagName: "canvas"): HTMLCanvasElement;
// 下面兩條仍遵循後聲明的優先
createElement(tagName: string): HTMLElement;
createElement(tagName: "canvas"): HTMLCanvasElement;
}
四。合併命名空間
類似於接口,多個同名命名空間也會發生成員合併,特殊之處在於命名空間還具有值含義,情況稍複雜一些
-
命名空間合併:各(同名)命名空間暴露出的接口進行合併,同時單個命名空間內部也進行接口合併
-
值合併:將後聲明的命名空間中暴露出的成員添加到先聲明的上
例如:
namespace Animals {
export class Zebra { }
}
namespace Animals {
export interface Legged { numberOfLegs: number; }
export class Dog { }
}
// 等價於
namespace Animals {
export interface Legged { numberOfLegs: number; }
export class Zebra { }
export class Dog { }
}
特殊的,未暴露出的成員(non-exported member)仍只在源命名空間可見(即便存在命名空間合併機制):
namespace Animal {
let haveWings = true;
export function animalsHaveWings() {
return haveWings;
}
}
namespace Animal {
export function doAnimalsHaveWings() {
// 錯誤 Cannot find name 'haveWings'.
return haveWings;
}
}
因為命名空間具有作用域隔離,未暴露出的成員不會被掛到命名空間上:
var Animal;
(function (Animal) {
var haveWings = true;
function animalsHaveWings() {
return haveWings;
}
Animal.animalsHaveWings = animalsHaveWings;
})(Animal || (Animal = {}));
(function (Animal) {
function doAnimalsHaveWings() {
// 錯誤 Cannot find name 'haveWings'.
return haveWings;
}
Animal.doAnimalsHaveWings = doAnimalsHaveWings;
})(Animal || (Animal = {}));
與類、函數及枚舉的合併
除了能與其它命名空間合併外,命名空間還能與類、函數以及枚舉合併
這種能力允許(在類型上)擴展現有類、函數與枚舉,用於描述 JavaScript 中的常見模式,比如給類添加靜態成員,給函數添加靜態屬性等等
P.S. 要求命名空間聲明必須後出現,否則報錯:
// 錯誤 A namespace declaration cannot be located prior to a class or function with which it is merged.
namespace A {
function f() { }
}
class A {
fn() { }
}
因為會發生覆蓋,而不是合併:
// 編譯結果
var A;
(function (A) {
function f() { }
})(A || (A = {}));
var A = /** @class */ (function () {
function A() {
}
A.prototype.fn = function () { };
return A;
}());
與類合併
可以通過命名空間來給現有 Class 添加靜態成員,例如:
class Album {
label: Album.AlbumLabel;
}
namespace Album {
export class AlbumLabel { }
}
與命名空間之間的合併規則一致,所以要暴露出 class AlbumLabel,允許其它聲明中的成員訪問
與函數合併
類似於命名空間與類的合併,與函數合併能夠給現有函數擴展靜態屬性:
function buildLabel(name: string): string {
return buildLabel.prefix + name + buildLabel.suffix;
}
namespace buildLabel {
export let suffix = "";
export let prefix = "Hello, ";
}
// test
buildLabel('Lily') === "Hello, Lily"
與枚舉合併
enum Color {
red = 1,
green = 2,
blue = 4
}
namespace Color {
export function mixColor(colorName: string) {
if (colorName == "yellow") {
return Color.red + Color.green;
}
else {
return -1;
}
}
}
// test
Color.mixColor('white');
讓枚舉擁有靜態方法看起來比較奇怪,但 JavaScript 裡確實存在類似的場景,相當於給屬性集添加行為:
// JavaScript
const Color = {
red: 1,
green: 2,
blue: 4
};
Color.mixColor = function(colorName) {/* ... */};
五。Class Mixin
類聲明不會與其它類或變量聲明發生合併,但可以通過 Class Mixin 來達到類似的效果:
function applyMixins(derivedCtor: any, baseCtors: any[]) {
baseCtors.forEach(baseCtor => {
Object.getOwnPropertyNames(baseCtor.prototype).forEach(name => {
Object.defineProperty(derivedCtor.prototype, name, Object.getOwnPropertyDescriptor(baseCtor.prototype, name));
});
});
}
通過工具函數把其它類原型上的東西都粘到目標類原型上去,使之擁有其它類的能力(行為):
class Editable {
public value: string;
input(s: string) { this.value = s; }
}
class Focusable {
focus() { console.log('Focused'); }
blur() { console.log('Blured'); }
}
// 從其它類獲得類型
class Input implements Editable, Focusable {
// 待實現的 Editable 接口
value: string;
input: (s: string) => void;
// 待實現的 Focusable 接口
focus: () => void;
blur: () => void;
}
// 從其它類獲得行為
applyMixins(Input, [Editable, Focusable]);
// log 'Focused'
new Input().focus();
P.S. 其中 implements Editable, Focusable 取源類的類型,類似於接口,具體見 Interfaces Extending Classes
六。模塊擴展
// 源碼文件 observable.js
export class Observable {
constructor(source) { this.source = source; }
}
// 源碼文件 map.js
import { Observable } from "./observable";
Observable.prototype.map = function (f) {
return new Observable(f(this.source));
}
這種模塊擴展方式在 JavaScript 中很常見,但在 TypeScript 下會得到報錯:
// 源碼文件 observable.ts
export class Observable<T> {
constructor(public source: T) { }
}
// 源碼文件 map.ts
import { Observable } from "./observable";
// 錯誤 Property 'map' does not exist on type 'Observable<any>'.
Observable.prototype.map = function (f) {
return new Observable(f(this.source));
}
此時可以通過模塊擴展(module augmentation)告知編譯器(類型系統)模塊中新增的成員:
// 源碼文件 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) {/* ... */}
其中,模塊名的解析方式與 import/export 一致,具體見 [模塊解析機制_TypeScript 筆記 14](/articles/模塊解析機制-typescript 筆記 14/),而模塊聲明中新增的擴展成員會被合併到源模塊中(就像本來就聲明在同一個文件中一樣)。能夠以這種方式擴展現有模塊,但有2 點限制:
-
無法在模塊擴展中添加頂層聲明,只能對擴展已存在的聲明
-
無法擴展默認導出,只能擴展具名導出(因為
default是保留字,無法按名擴展,具體見 Can not declaration merging for default exported class)
P.S. 上例在 Playground 等環境可能會遇到 declare module "./observable" 報錯:
Invalid module name in augmentation, module './observable' cannot be found.
Ambient module declaration cannot specify relative module name.
是模塊文件不存在引起的,在真實文件模塊中能夠正常編譯
全局擴展
也能以類似的方式擴展「全局模塊」(即修正全局作用域下的東西),例如:
// 源碼文件 observable.ts
export class Observable<T> {
constructor(public source: T) { }
}
declare global {
interface Array<T> {
toObservable(): Observable<T>;
}
}
Array.prototype.toObservable = function () {
return new Observable(this);
}
declare global 表示擴展全局作用域,新增的東西會被合併到 Array 等全局聲明中
暫無評論,快來發表你的看法吧