声明合并_TypeScript笔记16

一.简介

类似于 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值

(摘自类与类型

因此,可以把声明分为 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
}

对于函数成员,同名的看作函数重载

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,而模块声明中新增的扩展成员会被合并到源模块中(就像本来就声明在同一个文件中一样)。能够以这种方式扩展现有模块,但有2 点限制

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等全局声明中

参考资料

发表评论

电子邮件地址不会被公开。 必填项已用*标注

*

code