TypeScript-设计模式-全-
TypeScript 设计模式(全)
原文:
zh.annas-archive.org/md5/e54a5eb6f0fc19535a85bec884ae5cea译者:飞龙
第二章. 增加复杂性的挑战
程序的本质是可能分支的组合以及基于某些条件的自动化选择。当我们编写程序时,我们定义了分支中的内容,以及这个分支将在什么条件下被执行。
在项目的演变过程中,分支的数量通常会迅速增长,以及确定分支是否将被执行的条件的数量。
这对人类来说很危险,因为人类的脑容量有限。
在本章中,我们将实现一个数据同步服务。从实现一些非常基本的功能开始,我们将继续添加内容,看看事情会如何发展。
以下内容将涵盖:
-
设计多设备同步策略
-
相关的有用 JavaScript 和 TypeScript 技术和提示,包括对象作为映射和字符串字面量类型
-
策略模式如何帮助项目
实现基本功能
在我们开始编写实际代码之前,我们需要定义这种同步策略将是什么样的。为了使实现免受不必要的干扰,客户端将通过函数调用直接与服务器通信,而不是使用 HTTP 请求或套接字。此外,我们将在客户端和服务器端使用内存存储,即变量,来存储数据。
由于我们没有将客户端和服务器分开成两个实际的应用程序,并且我们没有真正使用后端技术,因此不需要太多的 Node.js 经验来理解本章。
然而,请注意,尽管我们省略了网络和数据库请求,但我们希望最终实现的逻辑核心可以在不进行太多修改的情况下应用于实际环境。因此,当涉及到性能问题时,我们仍然需要假设有限的网络资源,特别是对于通过服务器和客户端传输的数据,尽管实现将是同步的而不是异步的。在实际情况中,这不应该发生,但涉及异步操作将引入更多的代码,以及许多需要考虑的情况。但在接下来的章节中,我们将介绍一些关于异步编程的有用模式,如果你尝试实现本章中同步逻辑的异步版本,这将非常有帮助。
如果客户端没有修改已同步的内容,它将存储服务器上所有可用数据的副本,而我们需要做的是提供一组 API,使客户端能够保持其数据副本的同步。
因此,一开始真的很简单:比较最后修改的时间戳。如果客户端的时间戳比服务器上的旧,那么就更新数据副本以及新的时间戳。
创建代码库
首先,让我们创建server.ts和client.ts文件,分别包含Server类和Client类:
export class Server {
// ...
}
export class Client {
// ...
}
我更喜欢创建一个index.ts文件作为包的入口,它内部处理要导出的内容。在这种情况下,让我们导出所有内容:
export * from './server';
export * from './client';
要从测试文件(假设src/test/test.ts)中导入Server和Client类,我们可以使用以下代码:
import { Server, Client } from '../';
定义要同步的数据的初始结构
由于我们需要比较客户端和服务器的时间戳,我们需要在数据结构上有一个timestamp属性。我希望同步的数据是一个字符串,所以让我们在server.ts文件中添加一个带有timestamp属性的DataStore接口:
export interface DataStore {
timestamp: number;
data: string;
}
通过比较时间戳来获取数据
目前,同步策略是单向的,从服务器到客户端。所以我们需要做的就是简单比较时间戳;如果服务器有更新的,它将响应数据和服务器端的时间戳;否则,它将响应undefined:
class Server {
store: DataStore = {
timestamp: 0,
data: ''
};
getData(clientTimestamp: number): DataStore {
if (clientTimestamp < this.store.timestamp) {
return this.store;
} else {
return undefined;
}
}
}
现在我们已经为客户端提供了一个简单的 API,现在是时候实现客户端了:
import { Server, DataStore } from './';
export class Client {
store: DataStore = {
timestamp: 0,
data: undefined
};
constructor(
public server: Server
) { }
}
小贴士
在构造函数参数前加上访问修饰符(包括public、private和protected)将创建一个具有相同名称和相应可访问性的属性。当调用构造函数时,它还会自动分配值。
现在我们需要在Client类中添加一个synchronize方法来完成这项工作:
synchronize(): void {
let updatedStore = this.server.getData(this.store.timestamp);
if (updatedStore) {
this.store = updatedStore;
}
}
这很容易做到。然而,你是否已经开始觉得我们写的东西有些尴尬?
双向同步
通常,当我们谈论同步时,我们会从服务器获取更新,并将更改推送到服务器。现在我们将进行第二部分,如果客户端有更新的数据,则推送更改。
但首先,我们需要给客户端添加更新其数据的能力,通过在Client类中添加一个update方法:
update(data: string): void {
this.store.data = data;
this.store.timestamp = Date.now();
}
我们还需要服务器能够从客户端接收数据。因此,我们将Server类的getData方法重命名为synchronize,并使其满足新的任务:
synchronize(clientDataStore: DataStore): DataStore {
if (clientDataStore.timestamp > this.store.timestamp) {
this.store = clientDataStore;
return undefined;
} else if (clientDataStore.timestamp < this.store.timestamp) {
return this.store;
} else {
return undefined;
}
}
现在我们已经有了同步服务的初步实现。以后,我们将继续添加新功能,使其能够处理各种场景。
在实现基本功能时出现的问题
目前,我们写的代码太简单了,以至于可能出错。但仍然存在一些语义问题。
从服务器传递数据存储给客户端是没有意义的
我们在Server类的synchronize方法上使用了DataStore作为返回类型。但实际上我们传递的不是数据存储,而是涉及数据和其时间戳的信息。这个信息对象恰好在这个时间点具有与数据存储相同的属性。
此外,这也会误导后来阅读你代码的人(包括未来的你自己)。大多数时候,我们试图消除冗余。但这并不意味着所有看起来相同的东西。所以让我们将其定义为两个接口:
interface DataStore {
timestamp: number;
data: string;
}
interface DataSyncingInfo {
timestamp: number;
data: string;
}
我甚至更愿意创建另一个实例,而不是直接返回 this.store:
return {
timestamp: this.store.timestamp,
data: this.store.data
};
然而,如果两段代码在代码本身的角度来看具有不同的语义意义,但执行相同的功能,你可能考虑将这部分提取为一个实用工具。
使关系清晰
现在我们有两个分离的接口,DataStore 和 DataSyncingInfo,在 server.ts 中。显然,DataSyncingInfo 应该是服务器和客户端之间的共享接口,而 DataStore 在两边都是相同的,但实际上并没有共享。
因此,我们将创建一个单独的 shared.d.ts(如果它包含的不仅仅是 typings,也可以是 shared.ts),导出 DataSyncingInfo 并在 client.ts 中添加另一个 DataStore。
注意
不要盲目跟随。有时这是为了服务器和客户端具有完全相同的存储而设计的。如果是这种情况,接口应该是共享的。
特征增长
我们到目前为止所做的一切基本上都是无用的。但是,从现在开始,我们将开始添加功能,使其能够满足实际需求,包括与多个客户端同步多个数据项的能力,以及合并冲突。
同步多个项目
理想情况下,我们需要同步的数据将包含大量项目。如果这些项目数量非常有限,直接将 data 的类型更改为数组是可行的。
简单地用数组替换数据类型
现在让我们将 DataStore 和 DataSyncingInfo 接口中的 data 属性类型更改为 string[]。借助 TypeScript,你会得到由于这种更改导致的类型不匹配的错误。通过注释正确的类型来修复它们。
但显然,这远非一个高效的解决方案。
以服务器为中心的同步
如果数据存储包含大量数据,理想的方法是只更新那些未更新的项目。
例如,我们可以为每个单独的项目创建一个时间戳,并将这些时间戳发送到服务器,然后让服务器决定特定的数据项是否是最新的。这在某些场景中是一个可行的方案,例如检查软件扩展的更新。在快速网络上偶尔发送带有项目 ID 的数百个时间戳是可以的,但我们将为不同的场景使用另一种方法,否则我将没有太多可写的内容。
在手机上处理离线应用的用户数据同步是我们将要处理的,这意味着我们需要尽力避免浪费网络资源。
注意
这里有一个有趣的问题。用户数据同步和检查扩展更新之间有什么区别?考虑数据的大小、多设备的问题等等。
我们考虑发送所有项目的时间戳的原因是让服务器确定某些项目是否需要更新。然而,有必要在客户端存储所有数据项的时间戳吗?
如果我们选择不存储数据更改的时间戳,而是存储与服务器同步的数据的时间戳,那么我们只需发送最后一次成功同步的时间戳就可以获取所有最新的数据。然后,服务器将比较这个时间戳与所有数据项的最后修改时间戳,并决定如何响应。
如本部分标题所示,该过程以服务器为中心,依赖于服务器生成时间戳(尽管它不必这样做,实际上也不应该这样做)。
备注
如果你对这些时间戳的工作方式感到困惑,让我们再试一次。服务器将存储上次同步项的时间戳,而客户端将存储与服务器最后一次成功同步的时间戳。因此,如果服务器上没有项的时间戳晚于客户端,那么在该时间戳之后,服务器数据存储没有变化。但如果有一些变化,通过比较客户端的时间戳与服务器项的时间戳,我们将知道哪些项是较新的。
从服务器到客户端的同步
现在似乎有很多东西需要更改。首先,让我们处理从服务器到客户端的数据同步。
这是服务器端期望发生的情况:
-
为服务器上的每个数据项添加时间戳和标识
-
将客户端时间戳与服务器上的每个数据项进行比较
备注
如果这些项具有排序索引,我们实际上不需要将客户端的时间戳与服务器上的每个项进行比较。使用具有排序索引的数据库,性能是可以接受的。
- 返回比客户端拥有的更新的项以及一个新的时间戳。
在客户端期望发生的情况:
-
与服务器发送的最后一个时间戳同步
-
使用服务器响应的新数据更新本地存储
-
如果同步完成且没有错误,则更新本地最后同步的时间戳
更新接口
首先,我们现在在双方都有更新的数据存储。从服务器开始,数据存储现在包含一个数据项数组。因此,让我们定义 ServerDataItem 接口并更新 ServerDataStore:
export interface ServerDataItem {
id: string;
timestamp: number;
value: string;
}
export interface ServerDataStore {
items: {
[id: string]: ServerDataItem;
};
}
备注
{ [id: string]: ServerDataItem } 类型描述了一个对象,它以 id 类型为 string 的键,并具有 ServerDataItem 类型的值。因此,可以通过 items['the-id'] 访问 ServerDataItem 类型的项。
对于客户端,我们现在有不同的数据项和不同的存储。响应只包含所有数据项的一个子集,因此我们需要 ID 和一个以 ID 为索引的映射来存储数据:
export interface ClientDataItem {
id: string;
value: string;
}
export interface ClientDataStore {
timestamp: number;
items: {
[id: string]: ClientDataItem;
};
}
以前,客户端和服务器共享相同的 DataSyncingInfo,但这种情况将要改变。由于我们首先处理服务器到客户端的同步,所以我们现在只关心同步请求中的时间戳:
export interface SyncingRequest {
timestamp: number;
}
至于服务器的响应,它应该有一个更新的时间戳,与请求时间戳相比,数据项已发生变化:
export interface SyncingResponse {
timestamp: number;
changes: {
[id: string]: string;
};
}
我用Server和Client前缀那些接口,以便更好地区分。但如果你不是从server.ts和client.ts(在index.ts中)导出所有内容,这不是必要的。
更新服务器端
使用定义良好的数据结构,应该很容易实现我们期望的结果。首先,我们有synchronize方法,它接受一个SyncingRequest并返回一个SyncingResponse;并且我们需要有更新的时间戳:
synchronize(request: SyncingRequest): SyncingResponse {
let lastTimestamp = request.timestamp;
let now = Date.now();
let serverChanges: ServerChangeMap = Object.create(null);
return {
timestamp: now,
changes: serverChanges
};
}
提示
对于serverChanges对象,{}(一个对象字面量)可能是首先想到的(如果不是 ES6 Map)。但这样做并不绝对安全,因为它会拒绝__proto__作为键。更好的选择是Object.create(null),它接受所有字符串作为其键。
现在我们将添加比客户端更新的项到serverChanges:
let items = this.store.items;
for (let id of Object.keys(items)) {
let item = items[id];
if (item.timestamp > lastTimestamp) {
serverChanges[id] = item.value;
}
}
更新客户端
由于我们将ClientDataStore下的items的类型更改为映射,我们需要修复初始值:
store: ClientDataStore = {
timestamp: 0,
items: Object.create(null)
};
现在让我们更新synchronize方法。首先,客户端将发送一个带有时间戳的请求并从服务器获取响应:
synchronize(): void {
let store = this.store;
let response = this.server.synchronize({
timestamp: store.timestamp
});
}
然后,我们将保存更新的数据项到存储中:
let clientItems = store.items;
let serverChanges = response.changes;
for (let id of Object.keys(serverChanges)) {
clientItems[id] = {
id,
value: serverChanges[id]
};
}
最后,更新最后一次成功同步的时间戳:
clientStore.timestamp = response.timestamp;
注意
更新同步时间戳应该是完整同步过程中最后要做的事情。确保它不早于数据项存储,否则如果在未来的同步过程中出现任何错误或中断,可能会有一个损坏的离线副本。
注意
为了确保它按预期工作,具有相同更改信息的操作即使在多次应用时也应给出相同的结果。
从客户端到服务器的同步
对于以服务器为中心的同步过程,大多数更改都是通过客户端进行的。因此,我们需要在将它们发送到服务器之前组织这些更改。
单个客户端只关心它自己的数据副本。与从服务器同步数据到客户端的过程相比,这会有什么不同呢?好吧,想想我们最初为什么需要服务器上每个数据项的时间戳。我们需要它们是因为我们想知道与特定客户端相比哪些项是新的。
现在,对于客户端的更改:如果它们发生,它们需要同步到服务器,而不需要比较特定的时间戳。
然而,我们可能有多个需要同步更改的客户,这意味着后来时间做出的更改实际上可能会更早地同步,因此我们需要解决冲突。为了实现这一点,我们需要将最后修改时间添加到服务器上的每个数据项和客户端的更改项上。
我已经提到,服务器上存储的时间戳,用于确定需要同步到客户端的内容,不需要(而且最好不是)实际时间点的实际戳记。例如,它可以是所有客户端与服务器之间发生的同步次数。
更新客户端
为了高效地处理这个问题,我们可以在 ClientDataStore 中创建一个单独的映射,以数据项的 ID 作为键,最后修改时间作为值:
export interface ClientDataStore {
timestamp: number;
items: {
[id: string]: ClientDataItem;
};
changed: {
[id: string]: number;
};
}
你可能还想将其值初始化为 Object.create(null)。
现在我们更新客户端存储中的项时,我们也将最后修改时间添加到 changed 映射中:
update(id: string, value: string): void {
let store = this.store;
store.items[id] = {
id,
value
};
store.changed[id] = Date.now();
}
SyncingRequest 中的单个时间戳肯定不能再胜任这项工作了;我们需要为更改的数据添加一个位置,一个以数据项 ID 作为索引,更改信息作为值的映射:
export interface ClientChange {
lastModifiedTime: number;
value: string;
}
export interface SyncingRequest {
timestamp: number;
changes: {
[id: string]: ClientChange;
};
}
这里又出现了一个问题。如果一个客户端数据项的更改是在离线状态下进行的,系统时钟时间不正确,怎么办?显然,我们需要一些时间校准机制。然而,没有方法可以做到完美的校准。我们将做一些假设,这样我们就不需要为时间校准开启另一个章节:
-
客户端的系统时钟可能比服务器晚或早,但它以正常速度运行,不会在时间之间跳跃。
-
从客户端发送的请求在相对较短的时间内到达服务器。
基于这些假设,我们可以将这些构建块添加到客户端的 synchronize 方法中:
-
在将同步请求(当然,在发送到服务器之前)发送到服务器之前,添加客户端更改:
let clientItems = store.items; let clientChanges: ClientChangeMap = Object.create(null); let changedTimes = store.changed; for (let id of Object.keys(changedTimes)) { clientChanges[id] = { lastModifiedTime: changedTimes[id], value: clientItems[id].value }; } -
将服务器上的更改与客户端时钟的当前时间同步:
let response = this.server.synchronize({ timestamp: store.timestamp, clientTime: Date.now(), changes: clientChanges }); -
在成功同步后清理更改:
store.changed = Object.create(null);
更新服务器端
如果客户端按预期工作,它应该发送包含更改的同步请求。现在是时候让服务器能够处理来自客户端的这些更改了。
服务器端同步过程将分为两个步骤:
-
将客户端更改应用到服务器数据存储。
-
准备需要同步到客户端的更改。
首先,我们需要像之前提到的那样,将 lastModifiedTime 添加到服务器端数据项中:
export interface ServerDataItem {
id: string;
timestamp: number;
lastModifiedTime: number;
value: string;
}
我们还需要更新 synchronize 方法:
let clientChanges = request.changes;
let now = Date.now();
for (let id of Object.keys(clientChanges)) {
let clientChange = clientChanges[id];
if (
hasOwnProperty.call(items, id) &&
items[id].lastModifiedTime > clientChange.lastModifiedTime
) {
continue;
}
items[id] = {
id,
timestamp: now,
lastModifiedTime,
value: clientChange.value
};
}
注意
实际上,我们可以在这里使用 in 操作符而不是 hasOwnProperty,因为 items 对象是以 null 作为其原型的。但是,如果你使用的是通过对象字面量创建的对象,或者以其他方式,如 maps,那么对 hasOwnProperty 的引用将是你的朋友。
我们已经讨论了通过比较最后修改时间来解决冲突。同时,我们已经做出了假设,这样我们就可以通过在同步时传递客户端时间到服务器,轻松地校准客户端的最后修改时间。
我们将要做的校准是计算客户端时间与服务器时间的偏移量。这就是为什么我们做出了第二个假设:请求需要相对较短时间内轻松到达服务器。为了计算偏移量,我们可以简单地从服务器时间中减去客户端时间:
let clientTimeOffset = now - request.clientTime;
注意
为了使时间校准更准确,我们希望记录最早的时间戳,即请求击中服务器后的时间戳。在实践中,你可能会在开始处理一切之前记录请求击中服务器的时间戳。例如,对于 HTTP 请求,你可以在 TCP 连接建立后记录时间戳。
现在,客户端更改的校准时间是原始时间和偏移量的总和。我们可以通过比较校准的最后修改时间来决定是否保留或忽略客户端的更改。校准时间可能大于服务器时间;你可以选择使用服务器时间作为最大值或接受一点小误差。在这里,我们将采取简单的方法:
let lastModifiedTime = Math.min(
clientChange.lastModifiedTime + clientTimeOffset,
now
);
if (
hasOwnProperty.call(items, id) &&
items[id].lastModifiedTime > lastModifiedTime
) {
continue;
}
要使这真正工作,我们还需要排除与客户端更改冲突的服务器更改。为此,我们需要知道在冲突解决过程中幸存下来的更改是什么。一种简单的方法是排除时间戳等于 now 的项:
for (let id of Object.keys(items)) {
let item = items[id];
if (
item.timestamp > lastTimestamp &&
item.timestamp !== now
) {
serverChanges[id] = item.value;
}
}
因此,我们现在已经实现了一个完整的同步逻辑,能够处理实践中简单的冲突。
同步多种类型的数据
在这一点上,我们已将数据硬编码为 string 类型。但通常我们还需要存储各种数据,例如数字、布尔值、对象等。
如果我们正在编写 JavaScript,实际上我们不需要做任何改变,因为实现与某些数据类型无关。在 TypeScript 中,我们也不需要做太多:只需将每个相关 value 的类型更改为 any。但这意味着你正在失去类型安全,如果你对此感到满意,那当然是可以接受的。
但根据我的个人偏好,我希望如果可能的话,每个变量、参数和属性都应该有类型。因此,我们可能仍然有一个 value 类型为 any 的数据项:
export interface ClientDataItem {
id: string;
value: any;
}
我们还可以为特定数据类型有派生接口:
export interface ClientStringDataItem extends ClientDataItem {
value: string;
}
export interface ClientNumberDataItem extends ClientDataItem {
value: number;
}
但这似乎还不够好。幸运的是,TypeScript 提供了 泛型,因此我们可以将前面的代码重写如下:
export interface ClientDataItem<T> {
id: string;
value: T;
}
假设我们有一个接受多种类型数据项的存储 - 例如,数字和字符串 - 我们可以使用 union 类型声明如下:
export interface ClientDataStore {
items: {
[id: string]: ClientDataItem<number | string>;
};
}
如果你记得我们正在为离线移动应用做些事情,你可能会对更改中长属性名,如 lastModifiedTime 提出疑问。这是一个合理的问题,一个简单的解决方案是使用 tuple 类型,也许还可以与 enums 一起使用:
const enum ClientChangeIndex {
lastModifiedType,
value
}
type ClientChange<T> = [number, T];
let change: ClientChange<string> = [0, 'foo'];
let value = change[ClientChangeIndex.value];
您可以根据自己的喜好应用更多或更少的类型化内容。如果您还不熟悉它们,可以在此处了解更多信息:www.typescriptlang.org/handbook。
支持具有增量数据的多个客户端
使类型系统满足多种数据类型很容易。但在现实世界中,我们不会通过简单地比较最后修改时间来解决所有数据类型的冲突。一个例子是跨设备计算用户的每日活跃时间。
很明显,我们需要将多台设备上每天的所有活跃时间加起来。这就是我们将如何实现这一点:
-
在客户端同步之间累积活跃时长。
-
在与服务器同步之前,为每一段时间添加一个 UID(唯一标识符)。
-
如果 UID 尚不存在,则增加服务器端值,然后将 UID 添加到该数据项中。
但在我们实际着手这些步骤之前,我们需要一种方法来区分增量数据项和普通数据项,例如,通过添加一个type属性。
由于我们的同步策略是服务器中心的,因此只需要为同步请求和冲突合并提供相关信息。同步响应不需要包括变化的细节,只需合并后的值即可。
注意
我将停止逐步说明如何更新每个接口,因为我们正在接近最终结构。但如果您在这方面有任何问题,您可以查看完整的代码包以获取灵感。
更新客户端
首先,我们需要客户端支持增量变化。如果您已经考虑过这个问题,您可能已经对放置额外信息的位置,例如 UID,感到困惑。
这是因为我们将概念变化(名词)与值混淆了。在此之前这并不是问题,因为除了最后修改时间外,值就是变化的内容。我们使用一个简单的映射来存储最后修改时间,并保持存储的清洁,这在那种情况下平衡得很好。
但现在我们需要区分这两个概念:
-
值:值以静态方式描述数据项是什么
-
变化:变化描述了可能将数据项的值从一种转换为另一种的信息
我们需要有一种通用的变化类型,以及一种新的数据结构来处理带有数值的增量变化:
type DataType = 'value' | 'increment';
interface ClientChange {
type: DataType;
}
interface ClientValueChange<T> extends ClientChange {
type: 'value';
lastModifiedTime: number;
value: T;
}
interface ClientIncrementChange extends ClientChange {
type: 'increment';
uid: string;
increment: number;
}
注意
我们在这里使用的是string literal类型,这是在 TypeScript 1.8 中引入的。要了解更多信息,请参阅我们之前提到的 TypeScript 手册。
应对数据存储结构进行类似的变化。并且当我们在客户端更新一个项目时,我们需要根据不同的数据类型应用正确的操作:
update(id: string, type: 'increment', increment: number): void;
update<T>(id: string, type: 'value', value: T): void;
update<T>(id: string, type: DataType, value: T): void;
update<T>(id: string, type: DataType, value: T): void {
let store = this.store;
let items = store.items;
let storedChanges = store.changes;
if (type === 'value') {
// ...
} else if (type === 'increment') {
// ...
} else {
throw new TypeError('Invalid data type');
}
}
使用以下代码进行常规变化(当type等于'value'时):
let change: ClientValueChange<T> = {
type: 'value',
lastModifiedTime: Date.now(),
value
};
storedChanges[id] = change;
if (hasOwnProperty.call(items, id)) {
items[id].value = value;
} else {
items[id] = {
id,
type,
value
};
}
对于增量变化,需要更多几行代码:
let storedChange = storedChanges[id] as ClientIncrementChange;
if (storedChange) {
storedChange.increment += <any>value as number;
} else {
storedChange = {
type: 'increment',
uid: Date.now().toString(),
increment: <any>value as number
};
storedChanges[id] = storedChange;
}
注意
我个人偏好使用<T>进行any类型转换,以及使用as T进行非any类型转换。尽管它已在像 C#这样的语言中使用,但 TypeScript 中的as操作符最初是为了与 JSX 中的 XML 标签兼容而引入的。如果你愿意,你也可以在这里写<number><any>value或value as any as number。
不要忘记更新存储的值。只需将比较更新正常数据项时的=改为+=即可:
if (hasOwnProperty.call(items, id)) {
items[id].value += value;
} else {
items[id] = {
id,
type,
value
};
}
这一点也不难。但嘿,我们看到了分支。
我们一直在编写分支,但if (type === 'foo') { ... }和if (item.timestamp > lastTimestamp) { ... }这样的分支之间有什么区别呢?让我们记住这个问题,然后继续前进。
通过update方法添加必要的信息后,我们现在可以更新客户端的synchronize方法。但在实际场景中存在一个缺陷:同步请求成功发送到服务器,但客户端未能接收到服务器的响应。在这种情况下,当在失败的同步之后调用update时,增量会被添加到可能已同步的变更(通过其 UID 识别),在未来的同步中将被服务器忽略。为了解决这个问题,我们需要为所有已经开始同步过程的增量变更添加标记,并避免累积这些变更。因此,我们需要为相同的数据项创建另一个变更。
这实际上是一个很好的提示:因为变更涉及的信息是将一个值从一种形式转换为另一种形式,所以几个待同步的变更最终可能会应用到单个数据项上:
interface ClientChangeList<T extends ClientChange> {
type: DataType;
changes: T[];
}
interface SyncingRequest {
timestamp: number;
changeLists: {
[id: string]: ClientChangeList<ClientChange>;
};
}
interface ClientIncrementChange extends ClientChange {
type: 'increment';
synced: boolean;
uid: string;
increment: number;
}
现在我们尝试更新一个增量数据项时,我们需要从变更列表(如果有)中获取其最后变更,并查看它是否曾经参与过同步。如果它曾经参与过同步,我们将创建一个新的变更实例。否则,我们只需在客户端累积最后变更的increment属性值:
let changeList = storedChangeLists[id];
let changes = changeList.changes;
let lastChange =
changes[changes.length - 1] as ClientIncrementChange;
if (lastChange.synced) {
changes.push({
synced: false,
uid: Date.now().toString(),
increment: <any>value as number
} as ClientIncrementChange);
} else {
lastChange.increment += <any>value as number;
}
或者,如果变更列表尚不存在,我们需要将其设置起来:
let changeList = {
type: 'increment',
changes: [
{
synced: false,
uid: Date.now().toString(),
increment: <any>value as number
} as ClientIncrementChange
]
};
store.changeLists[id] = changeList;
我们还需要更新synchronize方法,在开始与服务器同步之前将增量变更标记为synced。但具体的实现需要你自己来完成。
更新服务器端
在我们添加处理增量变更的逻辑之前,我们需要让服务器端代码适应新的数据结构:
for (let id of Object.keys(clientChangeLists)) {
let clientChangeList = clientChangeLists[id];
let type = clientChangeList.type;
let clientChanges = clientChangeList.changes;
if (type === 'value') {
// ...
} else if (type === 'increment') {
// ...
} else {
throw new TypeError('Invalid data type');
}
}
正常数据项的变更列表将始终只包含一个变更。因此我们可以轻松迁移我们已编写的代码:
let clientChange = changes[0] as ClientValueChange<any>;
现在对于增量变更,我们需要将可能多个变更累积应用到单个变更列表中的数据项上:
let item = items[id];
for (
let clientChange
of clientChanges as ClientIncrementChange[]
) {
let {
uid,
increment
} = clientChange;
if (item.uids.indexOf(uid) < 0) {
item.value += increment;
item.uids.push(uid);
}
}
但请记住要处理时间戳或不存在指定 ID 的项目的情况:
let item: ServerDataItem<any>;
if (hasOwnProperty.call(items, id)) {
item = items[id];
item.timestamp = now;
} else {
item = items[id] = {
id,
type,
timestamp: now,
uids: [],
value: 0
};
}
如果不知道客户端增量数据项的当前值,我们无法保证该值是最新的。之前,我们通过比较时间戳与当前同步的时间戳来决定是否响应新值,但这种方法对于增量更改不再适用。
通过删除clientChangeLists中仍需要同步到客户端的键,可以简单地使这一过程工作。在准备响应时,它可以跳过clientChangeLists中仍存在的 ID:
if (
item.timestamp > lastTimestamp &&
!hasOwnProperty.call(clientChangeLists, id)
) {
serverChanges[id] = item.value;
}
记得为在解决冲突中未存活的普通数据项添加delete clientChangeLists[id];。
现在我们已经实现了一种同步逻辑,可以为离线应用程序完成很多工作。早些时候,我提出了关于增加分支的问题,这些分支看起来并不好。但如果你知道你的功能将在这里结束,或者至少有有限的变化,这并不是一个糟糕的实现,尽管我们很快就会越过平衡点,因为满足 80%的需求不会让我们感到足够满意。
支持更多冲突合并
虽然我们已经满足了 80%的需求,但我们仍然有很大可能想要一些额外的功能。例如,我们想要用户标记为当前月份可用的天数比例,并且用户应该能够从列表中添加或删除天数。我们可以用不同的方式实现这一点,但我们将选择一种简单的方式,就像往常一样。
我们将支持通过添加和删除等操作同步集合,并在客户端计算比率。
新的数据结构
为了描述集合更改,我们需要一个新的ClientChange类型。当我们向集合中添加或删除一个元素时,我们只关心对同一元素的最后操作。这意味着以下内容:
-
如果对同一元素进行了多个操作,我们只需要保留最后一个。
-
需要一个
time属性来解决问题。
因此,这里有新的类型:
enum SetOperation {
add,
remove
}
interface ClientSetChange extends ClientChange {
element: number;
time: number;
operation: SetOperation;
}
服务器端存储的集合数据将略有不同。我们将有一个以元素(以string形式)为键的映射,以及具有operation和time属性的结构作为值:
interface ServerSetElementOperationInfo {
operation: SetOperation;
time: number;
}
现在我们有足够的信息来从多个客户端解决冲突。并且我们可以通过键生成集合,这需要借助对元素进行的最后操作:
更新客户端
现在,客户端的update方法得到了一份兼职工作:保存集合更改,就像值和增量更改一样。我们需要为此新工作更新方法签名(不要忘记将'set'添加到DataType):
update(
id: string,
type: 'set',
element: number,
operation: SetOperation
): void;
update<T>(
id: string,
type: DataType,
value: T,
operation?: SetOperation
): void;
我们还需要添加另一个else if:
else if (type === 'set') {
let element = <any>value as number;
if (hasOwnProperty.call(storedChangeLists, id)) {
// ...
} else {
// ...
}
}
如果已经对这个集合进行了操作,我们需要找到并删除对目标元素的最后操作(如果有)。然后附加一个包含最新操作的新更改:
let changeList = storedChangeLists[id];
let changes = changeList.changes as ClientSetChange[];
for (let i = 0; i < changes.length; i++) {
let change = changes[i];
if (change.element === element) {
changes.splice(i, 1);
break;
}
}
changes.push({
element,
time: Date.now(),
operation
});
如果自上次成功同步以来没有进行任何更改,我们需要为最新的操作创建一个新的更改列表:
let changeList: ClientChangeList<ClientSetChange> = {
type: 'set',
changes: [
{
element,
time: Date.now(),
operation
}
]
};
storedChangeLists[id] = changeList;
再次提醒,不要忘记更新存储的值。这不仅仅是分配或累积值,但它仍然应该相当容易实现。
更新服务器端
就像我们对客户端所做的那样,我们需要添加一个相应的else if分支来合并类型为'set'的更改。我们还在clientChangeLists中删除 ID,无论是否有更新的更改,以实现更简单的实现:
else if (type === 'set') {
let item: ServerDataItem<{
[element: string]: ServerSetElementOperationInfo;
}>;
delete clientChangeLists[id];
}
冲突解决逻辑与我们对正常值冲突的处理相当相似。我们只需要对每个元素进行比较,并只保留最后一个操作。
当准备同步到客户端的响应时,我们可以通过将具有add作为最后操作的元素组合起来来生成集合:
if (item.type === 'set') {
let operationInfos: {
[element: string]: ServerSetElementOperationInfo;
} = item.value;
serverChanges[id] = Object
.keys(operationInfos)
.filter(element =>
operationInfos[element].operation ===
SetOperation.add
)
.map(element => Number(element));
} else {
serverChanges[id] = item.value;
}
最后,我们有一个正在工作的混乱(如果它实际上能工作的话)。干杯!
在实现一切时出现的问题
当我们开始添加功能时,事情实际上很好,如果你不是对设计感追求得过于执着。然后我们感觉到代码有点尴尬,因为我们看到了越来越多的嵌套分支。
因此,现在是时候回答问题了,我们编写的两种分支之间有什么区别?我对为什么我对if (type === 'foo') { ... }分支感到尴尬的理解是,它与上下文没有很强的关联。另一方面,比较时间戳是同步过程的一个更自然的部分。
再次提醒,我并不是说这是不好的。但这给我们一个提示,当我们开始失去控制(由于我们有限的脑容量,这只是复杂性的问题)时,我们可能从哪里开始我们的手术。
堆积类似但平行的过程
本章的大部分代码是用来处理客户端和服务器之间同步数据的过程。为了适应新功能,我们只是不断地在方法中添加新内容,例如update和synchronize。
你可能已经发现,大多数逻辑的轮廓都可以,并且应该,在多个数据类型之间共享。但我们没有这样做。
如果我们仔细观察,从代码文本的角度来看,重复实际上是很小的。以客户端的update方法为例,每个分支的逻辑似乎都不同。如果你还没有形成内置的抽象反应,你可能会就此停止。或者如果你不喜欢长函数,你可能会通过将其拆分为相同类的小函数来重构代码。这可能会让事情变得更好,但远远不够。
数据存储被极大地简化了
在实现中,我们与理想的内存存储进行了大量的直接操作。如果能有一个包装器,并使其真正的存储可互换,那会很好。
对于这个实现来说,这可能不是情况,因为它基于极其理想和简化的假设和要求。但添加一个包装器可能是一种提供有用辅助工具的方法。
确保正确
因此,让我们摆脱逐字符比较代码的错觉,并尝试找到一个可以应用于更新所有这些数据类型的抽象。这个抽象的两个关键点已经在上一节中提到:
-
一个
change包含将一个项目的值从一种转换为另一种所需的信息 -
在单次同步过程中,可能对单个数据项生成或应用多个变化
现在,从变化开始,让我们思考当客户端的update方法被调用时会发生什么。
寻找抽象
仔细看看客户端的update方法:
-
对于
'value'类型的数据,首先我们创建变化,包括一个新值,然后更新变化列表,使新创建的变化成为唯一的变化。之后,我们更新数据项的值。 -
对于
'increment'类型的数据,我们在变化列表中添加一个包含增量变化的变化;或者如果已经存在尚未同步的变化,则更新现有变化中的增量。然后,我们更新数据项的值。 -
最后,对于
'set'类型的数据,我们创建一个反映最新操作的变化。在将新的变化添加到变化列表后,我们也移除不再必要的变化。然后我们更新数据项的值。
事情越来越清晰。以下是当调用update时这些数据类型所发生的情况:
-
创建新的变化。
-
将新的变化合并到变化列表中。
-
将新的变化应用于数据项。
现在甚至更好。对于不同的数据类型,每一步都是不同的,但不同的步骤具有相同的轮廓;我们需要做的是为不同的数据类型实现不同的策略。
实施策略
使用单个update函数执行所有类型的更改可能会令人困惑。在我们继续之前,让我们将其拆分为三个不同的方法:update用于普通值,increase用于增量值,以及addTo/removeFrom用于集合。
然后,我们将创建一个新的私有方法applyChange,它将接受由其他方法创建的变化,并继续执行步骤 2 和步骤 3。它接受一个具有两个方法append和apply的策略对象:
interface ClientChangeStrategy<T extends ClientChange> {
append(list: ClientChangeList<T>, change: T): void;
apply(item: ClientDataItem<any>, change: T): void;
}
对于一个普通的数据项,策略对象可能如下所示:
let strategy: ClientChangeStrategy<ClientValueChange<any>> = {
append(list, change) {
list.changes = [change];
},
apply(item, change) {
item.value = change.value;
}
};
对于增量数据项,需要更多几行代码。首先,append方法:
let changes = list.changes;
let lastChange = changes[changes.length];
if (!lastChange || lastChange.synced) {
changes.push(change);
} else {
lastChange.increment += change.increment;
}
append方法之后跟随apply方法:
if (item.value === undefined) {
item.value = change.increment;
} else {
item.value += change.increment;
}
现在在applyChange方法中,我们需要注意非现有项目和变化列表的创建,并根据不同的数据类型调用不同的append和apply方法。
同样的技术可以应用于其他过程。尽管适用于客户端和服务器端的详细过程不同,但我们仍然可以将它们一起作为模块编写。
包装存储
我们将在具有读写能力的普通内存存储对象周围创建一个轻量级包装器,以服务器端存储为例:
export class ServerStore {
private items: {
[id: string]: ServerDataItem<any>;
} = Object.create(null);
}
export class Server {
constructor(
public store: ServerStore
) { }
}
为了满足我们的需求,我们需要为ServerStore实现get、set和getAll方法(或者更好的是,一个带有条件的find方法):
get<T, TExtra extends ServerDataItemExtra>(id: string):
ServerDataItem<T> & TExtra {
return hasOwnProperty.call(this.items, id) ?
this.items[id] as ServerDataItem<T> & TExtra : undefined;
}
set<T, TExtra extends ServerDataItemExtra>(
id: string,
item: ServerDataItem<T> & Textra
): void {
this.items[id] = item;
}
getAll<T, TExtra extends ServerDataItemExtra>():
(ServerDataItem<T> & TExtra)[] {
let items = this.items;
return Object
.keys(items)
.map(id => items[id] as ServerDataItem<T> & TExtra);
}
你可能已经注意到,从接口和泛型中,我还将ServerDataItem拆分成了公共部分和额外部分的交叉类型。
摘要
在本章中,我们参与了这样一个简化但与现实相关的项目的演变过程。从一个简单的代码库开始,这个代码库不可能出错,我们添加了许多功能,并经历了将可接受的变化组合起来并使整个项目变得混乱的过程。
我们总是试图通过优雅的命名或添加必要的语义冗余来编写可读的代码,但随着复杂性的增加,这并不会有多大帮助。
在这个过程中,我们学习了离线同步的工作原理。借助最常见的设计模式,例如策略模式(Strategy Pattern),我们成功地将项目拆分为小而可控的部分。
在接下来的章节中,我们将使用 TypeScript 中的代码示例来列出更多有用的设计模式,并尝试将这些设计模式应用到具体问题上。
第三章。创建型设计模式
在面向对象编程中,创建型设计模式是在对象实例化期间应用的设计模式。在本章中,我们将讨论这一类别的模式。
假设我们正在建造一个具有有效载荷和一个或多个阶段的火箭:
class Payload {
weight: number;
}
class Engine {
thrust: number;
}
class Stage {
engines: Engine[];
}
在传统的 JavaScript 中,有两种主要的方法来构建这样的火箭:
-
使用
new运算符的构造函数 -
工厂函数
对于第一种方法,事情可能如下所示:
function Rocket() {
this.payload = {
name: 'cargo ship'
};
this.stages = [
{
engines: [
// ...
]
}
];
}
var rocket = new Rocket();
对于第二种方法,它可能如下所示:
function buildRocket() {
var rocket = {};
rocket.payload = {
name: 'cargo ship'
};
rocket.stages = [
{
thrusters: [
// ...
]
}
];
return rocket;
}
var rocket = buildRocket();
从某个角度来看,它们几乎在做同样的事情,但在语义上差异很大。构造函数方法建议构建过程与最终产品之间有很强的关联。另一方面,工厂函数则暗示了其产品的接口,并声称有能力构建这样的产品。
然而,上述两种实现都没有提供根据特定需求模块化组装火箭的灵活性;这正是创建型设计模式所涉及的。
在本章中,我们将介绍以下创建型模式:
-
工厂方法:通过使用工厂的抽象方法而不是构造函数来构建实例,这允许子类通过实现或覆盖这些方法来改变构建的内容。
-
抽象工厂:定义兼容工厂及其产品的接口。因此,通过更改传递的工厂,我们可以更改构建产品的家族。
-
Builder:定义构建复杂对象的步骤,并通过改变步骤的顺序或使用不同的 Builder 实现来改变构建的内容。
-
Prototype:通过克隆参数化的原型来创建对象。因此,通过替换这些原型,我们可以构建不同的产品。
-
单例:确保(在某个范围内)只创建一个实例。
很有趣的是,尽管在 JavaScript 中创建对象的工厂函数方法看起来很原始,但它确实与我们将要讨论的一些模式有共同之处(尽管应用于不同的范围)。
工厂方法
在某些情况下,一个类无法准确预测它将创建哪些对象,或者其子类可能想要创建这些对象的更具体的版本。然后,可以应用工厂方法模式。
下图显示了将工厂方法模式应用于创建火箭的可能结构:

工厂方法是一种工厂构建对象的方法。以建造火箭为例;一个工厂方法可以是构建整个火箭或单个组件的方法。一个工厂方法可能依赖于其他工厂方法来构建其目标对象。例如,如果我们有一个在Rocket类下的createRocket方法,它可能会调用像createStages和createPayload这样的工厂方法来获取必要的组件。
工厂方法模式在合理的复杂性上提供了一定的灵活性。它通过实现(或覆盖)特定的工厂方法来允许可扩展的使用。以createStages方法为例,我们可以通过提供不同的createStages方法来创建单级火箭或双级火箭,分别返回一个或两个阶段。
参与者
典型工厂方法模式实现的参与者包括以下内容:
- 产品:
Rocket
定义一个将要创建为产品的火箭的抽象类或接口。
- 具体产品:
FreightRocket
实现特定的火箭产品。
- 创建者:
RocketFactory
定义可选的抽象工厂类以创建产品。
- 具体创建者:
FreightRocketFactory
实现或覆盖特定的工厂方法以按需构建产品。
模式范围
工厂方法模式将Rocket与其构造函数实现解耦,并使得工厂的子类可以根据需要进行相应的构建更改。具体的创建者仍然关心其组件的确切内容和构建方式。但实现或覆盖通常更多地关注每个组件,而不是整个产品。
实现
让我们从构建一个简单的单级火箭开始,该火箭携带一个默认的 0 重量有效载荷作为基本实现:
class RocketFactory {
buildRocket(): Rocket { }
createPayload(): Payload { }
createStages(): Stage[] { }
}
我们从创建组件开始。对于工厂方法createPayload,我们将简单地返回一个重量为 0 的有效载荷,对于工厂方法createStages,我们将返回一个单级,带有一个单独的引擎:
createPayload(): Payload {
return new Payload(0);
}
createStages(): Stage[] {
let engine = new Engine(1000);
let stage = new Stage([engine]);
return [stage];
}
在实现了创建火箭组件的方法之后,我们将使用工厂方法buildRocket将它们组合在一起:
buildRocket(): Rocket {
let rocket = new Rocket();
let payload = this.createPayload();
let stages = this.createStages();
rocket.payload = payload;
rocket.stages = stages;
return rocket;
}
现在我们有了简单火箭工厂的蓝图,但具有一定的可扩展性。要构建一个火箭(目前什么也不做),我们只需要实例化这个工厂并调用它的buildRocket方法:
let rocketFactory = new RocketFactory();
let rocket = rocketFactory.buildRocket();
接下来,我们将构建双级货运火箭,将卫星送入轨道。因此,与基本工厂实现相比,有一些不同之处。
首先,我们有不同的有效载荷、卫星,而不是一个 0 重量的占位符:
class Satellite extends Payload {
constructor(
public id: number
) {
super(200);
}
}
第二,我们现在有两个阶段,可能具有不同的规格。第一级将配备四个引擎:
class FirstStage extends Stage {
constructor() {
super([
new Engine(1000),
new Engine(1000),
new Engine(1000),
new Engine(1000)
]);
}
}
而第二级只有一个:
class SecondStage extends Stage {
constructor() {
super([
new Engine(1000)
]);
}
}
现在我们已经心中有数,这个新的货运火箭将是什么样子,让我们扩展工厂:
type FreightRocketStages = [FirstStage, SecondStage];
class FreightRocketFactory extends RocketFactory {
createPayload(): Satellite { }
createStages(): FreightRocketStages { }
}
小贴士
在这里,我们使用tuple的类型别名来表示货运火箭的阶段序列,即第一级和第二级。要了解更多关于类型别名的信息,请参阅www.typescriptlang.org/docs/handbook/advanced-types.html。
由于我们为Satellite添加了id属性,我们可能需要为工厂的每个实例提供一个计数器,然后为每个卫星创建一个唯一的 ID:
nextSatelliteId = 0;
createPayload(): Satellite {
return new Satellite(this.nextSatelliteId++);
}
让我们继续并实现createStages方法,该方法构建火箭的第一级和第二级:
createStages(): FreightRocketStages {
return [
new FirstStage(),
new SecondStage()
];
}
与原始实现相比,你可能已经注意到,我们已经自动将特定阶段构建过程与将它们组装成不同阶段的构造函数解耦。如果有助于的话,也可以应用另一个创建模式来初始化每个阶段。
后果
在前面的实现中,工厂方法buildRocket处理了构建步骤的大纲。我们很幸运,货运火箭与最初定义的第一个火箭在结构上是相同的。
但这种情况并不总是发生。如果我们想改变产品类(Rocket),我们就必须重写整个buildRocket,除了类名之外的所有内容。这看起来很令人沮丧,但可以通过将火箭实例的创建与构建过程解耦来解决:
buildRocket(): Rocket {
let rocket = this.createRocket();
let payload = this.createPayload();
let stages = this.createStages();
rocket.payload = payload;
rocket.stages = stages;
return rocket;
}
createRocket(): Rocket {
return new Rocket();
}
因此,我们可以通过重写createRocket方法来改变火箭类。然而,子类(例如FreightRocketFactory)的buildRocket方法的返回类型仍然是Rocket而不是像FreightRocket这样的类型。但是,由于创建的对象实际上是FreightRocket的实例,通过类型断言进行类型转换是有效的:
let rocket = FreightRocketFactory.buildRocket() as FreightRocket;
权衡是略微牺牲类型安全,但可以使用泛型来消除。不幸的是,在 TypeScript 中,从泛型类型参数中得到的只是一个类型而没有实际值。这意味着我们可能需要另一个抽象级别或其他可以使用类型推断帮助确保一切的图案。
前一个选项将引导我们走向抽象工厂模式。
注意
类型安全可能是选择模式时需要考虑的一个因素,但通常,它不会是决定性的。请注意,我们并不是试图仅仅因为这个单一原因就切换模式,我们只是在探索。
抽象工厂
抽象工厂模式通常定义了一组工厂方法的接口,而不指定具体产品。这允许整个工厂可替换,以便按照相同的生产大纲生产不同的产品:

产品(组件)的细节在图中省略,但请注意,这些产品属于两个平行的家族:ExperimentalRocket和FreightRocket。
与工厂方法模式不同,抽象工厂模式提取了另一个称为客户端的部分,负责塑造构建过程的大纲。这使得工厂部分更专注于生产每个组件。
参与者
典型抽象工厂模式实现的参与者包括以下内容:
- 抽象工厂:
RocketFactory
定义工厂的工业标准,该工厂提供制造组件或复杂产品的接口。
- 具体工厂:
ExperimentalRocketFactory,FreightRocketFactory
实现由抽象工厂定义的接口并构建具体产品。
- 抽象产品:
Rocket,Payload,Stage[]
定义工厂将要构建的产品接口。
- 具体产品:
ExperimentalRocket/FreightRocket,ExperimentalPayload/Satellite等等。
展示由具体工厂制造的实际情况产品。
- 客户端:
在工厂之间安排生产过程(仅当这些工厂符合工业标准时)。
模式范围
抽象工厂模式在多个具体工厂之上进行了抽象。在单个工厂或单个工厂分支的范围内,它就像工厂方法模式一样工作。然而,这个模式的高光之处在于使整个产品系列可互换。一个很好的例子可以是 UI 实现的主题组件。
实现
在抽象工厂模式中,客户端与具体工厂交互以构建整体产品。然而,在设计时,产品的具体类与客户端解耦,而客户端只关心工厂及其产品的外观,而不是它们确切是什么。
让我们先简化相关的类到接口:
interface Payload {
weight: number;
}
interface Stage {
engines: Engine[];
}
interface Rocket {
payload: Payload;
stages: Stage[];
}
当然,抽象工厂本身是:
interface RocketFactory {
createRocket(): Rocket;
createPayload(): Payload;
createStages(): Stage[];
}
建造步骤从工厂抽象出来并放入客户端,但我们仍然需要实现它:
class Client {
buildRocket(factory: RocketFactory): Rocket {
let rocket = factory.createRocket();
rocket.payload = factory.createPayload();
rocket.stages = factory.createStages();
return rocket;
}
}
现在我们遇到了我们在实现工厂方法模式时遇到的问题。由于不同的具体工厂构建不同的火箭,产品的类发生了变化。然而,现在我们有泛型来拯救我们。
首先,我们需要一个带有泛型类型参数的RocketFactory接口,该参数描述了一个具体的火箭类:
interface RocketFactory<T extends Rocket> {
createRocket(): T;
createPayload(): Payload;
createStages(): Stage[];
}
其次,更新客户端的buildRocket方法以支持泛型工厂:
buildRocket<T extends Rocket>(
factory: RocketFactory<T>
): T { }
因此,借助类型系统,我们将根据具体工厂的类型推断出火箭类型,从ExperimentalRocket和ExperimentalRocketFactory开始:
class ExperimentalRocket implements Rocket { }
class ExperimentalRocketFactory
implements RocketFactory<ExperimentalRocket> { }
如果我们用一个ExperimentalRocketFactory的实例调用客户端的buildRocket方法,返回类型将自动是ExperimentalRocket:
let client = new Client();
let factory = new ExperimentalRocketFactory();
let rocket = client.buildRocket(factory);
在我们完成ExperimentalRocketFactory对象的实现之前,我们需要为家族产品定义具体的类:
class ExperimentalPayload implements Payload {
weight: number;
}
class ExperimentalRocketStage implements Stage {
engines: Engine[];
}
class ExperimentalRocket implements Rocket {
payload: ExperimentalPayload;
stages: [ExperimentalRocketStage];
}
注意
为了使内容更紧凑,省略了有效载荷和阶段的简单初始化。如果它们对于这本书不是必要的,可以应用相同类型的省略。
现在我们可以定义这个具体工厂类的工厂方法:
class ExperimentalRocketFactory
implements RocketFactory<ExperimentalRocket> {
createRocket(): ExperimentalRocket {
return new ExperimentalRocket();
}
createPayload(): ExperimentalPayload {
return new ExperimentalPayload();
}
createStages(): [ExperimentalRocketStage] {
return [new ExperimentalRocketStage()];
}
}
让我们继续探讨另一个具体的工厂,该工厂制造货运火箭及其家族产品,从火箭组件开始:
class Satellite implements Payload {
constructor(
public id: number,
public weight: number
) { }
}
class FreightRocketFirstStage implements Stage {
engines: Engine[];
}
class FreightRocketSecondStage implements Stage {
engines: Engine[];
}
type FreightRocketStages =
[FreightRocketFirstStage, FreightRocketSecondStage];
继续讨论火箭本身:
class FreightRocket implements Rocket {
payload: Satellite;
stages: FreightRocketStages;
}
在定义了货运火箭家族的结构或类之后,我们就可以实现其工厂了:
class FreightRocketFactory
implements RocketFactory<FreightRocket> {
nextSatelliteId = 0;
createRocket(): FreightRocket {
return new FreightRocket();
}
createPayload(): Satellite {
return new Satellite(this.nextSatelliteId++, 100);
}
createStages(): FreightRocketStages {
return [
new FreightRocketFirstStage(),
new FreightRocketSecondStage()
];
}
}
现在,我们再次拥有两个火箭家族及其工厂,并且我们可以通过传递不同的工厂来使用相同的客户端构建不同的火箭:
let client = new Client();
let experimentalRocketFactory = new ExperimentalRocketFactory();
let freightRocketFactory = new FreightRocketFactory();
let experimentalRocket =
client.buildRocket(experimentalRocketFactory);
let freightRocket = client.buildRocket(freightRocketFactory);
后果
抽象工厂模式使得改变整个产品家族变得容易且顺畅。这是工厂级别抽象带来的直接好处。因此,它也带来了其他好处,同时也带来了一些缺点。
一方面,它提供了在特定家族产品内的更好兼容性。由于单个工厂构建的产品通常旨在协同工作,我们可以假设它们更容易合作。
但另一方面,它依赖于构建过程的共同轮廓,尽管对于良好的抽象构建过程,这通常不会成为问题。我们还可以在具体工厂和客户端上参数化工厂方法,使过程更加灵活。
当然,抽象工厂不一定是纯接口或没有实现任何方法的抽象类。实际实现应根据详细上下文来决定。
尽管抽象工厂模式和工厂方法模式具有不同级别的抽象,但它们封装的内容是相似的。对于构建具有多个组件的产品,工厂将产品分解为组件以获得灵活性。然而,固定家族的产品及其内部组件可能并不总是满足要求,因此我们可以考虑将建造者模式作为另一种选择。
建造者
当工厂模式暴露内部组件(如火箭的负载和阶段)时,建造者模式通过仅暴露构建步骤来封装它们,并直接提供最终产品。同时,建造者模式还封装了产品的内部结构。这使得更灵活地抽象和实现构建复杂对象成为可能。
建造者模式还引入了一个新的角色,称为导演,如下面的图所示。它与抽象工厂模式中的客户端非常相似,尽管它只关心构建步骤或管道:

现在从RocketBuilder对子类产品施加的唯一约束是Rocket的整体形状。这可能不会带来很多好处,因为我们之前定义的Rocket接口暴露了一些火箭的细节,而客户端(我指的是那些想要将卫星或其他类型的有效载荷送入太空的人)可能不太关心这些细节。对于这些客户端,他们可能想知道的只是火箭能够将有效载荷送入哪个轨道,而不是火箭有多少级以及具体是哪些级。
参与者
典型建造者模式实现的参与者包括以下内容:
- 建造者:
RocketBuilder
定义了一个构建产品的构建器接口。
- 具体建造者:
FalconBuilder
实现构建产品部分的方法,并跟踪当前的构建状态。
- 导演
定义步骤并与构建者协作以构建产品。
- 最终产品:
Falcon
由构建者构建的产品。
模式范畴
建造者模式与抽象工厂模式具有相似的范畴,它从将最终启动产品的完整操作集合中提取抽象。与抽象工厂模式相比,建造者模式在构建步骤及其关联上更加关注,而抽象工厂模式将这部分放在客户端,并使工厂专注于生产组件。
实现
由于我们现在假设阶段不是想要购买火箭以携带其有效载荷的客户所关心的问题,我们可以从通用Rocket接口中删除stages属性:
interface Rocket {
payload: Payload;
}
有一个名为“ sounding rocket”的火箭家族,它将探测器发送到近太空。这意味着我们甚至不需要有阶段的概念。SoundingRocket将只有一个engine属性(除了payload,它将是一个Probe),唯一的发动机将是一个SolidRocketEngine:
class Probe implements Payload {
weight: number;
}
class SolidRocketEngine extends Engine { }
class SoundingRocket implements Rocket {
payload: Probe;
engine: SolidRocketEngine;
}
但我们仍然需要火箭来发送卫星,这些卫星通常使用LiquidRocketEngine:
class LiquidRocketEngine extends Engine {
fuelLevel = 0;
refuel(level: number): void {
this.fuelLevel = level;
}
}
我们可能还想有一个相应的LiquidRocketStage抽象类来处理加注:
abstract class LiquidRocketStage implements Stage {
engines: LiquidRocketEngine[] = [];
refuel(level = 100): void {
for (let engine of this.engines) {
engine.refuel(level);
}
}
}
现在我们可以将FreightRocketFirstStage和FreightRocketSecondStage更新为LiquidRocketStage的子类:
class FreightRocketFirstStage extends LiquidRocketStage {
constructor(thrust: number) {
super();
let enginesNumber = 4;
let singleEngineThrust = thrust / enginesNumber;
for (let i = 0; i < enginesNumber; i++) {
let engine =
new LiquidRocketEngine(singleEngineThrust);
this.engines.push(engine);
}
}
}
class FreightRocketSecondStage extends LiquidRocketStage {
constructor(thrust: number) {
super();
this.engines.push(new LiquidRocketEngine(thrust));
}
}
FreightRocket将保持不变:
type FreightRocketStages =
[FreightRocketFirstStage, FreightRocketSecondStage];
class FreightRocket implements Rocket {
payload: Satellite;
stages = [] as FreightRocketStages;
}
当然,还有构建者。这次,我们将使用一个抽象类,该类部分实现了构建器,并应用了泛型:
abstract class RocketBuilder<
TRocket extends Rocket,
TPayload extends Payload
> {
createRocket(): void { }
addPayload(payload: TPayload): void { }
addStages(): void { }
refuelRocket(): void { }
abstract get rocket(): TRocket;
}
注意
在这个抽象类中实际上没有抽象方法。其中一个原因是特定步骤可能对某些构建者来说是可选的。通过实现 no-op 方法,子类可以简单地留出它们不关心的步骤为空。
这里是Director类的实现:
class Director {
prepareRocket<
TRocket extends Rocket,
TPayload extends Payload
>(
builder: RocketBuilder<TRocket, TPayload>,
payload: TPayload
): TRocket {
builder.createRocket();
builder.addPayload(payload);
builder.addStages();
builder.refuelRocket();
return builder.rocket;
}
}
注意
谨慎起见,如果没有明确提供构建上下文,构建实例将依赖于构建管道正在排队(无论是同步还是异步)。避免风险的一种方法(尤其是在异步操作中)是每次准备火箭时都初始化一个构建实例。
现在是时候实现具体的构建器了,从SoundingRocketBuilder开始,它使用只有一个SolidRocketEngine构建SoundingRocket:
class SoundingRocketBuilder
extends RocketBuilder<SoundingRocket, Probe> {
private buildingRocket: SoundingRocket;
createRocket(): void {
this.buildingRocket = new SoundingRocket();
}
addPayload(probe: Probe): void {
this.buildingRocket.payload = probe;
}
addStages(): void {
let payload = this.buildingRocket.payload;
this.buildingRocket.engine =
new SolidRocketEngine(payload.weight);
}
get rocket(): SoundingRocket {
return this.buildingRocket;
}
}
在这个实现中,有几个值得注意的地方:
-
addStages方法依赖于之前添加的有效载荷来添加具有正确推力规格的发动机。 -
refuel方法没有被重写(因此它仍然是 no-op),因为固体火箭发动机不需要加注。
我们已经对构建者提供的一些上下文有所了解,这可能会对结果产生重大影响。例如,让我们看看FreightRocketBuilder。如果我们不考虑addStages和refuel方法,它可能与SoundingRocket相似:
class FreightRocketBuilder
extends RocketBuilder<FreightRocket, Satellite> {
private buildingRocket: FreightRocket;
createRocket(): void {
this.buildingRocket = new FreightRocket();
}
addPayload(satellite: Satellite): void {
this.buildingRocket.payload = satellite;
}
get rocket(): FreightRocket {
return this.buildingRocket;
}
}
假设重量小于1000的有效载荷只需要一个阶段就能送入太空,而重量更大的有效载荷需要两个或更多阶段:
addStages(): void {
let rocket = this.buildingRocket;
let payload = rocket.payload;
let stages = rocket.stages;
stages[0] = new FreightRocketFirstStage(payload.weight * 4);
if (payload.weight >= FreightRocketBuilder.oneStageMax) {
stages[1] = FreightRocketSecondStage(payload.weight);
}
}
static oneStageMax = 1000;
当涉及到加油时,我们甚至可以根据有效载荷的重量来决定加油量:
refuel(): void {
let rocket = this.buildingRocket;
let payload = rocket.payload;
let stages = rocket.stages;
let oneMax = FreightRocketBuilder.oneStageMax;
let twoMax = FreightRocketBuilder.twoStagesMax;
let weight = payload.weight;
stages[0].refuel(Math.min(weight, oneMax) / oneMax * 100);
if (weight >= oneMax) {
stages[1]
.refuel((weight - oneMax) / (twoMax - oneMax) * 100);
}
}
static oneStageMax = 1000;
static twoStagesMax = 2000;
现在我们可以准备不同制造商的不同火箭,准备发射:
let director = new Director();
let soundingRocketBuilder = new SoundingRocketBuilder();
let probe = new Probe();
let soundingRocket
= director.prepareRocket(soundingRocketBuilder, probe);
let freightRocketBuilder = new FreightRocketBuilder();
let satellite = new Satellite(0, 1200);
let freightRocket
= director.prepareRocket(freightRocketBuilder, satellite);
后果
由于建造者模式对产品结构和构建步骤如何相互影响有更大的控制权,它通过自身子类化建造者提供了最大的灵活性,而不改变导演(在抽象工厂模式中扮演类似客户端的角色)。
原型
由于 JavaScript 是一种基于原型的编程语言,你可能会在不自知的情况下一直使用与原型相关的模式。
我们在抽象工厂模式中讨论了一个例子,部分代码如下:
class FreightRocketFactory
implements RocketFactory<FreightRocket> {
createRocket(): FreightRocket {
return new FreightRocket();
}
}
有时我们可能需要添加一个子类,只是为了在执行相同的new操作时更改类名。单类实例通常共享相同的方法和属性,因此我们可以克隆一个现有实例以创建新的实例。这就是原型的概念。
但在 JavaScript 中,由于内置了原型概念,new Constructor()基本上会做克隆方法会做的事情。所以实际上构造函数可以在某种程度上扮演具体工厂的角色:
interface Constructor<T> {
new (): T;
}
function createFancyObject<T>(constructor: Constructor<T>): T {
return new constructor();
}
利用这个特权,我们可以将产品或组件类作为其他模式的一部分进行参数化,使创建更加灵活。
在讨论 JavaScript 中的原型模式时,可能会容易忽略一些东西:带有状态的克隆。随着 ES6 中引入的class语法糖,它隐藏了原型修改,我们有时可能会忘记我们实际上可以直接修改原型:
class Base {
state: number;
}
let base = new Base();
base.state = 0;
class Derived extends Base { }
Derived.prototype = base;
let derived = new Derived();
现在,derived对象将保持base对象的state。当你想要创建特定实例的副本但又不希望这些副本的原型属性成为克隆对象的自有属性时,这可能很有用。
Singleton
有一些场景中,应该只存在特定类的单个实例,这导致了单例模式。
基本实现
JavaScript 中最简单的单例是一个对象字面量;它提供了一种快速且经济的方式创建一个唯一的对象:
const singleton = {
foo(): void {
console.log('bar');
}
};
但有时我们可能需要私有变量:
const singleton = (() => {
let bar = 'bar';
return {
foo(): void {
console.log(bar);
}
};
})();
或者我们想利用 ES6 中的匿名构造函数或类表达式:
const singleton = new class {
private _bar = 'bar';
foo(): void {
console.log(this._bar);
}
} ();
注意
请记住,private修饰符仅在编译时有效,编译成 JavaScript 后(尽管当然其可访问性将保留在.d.ts中)将简单地消失。
然而,有时可能需要创建“单例”新实例的要求。因此,一个普通的类仍然是有帮助的:
class Singleton {
bar = 'bar';
foo(): void {
console.log(bar);
}
private static _default: Singleton;
static get default(): Singleton {
if (!Singleton._default) {
Singleton._default = new Singleton();
}
return Singleton._default;
}
}
这种方法带来的另一个好处是延迟初始化:对象仅在第一次访问时才被初始化。
条件单例
有时我们可能希望根据某些条件获取“单例”。例如,每个国家通常只有一个首都,因此首都可以在特定国家的范围内被视为单例。
条件也可能是上下文的结果,而不是显式的参数。假设我们有一个类Environment及其派生类WindowsEnvironment和UnixEnvironment,我们希望通过使用Environment.default在各个平台上访问正确的环境单例,显然,可以通过default获取器进行选择。
对于更复杂的场景,我们可能需要一个基于注册的实现来使其可扩展。
摘要
在本章中,我们讨论了包括工厂方法、抽象工厂、建造者、原型和单例在内的几个重要的创建型设计模式。
从工厂方法模式开始,它以有限的复杂性提供灵活性,我们还探讨了抽象工厂模式、建造者模式和原型模式,这些模式具有相似级别的抽象,但关注不同的方面。这些模式比工厂方法模式具有更大的灵活性,但同时也更复杂。了解每个模式背后的理念,我们应该能够相应地选择和应用模式。
在比较不同创建型模式之间的差异时,我们也发现了它们之间许多共同之处。这些模式不太可能孤立于其他模式之外,其中一些甚至可以相互协作或补充。
在下一章中,我们将继续讨论有助于形成具有复杂结构的对象的组合型模式。
第四章:结构型设计模式
当创建型模式在灵活创建对象方面发挥作用时,结构型模式另一方面则是关于对象组合的模式。在本章中,我们将讨论适合不同场景的结构型模式。
如果我们仔细观察结构型模式,它们可以分为 结构型类模式 和 结构型对象模式。结构型类模式是与“相关方”本身玩弄的模式,而结构型对象模式则是将碎片编织在一起的模式(如组合模式)。这两种结构型模式在一定程度上相互补充。
本章我们将探讨以下模式:
-
组合:使用原始和组合对象构建树状结构。一个很好的例子就是构成完整页面的 DOM 树。
-
装饰器:动态地为类或对象添加功能。
-
适配器:提供了一个通用接口,并通过实现不同的具体适配器与不同的适配器协同工作。例如,可以考虑为单一的内容管理系统提供不同的数据库选择。
-
桥接:将抽象与其实现解耦,并使两者可互换。
-
外观:为复杂子系统的组合提供了一个简化的接口。
-
享元:共享被多次使用的无状态对象,以提高内存效率和性能。
-
代理:作为代理,在访问它管理的对象时承担额外的责任。
组合模式
同一类的对象可能因其属性或甚至特定的子类而有所不同,但一个复杂对象可能不仅仅有普通属性。以 DOM 元素为例,所有元素都是 Node 类的实例。这些节点形成树结构来表示不同的页面,但与根节点相比,这些树中的每个节点都是完整且统一的:
<html>
<head>
<title>TypeScript</title>
</head>
<body>
<h1>TypeScript</h1>
<img />
</body>
</html>
前面的 HTML 表示一个 DOM 结构,如下所示:

所有的前面对象都是 Node 的实例,它们实现了组合模式中 组件 的接口。其中一些节点(如本例中的 HTML 元素,除了 HTMLImageElement)有子节点(组件),而其他则没有。
参与者
组合模式实现的参与者包括:
-
组件:
节点定义接口并为组合对象实现默认行为。它还应包括访问和管理实例子组件的接口,以及可选的对其父组件的引用。
-
组合:包括一些 HTML 元素,如
HTMLHeadElement和HTMLBodyElement存储子组件并实现相关操作,当然也包括其自身的行为。
-
叶节点:
TextNode,HTMLImageElement定义原始组件的行为。
-
客户端:
操作组合及其组件。
模式范围
当对象可以并且应该递归地作为形成树结构的组件进行抽象时,适用组合模式。通常,当需要形成某种结构作为树时,这会是一个自然的选择,例如视图组件的树、抽象语法树或表示文件结构的树。
实现
我们将创建一个表示简单文件结构并具有有限组件类型的组合体。
首先,让我们导入相关的节点模块:
import * as Path from 'path';
import * as FS from 'fs';
注意
模块 path 和 fs 是 Node.js 的内置模块,请参阅 Node.js 文档以获取更多信息:nodejs.org/api/.
注意
我个人的偏好是,如果命名空间(同时不是函数)的首字母大写,这可以减少与局部变量的冲突机会。但 JavaScript 中命名空间的一种更流行的命名风格并不这样做。
现在,我们需要对组件进行抽象,比如 FileSystemObject:
abstract class FileSystemObject {
constructor(
public path: string,
public parent?: FileSystemObject
) { }
get basename(): string {
return Path.basename(this.path);
}
}
我们使用 抽象类,因为我们不期望直接使用 FileSystemObject。定义了一个可选的 parent 属性,以便我们可以访问特定对象的上级组件。还添加了 basename 属性,作为获取路径基本名的辅助工具。
FileSystemObject 预期应该有子类,即 FolderObject 和 FileObject。对于 FolderObject,它是一个可能包含其他文件夹和文件的组合体,我们将添加一个 items 属性(获取器),它返回它包含的其他 FileSystemObject:
class FolderObject extends FileSystemObject {
items: FileSystemObject[];
constructor(path: string, parent?: FileSystemObject) {
super(path, parent);
}
}
我们可以在 constructor 中初始化 items 属性,以实际存在于给定 path 的文件和文件夹:
this.items = FS
.readdirSync(this.path)
.map(path => {
let stats = FS.statSync(path);
if (stats.isFile()) {
return new FileObject(path, this);
} else if (stats.isDirectory()) {
return new FolderObject(path, this);
} else {
throw new Error('Not supported');
}
});
你可能已经注意到我们正在使用不同类型的对象来形成 items,同时我们也在将 this 作为新创建的子组件的 parent 传递。
对于 FileObject,我们将添加一个简单的 readAll 方法,该方法读取文件的所有字节:
class FileObject extends FileSystemObject {
readAll(): Buffer {
return FS.readFileSync(this.path);
}
}
目前,当文件夹对象被初始化时,我们从实际的文件系统中读取文件夹内的子项。如果我们希望按需访问此结构,这可能不是必要的。我们实际上可以创建一个获取器,仅在访问时调用 readdir,这样对象就会像真实文件系统的代理一样操作。
后果
组合模式中的原始对象和组合对象都共享组件接口,这使得开发者可以更容易地使用较少要记住的事物构建组合结构。
它还使得使用像 XML 和 HTML 这样的标记语言来表示极其复杂的对象成为可能,具有极大的灵活性。组合模式还可以通过递归渲染组件来简化渲染。
由于大多数组件都兼容拥有子组件或成为其父组件的子组件,我们可以轻松地创建与现有组件配合得很好的新组件。
装饰器模式
装饰器模式可以动态地向对象添加新功能,通常不会损害原始功能。装饰器模式中的“装饰器”一词与 ES-next 装饰器语法中的“装饰器”一词确实有一些相似之处,但它们并不完全相同。作为短语,经典的装饰器模式差异更大。
经典的装饰器模式与组合一起工作,简而言之,就是创建作为装饰工作的组件的装饰器。由于组合对象通常递归处理,装饰器组件会自动处理。因此,您可以选择它要做什么。
继承层次结构可能如下所示的结构:

装饰器是递归应用的,如下所示:

装饰器正确工作有两个先决条件:装饰器所装饰的上下文或对象的认识,以及应用装饰器的能力。组合模式可以轻松创建满足这两个先决条件的结构:
-
装饰器知道它装饰的内容,作为
component属性 -
装饰器在递归渲染时应用
然而,实际上并不需要采用组合结构来在 JavaScript 中获得装饰器模式的好处。由于 JavaScript 是一种动态语言,如果您能调用装饰器,您就可以向对象添加任何您想要的内容。
以 console 对象下的 log 方法为例,如果我们想在每次日志前添加时间戳,我们可以简单地用带有时间戳前缀的包装器替换 log 函数:
const _log = console.log;
console.log = function () {
let timestamp = `[${new Date().toTimeString()}]`;
return _log.apply(this, [timestamp, ...arguments]);
};
当然,这个例子与经典的装饰器模式关系不大,但它为在 JavaScript 中实现这种模式提供了一种不同的方法。特别是借助新的装饰器语法:
class Target {
@decorator
method() {
// ...
}
}
注意
TypeScript 提供了装饰器语法转换作为一项实验性功能。要了解更多关于装饰器语法的知识,请查看以下链接:www.typescriptlang.org/docs/handbook/decorators.html。
参与者
经典装饰器模式实现的参与者包括:
-
组件:
UIComponent定义可以装饰的对象的接口。
-
具体组件:
TextComponent定义具体组件的附加功能。
-
装饰器:
Decorator定义对要装饰的组件的引用,并管理上下文。使组件符合适当的接口和行为。
-
具体装饰器:
ColorDecorator,FontDecorator定义额外的功能,并在必要时暴露 API。
模式范围
装饰器模式通常关注对象,但由于 JavaScript 是基于原型的,装饰器可以通过它们的原型很好地与对象的类一起工作。
装饰器模式的经典实现可能与其他我们稍后将要讨论的模式有很多共同之处,而函数式装饰器似乎共享较少。
实现
在这部分,我们将讨论装饰器模式的两种实现。第一个将是经典的装饰器模式,通过包装符合 UIComponent 接口的新类来装饰目标。第二个将是使用新装饰器语法编写的装饰器,它处理目标对象。
经典装饰器
让我们从定义要装饰的对象的轮廓开始。首先,我们将 UIComponent 定义为一个抽象类,定义其抽象函数 draw:
abstract class UIComponent {
abstract draw(): void;
}
然后是一个扩展了 UIComponent 的 TextComponent,以及其文本内容为 Text 类:
class Text {
content: string;
setColor(color: string): void { }
setFont(font: string): void { }
draw(): void { }
}
class TextComponent extends UIComponent {
texts: Text[];
draw(): void {
for (let text of this.texts) {
text.draw();
}
}
}
接下来要定义装饰器的接口,以便装饰 TextComponent 类的实例对象:
class Decorator extends UIComponent {
constructor(
public component: TextComponent
) {
super();
}
get texts(): Text[] {
return this.component.texts;
}
draw(): void {
this.component.draw();
}
}
现在我们已经有了具体的装饰器。在这个例子中,ColorDecorator 和 FontDecorator 看起来很相似:
class ColorDecorator extends Decorator {
constructor(
component: TextComponent,
public color: string
) {
super(component);
}
draw(): void {
for (let text of this.texts) {
text.setColor(this.color);
}
super.draw();
}
}
class FontDecorator extends Decorator {
constructor(
component: TextComponent,
public font: string
) {
super(component);
}
draw(): void {
for (let text of this.texts) {
text.setFont(this.font);
}
super.draw();
}
}
注意
在上述实现中,draw 方法中的 this.texts 调用了在 Decorator 类上定义的 getter。在这个上下文中,this 理想情况下应该是 ColorDecorator 或 FontDecorator 的实例;它访问的 texts 最终将是其 component 属性中的数组。
注意
如果我们有嵌套装饰器,这可能会更有趣或更令人困惑。如果你后来感到困惑,试着画一个示意图。
现在是真正组装它们的时候了:
let decoratedComponent = new ColorDecorator(
new FontDecorator(
new TextComponent(),
'sans-serif'
),
'black'
);
在这个例子中,装饰器的嵌套顺序并不重要。因为无论是 ColorDecorator 还是 FontDecorator 都是一个有效的 UIComponent,它们可以很容易地插入并替换之前的 TextComponent。
使用 ES-next 语法装饰器
经典装饰器模式有一个限制,可以通过装饰的嵌套形式直接指出。这也适用于 ES-next 装饰器。看看下面的例子:
class Foo {
@prefix
@suffix
getContent(): string {
return '...';
}
}
小贴士
@ 字符后面的表达式评估为装饰器。虽然装饰器是一个处理目标对象的函数,但我们通常使用高阶函数来参数化装饰器。
现在我们有两个装饰器 prefix 和 suffix 装饰了 getContent 方法。乍一看,它们似乎是平行的,但如果我们打算在返回的内容上添加前缀和后缀,就像名字所暗示的那样,这个过程实际上将是递归的,而不是像经典实现那样平行。
为了让装饰器像我们预期的那样与其他元素协作,我们需要小心处理:
function prefix(
target: Object,
name: string,
descriptor: PropertyDescriptor
): PropertyDescriptor {
let method = descriptor.value as Function;
if (typeof method !== 'function') {
throw new Error('Expecting decorating a method');
}
return {
value: function () {
return '[prefix] ' + method.apply(this, arguments);
},
enumerable: descriptor.enumerable,
configurable: descriptor.configurable,
writable: descriptor.writable
};
}
注意
在当前的 ECMAScript 装饰器提案中,当装饰一个方法或属性(通常使用 getter 或 setter)时,你会接收到一个作为属性描述符的第三个参数。
注意
想了解更多关于属性描述符的信息,请查看以下链接:developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperty。
suffix 装饰器将与 prefix 装饰器类似。所以我会在这里保存代码行。
后果
装饰器模式的关键是能够动态地添加功能,并且通常期望装饰器之间能够友好地协同工作。装饰器模式的这些期望使其能够非常灵活地形成定制对象。然而,对于某些类型的装饰器来说,实际上要很好地协同工作可能会有困难。
考虑用多个装饰器装饰一个对象,就像实现示例的第二部分,装饰顺序是否重要?或者装饰顺序应该重要吗?
一个正确编写的装饰器应该始终能够工作,无论它在装饰器列表中的位置如何。通常,更受欢迎的是,装饰的目标在装饰器以不同顺序装饰时几乎表现出相同的行为。
适配器模式
适配器模式将现有的类或对象与另一个现有的客户端连接。它使得原本设计不兼容的类能够相互协作。
适配器可以是 类 适配器或 对象 适配器。类适配器扩展了适配者类,并公开了与客户端一起工作的额外 API。另一方面,对象适配器不扩展适配者类。相反,它将适配者存储为依赖项。
类适配器在需要访问适配者类的受保护方法或属性时很有用。然而,在 JavaScript 世界中,它也有一些限制:
-
适配者类需要可扩展性
-
如果客户端目标是除了纯接口之外的抽象类,没有使用 mixin,你不能使用相同的适配器类扩展适配者和客户端目标。
-
一个具有两组方法和属性的单一类可能会让人感到困惑
由于这些限制,我们将更多地讨论对象适配器。以浏览器端存储为例,我们假设我们有一个与具有正确签名(例如,通过 AJAX 在线存储数据的存储)的存储对象一起工作的客户端。现在我们希望客户端能够与 IndexedDB 协作以实现更快的响应和离线使用;我们需要为 IndexedDB 创建一个适配器来获取和设置数据:

我们将使用 Promise 来接收异步操作的结果或错误。如果您还不熟悉 Promise,请查看以下链接以获取更多信息:developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise。
参与者
参与者包括:
-
目标:
Storage定义与客户端一起工作的现有目标接口
-
适配器:
IndexedDB未设计成与客户端一起工作的实现
-
适配器:
IndexedDBStorage符合目标接口并与适配器交互
-
客户端
操作目标
模式范围
当现有的客户端类没有设计成与现有的适配器一起工作时,可以应用适配器模式。它专注于应用不同客户端和适配器组合时的独特 适配器 部分。
实现
从 Storage 接口开始:
interface Storage {
get<T>(key: string): Promise<T>;
set<T>(key: string, value: T): Promise<void>;
}
注意
我们使用泛型定义了 get 方法,这样如果我们既没有指定泛型,也没有将返回的 Promise 的值类型进行类型转换,那么值的类型将是 {}。这可能会在类型检查后失败。
在 MDN 上找到的示例的帮助下,我们现在可以设置 IndexedDB 适配器。访问 IndexedDBStorage:developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API/Using_IndexedDB。
IndexedDB 实例的创建是异步的。我们可以将打开操作放在 get 或 set 方法中,这样数据库就可以按需打开。但为了简化,让我们创建一个 IndexedDBStorage 实例,它已经有一个打开的数据库实例。
然而,构造函数通常没有异步代码。即使它们有,也不能在完成构造之前对实例应用更改。幸运的是,工厂方法模式与异步初始化配合得很好:
class IndexedDBStorage implements Storage {
constructor(
public db: IDBDatabase,
public storeName = 'default'
) { }
open(name: string): Promise<IndexedDBStorage> {
return new Promise<IndexedDBStorage>(
(resolve, reject) => {
let request = indexedDB.open(name);
// ...
});
}
}
在 open 方法的 Promise 解析器内部,我们将完成异步工作:
let request = indexedDB.open(name);
request.onsuccess = event => {
let db = request.result as IDBDatabase;
let storage = new IndexedDBStorage(db);
resolve(storage);
};
request.onerror = event => {
reject(request.error);
};
现在我们访问 IndexedDBStorage 的实例时,可以假设它有一个已打开的数据库并且准备好进行查询。要更改或从数据库获取值,我们需要创建一个事务。以下是方法:
get<T>(key: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
let transaction = this.db.transaction(this.storeName);
let store = transaction.objectStore(this.storeName);
let request = store.get(key);
request.onsuccess = event => {
resolve(request.result);
};
request.onerror = event => {
reject(request.error);
};
});
}
方法 set 类似。但默认情况下,事务是只读的,我们需要明确指定 'readwrite' 模式。
set<T>(key: string, value: T): Promise<void> {
return new Promise<void>((resolve, reject) => {
let transaction =
this.db.transaction(this.storeName, 'readwrite');
let store = transaction.objectStore(this.storeName);
let request = store.put(value, key);
request.onsuccess = event => {
resolve();
};
request.onerror = event => {
reject(request.error);
};
});
}
现在我们可以有一个可以替换之前客户端使用的存储。
后果
通过应用适配器模式,我们可以填补原本无法协同工作的类之间的差距。在这种情况下,适配器模式是一个相当直接且容易想到的解决方案。
但在其他场景中,如 IDE 扩展的调试 适配器,适配器模式的具体实现可能更具挑战性。
桥接模式
桥接模式将客户端操作的抽象与功能实现解耦,并使得添加或替换这些抽象和实现变得容易。
以一组 跨 API UI 元素为例:

我们有一个名为UIElement的抽象,它可以访问UIToolkit的不同实现,以便基于 SVG 或画布创建不同的 UI。在前面的结构中,桥接是UIElement和UIToolkit之间的连接。
参与者
桥接模式的参与者包括:
-
抽象:
UIElement定义客户端将要操作的对象的接口,并存储对其实现者的引用。
-
精细抽象:
TextElement,ImageElement扩展抽象以包含特定的行为。
-
实现者:
UIToolkit定义了一个通用实现者的接口,该接口最终将执行在抽象中定义的操作。实现者通常只关心基本操作,而抽象将处理高级操作。
-
具体实现者:
SVGToolkit,CanvasToolkit实现实现者接口并操作低级 API。
模式范围
尽管抽象和实现者解耦使桥接模式能够与多个抽象和实现者一起工作,但大多数情况下,桥接模式只与单个实现者一起工作。
如果你仔细观察,你会发现桥接模式与适配器模式极为相似。然而,适配器模式试图使现有类协作并专注于适配器部分,而桥接模式则预见到了差异,并为扮演适配器角色的抽象提供了一个深思熟虑且通用的接口。
实现
在我们讨论的示例中,一个有效实现可能并不简单。但我们仍然可以轻松地勾勒出其骨架。
从与桥接概念直接相关的实现者UIToolkit和抽象UIElement开始:
interface UIToolkit {
drawBorder(): void;
drawImage(src: string): void;
drawText(text: string): void;
}
abstract class UIElement {
constructor(
public toolkit: UIToolkit
) { }
abstract render(): void;
}
现在我们可以扩展UIElement以获得具有不同行为的精细抽象。首先是TextElement类:
class TextElement extends UIElement {
constructor(
public text: string,
toolkit: UIToolkit
) {
super(toolkit);
}
render(): void {
this.toolkit.drawText(this.text);
}
}
以及具有类似代码的ImageElement类:
class ImageElement extends UIElement {
constructor(
public src: string,
toolkit: UIToolkit
) {
super(toolkit);
}
render(): void {
this.toolkit.drawImage(this.src);
}
}
通过创建具体的UIToolkit子类,我们可以设法与客户端一起管理所有内容。但是,由于这可能导致我们现在不想接触的繁重工作,我们将通过在这个示例中使用指向undefined的变量来跳过它:
let toolkit: UIToolkit;
let imageElement = new ImageElement('foo.jpg', toolkit);
let textElement = new TextElement('bar', toolkit);
imageElement.render();
textElement.render();
在现实世界中,渲染部分也可能是一项繁重的工作。但由于它是在相对较高层次上编写的,它以不同的方式折磨你。
后果
尽管在上面的示例中抽象(UIElement)和适配器接口(Storage)的名称完全不同,但在静态组合中它们扮演着相似的角色。
然而,正如我们在模式范围部分提到的,桥接模式和适配器模式的目的不同。
通过解耦抽象和实现者,桥接模式为系统带来了极大的可扩展性。客户端不需要了解实现细节,这有助于构建更稳定的系统,因为它形成了一个更健康的依赖结构。
另一个桥接模式可能带来的好处是,通过正确配置的构建过程,它可以在对精炼抽象或具体实现者进行更改时减少编译时间,因为编译器不需要知道桥的另一端的信息。
外观模式
外观模式组织子系统并提供统一的更高层接口。一个可能您熟悉的例子是模块化系统。在 JavaScript(当然也包括 TypeScript)中,人们使用模块来组织代码。模块化系统使得项目更容易维护,因为一个干净的项目结构可以帮助揭示项目不同部分之间的相互联系。
一个项目被其他项目引用是很常见的,但显然引用其他项目的项目并不关心也不应该关心其依赖项的内部结构。因此,可以引入一个外观为依赖项目提供一个更高层的 API 并暴露对其依赖项真正重要的内容。
以机器人为例。构建机器人和其组件的人需要分别控制每个部分并让它们同时协作。然而,想要使用这个机器人的人只需要发送简单的命令,比如“行走”和“跳跃”。
为了最灵活的使用,机器人“SDK”可以提供诸如MotionController、FeedbackController、Thigh、Shank、Foot等类。可能如下图所示:

但当然,大多数想要控制或编程这个机器人的人并不想了解这么多细节。他们真正想要的不是一个包含所有功能的复杂工具箱,而是一个能够遵循他们命令的完整机器人。因此,机器人“SDK”实际上可以提供一个控制内部组件并暴露更简单 API 的外观:

不幸的是,外观模式给我们留下了一个如何设计外观 API 和子系统的开放问题。正确回答这个问题并不容易。
参与者
当涉及到它们的类别时,外观模式的参与者相对简单:
-
外观:
Robot定义一组更高层的接口,并使子系统协作。
-
子系统:
MotionController、FeedbackController、Thigh、Shank和Foot实现它们自己的功能,并在必要时与其他子系统进行内部通信。子系统是外观的依赖项,它们不依赖于外观。
模式范围
外观通常充当连接更高层系统和其子系统的枢纽。外观模式的关键在于在依赖项应该或不应该关心的依赖项之间划一条线。
实现
考虑放置一个具有左右腿的机器人,我们实际上可以添加一个名为 Leg 的另一个抽象层来管理 Thigh、Shank 和 Foot。如果我们打算将运动和反馈控制器分别分配给不同的腿,我们也可以将这两个添加到 Leg 中:
class Leg {
thigh: Thigh;
shank: Shank;
foot: Foot;
motionController: MotionController;
feedbackController: FeedbackController;
}
在我们向 Leg 添加更多细节之前,让我们首先定义 MotionController 和 FeedbackController。
MotionController 应该根据一个值或一组值来控制整个腿。在这里,我们将其简化为一个角度,以免被这个不可能的机器人分散注意力:
class MotionController {
constructor(
public leg: Leg
) { }
setAngle(angle: number): void {
let {
thigh,
shank,
foot
} = this.leg;
// ...
}
}
FeedbackController 应该是一个 EventEmitter 的实例,用于报告状态变化或有用的事件:
import { EventEmitter } from 'events';
class FeedbackController extends EventEmitter {
constructor(
public foot: Foot
) {
super();
}
}
现在,我们可以使 Leg 类相对完整:
class Leg {
thigh = new Thigh();
shank = new Shank();
foot = new Foot();
motionController: MotionController;
feedbackController: FeedbackController;
constructor() {
this.motionController =
new MotionController(this);
this.feedbackController =
new FeedbackController(this.foot);
this.feedbackController.on('touch', () => {
// ...
});
}
}
让我们把两条腿放在一起来勾勒出机器人的骨骼:
class Robot {
leftLegMotion: MotionController;
rightLegMotion: MotionController;
leftFootFeedback: FeedbackController;
rightFootFeedback: FeedbackController;
walk(steps: number): void { }
jump(strength: number): void { }
}
我省略了 Thigh、Shank 和 Foot 类的定义,因为我们实际上不会让机器人行走。现在,对于只想通过简单的 API 让机器人行走或跳跃的用户,他们可以通过具有所有连接的 Robot 对象来实现。
后果
Façade 模式松散了客户端和子系统之间的耦合。尽管它并没有完全解耦它们,因为你可能仍然需要与子系统定义的对象一起工作。
Façade 模式通常将客户端的操作转发到适当的子系统,甚至进行大量工作以使它们协同工作。
在 Façade 模式的帮助下,系统和系统内部的关系和结构可以保持清晰和直观。
Flyweight 模式
Flyweight 模式中的轻量级对象是一个无状态对象,它可以被多次共享于对象或类之间。显然,这表明 Flyweight 模式是一种关于内存效率的模式,也许如果对象的构建成本高昂,它还可能影响性能。
以绘制雪花为例。尽管真实的雪花各不相同,但当我们试图将它们绘制到画布上时,我们通常只有有限数量的样式。然而,通过添加诸如大小和变换等属性,我们可以使用有限的雪花样式创建一个美丽的雪景。
由于轻量级对象是无状态的,理想情况下它允许同时进行多个操作。当处理多线程内容时,你可能需要小心。幸运的是,JavaScript 通常单线程,并且如果所有相关代码都是同步的,它将避免这个问题。如果你的代码是异步的,你仍然需要在详细场景中小心处理。
假设我们有一些 Snowflake 类的轻量级对象:

当下雪时,它看起来会是这样:

在上面的图片中,不同风格的雪花是使用不同属性渲染的结果。
我们通常会有样式和图像资源被动态加载,因此我们可以使用 FlyweightFactory 来创建和管理轻量级对象。
参与者
Flyweight 模式最简单的实现有以下参与者:
-
F****lyweight:
Snowflake定义 flyweight 对象的类。
-
Flyweight factory:
FlyweightFactory创建和管理 flyweight 对象。
-
客户端。
存储目标的状态并使用 flyweight 对象来操作这些目标。
在这些参与者中,我们假设可以通过具有不同状态的 flyweight 来完成操作。有时,拥有允许自定义行为的具体 flyweight类也会很有帮助。
模式范围
Flyweight 模式是提高内存效率和性能努力的成果。实现关注实例的无状态性,通常客户端负责管理不同目标的具体状态。
实现
在雪花示例中,使 Flyweight 模式有用的原因是具有相同风格的雪花通常共享相同的图像。图像是消耗加载时间和占用显著内存的部分。
我们从一个假的Image类开始,它假装加载图像:
class Image {
constructor(url: string) { }
}
在我们的例子中,Snowflake类只有一个image属性,这是一个将被许多要绘制的雪花共享的属性。由于实例现在是无状态的,需要从上下文中获取参数进行渲染:
class Snowflake {
image: Image;
constructor(
public style: string
) {
let url = style + '.png';
this.image = new Image(url);
}
render(x: number, y: number, angle: number): void {
// ...
}
}
为了便于访问,flyweights 由一个工厂管理。我们将有一个SnowflakeFactory,它缓存了具有特定风格的创建的雪花对象:
const hasOwnProperty = Object.prototype.hasOwnProperty;
class SnowflakeFactory {
cache: {
[style: string]: Snowflake;
} = {};
get(style: string): Snowflake {
let cache = this.cache;
let snowflake: Snowflake;
if (hasOwnProperty.call(cache, style)) {
snowflake = cache[style];
} else {
snowflake = new Snowflake(style);
cache[style] = snowflake;
}
return snowflake;
}
}
准备好构建块后,我们将实现客户端(Sky),它将下雪:
const SNOW_STYLES = ['A', 'B', 'C'];
class Sky {
constructor(
public width: number,
public height: number
) { }
snow(factory: SnowflakeFactory, count: number) { }
}
我们将用随机位置的随机雪花填充天空。在此之前,让我们创建一个辅助函数,该函数生成一个介于 0 和最大值之间的数字:
function getRandomInteger(max: number): number {
return Math.floor(Math.random() * max);
}
然后完成Sky的snow方法:
snow(factory: SnowflakeFactory, count: number) {
let stylesCount = SNOW_STYLES.length;
for (let i = 0; i < count; i++) {
let style = SNOW_STYLES[getRandomInteger(stylesCount)];
let snowflake = factory.get(style);
let x = getRandomInteger(this.width);
let y = getRandomInteger(this.height);
let angle = getRandomInteger(60);
snowflake.render(x, y, angle);
} }
现在我们可能在天空中有成千上万的雪花,但只创建了三个Snowflake实例。你可以通过存储雪花的状态并动画化下雪来继续这个例子。
后果
Flyweight 模式减少了系统中涉及的对象总数。作为直接结果,它可能节省相当多的内存。当 flyweights 被处理大量目标的客户端使用时,这种节省变得更加显著。
Flyweight 模式也将额外的逻辑引入到系统中。何时使用或何时不使用此模式,从这个角度来看,又是开发效率与运行时效率之间的一种平衡游戏。尽管大多数时候,如果没有充分的理由,我们会选择开发效率。
代理模式
代理模式适用于程序需要了解或干预访问对象的行为时。代理模式有几个详细场景,我们可以通过它们的不同目的来区分这些场景:
-
远程代理:具有操作远程对象接口的代理,例如远程服务器上的数据项
-
虚拟代理:一种管理需要按需加载的昂贵对象的代理
-
保护代理: 控制对目标对象访问的代理,通常它验证权限并验证值
-
智能代理: 在访问目标对象时执行额外操作的代理
在适配器模式的部分,我们使用了工厂方法 open,该方法异步创建对象。作为权衡,我们必须让客户端在对象创建之前等待。
使用代理模式,我们现在可以按需打开数据库并同步创建存储实例。

注意
代理通常用于具有已知方法和属性的单一对象或对象。但是,通过 ES6 提供的新的 Proxy API,我们可以通过了解正在访问哪些方法或属性来执行更有趣的操作。请参阅以下链接获取更多信息:developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy。
参与者
代理模式的参与者包括:
-
代理:
IndexedDBStorage定义接口并实现操作以管理对主题的访问。
-
主题:
IndexedDB要由代理访问的主题。
-
客户端: 通过代理访问主题。
模式范围
尽管与适配器模式具有相似的结构,但代理模式的关键在于干预对目标对象的访问,而不是适配不兼容的接口。有时它可能会改变特定方法的结果或某些属性的值,但这可能是为了回退或异常处理目的。
实现
与纯适配器模式的示例相比,在这个实现中我们将有两个不同之处。首先,我们将使用构造函数创建 IndexedDBStorage 实例,并在需要时打开数据库。其次,我们将为 get 和 set 方法添加一个无用的权限检查。
现在我们调用 get 或 set 方法时,数据库可能已经打开,也可能没有。Promise 是表示可能悬而未决或已解决的值的绝佳选择。考虑以下示例:
let ready = new Promise<string>(resolve => {
setTimeout(() => {
resolve('biu~');
}, Math.random() * 1000);
});
setTimeout(() => {
ready.then(text => {
console.log(text);
});
}, 999);
当第二次超时触发时,很难判断 Promise ready 是否已解决。但整体行为很容易预测:它将在大约 1 秒后记录 'biu~' 文本。通过将 Promise 变量 ready 替换为方法或 getter,它将能够在需要时才开始异步操作。
因此,让我们从创建数据库打开 Promise 的 getter 开始重构 IndexedDBStorage 类:
private dbPromise: Promise<IDBDatabase>;
constructor(
public name: string,
public storeName = 'default'
) { }
private get dbReady(): Promise<IDBDatabase> {
if (!this.dbPromise) {
this.dbPromise =
new Promise<IDBDatabase>((resolve, reject) => {
let request = indexedDB.open(name);
request.onsuccess = event => {
resolve(request.result);
};
request.onerror = event => {
reject(request.error);
};
});
}
return this.dbPromise;
}
现在第一次我们访问属性 dbReady,它将打开数据库并创建一个 Promise,该 Promise 将在数据库打开时得到解决。为了使 get 和 set 方法能够正常工作,我们只需要将我们已实现的内容包装在一个 then 方法中,该方法跟随 dbReady Promise。
首先针对 get 方法:
get<T>(key: string): Promise<T> {
return this
.dbReady
.then(db => new Promise<T>((resolve, reject) => {
let transaction = db.transaction(this.storeName);
let store = transaction.objectStore(this.storeName);
let request = store.get(key);
request.onsuccess = event => {
resolve(request.result);
};
request.onerror = event => {
reject(request.error);
};
}));
}
紧接着是更新后的 set 方法:
set<T>(key: string, value: T): Promise<void> {
return this
.dbReady
.then(db => new Promise<void>((resolve, reject) => {
let transaction = db
.transaction(this.storeName, 'readwrite');
let store = transaction.objectStore(this.storeName);
let request = store.put(value, key);
request.onsuccess = event => {
resolve();
};
request.onerror = event => {
reject(request.error);
};
}));
}
现在我们终于有了IndexedDBStorage属性,它可以真正地替换支持该接口的客户端。我们还将添加一个简单的权限检查,使用一个描述读和写权限的普通对象:
interface Permission {
write: boolean;
read: boolean;
}
然后,我们将分别对get和set方法添加权限检查:
get<T>(key: string): Promise<T> {
if (!this.permission.read) {
return Promise.reject<T>(new Error('Permission denied'));
}
// ...
}
set<T>(key: string, value: T): Promise<void> {
if (!this.permission.write) {
return Promise.reject(new Error('Permission denied'));
}
// ...
}
当你思考权限检查部分时,可能会回想起装饰者模式,装饰者可以用来简化所写的代码。尝试使用装饰器语法自己实现这个权限检查。
后果
代理模式的实现通常可以被视为对特定对象或目标的操作封装。这种封装很容易增加,而不会给客户端带来额外的负担。
例如,一个工作的在线数据库代理可以做的不仅仅是充当一个普通的代理。它可能本地缓存数据和更改,或者在客户端不知情的情况下按计划同步。
摘要
在本章中,我们学习了结构型设计模式,包括组合、装饰者、适配器、桥接、外观、享元和代理。再次我们发现这些模式之间高度相关,甚至在某种程度上相似。
例如,我们将组合模式与装饰者模式混合,适配器模式与代理模式结合,比较了适配器模式和桥接模式。在探索的过程中,我们有时会发现,如果我们考虑到编写更好的代码,我们的代码最终以我们所列出的类似模式结束,这仅仅是一个自然的结果。
以适配器模式和桥接模式为例,当我们试图使两个类合作时,会出现适配器模式;当我们计划提前连接不同的类时,就会使用桥接模式。尽管这些模式之间没有实际的线条,但模式背后的技术通常是有用的。
在下一章中,我们将讨论行为模式,这些模式有助于形成算法和分配责任。
第五章。行为设计模式
如其名所示,行为设计模式是关于对象或类如何相互交互的模式。行为设计模式的实现通常需要某些数据结构来支持系统中的交互。然而,当应用时,行为模式和结构模式关注不同的方面。因此,你可能会发现行为设计模式类别中的模式通常比结构设计模式更简单或更直接。
在本章中,我们将讨论以下一些常见的模式:
-
责任链:组织不同范围的行为
-
命令:通过封装的上下文暴露内部命令
-
备忘录:提供一种管理状态的方法,这些状态不在其所有者之外,而不暴露详细的实现
-
迭代器:提供遍历的通用接口
-
中介者:它将耦合和逻辑上相关的对象分组,并在管理许多对象的系统中使互连更清晰
责任链模式
在许多场景下,我们可能希望应用某些可以从详细范围回退到更一般范围的动作。
一个很好的例子是 GUI 应用程序的帮助信息:当用户请求用户界面某部分的帮助信息时,期望显示尽可能具体的信息。这可以通过不同的实现来完成,对于一个网络开发者来说,最直观的实现可能是事件冒泡。
考虑一个 DOM 结构如下:
<div class="outer">
<div class="inner">
<span class="origin"></span>
</div>
</div>
如果用户点击span.origin元素,一个click事件将从span元素冒泡到文档根(如果useCapture为false):
$('.origin').click(event => {
console.log('Click on `span.origin`.');
});
$('.outer').click(event => {
console.log('Click on `div.outer`.');
});
默认情况下,它将触发前面代码中添加的所有事件监听器。为了在事件被处理时立即停止传播,我们可以调用其stopPropagation方法:
$('.origin').click(event => {
console.log('Click on `span.origin`.');
event.stopPropagation();
});
$('.outer').click(event => {
Console.log('Click on `div.outer`.');
});
虽然点击事件并不完全等同于帮助信息请求,但在自定义事件的支持下,处理带有必要详细或一般信息的帮助信息相当容易。
责任链模式的一个重要实现与错误处理相关。一个原始的例子可能是使用try...catch。考虑以下代码:我们有三个函数:foo、bar和biu,foo被bar调用,而bar被biu调用:
function foo() {
// throw some errors.
}
function bar() {
foo();
}
function biu() {
bar();
}
biu();
在函数bar和biu内部,我们可以做一些错误捕获。假设函数foo抛出两种类型的错误:
function foo() {
let value = Math.random();
if (value < 0.5) {
throw new Error('Awesome error');
} else if (value < 0.8) {
throw new TypeError('Awesome type error');
}
}
在函数bar中,我们希望处理TypeError,并让其他错误抛出:
function bar() {
try {
foo();
} catch (error) {
if (error instanceof TypeError) {
console.log('Some type error occurs', error);
} else {
throw error;
}
}
}
在函数biu中,我们希望添加更通用的处理,捕获所有错误,以便程序不会崩溃:
function biu() {
try {
bar();
} catch (error) {
console.log('Some error occurs', error);
}
}
因此,通过使用try...catch语句,你可能一直在不断地使用责任链模式,而没有注意到它。就像你可能一直在使用其他众所周知的设计模式一样。
如果我们将责任链模式的结构抽象为对象,我们可能会有如图所示的某种结构:

参与者
责任链模式的参与者包括:
-
处理器:定义了处理器与后继者之间的接口以及处理请求的方法。这通过
EventEmitter类和try...catch语法隐式完成。 -
具体处理器:
EventListener、catch块和类版本中的HandlerA/HandlerB。以回调函数、代码块和类等形式定义处理器。 -
客户端:启动通过链传递的请求。
模式作用域
责任链模式本身可以应用于程序中的许多不同作用域。它需要一个多级链来工作,但这个链可以有不同的形式。我们已经玩过事件以及具有结构级别的try...catch语句,这个模式也可以应用于具有逻辑级别的场景。
使用字符串标记不同作用域的对象:
let objectA = {
scope: 'user.installation.package'
};
let objectB = {
scope: 'user.installation'
};
现在我们有两个对象,它们的作用域由字符串指定,通过向这些作用域字符串添加过滤器,我们可以将特定操作应用于一般操作。
实现部分
在这部分,我们将实现我们在责任链模式介绍中提到的类版本。考虑可能请求帮助信息或反馈提示的请求:
type RequestType = 'help' | 'feedback';
interface Request {
type: RequestType;
}
注意
我们在这里使用字符串字面量类型与联合类型。这是 TypeScript 提供的一个非常有用的特性,它与现有的 JavaScript 编码风格兼容。更多信息请见以下链接:www.typescriptlang.org/docs/handbook/advanced-types.html。
这个模式的关键过程之一是遍历处理器的链,并找出对请求可用的最具体的处理器。实现这一目标有几种方法:通过递归调用后继者的handle方法,或者通过单独的逻辑遍历处理器后继链,直到确认请求已被处理。
第二种遍历链的逻辑需要确认请求是否被正确处理。这可以通过请求对象上的状态指示器或handle方法的返回值来完成。
在这部分,我们将采用递归实现。首先,我们希望处理器默认的处理行为是将请求转发给其后继者(如果有的话):
class Handler {
private successor: Handler;
handle(request: Request): void {
if (this.successor) {
this.successor.handle(request);
}
}
}
现在对于HelpHandler,它处理帮助请求,但转发其他请求:
class HelpHandler extends Handler {
handle(request: Request): void {
if (request.type === 'help') {
// Show help information.
} else {
super.handle(request);
}
}
}
FeedbackHandler的代码类似:
class FeedbackHandler extends Handler {
handle(request: Request): void {
if (request.type === 'feedback') {
// Prompt for feedback.
} else {
super.handle(request);
}
}
}
因此,可以通过某种方式构建一个处理程序链。如果请求进入这个链,它将被传递,直到处理程序识别并处理它。然而,在处理请求后,并不一定需要处理所有请求。处理程序可以始终将请求传递下去,无论这个请求是否被处理程序处理。
后果
责任链模式解耦了发出请求的对象与处理这些请求的逻辑之间的连接。发送者假设其请求可能,但不一定,能够得到适当的处理,而无需了解细节。对于某些实现,向链上的特定处理程序添加新的责任也非常容易。这为处理请求提供了显著的灵活性。
除了我们之前提到的例子之外,还有一个重要的try...catch变异,它可以在责任链模式中处理 - Promise。在更小的范围内,链可以表示为:
promise
.catch(TypeError, reason => {
// handles TypeError.
})
.catch(ReferenceError, reason => {
// handles ReferenceError.
})
.catch(reason => {
// handles other errors.
});
注意
ES Promise 对象上的标准catch方法不提供接受错误类型作为参数的重载,但许多实现都做到了。
在更大的范围内,这个链通常出现在代码与第三方库交互时。一种常见的用法是将其他库产生的错误转换为当前项目已知的错误。我们将在本书的后面部分更多地讨论异步代码的错误处理。
命令模式
命令模式涉及将操作封装为可执行的命令,在 JavaScript 中可以是对象或函数的形式。通常情况下,我们可能希望使操作依赖于某些调用者无法访问的上下文和状态。通过将这部分信息与命令一起存储并传递出去,这种情况可以得到妥善处理。
考虑一个极其简单的例子:我们想要提供一个名为wait的函数,它返回一个cancel处理程序:
function wait() {
let $layer = $('.wait-layer');
$layer.show();
return () => {
$layer.hide();
};
}
let cancel = wait();
setTimeout(() => cancel(), 1000);
上述代码中的cancel处理程序正是我们刚才提到的命令。它使用闭包存储上下文($layer),并以函数wait的返回值的形式传递出去。
JavaScript 中的闭包提供了一种非常简单的方式来存储命令上下文和状态,然而,直接的缺点是上下文/状态与命令函数之间的灵活性会受到影响,因为闭包是词法确定的,无法在运行时更改。如果命令只预期在固定的上下文和状态下调用,这将是可行的,但对于更复杂的情况,我们可能需要将它们构建为具有适当数据结构的对象。
下图显示了命令模式中参与者之间的整体关系:

通过命令对象正确地分离上下文和状态,命令模式也可以很好地与享元模式协同工作,如果你想要多次重用命令对象的话。
基于命令模式的其他常见扩展包括撤销支持和具有多个命令的宏。我们将在实现部分稍后玩转这些内容。
参与者
命令模式的参与者包括:
-
命令:定义传递命令的一般接口,如果命令以函数的形式存在,则可以是函数签名。
-
具体命令:定义特定的行为和相关数据结构。它也可以是一个与声明为
Command的签名匹配的函数。在第一个例子中,cancel处理程序就是一个具体命令。 -
上下文:命令关联的上下文或接收者。在第一个例子中,它是
$layer。 -
客户端:创建具体的命令及其上下文。
-
调用者:执行具体命令。
模式范围
命令模式建议在单个应用程序或更大的系统中建议两个独立的部分:客户端和调用者。在简化的示例wait和cancel中,可能很难区分这些部分之间的差异。但界限是清晰的:客户端知道或控制要执行的命令的上下文,而调用者没有访问或不需要关心这些信息。
命令模式的关键是通过存储上下文和状态的命令在这两部分之间进行分离和桥接。
实现
对于编辑器来说,向第三方扩展公开命令以修改文本内容是很常见的。考虑一个包含有关正在编辑的文本文件信息的TextContext和一个与该上下文关联的抽象TextCommand类:
class TextContext {
content = 'text content';
}
abstract class TextCommand {
constructor(
public context: TextContext
) { }
abstract execute(...args: any[]): void;
}
当然,TextContext可以包含更多像语言、编码等信息。您可以在自己的实现中添加它们以获得更多功能。现在我们将创建两个命令:ReplaceCommand和InsertCommand。
class ReplaceCommand extends TextCommand {
execute(index: number, length: number, text: string): void {
let content = this.context.content;
this.context.content =
content.substr(0, index) +
text +
content.substr(index + length);
}
}
class InsertCommand extends TextCommand {
execute(index: number, text: string): void {
let content = this.context.content;
this.context.content =
content.substr(0, index) +
text +
content.substr(index);
}
}
这两个命令具有相似的逻辑,实际上InsertCommand可以被视为ReplaceCommand的一个子集。或者如果我们有一个新的删除命令,那么替换命令可以被视为删除和插入命令的组合。
现在让我们用客户端和调用者组装这些命令:
class Client {
private context = new TextContext();
replaceCommand = new ReplaceCommand(this.context);
insertCommand = new InsertCommand(this.context);
}
let client = new Client();
$('.replace-button').click(() => {
client.replaceCommand.execute(0, 4, 'the');
});
$('.insert-button').click(() => {
client.insertCommand.execute(0, 'awesome ');
});
如果我们更进一步,实际上可以有一个执行其他命令的命令。也就是说,我们可以有宏命令。尽管前面的例子单独来看并不需要创建宏命令,但会有一些场景中宏命令会很有帮助。由于这些命令已经与它们的上下文相关联,宏命令通常不需要有显式的上下文:
interface TextCommandInfo {
command: TextCommand,
args: any[];
}
class MacroTextCommand {
constructor(
public infos: TextCommandInfo[]
) { }
execute(): void {
for (let info of this.infos) {
info.command.execute(...info.args);
}
}
}
后果
命令模式将客户端(知道或控制上下文的人)和调用者(没有访问或不需要关心详细上下文的人)解耦。
它与组合模式配合得很好。考虑我们上面提到的宏命令示例:一个宏命令可以有其他宏命令作为其组件,因此我们将其视为一个组合命令。
命令模式的另一个重要案例是添加对撤销操作的支持。一种直接的方法是将 undo 方法添加到每个命令中。当请求撤销操作时,以相反的顺序调用命令的 undo 方法,我们只能希望每个命令都能正确撤销。然而,这种方法高度依赖于 undo 方法的完美实现,因为每个错误都会累积。为了实现更稳定的撤销支持,可以存储冗余信息或快照。
Memento 模式
在上一节关于命令模式的讨论中,我们提到了一个撤销支持实现,并发现仅基于反转所有操作来实现机制并不容易。然而,如果我们把对象的快照作为它们的历史,我们可能能够避免累积错误并使系统更加稳定。但随后我们遇到了一个问题:我们需要在对象的状态被封装在对象本身时存储对象的状态。
Memento 模式有助于这种情况。当一个备忘录携带了对象在某个时间点的状态时,它还控制了将状态设置回对象的过程。这使得以下示例中的内部状态实现隐藏在撤销机制之后:

在前面的结构中,我们有备忘录实例控制状态恢复。它也可以由保管人,即撤销机制,控制,用于简单的状态恢复情况。
参与者
Memento 模式的参与者包括:
-
Memento:存储对象的当前状态并定义
restore或其他用于将状态恢复到特定对象的 API -
Originator:处理需要存储其内部状态的对象
-
保管人:管理备忘录而不干预其内部内容
模式范围
Memento 模式主要做两件事:它防止保管人了解内部状态实现,并将状态检索和恢复过程从由 Caretaker 或 Originator 管理的状态中解耦。
当状态检索和恢复过程很简单时,如果你已经考虑了解耦的想法,拥有分离的备忘录并不会带来太多帮助。
实现
从一个空的 State 接口和 Memento 类开始。由于我们不希望 Caretaker 了解 Originator 或 Memento 内部状态的细节,我们希望将 Memento 的 state 属性设置为私有。在 Memento 中包含恢复逻辑也有助于这一点,因此我们需要 restore 方法。这样我们就不需要公开接口来读取备忘录内部的状态。
由于 JavaScript 中的对象赋值仅分配其引用,我们希望对状态进行快速复制(假设状态对象是单层的):
interface State { }
class Memento {
private state: State;
constructor(state: State) {
this.state = Object.assign({} as State, state);
}
restore(state: State): void {
Object.assign(state, this.state);
}
}
对于 Originator,我们使用 getter 和 setter 来创建和恢复特定的备忘录:
class Originator {
state: State;
get memento(): Memento {
return new Memento(this.state);
}
set memento(memento: Memento) {
memento.restore(this.state);
}
}
现在,Caretaker将管理与备忘录一起积累的历史:
class Caretaker {
originator: Originator;
history: Memento[] = [];
save(): void {
this.history.push(this.originator.memento);
}
restore(): void {
this.originator.memento = this.history.shift();
}
}
在某些 Memento 模式的实现中,为Originator的实例提供了一个getState方法,以便从备忘录中读取状态。但为了防止除Originator之外的类访问state属性,它可能依赖于诸如友元修饰符之类的语言特性来限制访问(TypeScript 中尚未提供)。
后果
Memento 模式使看护者更容易管理发起者的状态,并使扩展状态检索和恢复成为可能。然而,一个完美的实现可能依赖于我们之前提到的语言特性。使用备忘录也可能带来性能成本,因为它们通常包含冗余信息以换取稳定性。
迭代模式
迭代模式提供了一个通用的接口,用于访问聚合体的内部元素,而不暴露底层的数据结构。典型的迭代器包含以下方法或获取器:
-
first(): 将光标移动到聚合体中的第一个元素 -
next(): 将光标移动到下一个元素 -
end: 一个获取器,返回一个布尔值,指示光标是否在末尾 -
item: 一个获取器,返回当前光标位置的元素 -
index: 一个获取器,返回当前光标所在元素的位置索引
对于具有不同接口或底层结构的聚合体,它们的迭代器通常会有不同的实现,如下面的图示所示:

虽然客户端不需要担心聚合体的结构,但迭代器肯定需要。假设我们拥有构建迭代器所需的一切,可能会有多种创建迭代器的方法。在创建迭代器时,工厂方法被广泛使用,或者如果没有参数要求,则使用工厂获取器。
从 ES6 开始,添加了语法糖for...of,适用于所有具有Symbol.iterator属性的属性对象。这使得开发人员使用自定义列表和其他可迭代的类变得更加容易和舒适。
参与者
迭代模式的部分包括:
-
迭代器:
AbstractListIterator定义了通用的迭代器接口,该接口将遍历不同的聚合体。
-
具体迭代器:
ListIterator、SkipListIterator和ReversedListIterator实现特定的迭代器,用于遍历并跟踪特定的聚合体。
-
聚合体:
AbstractList定义了聚合体的基本接口,迭代器将与之协同工作。
-
具体聚合体:
List和SkipList定义了数据结构和工厂方法/获取器,用于创建关联的迭代器。
模式范围
迭代器模式为遍历聚合提供了统一的接口。在一个不依赖于迭代器的系统中,迭代器提供的主要功能可以很容易地被简单的辅助工具所取代。然而,随着系统的增长,这些辅助工具的可重用性可能会降低。
实现
在这部分,我们将实现一个简单的数组迭代器,以及一个 ES6 迭代器。
简单数组迭代器
让我们从为 JavaScript 数组创建一个迭代器开始,这应该非常简单。首先,是通用接口:
interface Iterator<T> {
first(): void;
next(): void;
end: boolean;
item: T;
index: number;
}
注意
请注意,TypeScript 对 ES6 已经声明了一个名为 Iterator 的接口。考虑将这段代码放入一个命名空间或模块中,以避免冲突。
简单数组迭代器的实现可以是:
class ArrayIterator<T> implements Iterator<T> {
index = 0;
constructor(
public array: T[]
) { }
first(): void {
this.index = 0;
}
next(): void {
this.index++;
}
get end(): boolean {
return this.index >= this.array.length;
}
get item(): T {
return this.array[this.index];
}
}
现在我们需要扩展原生 Array 的原型以添加一个 iterator 属性:
Object.defineProperty(Array.prototype, 'iterator', {
get() {
return new ArrayIterator(this);
}
});
为了使 iterator 成为 Array 实例的有效属性,我们还需要扩展 Array 的接口:
interface Array<T> {
iterator: IteratorPattern.Iterator<T>;
}
注意
这应该写在全球作用域下的命名空间之外。或者如果你在一个模块或环境模块中,你可能想尝试使用 declare global { ... } 来向现有的全局接口添加新属性。
ES6 迭代器
ES6 提供了 for...of 语法糖和其他辅助工具来支持 可迭代 对象,即实现了以下 Iterable 接口的对象:
interface IteratorResult<T> {
done: boolean;
value: T;
}
interface Iterator<T> {
next(value?: any): IteratorResult<T>;
return?(value?: any): IteratorResult<T>;
throw?(e?: any): IteratorResult<T>;
}
interface Iterable<T> {
[Symbol.iterator](): Iterator<T>;
}
假设我们有一个具有以下结构的类:
class SomeData<T> {
array: T[];
}
我们希望使其可迭代。更具体地说,我们希望它反向迭代。正如 Iterable 接口所建议的,我们只需要添加一个具有特殊名称 Symbol.iterator 的方法来创建一个 Iterator。让我们称这个迭代器为 SomeIterator:
class SomeIterator<T> implements Iterator<T> {
index: number;
constructor(
public array: T[]
) {
this.index = array.length - 1;
}
next(): IteratorResult<T> {
if (this.index <= this.array.length) {
return {
value: undefined,
done: true
};
} else {
return {
value: this.array[this.index--],
done: false
}
}
}
}
然后定义 iterator 方法:
class SomeData<T> {
array: T[];
[Symbol.iterator]() {
return new SomeIterator<T>(this.array);
}
}
现在,我们会有 SomeData 与 for...of 一起工作。
注意
迭代器也与生成器配合得很好;有关更多示例,请参阅以下链接:developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols。
影响
迭代器模式将迭代的使用与被迭代的 数据结构解耦。这种做法的直接好处是允许使用可互换的数据类,这些数据类可能具有完全不同的内部结构,例如数组和二叉树。此外,一个数据结构可以通过不同的迭代器以不同的遍历机制进行迭代,从而产生不同的顺序和效率。
在一个系统中实现统一的迭代器接口也可以帮助开发者面对不同的聚合时不会感到困惑。正如我们之前提到的,一些语言(比如你钟爱的 JavaScript)为迭代器提供了语言级别的抽象,使得生活更加简单。
中介者模式
UI 组件和相关对象之间的连接可能非常复杂。面向对象编程将功能分布在对象之间。这使得编码更加容易,逻辑更清晰、更直观;然而,这并不保证可重用性,有时在几天后再次查看代码时可能会感到困难(你可能仍然理解每个单独的操作,但如果网络变得非常复杂,你可能会对相互连接感到困惑)。
考虑一个用于编辑用户资料的页面。这里有独立的输入,如昵称和标语,以及相互关联的输入。以位置选择为例,可能会有一个树状级别的位置,较低级别的选项由较高级别的选择决定。然而,如果这些对象直接由一个巨大的控制器管理,将导致页面可重用性有限。在这种情况下形成的代码也倾向于具有人们难以理解的层次结构。
中介者模式试图通过将耦合元素和对象作为组进行分离,并在一组元素和其他对象之间添加一个导演来解决这个问题,如下面的图所示:

这些对象与其同事形成一个中介者,可以作为一个单一对象与其他对象交互。通过适当的封装,中介者将具有更好的可重用性,因为它具有合适的大小和合理划分的功能。在 Web 前端开发的世界中,有一些概念或实现非常适合中介者模式,如Web 组件和React。
参与者
中介者模式涉及到的参与者包括:
-
中介者:
通常,框架预先定义的抽象或骨架。定义了中介者同事之间通过该接口进行通信的接口。
-
具体中介者:
LocationPicker管理同事并使他们合作,为外部对象提供更高层次的接口。
-
同事类:
CountryInput,ProvinceInput,CityInput定义对其中介者的引用,并通知中介者其变化,并接受中介者发出的修改。
模式范围
中介者模式可以连接项目的许多部分,但不会对轮廓产生直接或巨大的影响。大部分的赞誉归功于中介者带来的可用性提高和更清晰的连接。然而,随着良好的整体架构,中介者模式可以在代码质量优化和项目维护方面提供很大帮助。
实现
使用像 React 这样的库将使实现中介者模式变得非常容易,但到目前为止,我们正在采用一种相对原始的方法,并手动处理更改。让我们考虑我们之前讨论的LocationPicker想要的结果,并希望它包括country、province和city字段:
interface LocationResult {
country: string;
province: string;
city: string;
}
现在我们可以勾勒出类 LocationPicker 的整体结构:
class LocationPicker {
$country = $(document.createElement('select'));
$province = $(document.createElement('select'));
$city = $(document.createElement('select'));
$element = $(document.createElement('div'))
.append(this.$country)
.append(this.$province)
.append(this.$city);
get value(): LocationResult {
return {
country: this.$country.val(),
province: this.$province.val(),
city: this.$city.val()
};
}
}
在我们能够告诉同事们如何合作之前,我们希望添加一个辅助方法 setOptions 用于更新 select 元素中的选项:
private static setOptions(
$select: JQuery,
values: string[]
): void {
$select.empty();
let $options = values.map(value => {
return $(document.createElement('option'))
.text(value)
.val(value);
});
$select.append($options);
}
我个人倾向于编写不依赖于特定实例的方法,即静态方法,并且这适用于 getCountries、getProvincesByCountry 和 getCitiesByCountryAndProvince 这些方法,它们简单地通过函数参数提供的信息返回一个列表(尽管我们实际上不会实现那部分):
private static getCountries(): string[] {
return ['-'].concat([/* countries */]);
}
private static getProvincesByCountry(country: string): string[] {
return ['-'].concat([/* provinces */]);
}
private static getCitiesByCountryAndProvince(
country: string,
province: string
): string[] {
return ['-'].concat([/* cities */]);
}
现在我们可以添加更新 select 元素选项的方法:
updateProvinceOptions(): void {
let country: string = this.$country.val();
let provinces = LocationPicker.getProvincesByCountry(country);
LocationPicker.setOptions(this.$province, provinces);
this.$city.val('-');
}
updateCityOptions(): void {
let country: string = this.$country.val();
let province: string = this.$province.val();
let cities = LocationPicker
.getCitiesByCountryAndProvince(country, province);
LocationPicker.setOptions(this.$city, cities);
}
最后,将这些同事编织在一起,并为 change 事件添加监听器:
constructor() {
LocationPicker
.setOptions(this.$country, LocationPicker.getCountries());
LocationPicker.setOptions(this.$province, ['-']);
LocationPicker.setOptions(this.$city, ['-']);
this.$country.change(() => {
this.updateProvinceOptions();
});
this.$province.change(() => {
this.updateCityOptions();
});
}
后果
调解器模式,就像许多其他设计模式一样,将一个 100 级的问题降级为两个 10 级的问题,并分别解决。一个设计良好的调解者通常具有适当的大小,并且通常倾向于在未来被重用。例如,我们可能不想将昵称输入与国家、省份和城市输入放在一起,因为这个组合在其他情况下不太可能出现(这意味着它们不是强相关的)。
随着项目的演变,调解者可能增长到一个不再高效的大小。因此,一个设计良好的调解者也应该考虑时间的维度。
摘要
在本章中,我们讨论了不同范围和不同场景的一些常见行为模式。责任链模式(Chain of Responsibility Pattern)和命令模式(Command Pattern)可以应用于相对广泛的范围,而本章中提到的其他模式通常更关注与对象和类直接相关的范围。
在本章中,我们讨论的行为模式与之前走过的创建型模式和结构型模式相比,彼此之间更不相似。一些行为模式可能相互竞争,但许多模式可以相互合作。例如,我们讨论了使用备忘录模式实现撤销支持的命令模式。许多其他模式可能并行合作,并完成自己的部分。
在下一章中,我们将继续讨论其他有用且广泛使用的其他行为设计模式。
第六章. 行为设计模式:持续
在上一章中,我们已经讨论了一些行为设计模式。在本章中,我们将继续讨论这一类别中的更多模式,包括:策略模式、状态模式、模板方法模式、观察者模式和访问者模式。
许多这些模式都共享同一个理念:统一形状,变化细节。以下是一个简要概述:
-
策略模式 和 模板模式: 定义了相同的算法轮廓
-
状态模式: 为具有相同接口的不同状态的对象提供不同的行为
-
观察者模式: 提供处理主题变化和通知观察者的统一过程
-
访问者模式: 有时与策略模式做相似的工作,但避免了策略模式处理许多不同类型对象可能需要的过于复杂的接口
本章将要讨论的这些模式可以在不同的范围内应用,就像其他类别中的许多模式一样。
策略模式
程序通常会有类似的轮廓来处理不同的目标,使用不同的详细算法。策略模式封装了这些算法,并在共享轮廓中使它们可互换。
考虑数据同步中冲突的合并过程,这是我们之前在第二章中讨论的,即《增加复杂性的挑战》。在重构之前,代码是这样的:
if (type === 'value') {
// ...
} else if (type === 'increment') {
// ...
} else if (type === 'set') {
// ...
}
但后来我们发现,我们可以从同步过程的不同阶段提取相同的轮廓,并将它们封装为不同的策略。重构后,代码的轮廓如下:
let strategy = strategies[type];
strategy.operation();
有时在 JavaScript 中,我们有很多种方式来组合和组织这些策略对象或类。策略模式的可能结构可以是:

在这个结构中,客户端负责从表中获取特定的策略并应用当前阶段的操作。
另一种结构是使用上下文对象,并让它们控制自己的策略:

因此,客户端只需要将特定的上下文与相应的策略相连接。
参与者
我们已经提到了策略模式的两种可能结构,因此让我们分别讨论参与者。对于第一种结构,参与者包括以下内容:
-
策略
定义策略对象或类的接口。
-
具体策略:
ConcreteStrategyA和ConcreteStrategyB实现
Strategy接口定义的具体策略操作。 -
策略管理器:
策略定义一个数据结构来管理策略对象。在示例中,它只是一个简单的哈希表,使用数据类型名称作为键,策略对象作为值。根据需求,它可能更复杂:例如,使用匹配模式或条件。
-
目标
应用策略对象中定义的算法的目标。
-
客户端
使目标和策略合作。
第二个结构的参与者包括以下内容:
-
策略和具体策略
与上一节相同。
-
上下文
定义应用策略对象的引用。为客户端提供相关的方法或属性获取器以进行操作。
-
客户端
管理上下文对象。
模式范围
策略模式通常应用于小或中型的范围。它提供了一种封装算法的方法,使得在相同的轮廓下管理这些算法变得更加容易。有时,策略模式也可以是整个解决方案的核心,一个很好的例子是我们一直在使用的同步实现。在这种情况下,策略模式构建了插件之间的桥梁,使得系统可扩展。但大多数时候,策略模式的基本工作是将具体的策略、上下文或目标解耦。
实现方式
实现开始于定义我们将要使用的对象的接口。我们有两种目标类型,字符串字面量类型'a'和'b'。类型'a'的目标有一个类型为string的result属性,而类型'b'的目标有一个类型为number的value属性。
我们将拥有的接口看起来是这样的:
type TargetType = 'a' | 'b';
interface Target {
type: TargetType;
}
interface TargetA extends Target {
type: 'a';
result: string;
}
interface TargetB extends Target {
type: 'b';
value: number;
}
interface Strategy<TTarget extends Target> {
operationX(target: TTarget): void;
operationY(target: TTarget): void;
}
现在我们将定义没有构造函数的具体策略对象:
let strategyA: Strategy<TargetA> = {
operationX(target) {
target.result = target.result + target.result;
},
operationY(target) {
target.result = target
.result
.substr(Math.floor(target.result.length / 2));
}
};
let strategyB: Strategy<TargetB> = {
operationX(target) {
target.value = target.value * 2;
},
operationY(target) {
target.value = Math.floor(target.value / 2);
}
};
为了让客户端更容易获取这些策略,我们将它们放入哈希表中:
let strategies: {
[type: string]: Strategy<Target>
} = {
a: strategyA,
b: strategyB
};
现在我们可以让它们与不同类型的目标一起工作:
let targets: Target[] = [
{ type: 'a' },
{ type: 'a' },
{ type: 'b' }
];
for (let target of targets) {
let strategy = strategies[target.type];
strategy.operationX(target);
strategy.operationY(target);
}
后果
策略模式使得在新的类别下为上下文或目标添加算法变得更容易可预见。它还通过隐藏行为选择中的琐碎分支,使流程的轮廓更加清晰。
然而,Strategy接口定义的算法的抽象可能会在尝试添加更多策略并满足它们的参数要求时不断增长。这对于管理目标和策略的客户端来说可能是一个问题。但对于其他结构,其中策略对象的引用是由上下文本身存储的,我们可以设法权衡可互换性。这会导致我们将在本章后面讨论的访问者模式。
正如我们之前提到的,如果有一个可扩展的策略管理器可用,或者上下文的客户端被设计为可扩展的,策略模式也可以提供显著的扩展性。
状态模式
当对象处于不同状态时,它们的行为可能完全不同。让我们先考虑一个简单的例子。考虑在两种状态下渲染和与自定义按钮交互:启用和禁用。当按钮处于启用状态时,它会亮起并改变鼠标悬停时的样式为活动状态,当然,它也处理点击事件;当禁用时,它会变暗并且不再关心鼠标事件。
我们可以想象一个具有两个操作的抽象:render(带有表示鼠标是否悬停的参数)和click;以及两个状态:启用和禁用。我们甚至可以进一步细分,拥有活动状态,但在我们的情况下这并不必要。

现在我们可以实现具有render和click方法的StateEnabled,同时实现只有render方法的StateDisabled,因为它不关心hover参数。在这个例子中,我们期望每个状态的方法都是可调用的。因此,我们可以有一个抽象类State,其中包含空的render和click方法。
参与者
状态模式的参与者包括以下内容:
-
状态
定义了正在内部切换的状态对象的接口。
-
具体状态:
StateEnabled和StateDisabled实现与上下文特定状态相对应的行为的
State接口。可能有一个可选的对其上下文的引用。 -
上下文
管理对不同状态的引用,并执行在活动状态上定义的操作。
模式范围
状态模式通常适用于具有功能规模的代码范围。它不指定谁要转移上下文的状态:这可能既可以是上下文本身,也可以是状态方法,或者控制上下文的代码。
实现
从State接口开始(如果存在要共享的操作或逻辑,它也可以是一个抽象类):
interface State {
render(hover: boolean): void;
click(): void;
}
定义了State接口后,我们可以转向Context并勾勒其轮廓:
class Context {
$element: JQuery;
state: State;
private render(hover: boolean): void {
this.state.render(hover);
}
private click(): void {
this.state.click();
}
onclick(): void {
console.log('I am clicked.');
}
}
现在我们将实现两个状态,StateEnabled和StateDisabled。首先,让我们处理StateEnabled,它关心hover状态并处理click事件:
class StateEnabled implements State {
constructor(
public context: Context
) { }
render(hover: boolean): void {
this
.context
.$element
.removeClass('disabled')
.toggleClass('hover', hover);
}
click(): void {
this.context.onclick();
}
}
接下来,对于StateDisabled,它只是忽略hover参数,当click事件发生时什么也不做:
class StateDisabled implements State {
constructor(
public context: Context
) { }
render(): void {
this
.context
.$element
.addClass('disabled')
.removeClass('hover');
}
click(): void {
// Do nothing.
}
}
现在我们已经准备好了启用和禁用的状态类。由于这些类的实例与上下文相关联,因此每当一个新的Context被初始化时,我们需要初始化每个状态:
class Context {
...
private stateEnabled = new StateEnabled(this);
private stateDisabled = new StateDisabled(this);
state: State = this.stateEnabled;
...
}
在调用活动状态的每个操作时传递上下文,也可以使用轻量级对象。
现在让我们通过监听和转发适当的事件来完成Context:
constructor() {
this
.$element
.hover(
() => this.render(true),
() => this.render(false)
)
.click(() => this.click());
this.render(false);
}
后果
状态模式减少了上下文对象可能多个方法中的条件分支。作为权衡,引入了额外的状态对象,尽管这通常不会是一个大问题。
状态模式中的上下文对象通常将操作委托给当前状态对象并转发它们。因此,具体状态定义的操作可能可以访问上下文本身。这使得使用轻量级对象重用状态对象成为可能。
模板方法模式
当我们谈论子类化或继承时,建筑通常是自下而上建造的。子类继承基础并提供更多功能。然而,有时反转结构也可能很有用。
考虑策略模式,它定义了过程的轮廓,并具有可互换的算法作为策略。如果我们在这个类层次结构下应用这种结构,我们将得到模板方法模式。
模板方法是一个抽象方法(可选带有默认实现),在更大过程轮廓下充当占位符。子类覆盖或实现相关方法以修改或完成行为。想象一下 TextReader 的骨架,我们期望其子类能够处理来自不同存储介质的文本文件,检测不同的编码并读取所有文本。我们可能考虑以下结构:

在这个例子中,TextReader 有一个名为 readAllText 的方法,通过两个步骤读取资源中的所有文本:从资源中读取所有字节(readAllBytes),然后使用特定的编码对这些字节进行解码(decodeBytes)。
结构还暗示了在实现模板方法的具体类之间共享实现的可能性。我们可能创建一个扩展 TextReader 并实现 decodeBytes 方法的抽象类 AsciiTextReader。然后构建扩展 AsciiTextReader 并实现 readAllBytes 方法的具体类 FileAsciiTextReader 和 HttpAsciiTextReader,以处理不同存储介质上的资源。
参与者
模板方法模式的参与者包括以下内容:
-
抽象类:
TextReader定义模板方法的签名,以及将一切编织在一起的算法的轮廓。
-
具体类:
AsciiTextReader,FileAsciiTextReader和HttpAsciiTextReader实现抽象类中定义的模板方法。在这个例子中,典型的具体类是
FileAsciiTextReader和HttpAsciiTextReader。然而,与定义算法轮廓相比,定义算法轮廓在分类中更为重要。
模式范围
模板方法模式通常应用于相对较小的范围。它提供了一种可扩展的方式来实现功能,并避免一系列算法的上层结构中的冗余。
实现
继承层次结构有两个级别:AsciiTextReader 将作为另一个抽象类子类化 TextReader。它实现了 decodeBytes 方法,但将 readAllBytes 留给其子类。从 TextReader 开始:
abstract class TextReader {
async readAllText(): Promise<string> {
let bytes = await this.readAllBytes();
let text = this.decodeBytes(bytes);
return text;
}
abstract async readAllBytes(): Promise<Buffer>;
abstract decodeBytes(bytes: Buffer): string;
}
提示
我们正在使用async和await与 Promise 一起,这些功能将在 ECMAScript 中到来。请参阅以下链接以获取更多信息:github.com/Microsoft/TypeScript/issues/1664 tc39.github.io/ecmascript-asyncawait/
现在,让我们将TextReader子类化为AsciiTextReader,它仍然保持抽象:
abstract class AsciiTextReader extends TextReader {
decodeBytes(bytes: Buffer): string {
return bytes.toString('ascii');
}
}
对于FileAsciiTextReader,我们需要导入 Node.js 的文件系统(fs)模块来执行文件读取:
import * as FS from 'fs';
class FileAsciiTextReader extends AsciiTextReader {
constructor(
public path: string
) {
super();
}
async readAllBytes(): Promise<Buffer> {
return new Promise<Buffer>((resolve, reject) => {
FS.readFile(this.path, (error, bytes) => {
if (error) {
reject(error);
} else {
resolve(bytes);
}
});
});
}
}
对于HttpAsciiTextReader,我们将使用流行的request包来发送 HTTP 请求:
import * as request from 'request';
class HttpAsciiTextReader extends AsciiTextReader {
constructor(
public url: string
) {
super();
}
async readAllBytes(): Promise<Buffer> {
return new Promise<Buffer>((resolve, reject) => {
request(this.url, {
encoding: null
}, (error, bytes, body) => {
if (error) {
reject(error);
} else {
resolve(body);
}
});
});
}
}
小贴士
两个具体的读取实现都将解析函数传递给 Promise 构造函数,以将异步 Node.js 风格回调转换为 Promise。有关更多信息,请阅读有关 Promise 构造函数的更多内容:developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise。
后果
与策略模式相比,模板方法模式为在现有系统之外构建具有相同算法轮廓的对象提供了便利。这使得模板方法模式成为构建工具类而不是内置固定过程的有用方式。
但模板方法模式由于没有*manager*,运行时灵活性较低。它还依赖于使用这些对象的客户端来完成工作。并且由于模板方法模式的实现依赖于子类化,它很容易导致在不同分支上有类似代码的层次结构。尽管可以通过使用像mixin这样的技术来优化。
观察者模式
观察者模式是一个重要的模式,它背后有一个在软件工程中非常重要的想法。它通常是 MVC 架构及其变体的关键部分。
如果你曾经编写过没有 Angular 或 React 等框架的丰富用户界面应用程序,或者编写过解决方案,你可能会在更改 UI 元素的类名和其他属性时遇到困难。更具体地说,控制同一组元素属性的相关代码位于与相关事件监听器相关的每个分支中,只是为了确保元素被正确更新。
考虑一个“执行”按钮,其disabled属性应由连接到服务器的WebSocket连接的状态以及当前活动项是否完成来决定。每当连接或活动项的状态更新时,我们都需要相应地更新按钮。最“方便”的方法可能是将两个有些相似的代码组放入两个事件监听器中。但这样,随着更多相关对象的参与,相似代码的数量会不断增加。
在这个“执行”按钮的例子中,问题在于,控制按钮的代码的行为是由原始事件驱动的。管理不同事件之间的连接和行为的大量工作直接由编写该代码的开发者承担。不幸的是,在这种情况下,复杂性呈指数增长,这意味着它可能很容易超过我们的脑容量。以这种方式编写代码可能会导致更多错误,并使维护更容易引入新的错误。
但美妙的是,我们可以找到乘积因子并输出所需的结果,而那些因子的参考是相关状态的一组。仍然以“执行”按钮的例子来说,按钮所关心的是:连接状态和活动项状态(假设它们是布尔值connected和loaded)。我们可以将代码分为两部分:一部分改变这些状态,另一部分更新按钮:
let button = document.getElementById('do-button');
let connected = false;
let loaded = false;
function updateButton() {
let disabled = !connected && !loaded;
button.disabled = disabled;
}
connection.on('statuschange', event => {
connected = event.connected;
updateButton();
});
activeItem.on('statuschange', event => {
loaded = event.loaded;
updateButton();
});
之前的示例代码已经包含了观察者模式的胚胎:主体(状态connected和loaded)和观察者(updateButton函数),尽管我们仍然需要在任何相关状态改变时手动调用updateButton。改进的结构可能看起来像以下图示:

但就像我们一直在讨论的例子一样,在许多情况下,观察者关心不止一个状态。单独将观察者附加到主体上可能不太令人满意。
解决这个问题的方法可以是多状态观察对象,为了实现这一点,我们可以形成一个包含子观察对象的复合观察对象。如果一个观察对象收到notify调用,它会唤醒其观察者,并同时通知其父对象。因此,观察者可以为多个状态的变化通知附加一个复合观察对象。
然而,创建复合对象本身的过程仍然可能令人烦恼。在动态编程语言如 JavaScript 中,我们可能有一个包含特定状态处理通知和直接通过隐式创建观察对象的状态管理器:
let stateManager = new StateManager({
connected: false,
loaded: false,
foo: 'abc',
bar: 123
});
stateManager.on(['connected', 'loaded'], () => {
let disabled =
!stateManager.connected && !stateManager.loaded;
button.disabled = disabled;
});
注意
在许多 MV*框架中,要观察的状态是通过内置解析器或类似机制从相关表达式自动分析的。
现在结构变得更简单了:

参与者
我们已经讨论了观察者模式的基本结构,包括主体和观察者,以及具有隐式主体的变体。基本结构的参与者包括以下内容:
-
主体
观察对象。定义了
attach或notify观察者的方法。观察对象也可以是一个包含子观察对象的复合对象,这允许使用相同的接口观察多个状态。 -
具体观察对象:
ConnectedSubject和LoadedSubject包含与主体相关的状态,并实现获取和设置其状态的方法或属性。
-
观察者
定义了一个对象接口,当观察者通知时,该对象会做出反应。在 JavaScript 中,它也可以是一个函数的接口(或签名)。
-
具体观察者:
DoButtonObserver定义了对观察的主题通知做出反应的动作。可以是一个与定义的签名匹配的回调函数。
在变体版本中,参与者包括以下内容:
-
状态管理器
管理一个复杂、可能的多级状态对象,包含多个状态。定义了将观察者附加到主题的接口,并在主题发生变化时通知这些观察者。
-
具体主题
特定状态的关键。例如,字符串
"connected"可能代表状态stateManager.connected,而字符串"foo.bar"可能代表状态stateManager.foo.bar。
观察者和具体观察者基本上与前面结构中描述的相同。但现在观察者是由状态管理器而不是主题对象来通知的。
模式范围
观察者模式是一种可以轻松构建项目一半的模式。在 MV*架构中,观察者模式可以将视图与业务逻辑解耦。视图的概念也可以应用于其他与显示信息相关的场景。
实现
我们提到的这两种结构都不难实现,但在生产代码中应该考虑更多细节。我们将采用具有中央状态管理器的第二种实现。
注意
为了简化实现,我们将使用get和set方法通过键访问特定的状态。但许多可用的框架可能通过 getter 和 setter 或其他机制来处理这些。
注意
要了解像 Angular 这样的框架如何处理状态变化,请阅读它们的文档或源代码(如有必要)。
我们将让StateManager继承EventEmitter,这样我们就不必过多关注像多个监听器这样的问题。但因为我们接受多个状态键作为主题,所以将为on方法添加一个重载。因此,StateManager的轮廓将如下所示:
type Observer = () => void;
class StateManager extends EventEmitter{
constructor(
private state: any
) {
super();
}
set(key: string, value: any): void { }
get(key: string): any { }
on(state: string, listener: Observer): this;
on(states: string[], listener: Observer): this;
on(states: string | string[], listener: Observer): this { }
}
小贴士
你可能已经注意到方法on的返回类型是this,这可能意味着会持续引用当前实例的类型。类型this对于链式调用方法非常有帮助。
键将是"foo"和"foo.bar",我们需要将键分割成单独的标识符,以便从state对象中访问值。让我们有一个私有方法_get,它接受一个identifiers数组作为输入:
private _get(identifiers: string[]): any {
let node = this.state;
for (let identifier of identifiers) {
node = node[identifier];
}
return node;
}
现在,我们可以在_get上实现方法get:
get(key: string): any {
let identifiers = key.split('.');
return this._get(identifiers);
}
对于方法set,我们可以获取要设置的属性最后一个标识符的父对象,这样就可以像这样工作:
set(key: string, value: any): void {
let identifiers = key.split('.');
let lastIndex = identifiers.length - 1;
let node = this._get(identifiers.slice(0, lastIndex));
node[identifiers[lastIndex]] = value;
}
但还有一件事,我们需要通知正在观察特定主题的观察者:
set(key: string, value: any): void {
let identifiers = key.split('.');
let lastIndex = identifiers.length - 1;
let node = this._get(identifiers.slice(0, lastIndex));
node[identifiers[lastIndex]] = value;
for (let i = identifiers.length; i > 0; i--) {
let key = identifiers.slice(0, i).join('.');
this.emit(key);
}
}
当我们完成通知部分后,让我们为on方法添加一个重载以支持多个键:
on(state: string, listener: Observer): this;
on(states: string[], listener: Observer): this;
on(states: string | string[], listener: Observer): this {
if (typeof states === 'string') {
super.on(states, listener);
} else {
for (let state of states) {
super.on(state, listener);
}
}
return this;
}
问题解决。现在我们有一个适用于简单场景的状态管理器。
后果
观察者模式解耦了主题和观察者。虽然观察者可能同时观察主题的多个状态,但它通常不关心哪个状态触发了通知。因此,观察者可能会进行不必要的更新,实际上对视图没有任何作用。
然而,对性能的影响在大多数情况下可能是微不足道的,甚至不需要提及它带来的好处。
通过将视图和逻辑分开,观察者模式可以显著减少可能的分支。这将有助于消除视图和逻辑耦合部分产生的错误。因此,通过正确应用观察者模式,项目将变得更加健壮且易于维护。
然而,还有一些细节我们需要注意:
-
更新状态的观察者可能导致循环调用。
-
对于像集合这样的更复杂的数据结构,重新渲染所有内容可能会很昂贵。在这种情况下,观察者可能需要更多关于变化的信息,以便只执行必要的更新。像 React 这样的视图实现以另一种方式做这件事;它们引入了一个称为虚拟 DOM的概念。通过在重新渲染实际的 DOM(这通常可能是性能瓶颈)之前更新和比较虚拟 DOM,它为不同的数据结构提供了一个相对通用的解决方案。
访问者模式
访问者模式提供了一个统一的接口来访问不同的数据或对象,同时允许具体访问者中的详细操作有所不同。访问者模式通常与组合一起使用,并且它被广泛用于遍历数据结构,如抽象语法树(AST)。但为了使那些不熟悉编译器内容的人更容易理解,我们将提供一个更简单的例子。
考虑一个包含多个要渲染元素的类似 DOM 的树:
[
Text {
content: "Hello, "
},
BoldText {
content: "TypeScript"
},
Text {
content: "! Popular editors:\n"
},
UnorderedList {
items: [
ListItem {
content: "Visual Studio Code"
},
ListItem {
content: "Visual Studio"
},
ListItem {
content: "WebStorm"
}
]
}
]
在 HTML 中的渲染结果将如下所示:

虽然在 Markdown 中看起来是这样的:

访问者模式允许同一类操作在相同的地方进行编码。我们将有具体的访问者,HTMLVisitor和MarkdownVisitor,它们分别通过遍历和递归地转换不同的节点来承担转换不同节点的责任。被访问的节点有一个accept方法,用于接受访问者以执行转换。访问者模式的整体结构可以分为两部分,第一部分是访问者抽象及其具体子类:

第二部分是待访问节点的抽象及其具体子类:

参与者
访问者模式的参与者包括以下内容:
-
访问者:
NodeVisitor定义与每个元素类对应的操作接口。在具有静态类型和方法重载的语言中,方法名可以统一。但在 JavaScript 中,它需要额外的运行时检查,因此我们将使用不同的方法名来区分它们。操作方法通常以
visit命名,但在这里我们使用append,因为它与上下文更相关。 -
具体访问者:
HTMLVisitor和MarkdownVisitor实现具体访问者的每个操作,并处理任何内部状态。
-
元素:
Node定义接受访问者实例的元素接口。方法通常命名为
accept,尽管在这里我们使用appendTo以更好地匹配上下文。元素本身可以是复合的,并通过其子元素传递访问者。 -
具体元素:
Text、BoldText、UnorderedList和ListItem实现
accept方法,并从与元素实例对应的访问者实例调用该方法。 -
客户端:
列出元素并对其应用访问者。
模式范围
访问者模式可以在系统中形成一个大型功能。对于某些分类的程序,它也可能形成核心架构。例如,Babel 使用访问者模式进行 AST 转换,而 Babel 的插件实际上是一个可以访问和转换它关心的元素的访问者。
实现
我们将实现 HTMLVisitor 和 MarkdownVisitor,它们可以将节点转换为文本,正如我们之前讨论的那样。从上层抽象开始:
interface Node {
appendTo(visitor: NodeVisitor): void;
}
interface NodeVisitor {
appendText(text: Text): void;
appendBold(text: BoldText): void;
appendUnorderedList(list: UnorderedList): void;
appendListItem(item: ListItem): void;
}
继续使用执行类似操作的具体系列节点,Text 和 BoldText:
class Text implements Node {
constructor(
public content: string
) { }
appendTo(visitor: NodeVisitor): void {
visitor.appendText(this);
}
}
class BoldText implements Node {
constructor(
public content: string
) { }
appendTo(visitor: NodeVisitor): void {
visitor.appendBold(this);
}
}
然后列出内容:
class UnorderedList implements Node {
constructor(
public items: ListItem[]
) { }
appendTo(visitor: NodeVisitor): void {
visitor.appendUnorderedList(this);
}
}
class ListItem implements Node {
constructor(
public content: string
) { }
appendTo(visitor: NodeVisitor): void {
visitor.appendListItem(this);
}
}
现在我们有了要访问的结构元素,我们将开始实现具体的访问者。这些访问者将有一个 output 属性用于转换后的字符串。HTMLVisitor 首先开始:
class HTMLVisitor implements NodeVisitor {
output = '';
appendText(text: Text) {
this.output += text.content;
}
appendBold(text: BoldText) {
this.output += `<b>${text.content}</b>`;
}
appendUnorderedList(list: UnorderedList) {
this.output += '<ul>';
for (let item of list.items) {
item.appendTo(this);
}
this.output += '</ul>';
}
appendListItem(item: ListItem) {
this.output += `<li>${item.content}</li>`;
}
}
注意 appendUnorderedList 中的循环,它处理其自己的列表项的访问。
类似的结构也适用于 MarkdownVisitor:
class MarkdownVisitor implements NodeVisitor {
output = '';
appendText(text: Text) {
this.output += text.content;
}
appendBold(text: BoldText) {
this.output += `**${text.content}**`;
}
appendUnorderedList(list: UnorderedList) {
this.output += '\n';
for (let item of list.items) {
item.appendTo(this);
}
}
appendListItem(item: ListItem) {
this.output += `- ${item.content}\n`;
}
}
现在基础设施已经就绪,让我们创建从开始就想象中的树状结构:
let nodes = [
new Text('Hello, '),
new BoldText('TypeScript'),
new Text('! Popular editors:\n'),
new UnorderedList([
new ListItem('Visual Studio Code'),
new ListItem('Visual Studio'),
new ListItem('WebStorm')
])
];
最后,使用访问者构建输出:
let htmlVisitor = new HTMLVisitor();
let markdownVisitor = new MarkdownVisitor();
for (let node of nodes) {
node.appendTo(htmlVisitor);
node.appendTo(markdownVisitor);
}
console.log(htmlVisitor.output);
console.log(markdownVisitor.output);
后果
策略模式和访问者模式都可以应用于处理对象的场景。但策略模式依赖于客户端处理所有相关参数和上下文,如果不同对象的预期行为差异很大,这会使抽象变得难以精致。访问者模式通过解耦访问动作和要执行的操作来解决这个问题。
通过传递不同的访问者,访问者模式可以对对象应用不同的操作,而无需更改其他代码,尽管这通常意味着添加新元素,并会导致向抽象访问者和所有其具体子类添加相关操作。
像前一个示例中的 NodeVisitor 这样的访问者可能自身存储状态(在那个例子中,我们存储了转换后节点的输出)并且可以根据累积的状态应用更高级的操作。例如,可以确定已经添加到输出中的内容,因此我们可以根据当前正在访问的节点应用不同的行为。
然而,为了完成某些操作,可能需要从元素中暴露额外的公共方法。
摘要
在本章中,我们已经讨论了其他行为设计模式,作为前一章的补充,包括策略(Strategy)、状态(State)、模板方法(Template Method)、观察者(Observer)和访问者(Visitor)模式。
策略模式非常常见且有用,它可能在一个项目中出现多次,形式各异。你可能不知道你每天都在一个日常框架中使用观察者模式。
在了解了那些模式之后,你可能会发现每个模式背后都有许多共同的想法。值得思考这些想法背后的东西,甚至让这些想法在你的脑海中形成轮廓。
在下一章中,我们将继续介绍一些与 JavaScript 和 TypeScript 相关的实用模式,以及这些语言的重要场景。
第七章. JavaScript 和 TypeScript 中的模式和架构
在前四章中,我们介绍了常见的和经典的设计模式,并在 JavaScript 或 TypeScript 中讨论了一些变体。在本章中,我们将继续探讨一些与语言紧密相关且具有常见应用的架构和模式。我们没有很多页面来扩展,当然也不能在一章中涵盖所有内容,所以请把它当作开胃菜,并自由探索更多。
本章中的许多主题都与异步编程相关。我们将从基于 Promise 的 Node.js Web 架构开始。这是一个较大的主题,其中包含有趣的想法,包括响应和权限的抽象以及错误处理技巧。然后我们将讨论如何使用ECMAScript(ES)模块语法组织模块。本章将以几个有用的异步技术结束。
总体来说,本章将涵盖以下主题:
-
与 Promise 相关的架构和技术
-
Web 应用程序中响应和权限的抽象
-
将项目模块化以实现扩展
-
其他有用的异步技术
注意
再次强调,由于篇幅有限,一些相关代码被大大简化,实际上只能应用其理念本身。
基于 Promise 的 Web 架构
为了更好地理解 Promise 与传统回调之间的区别,考虑以下异步任务:
function process(callback) {
stepOne((error, resultOne) => {
if (error) {
callback(error);
return;
}
stepTwo(resultOne, (error, resultTwo) => {
if (error) {
callback(error);
return;
}
callback(undefined, resultTwo + 1);
});
});
}
如果我们以上述方式用 Promise 风格编写,将会如下所示:
function process() {
return stepOne()
.then(result => stepTwo(result))
.then(result => result + 1);
}
正如前面的例子所示,Promise 使得使用扁平链而不是嵌套回调来编写异步操作变得简单且自然。但 Promise 最令人兴奋的事情可能是它为错误处理带来的好处。在基于 Promise 的架构中,抛出错误可以是安全和愉快的。你不必在链式调用异步操作时显式处理错误,这使得错误发生的可能性降低。
随着与 ES6 兼容运行时的使用日益增长,Promise 已经作为默认选项存在。实际上,我们有很多 Promise 的 polyfills(包括我用 TypeScript 编写的*ThenFail*),因为编写 JavaScript 的人大致上指的是创造了轮子的人群。
Promise 与其他 Promise 协同工作得很好:
-
一个与Promises/A+ 兼容的实现应该与其他Promises/A+ 兼容的实现一起工作
-
Promise 在基于 Promise 的架构中表现最佳
如果你刚开始接触 Promise,你可能会抱怨在使用基于回调的项目中使用 Promise。使用 Promise 库提供的异步助手,如Promise.each(非标准),是人们尝试 Promise 的常见原因,但结果证明他们有更好的替代方案(对于基于回调的项目),例如流行的async库。
使你决定切换的原因不应该是这些辅助工具(因为老式的回调也有很多),而应该是一个更容易处理错误或利用基于承诺的 ES async/await 功能的方法,这是基于承诺的。
承诺化现有模块或库
虽然承诺在基于承诺的架构中表现最好,但仍然可以通过承诺化现有模块或库以较小的范围开始使用承诺。
让我们以 Node.js 风格的回调为例:
import * as FS from 'fs';
FS.readFile('some-file.txt', 'utf-8', (error, text) => {
if (error) {
console.error(error);
return;
}
console.log('Content:', text);
});
你可能期望被承诺化的 readFile 函数看起来像以下这样:
FS
.readFile('some-file.txt', 'utf-8')
.then(text => {
console.log('Content:', text);
})
.catch(reason => {
Console.error(reason);
});
实现被承诺化的函数 readFile 可以很简单:
function readFile(path: string, options: any): Promise<string> {
return new Promise((resolve, reject) => {
FS.readFile(path, options, (error, result) => {
if (error) {
reject(error);
} else {
resolve(result);
}
});
});
}
注意
我在这里使用类型 any 来减少代码示例的大小,但在实际操作中,我建议尽可能不要使用 any。
有一些库能够自动承诺化方法。不过,不幸的是,如果没有可用的承诺化版本,你可能需要自己编写声明文件来承诺化这些方法。
Express 中的视图和控制器
我们中的许多人可能已经与 Express 等框架一起工作过。这就是我们在 Express 中使用 JSON 渲染视图或响应的方式:
import * as Path from 'path';
import * as express from 'express';
let app = express();
app.set('engine', 'hbs');
app.set('views', Path.join(__dirname, '../views'));
app.get('/page', (req, res) => {
res.render('page', {
title: 'Hello, Express!',
content: '...'
});
});
app.get('/data', (req, res) => {
res.json({
version: '0.0.0',
items: []
});
});
app.listen(1337);
我们通常会分离控制器和路由配置:
import { Request, Response } from 'express';
export function page(req: Request, res: Response): void {
res.render('page', {
title: 'Hello, Express!',
content: '...'
});
}
因此,我们可能对现有路由有更好的了解,并且更容易管理控制器。此外,可以引入自动化路由,这样我们就不必总是手动更新路由:
import * as glob from 'glob';
let controllersDir = Path.join(__dirname, 'controllers');
let controllerPaths = glob.sync('**/*.js', {
cwd: controllersDir
});
for (let path of controllerPaths) {
let controller = require(Path.join(controllersDir, path));
let urlPath = path.replace(/\\/g, '/').replace(/\.js$/, '');
for (let actionName of Object.keys(controller)) {
app.get(
`/${urlPath}/${actionName}`,
controller[actionName]
);
}
}
上述实现当然过于简单,不足以覆盖日常使用,但它展示了自动化路由可能如何工作:基于文件结构的约定。
现在,如果我们正在使用承诺编写的异步代码,控制器中的操作可能如下所示:
export function foo(req: Request, res: Response): void {
Promise
.all([
Post.getContent(),
Post.getComments()
])
.then(([post, comments]) => {
res.render('foo', {
post,
comments
});
});
}
注意
我们在一个参数内部解构数组。Promise.all 返回一个承诺,该承诺是一个数组,其元素对应于传入的可解析值的值。(可解析意味着一个普通值或可能解析为普通值的类似承诺的对象。)
但这还不够;我们仍然需要正确处理错误,或者在某些承诺实现中,如果承诺链没有被拒绝处理程序(这很糟糕)处理,前面的代码可能会静默失败。在 Express 中,当发生错误时,你应该使用错误对象调用 next(传递给回调的第三个参数):
import { Request, Response, NextFunction } from 'express';
export function foo(
req: Request,
res: Response,
next: NextFunction
): void {
Promise
// ...
.catch(reason => next(reason));
}
现在,我们对这种方法的正确性感到满意,但这并不是承诺的工作方式。在控制器范围内,可以通过显式使用回调来处理错误,并且最简单的方法是返回承诺链并将其交给之前执行路由逻辑的代码。因此,控制器可以写成这样:
export function foo(req: Request, res: Response) {
return Promise
.all([
Post.getContent(),
Post.getComments()
])
.then(([post, comments]) => {
res.render('foo', {
post,
comments
});
});
}
但,我们能否让它更好?
响应的抽象
我们已经返回了一个 Promise 来告诉是否发生错误。所以现在返回的 Promise 表示响应的状态:成功或失败。但为什么我们仍然调用res.render()来渲染视图?返回的 promise 对象可以是响应本身,而不仅仅是错误指示器。
再次思考控制器:
export class Response { }
export class PageResponse extends Response {
constructor(view: string, data: any) { }
}
export function foo(req: Request) {
return Promise
.all([
Post.getContent(),
Post.getComments()
])
.then(([post, comments]) => {
return new PageResponse('foo', {
post,
comments
});
});
}
返回的响应对象可能因不同的响应输出而异。例如,它可以是类似于前一个示例中的PageResponse,JSONResponse,StreamResponse,甚至简单的Redirection。
由于在大多数情况下,PageResponse或JSONResponse被应用,并且PageResponse的视图通常可以通过控制器路径和动作名称隐含,因此从普通数据对象自动生成具有适当视图的这两个响应是有用的:
export function foo(req: Request) {
return Promise
.all([
Post.getContent(),
Post.getComments()
])
.then(([post, comments]) => {
return {
post,
comments
};
});
}
这就是基于 Promise 的控制器应该如何响应。有了这个想法,让我们用响应的抽象更新路由代码。之前,我们直接将控制器动作作为 Express 请求处理程序传递。现在我们需要通过解析返回值对动作进行一些包装,并基于解析结果应用操作:
-
如果它实现,并且是
Response的实例,则将其应用于 Express 传入的res对象。 -
如果它实现,并且是一个普通对象,如果没有找到视图,则构建一个
PageResponse或JSONResponse,并将其应用于res对象。 -
如果它拒绝,则使用原因调用
next函数。
之前,它看起来是这样的:
app.get(`/${urlPath}/${actionName}`, controller[actionName]);
现在它多了几行:
let action = controller[actionName];
app.get(`/${urlPath}/${actionName}`, (req, res, next) => {
Promise
.resolve(action(req))
.then(result => {
if (result instanceof Response) {
result.applyTo(res);
} else if (existsView(actionName)) {
new PageResponse(actionName, result).applyTo(res);
} else {
new JSONResponse(result).applyTo(res);
}
})
.catch(reason => next(reason));
});
然而,到目前为止,我们只能处理GET请求,因为我们硬编码了app.get()在我们的路由实现中。糟糕的视图匹配逻辑在实践中也几乎无法使用。我们需要使操作可配置,并且 ES 装饰器可以在这里做得很好:
export default class Controller {
@get({
view: 'custom-view-path'
})
foo(req: Request) {
return {
title: 'Action foo',
content: 'Content of action foo'
};
}
}
我将把实现留给你,并自由地让它变得很棒。
权限抽象
权限在项目中扮演着重要的角色,尤其是在有不同用户组的系统中,例如论坛。权限的抽象应该可扩展以满足不断变化的需求,并且应该易于使用。
在这里,我们将讨论控制器动作层面的权限抽象。将执行一个或多个动作的可读性视为权限。用户的权限可能由几个权限组成,并且通常同一级别的用户通常会有相同的权限集合。因此,我们可能有一个更大的概念,即组。
抽象可以基于组和权限同时工作,或者仅基于权限(此时组只是权限集合的别名):
-
同时基于权限和组进行验证的抽象更容易构建。您不需要创建一个包含哪些操作可以由特定用户组执行的大列表;只有在必要时才需要细粒度的权限。
-
基于权限验证的抽象在描述权限方面具有更好的控制和更大的灵活性。例如,您可以轻松地从用户的权限中移除一小部分权限。
然而,这两种方法在高级抽象方面相似,主要区别在于实现。我们讨论过的权限抽象的一般结构如下:

参与者包括以下内容:
-
权限:描述与特定操作对应的详细权限
-
组:定义一组权限
-
权限:描述用户能够做什么;由用户所属的组和用户拥有的权限组成
-
权限描述符:描述用户的权限如何足够;由可能的组和权限组成
预期错误
使用 Promise 消除的一个重大担忧是,我们不需要担心在callback中抛出错误会大多数情况下导致应用程序崩溃。错误将通过 Promise 链流动,如果没有被捕获,将由我们的路由器处理。错误可以大致分为预期错误和意外错误。预期错误通常是由错误的输入或可预见的异常引起的,而意外错误通常是由项目依赖的 bug 或其他库引起的。
对于预期错误,我们通常希望提供用户友好的响应,带有可读的错误消息和代码,以便用户可以自行找到解决方案或向我们提供有用的上下文进行报告。对于意外错误,我们也会想要合理的响应(通常描述为未知错误),详细的客户端日志(包括真实错误名称、消息、堆栈信息等),甚至警报以尽快通知团队。
定义和抛出预期错误
路由器需要处理不同类型的错误,而实现这一点的一个简单方法是从通用的ExpectedError类派生子类,并抛出其实例:
import ExtendableError from 'extendable-error';
class ExpectedError extends ExtendableError {
constructor(
message: string,
public code: number
) {
super(message);
}
}
注意
extendable-error是我处理堆栈跟踪和message属性的包。您也可以直接扩展Error类。
因此,当接收到预期错误时,我们可以安全地将错误消息作为响应的一部分输出。如果不是ExpectedError的实例,我们可以输出预定义的unknown错误消息,并将详细的错误信息记录下来。
错误转换
一些错误,如由不稳定网络或远程服务引起的错误,是预期的;我们可能想要捕获这些错误并将它们再次作为预期错误抛出。但这实际上相当简单。然后可以应用集中的错误转换过程,以减少管理这些错误所需的工作量。
转换过程包括两个部分:过滤(或匹配)和转换。有许多方法可以过滤错误,例如以下方法:
-
按错误类过滤:许多第三方库会抛出特定类的错误。以流行的 Node.js ORM Sequelize 为例,它抛出
DatabaseError、ConnectionError、ValidationError等。通过检查它们是否是特定错误类的实例来过滤错误,我们可以轻松地从错误堆中挑选出目标错误。 -
按字符串或正则表达式过滤:有时一个库可能会抛出错误,这些错误本身就是
Error类的实例,而不是其子类;这使得这些错误与其他错误区分起来更困难。在这种情况下,我们可能通过它们的消息、关键词或正则表达式来过滤这些错误。 -
按作用域过滤:可能存在具有相同错误消息的同一错误类的实例应该导致不同的响应。其中一个原因可能是抛出特定错误的操作处于较低级别,但被不同作用域中的上层结构使用。因此,可以为这些错误添加一个
scope标记,使它们更容易被过滤。
可能还有更多过滤错误的方法,并且它们通常能够相互协作。通过恰当地应用这些过滤器和转换错误,我们可以减少分析系统内部发生情况时的噪音,并在问题出现时更快地定位它们。
模块化项目
在 ES6 之前,JavaScript 有很多模块解决方案,其中最著名的两个是 AMD 和 commonjs。AMD 是为异步模块加载而设计的,主要应用于浏览器,而 commonjs 则同步加载模块,这正是 Node.js 模块系统的工作方式。
要使其异步工作,编写 AMD 模块需要更多的字符。而且由于 browserify 和 webpack 等工具的流行,commonjs 甚至对于浏览器项目也变得流行起来。
内部模块的正确粒度可以帮助项目保持其结构健康。考虑以下项目结构:
project
├─controllers
├─core
│ │ index.ts
│ │
│ ├─product
│ │ index.ts
│ │ order.ts
│ │ shipping.ts
│ │
│ └─user
│ index.ts
│ account.ts
│ statistics.ts
│
├─helpers
├─models
├─utils
└─views
假设我们正在编写一个控制器文件,该文件将导入由core/product/order.ts文件定义的模块。以前,使用 commonjs 的require风格,我们可能想要编写以下内容:
const Order = require('../core/product/order');
现在,使用新的 ES import语法,它将是以下这样:
import * as Order from '../core/product/order';
等等,这不是本质上是一样的吗?有点像。但你可能已经注意到我放入文件夹中的几个index.ts文件。现在,在core/product/index.ts文件中,我们可以有如下所示:
import * as Order from './order';
import * as Shipping from './shipping';
export { Order, Shipping }
或者,我们可以有如下所示:
export * from './order';
export * from './shipping';
有什么区别?这两种重新导出模块的方法背后的思想可能不同。第一种风格在我们将Order和Shipping视为命名空间时效果更好,在这个命名空间下,实体名称可能不容易区分不同组。采用这种风格,文件是构建这些命名空间的自然边界。第二种风格削弱了两个文件的命名空间属性,并使用它们作为组织同一更大类别下对象和类的工具。
使用这些文件作为命名空间的好处是,多级重导出是可行的,而命名空间的弱化使得随着重导出级别的增加,理解不同的标识符名称变得更加困难。
异步模式
当我们用网络或文件系统 I/O 编写 JavaScript 时,有 95%的可能性我们在做异步操作。然而,异步代码可能会在时间维度上极大地降低可确定性。但幸运的是,JavaScript 通常是单线程的;这使得我们能够在大多数情况下无需使用锁等机制来编写可预测的代码。
编写可预测的代码
可预测的代码依赖于可预测的工具(如果你在使用任何的话)。考虑以下这样的辅助工具:
type Callback = () => void;
let isReady = false;
let callbacks: Callback[] = [];
setTimeout(() => {
callbacks.forEach(callback => callback());
callbacks = undefined;
}, 100);
export function ready(callback: Callback): void {
if (!callbacks) {
callback();
} else {
callbacks.push(callback);
}
}
此模块导出了一个ready函数,该函数将在ready时调用传入的回调函数。它将确保即使在该之后添加的回调也会被调用。然而,你无法确定回调是否会在当前的事件循环中被调用:
import { ready } from './foo';
let i = 0;
ready(() => {
console.log(i);
});
i++;
在前面的例子中,当回调被调用时,i可以是 0 或 1。再次强调,这并不错,甚至不坏,它只是让代码的可预测性降低。当其他人阅读这段代码时,他们需要考虑程序如何运行的两种可能性。为了避免这个问题,我们可以简单地用setImmediate(在旧浏览器中可能会回退到setTimeout)包装同步调用:
export function ready(callback: Callback): void {
if (!callbacks) {
setImmediate(() => callback());
} else {
callbacks.push(callback);
}
}
编写可预测的代码实际上不仅仅是编写可预测的异步代码。上面的高亮行也可以写成setImmediate(callback),但这会让阅读你代码的人三思:callback将如何被调用以及arguments是什么?
考虑以下代码行:
let results = ['1', '2', '3'].map(parseInt);
数组results的值是多少?当然不是[1, 2, 3]。因为传递给map方法回调函数的参数有几个:当前项的值、当前项的索引和整个数组,而parseInt函数接受两个参数:要解析的字符串和基数。因此results实际上是以下代码片段的结果:
[parseInt('1', 0), parseInt('2', 1), parseInt('3', 2)];
然而,直接编写setImmediate(callback)实际上是可行的,因为这些函数的 API(包括setTimeout、setInterval、process.nextTick等)被设计成这样使用。而且可以合理假设将要维护此项目的人也知道这一点。但对于签名不太为人所知的其他异步函数,建议使用显式参数来调用它们。
异步创建型模式
我们在第三章中讨论了许多创建型模式,创建型设计模式。虽然构造函数不能是异步的,但其中一些模式可能存在应用于异步场景的问题。但其他模式只需稍作修改即可用于异步使用。
在 第四章 中,我们通过一个打开数据库并异步创建存储对象的示例,探讨了适配器模式。
class Storage {
private constructor() { }
open(): Promise<Storage> {
return openDatabase()
.then(db => new Storage(db))
}
}
在代理模式中,我们使存储对象从其构造函数立即可用。当调用对象的方法时,它等待初始化完成并完成操作:
class Storage {
private dbPromise: Promise<IDBDatabase>;
get dbReady(): Promise<IDBDatabase> {
if (this.dbPromise) {
return this.dbPromise;
}
// ... }
get<T>(): Promise<T> {
return this
.dbReady
.then(db => {
// ...
});
}
}
这种方法的缺点是,所有依赖初始化的成员都必须是异步的,尽管大多数情况下它们只是异步的。
异步中间件和钩子
中间件的概念在 Express 等框架中得到了广泛的应用。中间件通常按顺序处理其目标。在 Express 中,中间件的添加顺序大致与添加的顺序相同,而没有不同的阶段。然而,一些其他框架提供了不同时间阶段的钩子。例如,有在 安装前、安装后、卸载后 等触发的事件。
注意
Express 的中间件机制实际上是一种责任链模式的变体。根据要使用的具体中间件,它可能更像钩子而不是责任链,其作用程度有所不同。
实现中间件或钩子的原因多种多样。可能包括以下内容:
-
可扩展性:大多数情况下,它们的应用是由于可扩展性的需求。新的规则和流程可以通过新的中间件或钩子轻松添加。
-
解耦与业务逻辑的交互:一个只应关注业务逻辑的模块可能需要与接口进行潜在交互。例如,我们可能期望在处理操作时能够输入或更新凭据,而无需重启一切。因此,我们可以创建一个中间件或钩子,这样我们就不需要将它们紧密耦合。
异步中间件的实现可能很有趣。以 Promise 版本为例:
type Middleware = (host: Host) => Promise<void>;
class Host {
middlewares: Middleware[] = [];
start(): Promise<void> {
return this
.middlewares
.reduce((promise, middleware) => {
return promise.then(() => middleware(this));
}, Promise.resolve());
}
}
在这里,我们使用 reduce 来完成这个任务。我们传递了一个以 undefined 作为初始值的已解决的 Promise,并将其与 middleware(this) 的结果链式连接。实际上,这正是许多 Promise 库中 Promise.each 辅助函数的实现方式。
基于事件的流解析器
当创建一个依赖于套接字的应用程序时,我们通常需要一个轻量级的“协议”供客户端和服务器进行通信。与已经处理所有事情的 XHR 不同,使用套接字时,你需要定义边界,以防止数据混淆。
通过套接字传输的数据可能会连接或拆分,但 TCP 连接确保字节传输的顺序和正确性。考虑一个只包含两个部分的微小协议:一个 4 字节的无符号整数,后面跟着一个与 4 字节无符号整数匹配的字节长 JSON 字符串。
例如,对于 JSON "{}",数据包可能如下所示:
Buffer <00 00 00 02 7b 7d>
要构建这样的数据包,我们只需要将 JSON 字符串转换为Buffer(使用如utf-8这样的编码,这是 Node.js 的默认编码),然后在其前面添加其长度:
function buildPacket(data: any): Buffer {
let json = JSON.stringify(data);
let jsonBuffer = new Buffer(json);
let packet = new Buffer(4 + jsonBuffer.length);
packet.writeUInt32BE(jsonBuffer.length, 0);
jsonBuffer.copy(packet, 4, 0);
return packet;
}
当套接字客户端接收到新的缓冲区时,它会触发一个data事件。假设我们将发送以下 JSON 字符串:
// 00 00 00 02 7b 7d
{}
// 00 00 00 0f 7b 22 6b 65 79 22 3a 22 76 61 6c 75 65 22 7d
{"key":"value"}
我们可能会像这样接收它们:
-
分别获取两个缓冲区;每个缓冲区都是一个完整的包,包含长度和 JSON 字节
-
通过连接两个缓冲区获得一个单独的缓冲区
-
获取两个或更多缓冲区;至少有一个之前发送的包被分割成几个。
整个过程都是异步发生的。但是,就像套接字客户端会触发一个data事件一样,当解析器解析完一个完整的包后,它也可以触发自己的data事件。用于解析我们的小协议的解析器可能只有两个状态,对应于头部(JSON 字节长度)和主体(JSON 字节),并且data事件的触发发生在成功解析主体之后:
class Parser extends EventEmitter {
private buffer = new Buffer(0);
private state = State.header;
append(buffer: Buffer): void {
this.buffer = Buffer.concat([this.buffer, buffer]);
this.parse();
}
private parse(): void { }
private parseHeader(): boolean { }
private parseBody(): boolean { }
}
由于长度限制,我无法在这里放置解析器的完整实现。对于完整的代码,请参阅第七章代码包中的src/event-based-parser.ts文件,JavaScript 和 TypeScript 中的模式和架构。
因此,这样的解析器的使用方法如下:
import * as Net from 'net';
let parser = new Parser();
let client = Net.connect(port);
client.on('data', (data: Buffer) => {
parser.append(data);
});
parser.on('data', (data: any) => {
console.log('Data received:', data);
});
摘要
在本章中,我们讨论了一些有趣的想法和由这些想法形成的架构。大多数主题都集中在小范围内,并完成自己的任务,但也有一些想法是将整个系统组合起来。
实现诸如预期错误和项目模块管理方法等技术所需的代码并不难应用。但是,如果应用得当,它可以为整个项目带来显著的便利。
然而,正如我在本章开头已经提到的,JavaScript 和 TypeScript 中有太多美好的事物无法在一个章节中涵盖或提及。请不要在这里停止,继续探索。
许多模式和架构是软件工程中一些基本原理的结果。这些原理可能并不总是适用于每个场景,但当你感到困惑时,它们可能会有所帮助。在下一章中,我们将讨论面向对象设计中的 SOLID 原则,并找出这些原则如何帮助形成有用的模式。
第八章. SOLID 原则
SOLID 原则是由 Uncle Bob(罗伯特·C·马丁)总结的知名面向对象设计(OOD)原则。SOLID 这个词来源于它所代表的五个原则的首字母缩写,包括单一职责原则、开闭原则、里氏替换原则、接口隔离原则和依赖倒置原则。这些原则彼此之间紧密相关,并在实践中可以提供很好的指导。
这里是 Uncle Bob 广泛使用的 SOLID 原则总结:
-
单一职责原则:一个类应该只有一个,且仅有一个改变的理由
-
开闭原则:你应该能够扩展一个类的行为,而不需要修改它
-
里氏替换原则:派生类必须可替换其基类
-
接口隔离原则:创建细粒度的、特定于客户端的接口
-
依赖倒置原则:依赖于抽象,而不是具体实现
在本章中,我们将逐一探讨这些原则,并了解这些原则如何帮助形成一个“闻起来”很好的设计。
但在我们继续之前,我想提到,这些原则存在的一些原因可能与它们被提出时的时代、人们使用的语言以及他们的构建或分发过程有关,甚至与计算资源有关。当应用于当今的 JavaScript 和 TypeScript 项目时,其中的一些细节可能不是必要的。更多地思考这些原则想要防止人们陷入的问题,而不是一个原则应该如何遵循的直文字面描述。
单一职责原则
单一职责原则声明,一个类应该只有一个,且仅有一个改变的理由。在这句话中,“理由”这个词的定义很重要。
示例
考虑一个Command类,它被设计成可以与命令行界面和图形用户界面一起工作:
class Command {
environment: Environment;
print(items: ListItem[]) {
let stdout = this.environment.stdout;
stdout.write('Items:\n');
for (let item of items) {
stdout.write(item.text + '\n');
}
}
render(items: ListItem[]) {
let element = <List items={items}></List>;
this.environment.render(element);
}
execute() { }
}
要使这真正工作,execute方法需要处理命令执行和结果显示:
class Command {
..
execute() {
let items = ...;
if (this.environment.type === 'cli') {
this.print(items);
} else {
this.render(items);
}
}
}
在这个例子中,有两个改变的理由:
-
命令是如何被执行的。
-
命令的结果如何在不同的环境中显示。
这些原因导致在不同维度上的变化,并违反了单一职责原则。这可能会导致随着时间的推移出现混乱的情况。更好的解决方案是将这两个职责分开,并由CommandEnvironment来管理:

这看起来熟悉吗?因为它是一种访问者模式的变体。现在,是环境在执行特定命令并基于具体环境类处理其结果。
选择一个轴
你可能会想,CommandResult不是通过在不同的环境中显示内容的能力而违反了单一职责原则吗?是的,也不是。当这个原因的轴线设置为显示内容时,它没有;但如果轴线设置为在特定环境中显示,它就做到了。但考虑到整体结构,命令的结果预期是一个可以适应不同环境的输出。因此,这个原因是单维的,并证实了这个原则。
开放封闭原则
开放封闭原则声明,你应该能够扩展一个类的行为,而不需要修改它。这个原则是由 Bertrand Meyer 在 1988 年提出的:
软件实体(类、模块、函数等)应该对扩展开放,但对修改封闭。
一个程序依赖于它所使用的所有实体,这意味着改变这些实体已经被使用的部分可能会使整个程序崩溃。因此,开放封闭原则的想法很简单:我们最好有那些除了自我扩展外不会以任何方式改变的实体。
这意味着一旦编写并通过测试,理想情况下,它应该永远不会因为新添加的功能而改变(当然,它需要继续通过测试)。再次强调,理想情况下。
示例
考虑一个处理服务器请求和响应的 API 中心。我们将有几个以模块形式编写的文件,包括http-client.ts、hub.ts和app.ts(但在这个例子中,我们实际上不会编写http-client.ts,你需要发挥一些想象力)。
将下面的代码保存为文件hub.ts。
import { HttpClient, HttpResponse } from './http-client';
export function update(): Promise<HttpResponse> {
let client = new HttpClient();
return client.get('/api/update');
}
将下面的代码保存为文件app.ts。
import Hub from './hub';
Hub
.update()
.then(response => JSON.stringify(response.text))
.then(result => {
console.log(result);
});
勇敢地完成了!现在我们有了app.ts与http-client.ts严重耦合。如果我们想将这个 API 中心适配到像 WebSocket 这样的东西,BANG。
那么我们如何创建那些对扩展开放但对修改封闭的实体呢?关键在于一个稳定的抽象,能够适应变化。考虑我们在第四章中使用的适配器模式示例,结构型设计模式中,我们有一个Storage接口,它将数据库操作的实现与客户端隔离开。假设该接口设计得很好,能够满足即将到来的功能需求,那么它可能永远不会改变,或者在整个程序生命周期中只需要扩展。
JavaScript 和 TypeScript 中的抽象
猜猜看,我们心爱的 JavaScript 没有接口,它是动态类型的。我们甚至无法实际编写一个接口。然而,我们仍然可以写下关于抽象的文档,并仅通过遵守该描述来创建新的具体实现。
但 TypeScript 提供了接口,我们当然可以利用它。考虑前一个章节中的CommandResult类。我们将其编写为一个具体类,但它可能有子类会覆盖print或render方法以实现定制输出。然而,TypeScript 中的类型系统只关心类型的形状。这意味着,当你声明一个类型为CommandResult的实体时,该实体不需要是CommandResult的一个实例:任何具有兼容类型(在这种情况下即具有具有适当签名的print和render方法的任何对象)都可以完成这项工作。
例如,以下代码是有效的:
let environment: Environment;
let command: Command = {
environment,
print(items) { },
render(items) { },
execute() { }
};
早期重构
我再次强调,开放封闭原则只有在理想情况下才能完美遵循。这可能有两个原因:
-
并非系统中的所有实体都能同时开放于扩展而封闭于修改。总会有需要打破现有实体封闭性以完成其功能性的变化。当我们设计接口时,我们需要针对不同可预见的情境采取不同的策略来创建稳定的封闭。但这需要显著的经验,而且没有人能做得完美。
-
我们中没有谁是设计出持久且健康程序的高手。即使经过深思熟虑,最初设计的抽象在面对不断变化的需求时也可能显得粗糙。
所以当我们期望实体对修改封闭时,并不意味着我们应该只是站在那里看着它被封闭。相反,当事情还在控制之下时,我们应该重构,并在重构时保持抽象处于开放于扩展和封闭于修改的状态。
Liskov 替换原则
开放封闭原则是保持代码可维护和可重用的基本原则。而开放封闭原则的关键在于抽象的多态性。像实现接口或扩展类这样的行为会形成多态的形状,但这可能还不够。
Liskov 替换原则声明,派生类必须可替换为其基类。或者用提出这一原则的 Barbara Liskov 的话说:
我们想要的类似于以下替换属性:如果对于类型 S 的每个对象 o1,都有一个类型 T 的对象 o2,对于所有以 T 定义的程序 P,当用 o1 替换 o2 时,P 的行为保持不变,那么 S 是 T 的子类型。
无关紧要。让我们再试一个:任何可预见的类实例的使用都应该与该类的派生类的实例一起工作。
示例
下面我们用一个直接的违反示例来开始。考虑Noodles和InstantNoodles(Noodles的一个子类)被烹饪的情况:
function cookNoodles(noodles: Noodles) {
if (noodles instanceof InstantNoodles) {
cookWithBoiledWaterAndBowl(noodles);
} else {
cookWithWaterAndBoiler(noodles);
}
}
现在如果我们想要一些炒面... cookNoodles函数似乎无法处理这种情况。显然,这违反了 Liskov 替换原则,但这并不意味着它是一个糟糕的设计。
让我们考虑 Uncle Bob 在其关于此原则的文章中写的另一个例子。我们正在创建一个名为Square的类,它是Rectangle的子类,但不是添加新功能,而是给Rectangle添加了一个约束:正方形的宽度和高度应该始终相等。假设我们有一个允许设置宽度和高度的Rectangle类:
class Rectangle {
constructor(
private _width: number;
private _height: number;
) { }
set width(value: number) {
this._width = value;
}
set height(value: number) {
this._height = value;
}
}
现在我们遇到了其子类Square的问题,因为它从Rectangle获得了width和height设置器,而它不应该这样做。我们当然可以覆盖这些设置器并使它们同时更新宽度和高度。但在某些情况下,客户端可能并不希望这样做,因为这样做会使程序更难预测。
Square和Rectangle示例违反了 Liskov 替换原则。不是因为我们没有找到一种好的继承方式,而是因为Square不符合Rectangle的行为,它一开始就不应该成为它的子类。
替换约束
类型是编程语言中的一个重要部分,即使在 JavaScript 中也是如此。但拥有相同的形状,处于相同的层次结构并不意味着它们可以在不引起痛苦的情况下相互替换。不仅仅是形状,完整的行为了解 Liskov 替换原则所坚持的实现才是真正重要的。
接口隔离原则
我们已经讨论了抽象在面向对象设计中所扮演的重要角色。通常,抽象及其未分离的派生类会形成层次树结构。这意味着当你选择创建一个分支时,你为另一个分支上的所有对象创建了一个并行抽象。
对于只有一层继承的类族,这并不是问题:因为这正是你希望那些类从其派生出来的。但对于深度更大的层次结构,这可能会成为问题。
示例
考虑我们在第六章中用到的TextReader示例,在行为设计模式:连续中,我们有FileAsciiTextReader和HttpAsciiTextReader从AsciiTextReader派生出来。但如果我们想要其他理解 UTF-8 编码的读取器怎么办?
为了实现这个目标,我们有两个常见的选择:将接口分离成两个,用于不同对象之间的协作,或者将接口分离成两个,然后由单个类实现。
对于第一种情况,我们可以通过两个抽象类BytesReader和TextReader来重构代码:

对于第二种情况,我们可以将readAllBytes和decodeBytes方法分离到两个接口上,例如,BytesReader和BytesDecoder。因此,我们可以分别实现它们,并使用混入等技术将它们组合在一起:

关于这个例子的一个有趣的观点是,上面的TextReader本身就是一个抽象类。为了使这个混入(mixin)真正工作,我们需要创建一个TextReader的具体类(实际上不需要实现readAllBytes和decodeBytes),然后混入两个BytesReader和BytesDecoder的具体类。
合适的粒度
据说,通过创建更小的接口,我们可以避免客户端使用它不需要功能的庞大类。这可能会导致不必要的资源使用,但在实践中,这通常不会成为问题。接口分离原则最重要的部分仍然是关于保持代码的可维护性和可重用性。
然后问题再次出现,接口应该有多小?我不认为我有一个简单的答案。但我确信,太小可能不会有所帮助。
依赖倒置原则
当我们谈论依赖时,自然的理解是从下到上的依赖,就像建筑物的建造过程一样。但与可以持续数十年几乎不变化的建筑物不同,软件在其生命周期中不断变化。每一次变化都有成本,或多或少。
依赖倒置原则声明,实体应该依赖于抽象,而不是具体实现。高级代码不应该直接依赖于低级实现,而应该依赖于导致这些实现的抽象。这就是为什么它是“倒置”的。
示例
仍然以 HTTP 客户端和 API 中心为例,这显然违反了依赖倒置原则,考虑到可预见的应用,API 中心应该依赖于一个连接客户端和服务器的信息传递机制,而不是裸露的 HTTP 客户端。这意味着我们应该在 HTTP 客户端的具体实现之前有一个信息传递的抽象层:

分离层
与本章讨论的其他原则相比,依赖倒置原则更关注模块或包的范围。由于抽象通常比具体实现更稳定,遵循依赖倒置原则,我们可以最小化低级变化对高级行为的影响。
但对于 JavaScript(或 TypeScript)项目来说,由于语言是动态类型的,这个原则更多的是一种指导性的思想,它引导不同层代码实现之间形成稳定的抽象。
最初,遵循这一原则的一个重要好处是,如果模块或包相对较大,通过抽象分离它们可以在编译过程中节省大量时间。但对于 JavaScript,我们不必担心这一点;而对于 TypeScript,我们也不必为了对分离的模块进行更改而重新编译整个项目。
摘要
在本章中,我们通过简单的示例介绍了众所周知的 SOLID 原则。有时,遵循这些原则可能会引导我们到一个有用的设计模式。我们还发现,这些原则之间有着强烈的联系。通常,违反其中之一可能表明存在其他违规行为。
这些原则对于面向对象设计(OOD)可能非常有帮助,但如果应用不当,也可能过度。一个设计良好的系统应该恰好确认这些原则,否则可能会造成伤害。
在下一章中,我们将有更多时间来探讨一个完整的流程,其中包括测试和持续集成。
第九章. 企业应用程序之路
在了解了常见的设计模式之后,我们现在有了代码设计的基石。然而,软件工程更多的是关于编写优美的代码。当我们试图保持代码健康和健壮时,我们仍然有很多工作要做,以确保项目和团队健康、健壮,并准备好扩展。在本章中,我们将讨论 Web 应用程序工作流程中的流行元素,以及如何设计适合您团队的工作流程。
第一部分将是设置我们的演示项目的构建步骤。我们将快速介绍如何使用 webpack(目前最受欢迎的打包工具之一)构建前端项目。然后我们将配置测试、代码检查器,并设置持续集成。
在工作流程集成方面有很多不错的选择。我个人更喜欢为私人项目使用 Team Foundation Server,或者为开源项目使用 GitHub 和 Travis-CI 的组合。虽然 Team Foundation Server(或其基于云的版本 Visual Studio Team Services)为整个应用程序生命周期提供了一站式解决方案,但 GitHub 和 Travis-CI 的组合在 JavaScript 社区中更为流行。在本章中,我们将使用 GitHub 和 Travis-CI 提供的服务来构建我们的工作流程。
这里是我们将要介绍的内容:
-
使用 webpack 打包前端资源。
-
设置测试和代码检查器。
-
掌握 Git 分支模型和其他 Git 相关的工作流程。
-
将 GitHub 仓库与 Travis-CI 连接。
-
自动部署的简要介绍。
创建应用程序
我们在 第一章 工具和框架 中讨论了为前端和后端项目创建 TypeScript 应用程序。现在我们将创建一个同时包含两个 TypeScript 项目的应用程序。
在 SPA 和“常规”Web 应用程序之间做出决定
不同目的的应用程序会导致不同的选择。单页应用(SPA)在加载后通常提供更好的用户体验,但也可能导致 SEO 方面的权衡,并可能依赖于更复杂的 MV* 框架,如 Angular。
一种构建 SEO 友好型 SPA 的解决方案是构建一个在前后端都运行 相同 代码的通用(或同构)应用程序,但这可能会引入更多的复杂性。或者可以配置反向代理,利用像 Phantom 这样的工具自动渲染自动生成的页面。
在这个演示项目中,我们将选择一个更传统的多页面 Web 应用程序来构建。以下是客户端项目的文件结构:

考虑团队协作
在我们实际开始创建真实世界的应用程序之前,我们需要提出一个合理的应用程序结构。适当的应用程序结构不仅仅是代码编译和运行的地方。它应该是一个结果,考虑到团队成员如何一起工作。
例如,在前面展示的此演示客户端结构中涉及到了一个命名约定:页面资源以页面名称命名,而不是它们的类型(例如,style.scss)或像index.ts这样的名称。这个约定的背后考虑是使文件通过键盘导航更加友好。
当然,只有当你们团队中相当一部分开发者对键盘导航感到满意时,这种考虑才是有效的。除了操作偏好之外,团队的体验和背景也应该被认真考虑:
-
应该为你们团队启用“全栈”模式吗?
-
应该为你们团队中的每位工程师启用“全栈”模式吗?
-
你应该如何在前后端之间分配工作?
通常,限制前端工程师访问客户端开发的访问权限既不必要也不高效。如果可能的话,前端工程师可以接管后端的控制器层,将核心业务模型和逻辑留给更专注于后端开发的工程师。
我们在同一个存储库中拥有客户端和服务器端项目,以便在开发期间更容易集成。但这并不意味着前端或后端代码库中的所有内容都应该在这个单一存储库中。相反,在实践中,多个模块可以由不同的开发者提取和维护。例如,你可以将数据库模型和业务逻辑模型从后端的控制器中分离出来。
构建和测试项目
我们已经在本书的开头讨论了构建和测试 TypeScript 项目。在本节中,我们将进一步探讨前端项目,包括使用 Webpack 加载静态资产以及代码检查的基础。
使用 webpack 进行静态资源打包
模块化有助于代码保持健康结构,并使其易于维护。然而,如果开发时在小模块中编写的代码直接部署到生产使用而没有打包,可能会导致性能问题。因此,静态资源打包成为前端工程的一个严肃话题。
回到过去,打包 JavaScript 文件仅仅是丑化源代码并将文件连接在一起。项目可能也会进行模块化,但以全局的方式。然后我们有了像 Require.js 这样的库,模块不再自动暴露到全局作用域。
但是,正如我之前提到的,让客户端分别下载模块文件对于性能来说并不理想;很快我们就有了像 browserify 这样的工具,后来还有 webpack——目前最受欢迎的前端打包工具之一。
webpack 简介
Webpack 是一个针对前端项目集成的打包工具(至少最初是针对前端项目的)。它旨在打包不仅限于 JavaScript,还包括前端项目中的其他静态资源。Webpack 内置了对 异步模块定义(AMD)和 commonjs 的支持,并且可以通过插件加载 ES6 或其他类型的资源。
注意
ES6 模块支持将在 webpack 2.0 中内置,但到本章编写时,你仍然需要像 babel-loader 或 ts-loader 这样的插件来使其工作。当然,我们稍后也会使用 ts-loader。
要通过 npm 安装 webpack,请执行以下命令:
$ npm install webpack -g
将 JavaScript 打包
在我们实际使用 webpack 加载 TypeScript 文件之前,我们将快速浏览 JavaScript 打包的过程。
首先,让我们在 client/src/ 目录下创建名为 index.js 的文件,并在其中包含以下代码:
var Foo = require('./foo');
Foo.test();
然后在同一文件夹中创建名为 foo.js 的文件,内容如下:
exports.test = function test() {
console.log('Hello, Webpack!');
};
现在我们可以使用 webpack 命令行界面将它们打包成一个单独的文件:
$ webpack ./client/src/index.js ./client/out/bundle.js
通过查看 webpack 生成的 bundle.js 文件,你会看到 index.js 和 foo.js 的内容都被封装进那个单独的文件中,以及 webpack 的引导代码。当然,我们更愿意每次都不在命令行中输入这些文件路径,而是使用配置文件。
Webpack 以 JavaScript 文件的形式提供配置文件支持,这使得它能够更灵活地自动生成必要的数据,如捆绑条目。让我们创建一个简单的配置文件,执行之前命令的功能。
创建文件 client/webpack.config.js,包含以下行:
'use strict';
const Path = require('path');
module.exports = {
entry: './src/index',
output: {
path: Path.join(__dirname, 'out'),
filename: 'bundle.js'
}
};
这里有两件事需要提及:
-
entry字段的值不是文件名,而是模块 ID(大多数情况下这是未解析的)。这意味着你可以省略.js扩展名,但在引用文件时默认需要以./或../前缀。 -
输出路径必须是绝对路径。使用
__dirname构建绝对路径可以确保如果我们不在与配置文件相同的目录下执行 webpack,它也能正常工作。
加载 TypeScript
现在我们将使用 webpack 插件 ts-loader 加载和转换我们喜爱的 TypeScript。在更新配置之前,让我们安装必要的 npm 包:
$ npm install typescript ts-loader --save-dev
如果一切顺利,你应该已经安装了 TypeScript 编译器和 ts-loader 插件。我们可能还想将 index.js 和 foo.js 文件重命名并更新为 TypeScript 文件。
将 index.js 重命名为 index.ts 并更新模块导入语法:
import * as Foo from './foo';
Foo.test();
将 foo.js 重命名为 foo.ts 并更新模块导出语法:
export function test() {
console.log('Hello, Webpack!');
}
当然,我们还想为 TypeScript 文件添加 tsconfig.json 文件(在 client 文件夹中):
{
"compilerOptions": {
"target": "es5",
"module": "commonjs"
},
"exclude": [
"out",
"node_modules"
]
}
注意
编译器选项 outDir 在这里被省略了,因为它在 webpack 配置文件中管理。
要使 webpack 通过 ts-loader 与 TypeScript 一起工作,我们需要在配置文件中告诉 webpack 一些信息:
-
Webpack 需要解析具有
.ts扩展名的文件。Webpack 有一个默认的扩展名列表用于解析,包括''(空字符串)、'.webpack.js'、'.web.js'和'.js'。我们需要将'.ts'添加到这个列表中,以便它能够识别 TypeScript 文件。 -
Webpack 需要使用
ts-loader加载.ts模块,因为它本身不编译 TypeScript。
这里是更新后的 webpack.config.js:
'use strict';
const Path = require('path');
module.exports = {
entry: './src/index',
output: {
path: Path.join(__dirname, 'bld'),
filename: 'bundle.js'
},
resolve: {
extensions: ['', '.webpack.js', '.web.js', '.ts', '.js']
},
module: {
loaders: [
{ test: /\.ts$/, loader: 'ts-loader' }
]
}
};
现在再次在 client 文件夹下执行 webpack 命令,我们应该得到预期的编译和打包输出。
在开发期间,我们可以启用 TypeScript 的 转译模式(对应于编译器的 isolatedModules 选项),以在编译更改文件时获得更好的性能。但这意味着我们需要依赖 IDE 或编辑器来提供错误提示。并且记得在调试后禁用转译模式进行另一次编译,以确保一切仍然正常工作。
要启用转译模式,添加一个 ts 字段(由 ts-loader 插件定义),并将 transpileOnly 设置为 true:
module.exports = {
...
ts: {
transpileOnly: true
}
};
代码拆分
为了利用跨页面的代码缓存,我们可能想要将打包的模块拆分为通用组件。webpack 提供了一个名为 CommonsChunkPlugin 的内置插件,可以提取公共模块并将它们单独打包。
例如,如果我们创建另一个名为 bar.ts 的文件,它像 index.ts 一样导入 foo.ts,则 foo.ts 可以被视为一个公共块并单独打包:
module.exports = {
entry: ['./src/index', './src/bar'],
...
plugins: [
new Webpack.optimize.CommonsChunkPlugin({
name: 'common',
filename: 'common.js'
})
]
};
对于多页面应用程序,通常不同的页面有不同的入口脚本。我们不必手动更新配置文件中的 entry 字段,可以利用它是 JavaScript 的特性来自动生成适当的入口。为此,我们可能需要 npm 包 glob 的帮助来匹配页面入口:
$ npm install glob --saved-dev
然后更新 webpack 配置文件:
const glob = require('glob');
module.exports = {
entry: glob
.sync('./src/pages/*/*.ts')
.filter(path =>
Path.basename(path, '.ts') ===
Path.basename(Path.dirname(path))
),
...
};
代码拆分可能是一个相当复杂的深入主题,所以我们在这里停止,并让您去探索。
加载其他静态资源
正如我们所提到的,webpack 还可以用来加载其他静态资源,如样式表及其扩展。例如,您可以使用 style-loader、css-loader 和 sass-loader/less-loader 的组合来加载 .sass/.less 文件。
配置与 ts-loader 类似,因此我们不会额外花费页面来介绍它们。更多信息,请参考以下网址:
-
Webpack 中的嵌入式样式表:
webpack.github.io/docs/stylesheets.html -
Webpack 的 SASS 加载器:
github.com/jtangelder/sass-loader -
Webpack 的 LESS 加载器:
github.com/webpack/less-loader
将 TSLint 添加到项目中
一致的代码风格是代码质量的重要因素,当涉及到代码风格时,linters 是我们的最佳拍档(它们也帮助我们避免常见的错误)。对于 TypeScript 的 linting,TSLint 目前是最佳选择。
TSLint 的安装和配置很简单。首先,让我们全局安装tslint命令:
$ npm install tslint -g
然后,我们需要在项目根目录下使用以下命令初始化一个配置文件:
$ tslint --init
然后,TSLint 将生成一个名为tslint.json的默认配置文件,你可以根据自己的喜好进行自定义。现在我们可以用它来检查我们的 TypeScript 源代码:
$ tslint */src/**/*.ts
将 webpack 和 tslint 命令与 npm 脚本集成
正如我们之前提到的,使用 npm 脚本的一个优点是它们可以通过将node_modules/.bin添加到PATH来正确处理带有可执行文件的本地包。为了使我们的应用程序更容易被其他开发者构建和测试,我们可以将webpack和tslint作为开发依赖项安装,并将相关的脚本添加到package.json中:
"scripts": {
"build-client": "cd client && webpack",
"build-server": "tsc --project server",
"build": "npm run build-client && npm run build-server",
"lint": "tslint ./*/src/**/*.ts",
"test-client": "cd client && mocha",
"test-server": "cd server && mocha",
"test": "npm run lint && npm run test-client && npm run test-server"
}
版本控制
回想起我的高中时代,我对版本控制工具一无所知。我能做的最好的事情就是每天将我的代码存档到 U 盘上。是的,我确实丢失过一次!
现在,随着 Git 等版本控制工具的兴起以及 GitHub 和 Visual Studio Team Services 等多家免费服务的可用性,使用版本控制工具管理代码已经成为每个开发者的日常工作。
作为最受欢迎的版本控制工具,Git 已经在你的工作或个人项目中扮演着重要的角色。在本节中,我们将讨论在团队中使用 Git 的流行实践。
注意
注意,我假设你已经具备了 Git 的基本知识,并且知道如何进行init、commit、push、pull和merge等操作。如果不是这样,请在继续之前动手尝试理解这些操作。
注意
请查看这个快速教程:try.github.io/。
Git flow
版本控制扮演着非常重要的角色,它不仅影响源代码管理过程,还塑造了整个产品开发和交付的工作流程。因此,一个成功的分支模型成为一个严肃的选择。
Git flow 是一组 Git 扩展,它为 Vincent Driessen 提出的分支模型提供了高级的仓库操作。Git flow这个名字通常也指代分支模型。
在这个分支模型中,有两个主要的分支:master和develop,以及三种不同类型的支持分支:feature、hotfix和release。
在 Git flow 扩展的帮助下,我们可以轻松地应用这个分支模型,而无需记住和输入详细的命令序列。要安装,请查看 Git flow 的安装指南:github.com/nvie/gitflow/wiki/Installation。
在我们能够使用 Git flow 创建和合并分支之前,我们需要进行初始化:
$ git flow init -d
注意
其中 -d 表示使用默认的分支命名约定。如果您想自定义,可以省略 -d 选项并回答有关 git flow init 命令的问题。
这将创建(如果不存在)master 和 develop 分支,并将 Git flow 相关配置保存到本地仓库。
主分支
分支模型定义了两个主要分支:master 和 develop。这两个分支存在于当前仓库的生命周期中:

注意
前面的图显示了 develop 和 master 分支之间简化的关系。
-
分支 master:
master分支的 HEAD 应始终包含生产就绪的源代码。这意味着在这个分支模型中,master分支上不进行日常开发,只有经过完全测试并且可以快速前向合并的提交才能合并到这个分支。 -
分支 develop:
develop分支的 HEAD 应包含交付的开发源代码。对develop分支的更改最终将合并到master,但通常不是直接合并。我们将在讨论release分支时再详细说明。
支持分支
在 Git flow 的分支模型中,有三种类型的支持分支:feature、hotfix 和 release。它们大致的功能已经由它们的名字暗示,我们将在后续的细节中了解更多。
功能分支
功能分支只与 develop 分支有直接交互,这意味着它从 develop 分支检出并合并回 develop 分支。功能分支可能是三种分支中最简单的一种。
要使用 Git flow 创建功能分支,只需执行以下命令:
$ git flow feature start <feature-name>
现在 Git flow 将自动检出以 feature/<feature-name> 命名的新分支,您就可以开始开发和偶尔提交更改了。
完成功能开发后,Git flow 可以通过以下命令自动将内容合并回 develop 分支:
$ git flow feature finish <feature-name>
功能分支通常由被分配开发该特定功能的开发者启动,并由该开发者本人或 develop 分支的所有者(例如,如果需要代码审查)合并。
发布分支
在产品的单个迭代中,在完成功能开发后,我们通常需要一个阶段来全面测试一切、修复错误,并实际上准备好发布。这个阶段的工作将在发布分支上完成。
与功能分支不同,一个仓库通常一次只有一个活跃的发布分支,并且通常由仓库所有者创建。当开发分支即将达到发布状态并且即将开始全面测试时,我们可以使用以下命令创建一个发布分支:
$ git flow release start <version>
从现在起,本迭代将要发布的错误修复应该合并或提交到release/<version>分支,并且对当前release分支的更改可以随时合并回develop分支。
如果测试顺利并且重要错误已被修复,我们就可以完成这次发布并将其上线:
$ git flow release finish <version>
执行此命令后,Git flow 会将当前发布分支合并到master和develop分支。所以在标准的 Git flow 分支模型中,develop分支不会直接合并到master,尽管在发布完成后,develop和master分支上的内容可能相同(如果在发布阶段没有对develop分支进行更多更改)。
注意
完成当前发布通常意味着迭代的结束,这个决定应该经过认真考虑。
hotfix 分支
不幸的是,在开发者的世界中存在一种现象:在代码上线之前,总是更难找到错误。发布后,如果发现了严重的错误,我们就必须使用 hotfix 来纠正问题。
hotfix分支有点像发布分支,但持续时间更短(因为你可能希望尽快将其合并)。与从develop分支检出功能分支不同,hotfix分支是从master分支检出的。完成工作后,它应该像发布分支一样合并回master和develop分支。
要创建一个hotfix分支,同样可以执行以下命令:
$ git flow hotfix start <hotfix-name>
最后,执行以下命令:
$ git flow hotfix finish <hotfix-name>
Git flow 总结
在 Git flow 中,除了分支模型本身之外,我认为最有价值的想法是,一个迭代的清晰概述。你可能不需要遵循迄今为止提到的每个步骤来使用 Git flow,但只需让它适应你的工作。例如,对于可以在单个提交中完成的小功能,你可能实际上不需要一个功能分支。但相反,如果迭代本身变得混乱,Git flow 可能不会带来太多价值。
基于 pull request 的代码审查
代码审查可能是团队合作中非常重要的环节。它确保代码本身的质量可接受,并帮助新成员纠正对项目的误解,快速积累经验而不会走错路。
如果你尝试向 GitHub 上的开源项目贡献代码,你一定熟悉 pull requests 或 PR。实际上有一些工具或 IDE 内置了代码审查工作流程。但使用 GitHub 和其他自托管的如 GitLab 服务,我们可以顺利完成任务,而不依赖于特定的工具。
配置分支权限
对master和develop等特定分支的访问限制在技术上不是必需的。但没有这些限制,开发者可以轻易地跳过代码审查,因为他们能够这样做。在 Visual Studio Team Foundation Server 提供的服务中,我们可以在策略中添加自定义检查来强制代码审查。但在 GitHub 和 GitLab 等较轻量级的服务中,可能更难实现类似的功能。
最简单的方法可能是让更合格且熟悉当前项目的开发者拥有写入develop分支的权限,并口头限制该组的代码审查。对于其他在此项目上工作的开发者,现在强制进行拉取请求以获取他们合并的更改。
注意
GitHub 要求指定分支的推送权限的组织账户。除此之外,GitHub 提供状态 API,可以添加合并限制,以便只有具有有效状态的分支才能合并。
合并前的评论和修改
那些流行的 Git 服务的一个好处是,审阅者以及可能的其他同事可以对您的拉取请求或甚至特定的代码行进行评论,以提出他们的关注或建议。相应地,您可以修改活动拉取请求,使事情更接近完美。
此外,问题与拉取请求之间的引用在对话中显示。这连同评论和修改记录一起,使当前拉取请求的上下文清晰且可追溯。
提交前的测试
理想情况下,我们希望我们做出的每个提交都能通过测试和代码检查。但因为我们都是人类,我们很容易忘记在提交更改之前运行测试。然后,如果我们已经设置了项目的持续集成(我们很快就会谈到),推送更改会使它变红。如果你的同事设置了带有警报的 CI 轻量级服务,你将使它闪烁并发出声音。
为了避免不断破坏构建,您可能希望向您的本地仓库添加一个pre-commit钩子。
Git 钩子
Git 为操作或事件的特定阶段提供了各种钩子。在初始化 Git 仓库后,Git 将在.git/hooks目录下创建钩子样本。
现在让我们在.git/hooks目录下创建一个名为pre-commit的文件,并包含以下内容:
#!/bin/sh
npm run test
注意
钩子文件不一定要是 bash 文件,它可以是任何可执行文件。例如,如果你想使用 Node.js 钩子,你可以更新 shebang 为#!/usr/bin/env node,然后使用 JavaScript 编写钩子。
现在 Git 将在每次提交更改之前运行测试。
自动添加 pre-commit 钩子
将钩子手动添加到本地仓库可能很简单,但幸运的是,我们有像pre-commit这样的 npm 包,当安装时它会自动添加 pre-commit 钩子(因为您通常可能需要运行npm install)。
要使用 pre-commit 包,只需将其作为开发依赖项安装:
$ npm install pre-commit --save-dev
它将读取您的 package.json 并执行带有 pre-commit 或 precommit 字段的 npm 脚本:
{
..
"script": {
"test": "istanbul cover ..."
},
"pre-commit": ["test"]
}
注意
在撰写本文时,npm 包 pre-commit 使用符号链接创建 Git 钩子,这需要在 Windows 上具有管理员权限。但创建符号链接失败不会阻止 npm install 命令完成。因此,如果你使用 Windows,你可能需要确保 pre-commit 正确安装。
持续集成
持续集成(CI)是指定期将项目或解决方案的多个部分集成在一起的做法。根据项目的大小,集成可以是每次单个更改或按时间表进行。
持续集成的主要目标是避免集成问题,它还强制执行频繁的自动化测试的纪律,这有助于更早地发现错误并防止功能的退化。
有许多具有持续集成支持的解决方案或服务。例如,自托管的 TFS 和 Jenkins 服务,或基于云的 Visual Studio Team Services、Travis-CI 和 AppVeyor 服务。我们将通过我们的演示项目来介绍 Travis-CI 的基本配置。
将 GitHub 仓库与 Travis-CI 连接
我们将使用 GitHub 作为持续集成背后的 Git 服务。首先,让我们准备好我们的 GitHub 仓库和 Travis-CI 设置:
-
创建一个对应的远程仓库作为 origin,并将本地仓库推送到 GitHub:
$ git remote add origin https://github.com/<username>/<repo>.git $ git push -u origin master -
使用您的 GitHub 账户登录 Travis-CI:
travis-ci.org/auth。 -
前往账户页面,找到我们正在工作的项目,然后切换到仓库。
现在我们需要做的唯一一件事是创建一个合适的 Travis-CI 配置文件,以使持续集成设置生效。Travis-CI 对许多语言和运行时提供了内置支持。它提供了多个版本的 Node.js,使得测试 Node.js 项目变得极其简单。
在项目的根目录下创建文件 .travis.yml,内容如下:
language: node_js
node_js:
- "4"
- "6"
before_script:
- npm run build
此配置文件告诉 Travis-CI 使用 Node.js v4 和 v6 进行测试,并在测试前执行命令 npm run build(它将自动运行 npm test 命令)。
几乎准备好了!现在添加并提交新的 .travis.yml 文件,并将其推送到 origin。如果一切顺利,我们应该会看到 Travis-CI 短时间内开始构建此项目。
注意
现在,你可能到处都能看到构建状态徽章,并且很容易将其添加到自己的项目的 README.md 中。在 Travis-CI 的项目页面上,你应该会在项目名称旁边看到一个徽章。复制其 URL 并将其作为图片添加到 README.md 中:

部署自动化
除了版本控制工具外,Git 还因其相对简单的部署自动化而受到欢迎。在本节中,我们将动手配置基于 Git 的自动化部署。
基于 Git 服务器端钩子的被动部署
被动部署的想法很简单:当客户端将提交推送到服务器上的裸仓库时,Git 的 post-receive 钩子将被触发。因此,我们可以添加检查出更改并启动部署的脚本。
客户端和服务器端 Git 部署解决方案中涉及到的元素包括:

为了使此机制工作,我们需要执行以下步骤:
-
使用以下命令在服务器上创建一个裸仓库:
$ mkdir deployment.git $ cd deployment.git $ git init --bare注意
一个裸仓库通常具有
.git扩展名,可以作为共享的集中式位置。与普通仓库不同,裸仓库没有源文件的副本,其结构与普通仓库的.git目录内部结构非常相似。 -
将
deployment.git添加为项目的远程仓库,并尝试将master分支推送到deployment.git仓库:$ cd ../demo-project $ git remote add deployment ../deployment.git $ git push -u deployment master注意
在本例中,我们将添加一个本地裸仓库作为远程仓库。创建真正的远程仓库可能需要额外的步骤。
-
为
deployment.git仓库添加一个post-receive钩子。我们已经与客户端 Git 钩子pre-commit一起工作过,服务器端钩子的工作方式相同。
但是,当涉及到严肃的生产部署时,如何编写钩子可能是一个难以回答的问题。例如,我们如何最小化部署新构建的影响?
如果我们已经设置了具有高可用性负载均衡的应用程序,那么其中之一离线几分钟可能不是大问题。但当然,在这种情况下,不是所有这些都会离线。因此,以下是客户端和服务器端部署脚本的一些基本要求:
-
部署应按照一定的顺序进行
-
部署应温柔地停止运行服务
我们可以通过以下方式做得更好:
-
在之前的部署目录外构建
-
只有在新的部署应用准备好立即启动后,才尝试停止正在运行的服务
基于定时器或通知的主动部署
我们可以使用其他工具自动拉取和构建应用程序,而不是使用 Git 钩子。这样,我们就不再需要客户端分别将更改推送到服务器。相反,服务器上的程序将从远程仓库拉取更改并完成部署。
虽然首选通知机制以避免频繁抓取,但已经存在像 PM2 这样的工具,它们内置了自动化部署。您也可以考虑使用基于云或自托管 Git 服务提供的钩子构建自己的工具。
摘要
在这一章的最后,我们从构建和测试开始,构建了完整工作流程的概要,直到持续集成和自动化部署。我们介绍了一些流行的服务或工具,并为读者提供了其他发现和探索的选项。
在众多选择中,你可能会同意最适合你团队的工作流程是那个最适合的工作流程。仅从人的角度而不是仅从技术角度考虑是软件工程的一个重要部分,这同样是保持团队高效(也许还有快乐)的关键。
团队或人群的悲哀之处在于,通常只有其中的一小部分人能够保持热情。我们谈论过寻找平衡点,但那正是我们需要练习的。在大多数情况下,期望团队中的每个人都找到正确的点是不切实际的。当涉及到团队项目时,我们最好有可以自动验证的规则,而不是不可测试的惯例。
在阅读这本书之后,我希望读者能够了解构建步骤、工作流程的概要,以及当然的常见设计模式的知识。但与不同术语和模式的冷冰冰的解释相比,我更想传达一些更重要的想法:
-
我们作为人类是平凡的,应该始终将我们的工作划分为可控的部分,而不是像天才一样行事。这也是我们需要设计软件来使我们的生活更轻松的原因。
-
我们也并不可靠,尤其是在一些规模较大的集体(比如一个团队)中。
-
作为一名学习者,始终尝试理解结论背后的原因或现象背后的机制。


浙公网安备 33010602011771号