vlambda博客
学习文章列表

你应该了解的5种TypeScript设计模式

作者 | Fernando Doglio
译者 | 王强
策划 | 蔡芳芳

本文最初发布于 Medium 网站,经原作者授权由 InfoQ 中文站翻译并分享。

设计模式是解决问题的良好模板,开发人员可以在自己的项目应用这些模式处理需求。现实中应付各种需求的模式数不胜数,一篇文章无法尽述。不过它们可以大致分为三个类别:

  • 结构模式,负责处理不同组件(或类)之间的关系,并形成新结构以提供新功能。结构模式的例子有组合(Composite)、适配器(Adapter)和装饰器(Decorator)。

  • 行为模式,它们能将组件之间的通用行为抽象为一个单独的实体,进而与你的创建模式结合起来。行为模式的例子包括命令(Command)、策略(Strategy)以及我个人最喜欢的一种:观察者(Observer)模式。

  • 创建模式,它们专注于类的实例化,简化新实体的创建过程,例如工厂(Factory)方法、单例(Singleton)和抽象工厂(Abstract Factory)。

虽然它们可以直接在 JavaScript 中实现,特别是有了 ES6 后实现起来更容易了,但 TypeScript 采用的 OOP 方法使得开发人员可以简单明了地遵循通用指南(甚至来自其他 OOP 语言),从而获得这些模式的所有好处(而标准 JS 相比之下多少存在一些限制)。

    单例    

单例模式可能是最著名的设计模式之一。这是一种创建模式,它可以确保无论你多少次实例化一个类,你都只会有一个实例。

这是处理数据库连接之类场景的好方法,因为你可能希望一次只处理一个连接,而不必在每次用户请求时都重新连接。
//Simulate a database connectino class
class MyDBConn{
    protected static instance: MyDBConn | null = null
    private id: number = 0
    constructor() {
        //... db connection logic
        this.id = Math.random() //the ID could represent the actual connection to the db
    }

    public getID(): number {
        return this.id
    }
    public static getInstance(): MyDBConn {
        if(!MyDBConn.instance) {
            MyDBConn.instance = new MyDBConn()
        }
        return MyDBConn.instance
    }
}

const connections = [
                        MyDBConn.getInstance(),
                        MyDBConn.getInstance(),
                        MyDBConn.getInstance(),
                        MyDBConn.getInstance(),
                        MyDBConn.getInstance()
                ]
connections.forEach( c => {
    console.log(c.getID())
现在你不能直接实例化这个类,但使用 getInstance 方法时,你可以确保不会有多个实例。在上面的示例中,你可以看到包装数据库连接的伪类是怎样从这一模式中受益的。很容易将 id 属性视为实际连接,而这个小测试向你展示了,无论你调用 getInstance 方法多少次,“连接”总是相同的。代码的输出是:
0.4047087250990713
0.4047087250990713
0.4047087250990713
0.4047087250990713
0.4047087250990713
工厂方法

如前所述,工厂方法像单例一样也是一种创建模式。但这种模式不是直接针对我们要创建的对象,而只管理它们的创建过程。

解释一下:假设你要编写移动一些交通工具的代码,它们的类型有很大区别(例如汽车、自行车和飞机),移动代码应封装在每个交通工具类中,但调用这些 move 代码的方法可以是通用的。

这里的问题是如何处理对象创建?你可能有一个具有 3 个方法的 creator 类,或者一个接收一个参数的方法。无论哪种情况,要扩展这种逻辑以支持创建更多交通工具,都需要你修改同一个类。

但如果你决定使用工厂方法模式,则可以执行以下操作:

现在,创建新对象所需的代码被封装到一个新类中,每种交通工具类型都对应一个。这样如果将来需要添加新类型,则只需添加一个新的类,不必修改任何现有的类。

来看看我们如何使用 TypeScript 来实现这一点:
interface Vehicle {
    move(): void
}
//The classes we care about, the "move" method is where our "business logic" would live
class Car implements Vehicle {
    public move(): void {
        console.log("Moving the car!")
    }
}
class Bicycle implements Vehicle {
    public move(): void {
        console.log("Moving the bicycle!")
    }
}
class Plane implements Vehicle {
    public move(): void {
        console.log("Flying the plane!")
    }
}
//The VehicleHandler is "abstract" because noone is going to instantiate it
//We want to extend it and implement the abstract method
abstract class VehicleHandler {
    //This is the method real handlers need to implement
    public abstract createVehicle(): Vehicle
    //This is the method we care about, the rest of the business logic resides here
    public moveVehicle(): void 
{
        const myVehicle = this.createVehicle()
        myVehicle.move()
    }
}
//Here is where we implement the custom object creation
class PlaneHandler extends VehicleHandler{
    public createVehicle(): Vehicle {
        return new Plane()
    }
}
class CarHandler  extends VehicleHandler{
    public createVehicle(): Vehicle {
        return new Car()
    }
}
class BicycleHandler  extends VehicleHandler{
    public createVehicle(): Vehicle {
        return new Bicycle()
    }
}
/// User code...
const planes = new PlaneHandler()
const cars = new CarHandler()
planes.moveVehicle()
cars.moveVehicle()

本质上,我们最终关心的是自定义处理程序(handler)。之所以叫它们处理程序,是因为它们不仅负责创建对象,而且具有使用它们的逻辑(如 moveVehicle 方法所示)。这种模式的优点在于,如果要添加新的类型,你要做的就是添加其交通工具类和其处理程序类,而无需改动其他类的代码。

 观察者 

在所有模式中,我最喜欢的是观察者,这是因为我们可以用它来实现的行为类型。听说过 ReactJS 吗?它就是基于观察者模式的。前端 JavaScript 中的事件处理程序听过吗?也是基于它的,起码理论上是一致的。

关键在于,通过观察者模式,你可以实现它们以及更多事物。

本质上,这种模式表明你具有一组观察者对象,这些对象将对观察到的实体的状态变化做出反应。为了做到这一点,一旦观察端收到更改,就会调用一个方法来通知观察者。

实践中这种模式相对容易实现,来看代码:
type InternalState = {
    event: String
}
abstract class Observer {
    abstract update(state:InternalState): void 
}

abstract class Observable
{
    protected observers: Observer[] = [] //the list of observers
    protected state:InternalState = { event: "" } //the internal state observers are watching
    public addObserver(o:Observer):void {
        this.observers.push(o)
    }

    protected notify() {
        this.observers.forEach( o => o.update(this.state) )
    }
}
//Actual implementations
class ConsoleLogger extends Observer  {
    public update(newState: InternalState) {
        console.log("New internal state update: ", newState)
    }
}
class InputElement extends Observable {
    public click():void {
        this.state = { event: "click" }
        this.notify()
    }
}
const input = new InputElement()
input.addObserver(new ConsoleLogger())
input.click()

如你所见,通过两个抽象类,我们可以定义 Observer,它代表对 Observable 实体上的更改做出反应的对象。在上面的示例中,我们假装有一个被点击的 InputElement 实体(类似于你在前端的 HTML 输入字段),以及一个 ConsoleLogger,用于自动记录 Console 发生的所有事情。这种模式的优点在于,它使我们能够了解 Observable 的内部状态并对其做出反应,而不必弄乱其内部代码。我们可以继续添加执行其他操作的 Observer,甚至包括对特定事件做出反应的观察者,然后让它们的代码决定对每个通知执行的操作。

装饰器 

装饰器模式会在运行时向现有对象添加行为。从某种意义上说,你可以将其视为动态继承,因为即使你没有创建新类来添加行为,也会创建具有扩展功能的新对象。

比如你有一个带有 move 方法的 Dog 类,现在你想扩展其行为,因为你想要一只超人狗(当你让它移动时它可以飞起来)和一只游泳狗(当你告诉它移动时就钻进水里)。

一般来说,你会在 Dog 类中添加标准移动行为,然后以两种方式扩展该类,即 SuperDog 和 SwimmingDog 类。但是,如果你想将两者混合起来,则必须再创建一个新类来扩展它们的行为。其实这里有更好的方法。

组合(Composition)使你可以将自定义行为封装在不同的类中,然后使用该模式将原始对象传递给它们的构造器来创建这些类的新实例。看一下代码:

abstract class Animal {
    abstract move(): void
}
abstract class SuperDecorator extends Animal {
    protected comp: Animal

    constructor(decoratedAnimal: Animal) {
        super()
        this.comp = decoratedAnimal
    }

    abstract move(): void
}
class Dog extends Animal {
    public move():void {
        console.log("Moving the dog...")
    }
}
class SuperAnimal extends SuperDecorator {
    public move():void {
        console.log("Starts flying...")
        this.comp.move()
        console.log("Landing...")
    }
}
class SwimmingAnimal extends SuperDecorator {
    public move():void {
        console.log("Jumps into the water...")
        this.comp.move()
    }
}

const dog = new Dog()
console.log("--- Non-decorated attempt: ")
dog.move()
console.log("--- Flying decorator --- ")
const superDog = new SuperAnimal(dog)
superDog.move()
console.log("--- Now let's go swimming --- ")
const swimmingDog = new SwimmingAnimal(dog)
swimmingDog.move()

注意一些细节:

  • 实际上,SuperDecorator 类扩展了 Animal 类,Dog 类也扩展了这个类。这是因为装饰器需要提供与其尝试装饰的类相同的公共接口。

  • SuperDecorator 类是 abstract,也就是说你实际上并没有使用它,只是用它来定义构造器,该构造器会将原始对象的副本保留在受保护的属性中。公共接口是在自定义装饰器内部完成覆盖的。

  • SuperAnimal 和 SwimmingAnimal 是实际的装饰器,它们是添加额外行为的装饰器。

这种设置的好处是,由于所有装饰器也间接扩展了 Animal 类,因此如果你要将两种行为混合在一起,则可以执行以下操作:
console.log("--- Now let's go SUPER swimming --- ")
const superSwimmingDog = new SwimmingAnimal(superDog)
superSwimmingDog.move()

如果你要使用经典继承,则动态结果会多得多。

    组合    

最后来看组合模式,这是打包处理多个相似对象时非常有用且有趣的模式。

这种模式使你可以将一组相似的组件作为一个组来处理,从而对它们执行特定的操作并汇总所有结果。

这种模式的有趣之处在于,它不是一个简单的对象组,它可以包含很多实体或实体组,每个组可以同时包含更多组。这就是我们所说的树。

看一个例子:
interface IProduct {

    getName(): string
    getPrice(): number 
}
//The "Component" entity
class Product implements IProduct {
    private price:number 
    private name:string

    constructor(name:string, price:number) {
        this.name = name
        this.price = price
    }

    public getPrice(): number {
        return this.price
    }

    public getName(): string {
        return this.name
    }
}
//The "Composite" entity which will group all other composites and components (hence the "IProduct" interface)
class Box implements IProduct {
    private products: IProduct[] = []

    contructor() {
        this.products = []
    }

    public getName(): string {
        return "A box with " + this.products.length + " products"
    }

    add(p: IProduct):void {
        console.log("Adding a ", p.getName(), "to the box")
        this.products.push(p)
    }
    getPrice(): number {
        return this.products.reduce( (curr: number, b: IProduct) => (curr + b.getPrice()), 0)
    }
}
//Using the code...
const box1 = new Box()
box1.add(new Product("Bubble gum", 0.5))
box1.add(new Product("Samsung Note 20", 1005))
const box2 = new Box()
box2.add( new Product("Samsung TV 20in", 300))
box2.add( new Product("Samsung TV 50in", 800))
box1.add(box2)
console.log("Total price: ", box1.getPrice())

在上面的示例中,我们可以将 Product 放入 Box 中,也可以将 Box 放入其他 Box 中。这是组合的经典示例,因为你要达到的目的是获得要交付产品的完整价格,因此你要在大 Box 中添加每个元素的价格(包括每个较小 Box 的价格)。这样,通常称为“component”的元素是 Product 类,也称为树内的“leaf”元素。这是因为该实体没有子级。Box 类本身是组合类,具有子列表,所有子类都实现相同的接口。最后一部分代码是因为你希望能够遍历所有子项并执行相同的方法(请记住,这里的子项可以是另一个较小的组合)。

该示例的输出应为:
Adding a  Bubble gum to the box
Adding a  Samsung Note 20 to the box
Adding a  Samsung TV 20in to the box
Adding a  Samsung TV 50in to the box
Adding a  A box with 2 products to the box
Total price: 2105.5

因此,在处理遵循同一接口的多个对象时,请考虑使用这种模式。它将复杂性隐藏在单个实体(组合本身)中,你会发现它有助于简化与小组的互动。

    小结    

设计模式是用于解决问题的完美工具,但你必须先了解它们,并针对自身面对的场景做一些调整才能让它们起作用,或者修改你的业务逻辑以配合模式。无论是哪种方式,都是一项不错的投资。

你最喜欢哪种模式呢?你会经常在项目中使用它们吗?在评论中分享自己的看法吧!

延伸阅读

https://blog.bitsrc.io/design-patterns-in-typescript-e9f84de40449

今日荐文


活动推荐

谷歌云游戏盛会为你解读游戏行业的最新前沿趋势;最大限度地降低基础架构复杂性, 助力开发者打造出色的游戏体验;如何用谷歌云产品打造出色的游戏体验?游戏出海的关键难点有哪些?一切尽在谷歌云游戏专区,带给你游戏行业的第一手前沿资讯、活动解读与技术解决方案,快扫描二维码或点击阅读原文开启游戏产品的蜕变之旅!