Typescript 玩转设计模式 之 创建型模式
我们团队的工作是用单页面应用的方式实现 web 工具。涉及到数万到十数万行的前端代码的管理,而且项目周期长达数年。怎么样很好地管理好这种量级的前端代码,在迭代的过程中能保持代码的新鲜度,对我们来说是个挑战。
一个运行良好的项目,除了要有好的架构外,还需要各个功能模块有良好的设计,学习设计模式,就是希望能有技巧地设计新功能和重构已有代码。
在网上看到很多说法,说学习设计模式作用不大,有些模式已经过时了,不学也能工作,学了反而容易过度设计。
我认为对事物的理解是“学习——领悟——突破”的过程。不懂的时候先学习,当学到的东西和实践经验有差异时结合思考可以领悟,等领悟到了其中的原理时,就可以不拘泥于学到的内容从而根据自己的场景灵活运用了。而过度设计显然还是在学习和领悟之间而已。
设计模式也是这样,《设计模式》里列举的 23 种设计模式并不是全部,模式的运用上往往也不是分得那么清楚,常常是多种模式混合使用。学习设计模式就像《倚天屠龙记》里张无忌学习太极拳一样,先学习招式,再打几遍,最终忘记这些招式。23 种设计模式只是招式,我们学习的目的是为了提高自己的设计水平,达到能结合场景信手拈来设计方案,不拘泥于招式的“大乘”境界。
在学习设计模式的过程中,我发现 4 人帮的原书 demo 代码是 C++的,而网上设计模式文章的 demo 多是 java 的。因此结合前端的 js 语言特性,整理了一遍各个模式的 demo,方便有志于学习设计模式的同学们理解,共同进步。
场景描述
假设我们要实现一个迷宫,原始代码如下:
function createMazeDemo() {
const maze = new Maze();
const r1 = new Room(1);
const r2 = new Room(2);
const door = new Door(r1, r2);
maze.addRoom(r1);
maze.addRoom(r2);
r1.setSide('east', new Wall());
r1.setSide('west', door);
return maze;
}
createMazeDemo();
我们已经实现了一个迷宫,这时候新的需求来了,迷宫里所有的东西都被施了魔法,但还是要重用现有的布局(所有构件类,如 Room、Wall、Door 都要换成新的类)。
可以看到这样的硬编码方式不够灵活,那么如何改造 createMaze 方法以让他方便地用新类型的对象创建迷宫呢?
通用概念定义
- 系统:整个程序生成的内容,如迷宫就是一个系统;
- 产品:组成系统的对象,如迷宫的门,房间,墙分别是一种产品;
抽象工厂(Abstract factory)
定义
提供一个创建一系列相关或相互依赖对象的接口,而无需指定它们具体的类。
结构
抽象工厂模式包含如下角色:
- AbstractFactory:抽象工厂
- ConcreteFactory:具体工厂
- AbstractProduct:抽象产品
- Product:具体产品
示例
// 迷宫的基类
class Maze {
addRoom(room: Room): void {
}
}
// 墙的基类
class Wall {
}
// 房间的基类
class Room {
constructor(id: number) {
}
setSide(direction: string, content: Room|Wall): void {
}
}
// 门的基类
class Door {
constructor(roo1: Room, room2: Room) {
}
}
// 迷宫工厂的基类,定义了生成迷宫各个构件的接口和默认实现,
// 子类可以复写接口的实现,返回不同的具体类对象。
class MazeFactory {
makeMaze(): Maze {
return new Maze();
}
makeWall(): Wall {
return new Wall();
}
makeRoom(roomId: number): Room {
return new Room(roomId);
}
makeDoor(room1: Room, room2: Room): Door {
return new Door(room1, room2);
}
}
// 通过传入工厂对象,调用工厂的接口方法创建迷宫,
// 由于工厂的接口都是一样的,所以传入不同的工厂对象,就能创建出不同系列的具体产品
function createMazeDemo(factory: MazeFactory): Maze {
const maze = factory.makeMaze();
const r1 = factory.makeRoom(1);
const r2 = factory.makeRoom(2);
const door = factory.makeDoor(r1, r2);
maze.addRoom(r1);
maze.addRoom(r2);
r1.setSide('east', factory.makeWall());
r1.setSide('west', door);
return maze;
}
// 标准系列工厂对象,工厂的每个产品都是标准的
const standardSeries = new MazeFactory();
// 创建出标准的迷宫
createMazeDemo(standardSeries);
// 附了魔法的房间,继承自房间的基类
class MagicRoom extends Room {
...
}
// 附了魔法的门,继承自门的基类
class MagicDoor extends Door {
...
}
// 魔法系列的工厂,工厂的房间和门是被附了魔法的
class MagicMazeFactory extends MazeFactory {
makeRoom(roomId: number): Room {
return new MagicRoom(roomId);
}
makeDoor(room1: Room, room2: Room): Door {
return new MagicDoor(room1, room2);
}
...
}
// 魔法系列工厂对象,工厂创建出的门和房间是附了魔法的
const magicSeries = new MagicMazeFactory();
createMazeDemo(magicSeries);
适用场景
- 一系列相关的产品对象。如有魔法的房间和有魔法的门,都属于有魔法的迷宫构件,有一定相关性;
- 系统要由多个产品系列的一个来配置。如当用户有多种迷宫样式风格可以选择,当选择黑色风格时,所有迷宫的构件都需要是黑色风格的构件;
- 系统要独立于具体的产品类。如编写迷宫程序时,不需要关心用的是哪个具体的房间类,只需要知道房间基类的接口就可以操作房间。各种迷宫工厂返回的房间类都是继承自房间基类,接口是一致的,即使普通房间换成了有魔法的房间,也不需要操作房间的代码,如迷宫的布局代码,关心改变。
优点
- 分离了具体的类。如迷宫要改成有魔法的迷宫,迷宫的布局部分代码
createMazeDemo
不需要修改; - 易于交换产品系列。迷宫换成有魔法的迷宫时,只需要对
createMazeDemo
传入新的工厂对象即可; - 有利于产品的一致性。同一个系列的产品,都是相关性比较高的。如魔法迷宫的具体对象,都是带魔法的。
缺点
- 难以支持新种类的产品。这是因为抽象工厂的接口确定了可以被创建的产品集合,支持新种类的产品就需要扩展该工厂接口,这将涉及到抽象工厂类及其所有子类的改变。
相关模式
- 抽象工厂模式通常用工厂方法实现,也可以用原型模式实现。
- 一个具体的工厂通常是一个单例模式。
建造者(Builder)
定义
将一个复杂对象的构建与它的表示分离,使得同样的构建过程可以创建不同的表示。
结构
建造者模式包含如下角色:
- Builder:抽象建造者
- ConcreteBuilder:具体建造者
- Director:指挥者
- Product:产品角色
示例
import { Maze, Wall, Room, Door } from './common';
// 迷宫建造者基类,定义了所有生成迷宫构件的接口,以及最终返回完整迷宫的接口
// 自身不创建迷宫,仅仅定义接口
class MazeBuilder {
buildMaze(): void {}
buildWall(roomId: number, direction: string): void {}
buildRoom(roomId: number): void {}
buildDoor(roomId1: number, roomId2: number): void {}
getCommonWall(roomId1: number, roomId2: number): Wall { return new Wall(); };
getMaze(): Maze|null { return null; }
}
// 创建迷宫的流程
// 相比最原始的代码,使用建造者模式只需要声明建造过程,而不需要知道建造过程中用到的每个构件的所有信息
// 比如,建造门的时候,只需要声明要建造一扇门,而不需要关心建造门的方法内部是如何将门与房间关联起来的
// 建造者模式只在迷宫被完全建造完成时,才从建造者对象里取出整个迷宫,从而能很好地反映出完整的建造过程
function createMaze(builder: MazeBuilder) {
builder.buildMaze();
builder.buildRoom(1);
builder.buildRoom(2);
builder.buildDoor(1, 2);
return builder.getMaze();
}
// 标准迷宫的建造者,继承自建造者基类
class StandardMazeBuilder extends MazeBuilder {
currentMaze: Maze;
constructor() {
super();
this.currentMaze = new Maze();
}
getMaze(): Maze|null {
return this.currentMaze;
}
buildRoom(roomId: number): void {
if (this.currentMaze) {
const room = new Room(roomId);
this.currentMaze.addRoom(room);
room.setSide('north', new Wall());
room.setSide('south', new Wall());
room.setSide('east', new Wall());
room.setSide('west', new Wall());
}
}
buildDoor(roomId1: number, roomId2: number): void {
const r1 = this.currentMaze.getRoom(roomId1);
const r2 = this.currentMaze.getRoom(roomId2);
const door = new Door(r1, r2);
r1.setSide(this.getCommonWall(roomId1, roomId2), door);
r2.setSide(this.getCommonWall(roomId2, roomId1), door);
}
}
// 建造一个标准的迷宫
const standardBuilder = new StandardMazeBuilder();
createMaze(standardBuilder);
/**
* 建造者也可以根本不建造具体的构件,而只是对建造过程进行计数。
*/
// 计数的数据结构声明
interface Count {
rooms: number;
doors: number;
}
// 不创建迷宫,只记数的建造者,也继承自建造者基类
class CountingMazeBuilder extends MazeBuilder {
doors = 0;
rooms = 0;
buildRoom(): void {
this.rooms += 1;
}
buildDoor(): void {
this.doors += 1;
}
getCounts(): Count {
return {
rooms: this.rooms,
doors: this.doors,
};
}
}
const countBuilder = new CountingMazeBuilder();
createMaze(countBuilder);
countBuilder.getCounts();
适用场景
- 当创建复杂对象的算法应该独立于该对象的组成部分以及他们的装配方式时。上例来看,建造迷宫的过程相当于算法,而每个生成构件的方法包含了生成具体对象以及将具体对象装配到迷宫上的方式。同一个建造过程可以适配多种不同的建造者,而同一个建造者可以支持多种不同的建造过程,两者是相互独立的。
- 当构造过程必须允许被构造的对象有不同的表示时。建造过程相同,但需要支持多种不同的装配方式,如上例中,相同的建造过程需要支持两种不同的场景,一种场景需要建造出真实的迷宫,另一种场景只需要统计建造的过程中建了多少间房价和多少扇门。
优点
- 可以改变一个产品的内部表示。接口可以隐藏产品的表示和内部结构,同时隐藏了产品的装配过程。因为产品是通过抽象接口构造的,你在改变该产品的内部表示时所要做的只是定义一个新的建造者。
- 将构造代码和表示代码分开。建造者模式通过封装一个复杂对象的创建和表示方式提高了对象的模块性。客户不需要知道定义产品内部结构的类的所有信息。
- 可对构造过程进行更精细的控制。建造者模式仅当该产品完成时才从建造者中取回它,因此建造者模式相比其他创建型模式能更好地反映产品的构造过程。
相关模式
- 抽象工厂与建造者模式相似,因为它也可以创建复杂对象。主要的区别是建造者模式着重于一步步构造一个复杂对象。而抽象工厂着重于多个系列的产品对象。建造者在最后的一步返回产品,而对于抽象工厂来说,产品是立即返回的。
- 组合模式通常是用建造者模式生成的。
工厂方法(Factory Method)
定义
定义一个用于创建对象的接口,让子类决定实例化哪个类。工厂方法使一个类的实例化延迟到其子类。
结构
工厂方法模式包含如下角色:
- Product:抽象产品
- ConcreteProduct:具体产品
- Factory:抽象工厂
- ConcreteFactory:具体工厂
示例
子类决定实例化具体产品
import { Maze, Wall, Room, Door } from './common';
// 迷宫游戏类
class MazeGame {
// 创建迷宫的主方法
createMaze(): Maze {
const maze = this.makeMaze();
const r1 = this.makeRoom(1);
const r2 = this.makeRoom(2);
const door = this.makeDoor(r1, r2);
maze.addRoom(r1);
maze.addRoom(r2);
r1.setSide('north', this.makeWall());
r1.setSide('east', door);
return maze;
}
// 以下是工厂方法,通过工厂方法创建构件,而不是直接在主方法中 new 出具体类
// 工厂方法最重要的是定义出返回产品的接口,虽然这里提供了默认实现,但也可以只提供接口,让子类来实现
makeMaze(): Maze { return new Maze(); }
makeRoom(roomId: number): Room { return new Room(roomId); }
makeWall(): Wall { return new Wall(); }
makeDoor(room1: Room, room2: Room): Door { return new Door(room1, room2); }
}
// 创建普通迷宫游戏
const mazeGame = new MazeGame();
mazeGame.createMaze();
// 带炸弹的墙
class BombedWall extends Wall {
...
}
// 带炸弹的房间
class RoomWithABomb extends Room {
...
}
// 子类可以复写工厂方法,以下是带炸弹的迷宫游戏类
class BombedMazeGame extends MazeGame {
// 复写创建墙的方法,返回一面带炸弹的墙
makeWall(): Wall {
return new BombedWall();
}
// 复写创建房间的方法,返回一个带炸弹的房间
makeRoom(roomId: number): Room {
return new RoomWithABomb(roomId);
}
}
// 创建带炸弹的迷宫游戏
const bombedMazeGame = new BombedMazeGame();
bombedMazeGame.createMaze();
参数化工厂方法
class Creator {
createProduct(type: string): Product {
if (type === 'normal') return new NormalProduct();
if (type === 'black) return new BlackProduct();
return new DefaultProduct();
}
}
// 子类可以很容易地扩展或改变工厂方法返回的产品
class MyCreator extends Creator {
createProduct(type: string): Product {
// 改变产品
if (type === 'normal) return new MyNormalProduct();
// 扩展新的产品
if (type === 'white') return new WhiteProduct();
// 注意这个操作的最后一件事是调用父类的`createProduct`,这是因为子类仅对某些 type 的处理上与父类不同,对其他的 type 不感兴趣
return Creator.prototype.createProduct.call(this, type);
}
}
适用场景
- 当一个类不知道他所必须创建的对象的类的时候。
- 当一个类希望由他的子类来指定他所创建的对象的时候。
- 当类将创建对象的职责委托给多个帮助子类的某一个,并且你希望将哪一个帮助子类是代理者这一信息局部化的时候。这就是参数化工厂方法的场景,将通过参数指定具体类的过程局部化在工厂方法中。
优点
- 用工厂方法在一个类的内部创建对象通常比直接创建对象更灵活。相比于在
createMaze
方法中直接创建对象const r1 = new Room(1);
,用工厂方法const r1 = this.makeRoom(1)
,可以在子类中复写makeRoom
方法来实例化不同的房间,能更灵活地应对需求变化。
相关模式
- 抽象工厂经常用工厂方法来实现。
- 工厂方法通常在模板方法模式中被调用。
- 原型模式不需要创建子类,但是通常要求一个针对产品类的初始化操作。而工厂方法不需要这样的操作。
原型(Prototype)
定义
用原型实例指定创建对象的种类,并且通过复用这些原型创建新的对象。
示例
在其他语言里,原型模式是通过拷贝一个对象,然后修改新对象的属性,从而减少类的定义和实例化的开销。
但由于 js 天然支持 prototype,因此原型的实现方式与其他类继承语言有些不同,不需要通过对象提供 clone
方法来实现模型模式。
import { Maze, Wall, Room, Door } from './common';
interface Prototype {
prototype?: any;
}
// 根据原型返回对象
function getNewInstance(prototype: Prototype, ...args: any[]): Wall|Maze|Room|Door {
const proto = Object.create(prototype);
const Kls = class {};
Kls.prototype = proto;
return new Kls(...args);
}
// 迷宫工厂,定义了生成构件的接口
class MazeFactory {
makeWall(): Wall { return new Wall(); }
makeDoor(r1: Room, r2: Room): Door { return new Door(r1, r2); }
makeRoom(id: number): Room { return new Room(id); }
makeMaze(): Maze { return new Maze(); }
}
// 原型迷宫工厂,根据初始化时传入的原型改变返回的迷宫构件
class MazePrototypeFactory extends MazeFactory {
mazePrototype: Prototype;
wallPrototype: Prototype;
roomPrototype: Prototype;
doorPrototype: Prototype;
constructor(mazePrototype: Prototype, wallPrototype: Prototype, roomPrototype: Prototype, doorPrototype: Prototype) {
super();
this.mazePrototype = mazePrototype;
this.wallPrototype = wallPrototype;
this.roomPrototype = roomPrototype;
this.doorPrototype = doorPrototype;
}
makeMaze() {
return getNewInstance(this.mazePrototype);
}
makeRoom(id: number) {
return getNewInstance(this.roomPrototype, id);
}
makeWall() {
return getNewInstance(this.wallPrototype);
}
makeDoor(r1: Room, r2: Room): Door {
const door = getNewInstance(this.doorPrototype, r1, r2);
return door;
}
}
// 创建迷宫的过程
function createMaze(factory: MazeFactory): Maze {
const maze = factory.makeMaze();
const r1 = factory.makeRoom(1);
const r2 = factory.makeRoom(2);
const door = factory.makeDoor(r1, r2);
maze.addRoom(r1);
maze.addRoom(r2);
r1.setSide('east', factory.makeWall());
r1.setSide('west', door);
return maze;
}
// 各个迷宫构件的原型
const mazePrototype = {...};
const wallPrototype = {...};
const roomPrototype = {...};
const doorPrototype = {...};
// 生成简单的迷宫
const simpleMazeFactory = new MazePrototypeFactory(mazePrototype, wallPrototype, roomPrototype, doorPrototype);
createMaze(simpleMazeFactory);
// 带有炸弹的迷宫构件的原型
const bombedWallPrototype = {...};
const roomWithABombPrototype = {...};
// 生成带有炸弹的迷宫
const bombedMazeFactory = new MazePrototypeFactory(mazePrototype, bombedWallPrototype, roomWithABombPrototype, doorPrototype);
createMaze(bombedMazeFactory);
适用场景
- 要实例化的类是在运行时刻指定时。如通过动态加载的方式拿到原型后,在程序运行的过程中根据原型直接创建出实例,而不是通过预先定义好的 class
new
出对象。或者类需要根据运行环境动态改变,可以通过修改原型来产生不同的对象。
优点
- 运行时增加产品。因为可以在运行时动态根据原型生成新的种类的对象。
- 减少子类的构造,减少在系统中用到的类的数量。
- 用类动态配置应用。在运行时动态加载类。
相关模式
- 原型模式和抽象工厂模式在某种方面是互相竞争的。但是他们也可以一起使用。抽象工厂可以存储一个原型的集合,并且返回产品对象。
- 大量使用组合模式和装饰模式的设计通常也可以从原型模式中获益。
单例(Singleton)
定义
保证一个类仅有一个实例,并提供一个访问它的全局访问点。
结构
单例模式包含如下角色:
- Singleton:单例
示例
简单的单例
class MazeFactory {
// 将 constructor 设为私有,防止通过 new 该类产生多个对象,破坏单例
private constructor() {}
static instance: MazeFactory;
// 如果已经有了对象,则返回缓存的对象,不然就创建一个对象并缓存,保证系统内最多只有一个该类的对象
static getInstance(): MazeFactory {
if (!MazeFactory.instance) {
MazeFactory.instance = new MazeFactory();
}
return MazeFactory.instance;
}
}
根据参数选择要实例化的迷宫工厂
class BombedMazeFactory extends MazeFactory {
...
}
class AdvancedMazeFactory {
// 将 constructor 设为私有,防止通过 new 该类产生多个对象,破坏单例
private constructor() {}
static instance: MazeFactory;
static getInstance(type: string): MazeFactory {
if (!AdvancedMazeFactory.instance) {
if (type === 'bombed') {
AdvancedMazeFactory.instance = new BombedMazeFactory();
} else {
AdvancedMazeFactory.instance = new MazeFactory();
}
}
return AdvancedMazeFactory.instance;
}
}
适用场景
- 当类只能有一个实例,而且使用者可以从一个众所周知的访问点访问它时;
- 当这个唯一的实例应该是通过子类化可扩展的,如上例中根据参数扩展,并且使用者应该无需更改代码就能使用一个扩展的实例时;
优点
- 对唯一实例可以进行有效的受控访问。
- 防止存储唯一实例的全局变量污染命名空间。
- 可以在实例化方法中改变具体使用的实例。
- 允许可变数目的实例。这个模式使你易于改变你的想法,并允许单例的类同时存在多个实例。因为实例化的入口在一个地方,可以方便地控制允许同时存在的实例数量。
相关模式
- 很多模式可以用单例实现,如抽象工厂,建造者,和原型。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
上一篇: 用 TypeScript 编写发布函数库
下一篇: WAF Bypass 介绍分析
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论