【进阶】JavaScript这门语言下的14种设计模式

692 阅读18分钟

GoF 中介绍了23种设计模式,这些设计模式是否全部都适用于JavaScript这门语言?答案必然是否定的。

年前,为公司开发做了一个以electron为基础的工具,需要与C#交互,起先是用node-ffi,后来换成了node的child_process,也不知道是否正确。最近又要追加功能,发现之前写的代码实在是不好拓展。虽然也重构了几次,但是效果没有达到预期,就找来一些书看一下,其中关于设计模式的介绍就看到了《Javascript设计模式与开发实践》——曾探 ,这本书中介绍了16种适合于javascript语言的特殊模式。这本书是15年修订,到现在应该已经算古老了吧,不过书中的内容大多数还是比较实用的,也是向上人群可以看的一本书。

书中罗列了:

  1. 单例
  2. 策略
  3. 代理
  4. 迭代器
  5. 发布订阅(未在GoF 之列,有人认为和观察者是一致的)
  6. 命令
  7. 组合
  8. 模板方法
  9. 享元
  10. 责任链
  11. 中介者
  12. 装饰器
  13. 状态机
  14. 适配器

共计14种模式,还未介绍的有10种,他们是:

  1. 原型
  2. 工厂
  3. 抽象工厂
  4. 建造者
  5. 桥接
  6. 外观
  7. 观察者
  8. 访问者
  9. 备忘录

我们先从未列入的模式入手:

1. 原型模式(Prototype)

众所周知,Javascript本身就是一种基于原型的变成语言,所有的对象都是基于原型链的设计模式,因此作者不需要将其列入,那我们实际上也不太需要详细叙说。

2. 工厂模式(Factory)

对于强类型校验的语言来说,需要有一个明显的对象创建过程,而工厂模式就是为此而设计的。

当我们需要构建一个复杂的对象时,需要用到工厂模式。一方面是降低相同对象创建过程的模板代码,另一方面是减少页面上使用new关键字的频次,从而让代码专注于业务处理,并且语义上更加可读,且在该对象未来有较大的创建过程修改时,减少修改的代码量。

但是对于js来说,js的灵活性保证了我们很少需要创建复杂对象,也就很少拥该模式构建对象。

一般编程语言中将会使用 Factory 这一名词作为工厂模式的后缀,比如著名的BeanFactory

3. 抽象工厂(Abstract Factory)

抽象工厂被成为工厂的工厂。

与一般工厂只生产一个对象不同,抽象工厂内可以创建多种对象。每个对象之间不会有必然的关系,但是却会和主体有一定关系,一般举例子都会用绘画来说明问题。

当我们用Ps或者其他的工具绘画时,会有颜色、形状等一系列绘画工具,这些一般是为了绘画而准备的,形状有矩形、圆形等不同图案,颜色有红色、绿色等不同色彩。使用抽象工厂可以将他们放在在一起,避免了分散查找不同的工具。

下面是一段伪代码:

DrawToolsAbstructFactory{
     Shape getShape();
     Color getColor();
}

ShapeFacotory extends DrawToolsAbstructFactory {
        Shape getShape(){
            ...
            return Shape
        }
}

ColorFactory extends DrawToolsAbstructFactory{
    Color getColor(){
        ...
        return Color
    }
}

Producer {
    DrawToolsAbstructFactory getFactory(typename){
           if(typename=='shape'){
               return ShapeFacotory
           }
           
           if(typename == 'color'){
               return ColorFactory
           }
    }
}

Producer.getFactory('color').getColor();

抽象工厂的优点是内聚,但是缺点也很明显,就是拓展性较差,每增加一种类型,就需要更新所有的工厂子类,所以不适合较复杂的应用场景。自然对于灵活的js而言,是不需要的。

4.建造者(Builder)

建造者模式和工厂模式有些类似,工厂模式是生产某种对象同一型号,相当于流水线。而建造者是生产某种对象的自定义型号,相当于极客。

拿电脑主机来说:同一工厂模式下所生产出来的主机是相同的,相同配置相同大小。而建造模式则可以自由组合,可以给到推荐选项,但是最终是由构建的的分部不同 —— 不同的机箱、电源、主板、显卡、CPU,最终生产一个配置不同的电脑主机。

java中最熟悉的应该是StringBuilder,毕竟面试时总要问 StringBuilderStringBuffer的区别。

此外,我们也可以把它与模板方法放到一起,但是又有少于不同。模板方法是执行环节的自定义,而建造模式则是在创建过程的自定义。

5.桥接(Bridge)

讲到桥接这两个字,我自己会联想到路由器的桥接模式路由模式

光猫在桥接模式下,在路由器的管理页面配置上网账号,家中没有配备路由器,则需要直接在光猫的配置页面配置上网功能。由光猫承担路由模式,大多数光猫安装时都是直接拨号,所以一半的光猫都是路由模式。但是由于路由模式下光猫需要同时完成拨号光电转换的两种功能,因此网络时常会卡顿。

以上是一个生活小常识。

它是一种对象结构模型

桥接模式,就是将某个类型由原来的自我实现能力变成外部实现能力,通过关系组合,整体上保持一致的(光猫+路由器变成了一个上网整体)。就像这里路由器和光猫,光猫如果同时实现光电转换和拨号功能,势必会让光猫自身的能力下降,不如将上网功能交给路由器,自身也能全身心的投入到信号转换中去。

网上好多拿手机、画图举的例子,我就不多说了。

6. 外观(Facade)

MVC中的Service是被我们忽略的外观模式

外观模式本质上就是将多个调用逻辑放到一个方法里,当该对象被执行时,执行其他深层次的方法,但是本身并不保留任何状态。

而在阿里的中台玄学中,中台模式也是外观模式的一种。

外观模式隐藏了执行细节,保证输入和输出的参数类型的一致性。但是外观模式的拓展性较差,是强耦合的关系。也违反了开闭原则,但是优点是保证了输入和输出的任意一端的变化对系统的整体变化影响不大。

7. 观察者(Observer)

我想使用vue的同学对这个模式很是熟悉,已经是背的滚瓜烂熟了。观察者分为两部分,第一部分是被观察的对象,第二部分是订阅观察的对象。

观察者的理解很容:

你奶奶跟你说,如果你爸打你,你就告诉奶奶。

这个故事阐述了了三方的关系:

  • 你爸:变化
  • 你:观察
  • 你奶奶:收到观察结果通知

不过实际执行中,我们不会只告知一方,而是会通知多方,可能告诉你爷爷,也可能告诉你认为可以保护你的人。

另一个不恰当的例子是:打小报告,妥妥的符合上面的观察者不是吗?

Vue中观察者的设计稍微的讲述一下:

我们都知道vue2使用 Object.definePropertyvue3将它更新为Proxy ,重定义对象的data属性。重写时,会设置Dep收集订阅人Watcher的订阅消息,当data属性被更新时,会由Dep通知Watcher,然后Watcher再执行view的更新工作

讲道理,如果这本书现在写,应该就会把它放到js的设计模式里去了吧

8. 访问者(Visitor)

GoF作者评价说:最复杂的设计模式,大多情况下,你不需要使用访问者模式,但是一旦需要使用它时,那就真的需要使用了

访问者模式中的三个元素:访问者(Visitor)、具体的访问(ConcreteVisitor)、被访问对象(Element)、具体的被访问对象(ConcreteElement)。

这里的例子是借用他人智慧,讲述了房主人和客人同时喂养猫狗的事情,我们可以抽象的来看待这件事:首先,地点是一个具体的位置:Home,房主和客人划归为Person,猫狗划归为Animal

那就有了如下伪代码:

Person {
    void feed(Cat)
    void feed(Dog)
}

Animal {
   accpet(Person)
}

Home {
    List<Animal> animals = new List<Animal>();
    
    add(Animal){  animals.add(Animal) ;}
    
    action(Person){
        forloop animal : animals {
            animal.accpet(Person)
        }
            
    }
}

于是乎上面的具体逻辑就变成了:

Owner:Person {
    void feed(Dog) {
        Dog
    }
    void feed(Cat) ...
}

Customer:Person {
    void feed(Dog) ...
    void feed(Cat) ...
}

Dog:Animal {
    accept(Person) ...
}

Cat:Animal {
    accept(Person) ...
}

执行时:

Home aHome  =  new Home();

aHome.add(new Dog)
aHome.add(new Cat)

aHome.action(new Owner)
aHome.action(new Customer)

看起来是够复杂的,仔细一想,无非就是是谁喂了狗和猫,猫狗在房子里不变,但是和猫狗互动的人在变化。不过以上都是强类型的问题,在js中,函数是一等公民,实现起来就相对容易些。

function Home(){
    let animals = []
    return {
        add(animal){
            animals.push(animal)
        },
        
        action(person){
            animals.forEach((animal)=>{
                animal.accept(person)
            })   
        }
    }
}
const Cat = { 
   name:"cat",
   accept(person){
       person.feed(this)
   } 
}

const Dog = { 
   name:"dog",
   accept(person){
       person.feed(this)
   } 
}
const Owner = {
    name:'Owner',
    feed(animal){
        console.log(`${this.name} feed ${animal.name}`)
    }
}

const MyHome = Home();
MyHome.add(Cat)
MyHome.add(Dog)

MyHome.action(Owner); 
// Owner feed cat
// Owner feed dog

在写文章时,对于访问者模式完全不了解,因此讲述的内容很可能有错误,希望看官擦亮眼睛,不要被虚假所迷惑

9. 备忘录(Memento)

备忘录让我想起来路由的前进和后退,每一个路由访问历史都是一次备忘记录

备忘录就是将每一次动作的结果记录下来,方便事情回到过去,是一个后悔药。备忘录就像是考试时的草稿,在脑海里运算的过程写在草稿上,可以方便追溯思考过程。

每一次内存快照,每一次的router.push,都将会是一次历史状态的保存。如果我们需要做撤销ctrl+z,那么就需要做状态保存。

function History(){
    let stateList = [];
    return {
        add(state){
            this.stateList.push(state)
        }
        get(index){
            return this.stateList[index]
        }
    }
}

const Router = {
    state:"",
    push(state){
        this.state = state
    },
    getStateFromMemento(memento){
        this.setState(memento.state)
    },
    saveStateToMemento(){
        return { state:this.state }
    }
}


const MyHistory = History()
Router.push("/")
Router.push("/index")

MyHistory.add(Router.saveStateToMemento()) //保存一次路由状态

Router.push("/home")
Router.state // /home

Route.getStateFromMemento(History.get(0))

Router.state // /index

是一个相对简单的模式。


讲述完了未在本书中存在的设计模式,下面就轮到已存在的内容了。写到这儿,着实有些困,相信看官也应该是乏了,但是书还要写,故事还要说,尽量写的完美些,生动些。这样文字就不是那么苍白,内容也不会没有吸引力了

1. 单例(Singleton)

假设单身的人叫单身狗,那么情侣应该怎么称呼?

js中的单例着实简单,在单线程下,随便申明一个对象就已经是单例的了。这点和多线程面向对象相比门槛低了不少。而今有了的模块(module)概念后,所有的包对象就是一个单例对象,在系统下不会重复的创建模块,只有对该模块的引用。

js中最大的单例对象就是globalThis,在浏览器中globalThiswindow的别名。

2. 策略(Strategy)

老鼠说得好,条条管道通我家

策略模式是一种执行方式,当输入不变时,选择不同的策略可以得到不同的输出结果。有时候我们会拿它同适配器模式作比较,这样会认识的深刻些。

就像下棋,当移动棋子时,不同的移动方式对最后的输赢起着决定性的作用,可能跳马会比平炮更加有用。


const Context = {
    strategy:undefined,
    setStrategy(strategy){
        this.strategy = strategy;
    },
    execute(num1,num2){
        return this.strategy(num1,num2)
    }
}

const AddStrategy = function (num1,num2){  return num1 + num2 }
const MultiplyStrategy = function (num1,num2){  return num1 * num2 }

Context.setStrategy(AddStrategy);
Context.execute(1,2) // 3

Context.setStrategy(MultiplyStrategy);
Context.execute(1,2) // 2

在实际开发中,我们会把它应用在表单校验上,对同一个表单字段提供不同的校验策略,最终验证得到的结果是否满足所有校验。

3. 代理(Proxy)

小明喜欢小红,但是又不敢表白,于是找到了小红的闺蜜代传情书,结果...

代理模式想必大家已经不陌生,特别是使用过vue3的同学。使用代理的目的是为了保护访问对象,有限制的对外访问。

var po = new Proxy({name:1},{
    set(target,key,value){
        if(key=='name' && value <0){
            return false
        }
        console.log(key,value)
        target[key] = value
    }
})

po.name = -1
po.name // 1

4. 迭代器(Iterator)

愚公移山时说子子孙孙无穷尽也。但是愚公忘了一件事情,万一,这个后辈人他没钱结婚?

不知道你们知不知道,在没有async await时,是如何解决异步嵌套执行问题的? 而我们当初使用的是 function* yield,也有一个著名的库叫co。 迭代器的next的结果通常为 {value:T,done:boolean}。js中,Map、Set两种数据类型通过.entries()获取数据的迭代器对象。

在java里,我常用到的迭代器应该是对Map对象的遍历,先从map中得到迭代器对象,然后再使用迭代器对象判断是否hasNex,然后用next访问下一个对象值。再经行类型转换,再做一定的处理。

迭代器的设计目的就是按照一定的规则遍历,forEach内部就是迭代器的一种实现方式。不过使用forEach时,不可以修改源对象的长度。

5. 发布订阅(未在GoF 之列,有人认为和观察者是一致的)

每天清晨就需要与设定好的闹钟做抗争

发布订阅和观察者模式,两者看起来是相同的,我们也可以归为一类。但是在实际的执行中,会给发布订阅中间追加一道委托执行队列,用来按序执行订阅消息推送。这时候的发布订阅看起来就会和中介模式有些相似。

发布订阅通常被设计为:

发布人 --> 发布消息 + 通知人回调  --> 消息队列  --> 按序执行  

而观察者一般是

观察员 --> 发布消息 + 通知人回调  --> 执行

发布订阅的内容是不会由发布人自己执行,而是通过委托他人代为执行,但是发布订阅又保留了对订阅人的控制,是介于观察者和中介模式的一种中间模式。

除此之外和观察者没有其余的区别了。

6. 命令(Command)

我命令你,必须读完这篇文章。

命令模式下,调用者不关心命令的执行过程,接收者不关心命令的来源,每一条命令是清晰明确的。

就像菜馆里,服务员只要协助用户下订单。下单完成后,通过命令发送给厨师,厨师只需要根据收到的订单出菜,通过命令告诉传菜员将这盘菜传递到多少号桌。

7. 组合(Composite)

领导总是说:我们要打组合拳。

组合模式会将一组相似的执行对象组合在一起,他们之间没有父子关系。就像遥控器,实现、开机、搜台、播放三个功能,通过组合按钮一键完成。

在对树型结构做操作时,可以用到组合模式,叶子节点和根节点就像文件夹与文件的关系一样,不存在父子关系,但是可以组合在一起。

8. 模板方法(Template)

被别人指导写代码时,总会听到到一句话:能复用的尽量复用

这算是常用的方法之一了,从一个基础模板方式派生出来的新方法,以增强代码的多样性。常见的有:Axios请求,比如get、post都是基于模板方法。比如 element-ui的dialog

模板方法的会预制好执行的步骤,但是会提供一定的参数以便修改。但是整个执行过程是不变的。

此外,vue2的hooks也是模板方法的一种。因为不管如何编写,vue总会按顺序执行这些hooks。

9. 享元(Flyweight)

奥卡姆剃(须)刀的原则就是:如无必要,勿增实体。看到手机里一批一批的os APP,很显然他错了,而且错的还很离谱。

它和单例不同的是,单例只维系一个对象,而它会维系多个相似对象。单例不会销毁对象,而享元会在对象一定时间内没有被使用时释放对象。

享元模式用到最多的就是线程池了,可以弹性伸缩,根据系统需要尽可能复用对象,节约资源。

10. 责任链(Chain of Responsibility)

击鼓传花的游戏,谁是最后一棒

考试时候,我们小炒的一种方式就是传纸条,但是我们不关心传递的纸条去哪儿,但是传递的对象都是纸条。

很容易联想到webpack的loader,loader是一种chain,一环套一环的执行,最终输出一个file。

11. 中介者(Mediator)

中介的作用就是传声筒,赚的是信息差。

还是喜欢用租房子来举例子,毕竟招租是中介的基本活之一。在没有中介之前,房东想要出租房子,就需要张贴广告,租房人需要满大街的找,需要一个一个联系,看看房东的房子是否合适,租金是否在合适的范围内,一两个还好,十个八个的房东自己也受不了,毕竟自己还要做其他工作。而租房者也不会因为看过一个房东的房子,就直接定下了。

房客面临的是如何选择合适的房屋,房东是选择合适的房客。两方面单线联系,就会变成一个蜘蛛网。房东自己忙不过来,租客自己也很难应付。

如果这时候有一个无关第三人,作为两边的沟通桥梁,单纯的做这件事,帮房东和房客之间做好沟通岂不是很好!?

那中介就是在这个环境下诞生的,中介有一个通讯里,有租客有房东,按照一定的条件进行分组,比如 租客希望租金在500以下,房东希望租金不低于450。那么条件重合下,由中介通知租客并且告知房东带看房子,合适的话就可以签约了。

中介会按照一定的规则将发布人的信息通知到订阅人,而订阅人也可以反过来告诉中介对房东的要求,他们是双向的。

中介模式和发布订阅有什么区别呢?

我们可以用聊天室举例子。

如果一个人想要收到其他人发布的消息,他有两种方式。

  • 第一个方式:订阅别人的消息。
  • 第二个方式:由别人通知他。

第一个方式问题很多,消息接收人需要订阅群内其他所有人的通知消息,这样每个人都需要订阅一个除了他之外的所有人,当一个群很庞大时,订阅量将是很可怕的存在n*(n-1)。而使用中介模式则只需要维护中介与订阅人之间的关系即可n。通过建立的规则有选择的将消息传递给对方,类似于消息总线。

12. 装饰器(Decorator)

《我是一个粉刷匠》

js中的装饰器是针对 class以及其属性和方法的,装饰器会修改被装饰对象的本身,和java中的注解是有本质区别的。

 注解:注释,解答。是一种配置形式。

这里对装饰器描述不会过多,网上也有大篇幅的文字介绍。

王婆自荐一下,d4axios 就是使用装饰器管理axios请求的一种方案。

13. 状态机(State)

不知道有没有一种符号能表示薛定谔的猫?

开发动作游戏时,也会涉及到 移动,跳跃,格挡,攻击,这几个状态互有影响,如果没有一个很好的模式,就会存在大量的if-else语句,代码就会极其难看。在PLC编程中,也经常涉及到状态的切换,使用action-state设计一张二维表格,将所有的情况列举出来。最终确定状态的转换关系,我们称之为有限状态机,它是对对象行为的一种建模方法。

14. 适配器(Adapter)

鞋合不合适,只有脚知道

适配器策略模式以及责任链模式三个是要放在一起比较的。

适配器,常见的是电源适配,输入不同电压,输出相同电压。

策略,输入相同的对象,配置不同策略,得到不同结果

责任链,输入相同,配置不同的责任环节,输出对象的结果

策略和适配器是一套相反流程,策略和责任链是一套相似流程。

策略注重结果(表单校验时,输入的校验值是相同的),责任链注重过程变化(loader处理时,传递的值是上一环的处理结果,值可能存在变化)