|
| 1 | +## 声明合并 |
| 2 | + |
| 3 | +### 介绍 |
| 4 | + |
| 5 | +TypeScript 中的某些独有概念在类型层面描述了 JavaScript 对象的形状。其中一个非常独有的概念就是“声明合并”。理解这个概念可以帮助你在处理现有的 JavaScript 时更加得心应手,同时,它也开启了通往更高级的抽象概念的大门。 |
| 6 | + |
| 7 | +对于本文而言,“声明合并”指的是编译器会将分开的两个相同名字的声明合并为一个声明。合并后的声明同时具有合并前声明的所有特性。任意数量的声明都可以合并到一起,不止局限于两个声明。 |
| 8 | + |
| 9 | +### 基础概念 |
| 10 | + |
| 11 | +在 TypeScript 中,一个声明创建的实体至少是下面三种类型的其中一种:命名空间、类型或者值。命名空间式的声明会创建一个命名空间,它包含的名字可以通过点访问符进行访问。类型式的声明会创建一个对已声明形状可见的类型,并且会绑定到给定的名字上。最后,值式的声明会创建在输出的 JavaScript 中可见的值。 |
| 12 | + |
| 13 | +| Declaration Type | Namespace | Type | Value | |
| 14 | +| :--------------: | :-------: | :--: | :---: | |
| 15 | +| Namespace | X | | X | |
| 16 | +| Class | | X | X | |
| 17 | +| Enum | | X | X | |
| 18 | +| Interface | | X | | |
| 19 | +| Type Alias | | X | | |
| 20 | +| Function | | | X | |
| 21 | +| Variable | | | X | |
| 22 | + |
| 23 | +理解每个声明会创建什么,才能更好地理解进行声明合并时会合并什么。 |
| 24 | + |
| 25 | +### 合并接口 |
| 26 | + |
| 27 | +最简单、并且可能是最常见的声明合并类型就是接口合并。在大多数情况下,合并操作会机械地将两个声明的所有成员放到一个同名的接口中。 |
| 28 | + |
| 29 | +```ts |
| 30 | +interface Box { |
| 31 | + height: number; |
| 32 | + width: number; |
| 33 | +} |
| 34 | +interface Box { |
| 35 | + scale: number; |
| 36 | +} |
| 37 | +let box: Box = { height: 5, width: 6, scale: 10 }; |
| 38 | +``` |
| 39 | + |
| 40 | +接口的非函数成员应该是唯一的。如果它们不是唯一的,那么类型必须相同。如果两个接口都声明了一个同名但不同类型的非函数成员,那么编译器会抛出一个错误。 |
| 41 | + |
| 42 | +对于函数类型的成员,每一个同名的函数成员都会被视为是同个函数的一个重载。还需要注意的是,如果前面的接口 `A` 和后面的接口 `A` 合并,那么第二个接口的优先级会比第一个接口高。 |
| 43 | + |
| 44 | +举个例子: |
| 45 | + |
| 46 | +```ts |
| 47 | +interface Cloner { |
| 48 | + clone(animal: Animal): Animal; |
| 49 | +} |
| 50 | +interface Cloner { |
| 51 | + clone(animal: Sheep): Sheep; |
| 52 | +} |
| 53 | +interface Cloner { |
| 54 | + clone(animal: Dog): Dog; |
| 55 | + clone(animal: Cat): Cat; |
| 56 | +} |
| 57 | +``` |
| 58 | + |
| 59 | +这三个接口将会合并为单个声明,如下所示: |
| 60 | + |
| 61 | +```ts |
| 62 | +interface Cloner { |
| 63 | + clone(animal: Dog): Dog; |
| 64 | + clone(animal: Cat): Cat; |
| 65 | + clone(animal: Sheep): Sheep; |
| 66 | + clone(animal: Animal): Animal; |
| 67 | +} |
| 68 | +``` |
| 69 | + |
| 70 | +注意,每个接口里面的成员都会保持原有的顺序,但是接口原先越靠前,在重载中的位置就越靠后。 |
| 71 | + |
| 72 | +这个规则有一个例外,那就是使用专有签名的时候。如果某个签名的参数类型是一个单独的字符串字面量类型(而不是字符串字面量的联合类型),那么这个签名会“冒泡”到达合并后的重载列表的顶端。 |
| 73 | + |
| 74 | +举个例子,下面的接口会进行合并: |
| 75 | + |
| 76 | +```ts |
| 77 | +interface Document { |
| 78 | + createElement(tagName: any): Element; |
| 79 | +} |
| 80 | +interface Document { |
| 81 | + createElement(tagName: "div"): HTMLDivElement; |
| 82 | + createElement(tagName: "span"): HTMLSpanElement; |
| 83 | +} |
| 84 | +interface Document { |
| 85 | + createElement(tagName: string): HTMLElement; |
| 86 | + createElement(tagName: "canvas"): HTMLCanvasElement; |
| 87 | +} |
| 88 | +``` |
| 89 | + |
| 90 | +合并后的 `Document` 声明如下所示: |
| 91 | + |
| 92 | +```ts |
| 93 | +interface Document { |
| 94 | + createElement(tagName: "canvas"): HTMLCanvasElement; |
| 95 | + createElement(tagName: "div"): HTMLDivElement; |
| 96 | + createElement(tagName: "span"): HTMLSpanElement; |
| 97 | + createElement(tagName: string): HTMLElement; |
| 98 | + createElement(tagName: any): Element; |
| 99 | +} |
| 100 | +``` |
| 101 | + |
| 102 | +### 合并命名空间 |
| 103 | + |
| 104 | +和接口类似,同名的命名空间的所有成员也会合并到一起。由于命名空间会创建命名空间和值,所以我们需要理解它们各自是怎么合并的。 |
| 105 | + |
| 106 | +对于命名空间的合并,在每个命名空间中声明的导出接口的类型定义自身会进行合并,形成一个单独的、包含合并后的接口定义的命名空间。 |
| 107 | + |
| 108 | +对于命名空间中的值的合并,如果给定名字的命名空间已经存在了,那么它会进行拓展,即接受已有的命名空间,同时将第二个命名空间的导出成员添加到第一个命名空间中。 |
| 109 | + |
| 110 | +下面例子中的 `Animals`: |
| 111 | + |
| 112 | +```ts |
| 113 | +namespace Animals { |
| 114 | + export class Zebra {} |
| 115 | +} |
| 116 | +namespace Animals { |
| 117 | + export interface Legged { |
| 118 | + numberOfLegs: number; |
| 119 | + } |
| 120 | + export class Dog {} |
| 121 | +} |
| 122 | +``` |
| 123 | + |
| 124 | +进行声明合并后,等同于: |
| 125 | + |
| 126 | +```ts |
| 127 | +namespace Animals { |
| 128 | + export interface Legged { |
| 129 | + numberOfLegs: number; |
| 130 | + } |
| 131 | + export class Zebra {} |
| 132 | + export class Dog {} |
| 133 | +} |
| 134 | +``` |
| 135 | + |
| 136 | +这种模式的命名空间合并很好理解,但我们还需要了解非导出成员的情况。非导出成员只在原始的(未合并的)命名空间中可见,这意味着在合并之后,来自其它声明的合并成员无法访问非导出成员。 |
| 137 | + |
| 138 | +看下面的例子会更加直观: |
| 139 | + |
| 140 | +```ts |
| 141 | +namespace Animal { |
| 142 | + let haveMuscles = true; |
| 143 | + export function animalsHaveMuscles() { |
| 144 | + return haveMuscles; |
| 145 | + } |
| 146 | +} |
| 147 | +namespace Animal { |
| 148 | + export function doAnimalsHaveMuscles() { |
| 149 | + return haveMuscles; // Error, because haveMuscles is not accessible here |
| 150 | + } |
| 151 | +} |
| 152 | +``` |
| 153 | + |
| 154 | +因为 `haveMuscles` 没有导出,所以只有共享相同的未合并命名空间的 `animalsHaveMuscles` 函数才能访问它。`doAnimalsHaveMuscles` 函数虽然是合并后的 `Animal` 命名空间的一部分,但它无法访问这个未导出的成员。 |
| 155 | + |
| 156 | +### 合并命名空间和类、函数、枚举 |
| 157 | + |
| 158 | +命名空间非常灵活,它也能和其它类型的声明合并。要做到这一点,命名空间声明必须跟在要合并的声明后面。最终的声明将具有两个声明类型的所有属性。TypeScript 使用这种能力对 JavaScript 和其它编程语言中的模式进行复刻。 |
| 159 | + |
| 160 | +### 合并命名空间和类 |
| 161 | + |
| 162 | +这为开发者提供了一种描述内部类的方式: |
| 163 | + |
| 164 | +```ts |
| 165 | +class Album { |
| 166 | + label: Album.AlbumLabel; |
| 167 | +} |
| 168 | +namespace Album { |
| 169 | + export class AlbumLabel {} |
| 170 | +} |
| 171 | +``` |
| 172 | + |
| 173 | +合并成员的可见性规则和[合并命名空间这一小节](#合并命名空间)描述的一样,所以我们必须导出 `AlbumLabel` 这个类,以方便合并后的类去访问它。最终我们会得到一个类,其内部包含另一个类。你也可以使用命名空间向已有的类添加更多的静态成员。 |
| 174 | + |
| 175 | +除了内部类这种模式,你还可能熟悉使用 JavaScript 创建一个函数,之后通过添加属性对函数进行拓展。TypeSCript 借助声明合并,以一种类型安全的方式去构建这种模式。 |
| 176 | + |
| 177 | +```ts |
| 178 | +function buildLabel(name: string): string { |
| 179 | + return buildLabel.prefix + name + buildLabel.suffix; |
| 180 | +} |
| 181 | +namespace buildLabel { |
| 182 | + export let suffix = ""; |
| 183 | + export let prefix = "Hello, "; |
| 184 | +} |
| 185 | +console.log(buildLabel("Sam Smith")); |
| 186 | +``` |
| 187 | + |
| 188 | +类似地,命名空间也可以用于为枚举拓展静态成员: |
| 189 | + |
| 190 | +```ts |
| 191 | +enum Color { |
| 192 | + red = 1, |
| 193 | + green = 2, |
| 194 | + blue = 4, |
| 195 | +} |
| 196 | +namespace Color { |
| 197 | + export function mixColor(colorName: string) { |
| 198 | + if (colorName == "yellow") { |
| 199 | + return Color.red + Color.green; |
| 200 | + } else if (colorName == "white") { |
| 201 | + return Color.red + Color.green + Color.blue; |
| 202 | + } else if (colorName == "magenta") { |
| 203 | + return Color.red + Color.blue; |
| 204 | + } else if (colorName == "cyan") { |
| 205 | + return Color.green + Color.blue; |
| 206 | + } |
| 207 | + } |
| 208 | +} |
| 209 | +``` |
| 210 | + |
| 211 | +### 不允许的合并 |
| 212 | + |
| 213 | +在 TypeScript 中,不是所有的合并都是允许的。就目前而言,类无法和其它类或者变量合并。关于模拟类合并的信息,可以查阅[ TypeScript 中的混入](https://www.typescriptlang.org/docs/handbook/mixins.html)这一小节。 |
| 214 | + |
| 215 | +### 模块增强 |
| 216 | + |
| 217 | +虽然 JavaScript 的模块不支持合并,但是你可以通过导入并更新模块,来实现对已有对象的增强。我们来看一个简易的观察者示例: |
| 218 | + |
| 219 | +```ts |
| 220 | +// observable.ts |
| 221 | +export class Observable<T> { |
| 222 | + // ...... |
| 223 | +} |
| 224 | +// map.ts |
| 225 | +import { Observable } from "./observable"; |
| 226 | +Observable.prototype.map = function (f) { |
| 227 | + // ...... |
| 228 | +}; |
| 229 | +``` |
| 230 | + |
| 231 | +在 TypeScript 中,这段代码也能运行,但是编译器并不了解 `Observable.prototype.map`。你可以使用模块增强告知编译器它的信息: |
| 232 | + |
| 233 | +```ts |
| 234 | +// observable.ts |
| 235 | +export class Observable<T> { |
| 236 | + // ...... |
| 237 | +} |
| 238 | +// map.ts |
| 239 | +import { Observable } from "./observable"; |
| 240 | +declare module "./observable" { |
| 241 | + interface Observable<T> { |
| 242 | + map<U>(f: (x: T) => U): Observable<U>; |
| 243 | + } |
| 244 | +} |
| 245 | +Observable.prototype.map = function (f) { |
| 246 | + // ...... |
| 247 | +}; |
| 248 | +// consumer.ts |
| 249 | +import { Observable } from "./observable"; |
| 250 | +import "./map"; |
| 251 | +let o: Observable<number>; |
| 252 | +o.map((x) => x.toFixed()); |
| 253 | +``` |
| 254 | + |
| 255 | +模块名的解析方式和 `import/export` 中的模块修饰符的解析方式相同。查阅[模块](https://www.typescriptlang.org/docs/handbook/modules.html)这一章以了解更多信息。模块增强中的声明会被合并,就好像它们是在原文件中声明的一样。 |
| 256 | + |
| 257 | +但是,这里有两个限制需要注意: |
| 258 | + |
| 259 | +1. 你不能在模块增强中去创建一个顶级声明 —— 你只能增强已有的声明 |
| 260 | +2. 默认导出无法被增强,只有命名导出才能被增强(因为你需要通过导出的名字对导出进行增强,而 `default` 是一个保留字 —— 查阅 [#14080](https://github.com/Microsoft/TypeScript/issues/14080) 了解更多细节)。 |
| 261 | + |
| 262 | +### 全局增强 |
| 263 | + |
| 264 | +你也可以从模块内部向全局作用域添加声明: |
| 265 | + |
| 266 | +```ts |
| 267 | +// observable.ts |
| 268 | +export class Observable<T> { |
| 269 | + // ...... |
| 270 | +} |
| 271 | +declare global { |
| 272 | + interface Array<T> { |
| 273 | + toObservable(): Observable<T>; |
| 274 | + } |
| 275 | +} |
| 276 | +Array.prototype.toObservable = function () { |
| 277 | + // ... |
| 278 | +}; |
| 279 | +``` |
| 280 | + |
| 281 | +全局增强和模块增强具有一样的行为和限制。 |
0 commit comments