“一次学习 终身受用” —— JavaScript设计模式

8,353 阅读25分钟

简介

处在知识点贼多的前端领域,总想着学习一些“高性价比“的知识,几经搜寻后,找到了小册中修言JavaScript 设计模式核⼼原理与应⽤实践,想起了这个有点了解但不多的“设计模式”,受益匪浅,将思考总结后的知识和大家分享下,希望共同进步。

在软件工程中,设计模式(design pattern)是对软件设计中普遍存在(反复出现)的各种问题,所提出的解决方案。 ——维基百科

设计模式代表了最佳的实践,通常被有经验的面向对象的软件开发人员所采用。

设计模式是一套被反复使用的、多数人知晓的、经过分类编目的、代码设计经验的总结。使用设计模式是为了重用代码、让代码更容易被他人理解、保证代码可靠性。

设计模式原则

  1. 单一职责原则:一个程序只做好一件事、如果功能过于复杂就拆分开,每个部分保持独立
  2. 开放/封闭原则:对扩展开放,对修改封闭;增加需求时,扩展新代码,而非修改已有代码
  3. 里氏替换原则:子类能覆盖父类、父类能出现的地方子类就能出现
  4. 接口隔离原则:保持接口的单一独立,类似单一职责原则,这里更关注接口
  5. 依赖倒转原则:面向接口编程,依赖于抽象而不依赖于具体, 使用方只关注接口而不关注具体类的实现。

在 JavaScript 设计模式中,主要用到的设计模式基本都围绕“单一功能”和“开放封闭”这两个原则来展开。

设计模式详解

本篇文章中舍去了一些设计模式,只留下了“前端中好用,面试中常考”的部分。

  • 创建型

    • 构造器模式
    • 原型模式
    • 工厂模式
    • 抽象工厂模式
    • 单例模式
  • 结构型

    • 装饰器模式
    • 适配器模式
    • 代理模式
  • 行为型

    • 观察者模式
    • 迭代器模式

创建型-构造器模式

在面向对象的编程语言中,构造器是一个类中用来初始化新对象的特殊方法,并且可以接受参数用来设定实例对象的属性和方法。

有一天接到个需求,我们需要将三年二班的教职工录入系统中,此时班里只有小明自己,定义学生时,三下五除二就写完了。

  // 定义学生
  let 小明 = {
      name:"小明",
      age:12,
      gender:'男',
      identity: '学生'
  }

又进行一天的招生后,来了小红和小强,于是CV后把他也加入了...

  // 定义学生
  let 小明 = {
      name:"小明",
      age:12,
      gender:'男',
      identity: '学生'
  }
  let 小红 = {
      name:"小红",
      age:13,
      gender:'女',
      identity: '学生'
  }
  let 小强 = {
      name:"小强", 
      age:13,
      gender:'男',
      identity: '学生'
  }

又过了两天你老板过来了 说:“三年二班杀疯了,一天之间招进来了80个学生”。此时继续以上写法,代码肯定是重复并且臃肿

此时构造器就派上了用场,在面向对象的编程语言中,构造器是一个类中用来初始化新对象的特殊方法,并且可以接受参数用来设定实例对象的属性和方法。

基本构造器 在 JS 中,ES6之前是没有类这个概念的,所以一般用函数来表示一个构造器,使用方法是在构造器函数前使用 new 关键字。

所以,基本的构造器模式看起来是这样的:

 function Student(name,gender,age){   // 注意,构造函数首字母一般为大写
     this.name = name;
     this.gender =gender;
     this.age =age;
     this.sayName = function(){
         console.log("我是" + this.name)
     }
 }
let 小明 = new Student("小明",'男',12)
let 小红 = new Student("小红",'女',13)
console.log(小明.sayName()) // -> "我是小明"

代码的冗余程度直线减少了,但也有个不理想的地方,就是每次创建一个新对象,都需要重新定义 sayName 这个方法。

为了使 sayName 这个方法在实例之间共享,我们使用原型(prototype)来优化。

创建型-原型模式

原型模式,就是创建一个共享的原型,通过拷贝这个原型来创建新的类,用于创建重复的对象,带来性能上的提升。

ps: 此块使用到原型链知识

继续上面例子:

function Student(name,gender,age){
    this.name = name;
    this.gender =gender;
    this.age =age;
}
// 如果在构造函数的原型属性上添加 sayName 方法,那么所有实例化的对象都会共享这个方法。优化代码是这样的:
Student.prototype.say = function(){ 
    console.log("我是" + this.name)
}
let 小明 = new Student("小明",'男',12)
console.log(小明.sayName()) // -> "我是小明"

扩展:ES6版本 ES6 支持了类的定义,所以写起来风格更加优雅。

class Student {
  constructor(name,gender,age) {
    this.name = name
    this.gender =gender;
    this.age =age;
    this.work = ['学习','玩游戏']
  }
  sayName(){
     console.log("我是" + this.name)
  }
}
let 小明 = new Student("小明",'男',12)
小明.sayName() // -> "我是小明"

特点:

构造函数内不定义属性和方法,把属性和方法都定义在构造函数的原型上。这样所有的对象实例都共享对象原型上的属性和方法

优点:

  1. 当创建新的对象实例较为复杂时,使用原型模式可以简化对象的创建过程,通过克隆一个已有实例可以提高新实例的创建效率。
  2. 多个实例可以共享原型上的属性和方法

缺点:

  1. 修改原型上的一些引用属性,所有实例对应的属性也将被改变,这样可能带来一些问题

创建型-工厂模式

由一个工厂对象决定创建某一种产品对象类的实例。主要用来创建同一类对象。

我们三年二班除了学生还有教师,或许还有专职的助教,此时我们的Student类并不能满足我们的需求,所以此时我们需要再创建“教师”与“助教”

// 教师
class Teacher {
  constructor(name,gender,age) {
    this.name = name
    this.gender = gender;
    this.age = age;
    this.work = ['教书','偷懒']
  }
}
// 助教
class AssistantTeacher {
  constructor(name,gender,age) {
    this.name = name
    this.gender =gender;
    this.age =age;
    this.work = ['协助教师','收钱']
  }
}

现在我们有三个类了(后面可能还会有更多的类),麻烦的事情来了:难道我每从数据库拿到一条数据,都要人工判断一下这个人的身份,然后手动给它分配构造器吗?可以实现,但不推荐,最好还是交给函数去处理:

function Factory(name, age, gemder,identity) { 
    switch(identity) { 
        case 'stucent': 
            return new Student(name, age, gender) 
        case 'teacher': 
            return new Teacher(name, age, gender) 
        case 'assistantTeacher': 
            return new AssistantTeacher(name, age, gender) 
    }
 }

看起来是好一些了,至少我们不用操心构造函数的分配问题了。

但如果再来几个身份,例如学生家长,例如寝室阿姨,难道要手写十个类、数十行 switch 吗?

当然不!

我们仔细观察上面的代码,发现每个类都有用name、age、gender、work这四个属性,它们之间的区别,也只在于 work 字段需要随 identity 字段取值的不同而改变,而其他三个不变,这样以来,我们是不是对共性封装的不够彻底呢?

现在我们把相同的逻辑封装回User类里,然后把这个承载了共性的 User 类和个性化的逻辑判断写入同一个函数:

function User(name , age, gender, identity, work) {
    this.name = name
    this.age = age
    this.gender = gender
    this.identity = identity 
    this.work = work
}

function Factory(name, age, gender, identity) {
    let work = []
    switch(identity) {
        case 'student':
            work = ['学习','玩游戏']
            break
        case 'teacher':
            work = ['教书','偷懒']
            break
        case 'assistantTeacher':
            work = ['协助教师','收钱']
        case 'xxx':
            // 其它身份
            ...
            
    return new User(name, age, gender, identity, work)
}

这样一来,是不是爽多了?我们要做的事情可以简单太多,不用时刻想着拿到的这组数据是什么工种,不用想着给他分配什么构造函数,更不用手写无数个构造函数!!Factory函数 已经帮我们做完了一切,而我们只需要像以前一样无脑传参就可以了,舒服了!

简单总结一下,工厂模式其实就是将创建对象的过程单独封装。就像去小卖铺买东西,你不必关心这个东西的制作过程,只用告诉老板你想要的,老板就会把物品return给你。

工厂模式很爽,因为他实现了无脑传参

创建型-抽象工厂模式

前言

在实际的业务中,我们往往面对的复杂度并非数个类、一个工厂可以解决,而是需要动用多个工厂。

我们继续看上个小节举出的例子,简单工厂函数最后长这样:

function Factory(name, age, gender, identity) {
    let work = []
    switch(identity) {
        case 'student':
            work = ['学习','玩游戏']
            break
        case 'teacher':
            work = ['教书','偷懒']
            break
        case 'assistantTeacher':
            work = ['协助教师','收钱']
        case 'xxx':
            // 教导主任
            ...

    return new User(name, age, gender, identity, work)
}

首先映入眼帘的是我们把所有身份塞进了同一个工厂,例如老师和学生,又例如之后可能会添加进来的教导主任,他们每种身份的权限都会存在着很大的差别,有些操作老师可以执行,又有些操作只有学校的管理层可以执行,因此我们需要对这个群体的对象进行单独的逻辑处理。

怎么办?去修改 Factory 的函数体,增加老师、教导主任相关的判断和处理逻辑吗?单从功能实现上来说,可以。但这么做会让代码变成💩山,因为学校还有校长、外包的食堂阿姨等等,每考虑到一个新的员工群体,就得去修改一次 Factory 的函数体。

这样做的后果是:

  1. 坑自己 —— Factory函数体会变得非常庞大,导致每次添加角色的时候都不敢下手,因为一旦写出Bug,就会导致整个Factory函数的崩坏,进而摧毁整个系统;
  2. 坑队友 —— Factory 的逻辑过于繁杂和混乱,没人想维护它;
  3. 坑测试 —— 每新加一个工种,他都需要整个Factory 的逻辑进行回归,因为改变是在 Factory 内部发生的

因为没有遵守开放封闭原则:对拓展开放,对修改封闭。

楼上这波操作错就错在我们不是在拓展,而是在疯狂地修改。

详解

抽象工厂模式(Abstract Factory Pattern)是围绕一个超级工厂创建其他工厂。该超级工厂又称为其他工厂的工厂。这种类型的设计模式属于创建型模式,它提供了一种创建对象的最佳方式。

作为上帝,我们想要创建一个动物,基本组成是躯体(Body)与灵魂(Soul)组成,我们准备开一个工厂来量产,但是我们又不知道具体生产的是什么类型的动物,只知道由这两部分组成,所以我先来一个抽象类来约定住动物的基本组成:

class AnimalFactory {
    // 创造躯体
    createBody (){
        throw new Error('抽象工厂方法不允许直接调用,你需要将我重写!');
    }
    // 创建灵魂
    createSoul(){
        throw new Error('抽象工厂方法不允许直接调用,你需要将我重写!');
    }
}

楼上这个类除了约定动物的基本构成外,啥也不干,如果你尝试new一个AnimalFactory实力并调用里面的方法,它都会给你报错。在抽象工厂模式里,楼上这个类就是我们食物链顶端最大的Boss——AbstractFactory(抽象工厂);

抽象工厂不干活,具体工厂(ConcreteFactory)干活!当我们明确了生产方案以后就可以化抽象为具体,比如现在需要生产哺乳动物,那我就可以定制一个具体工厂

    复制代码
    //具体工厂继承自抽象工厂
    class Mammals extends AnimalFactory {
        createBody() {
            // 提供哺乳动物的躯体
            return new MammalsBody();
        }
        createSoul() {
            // 提供哺乳动物的灵魂
            return new MammalsSoul()
        }
    }

这里我们在提供哺乳动物的时候,调用了两个构造函数:MammalsBody和MammalsSoul,它们分别用于生成哺乳动物的躯体与灵魂。像这种被我们拿来用于 new 出具体对象的类,叫做具体产品类(ConcreteProduct)。具体产品类往往不会孤立存在,不同的具体产品类往往有着共同的功能,比如哺乳动物的躯体和爬行动物的躯体,虽身体中有着不同的构造,带起码都有个壳。因此我们可以用一个抽象产品(AbstractProduct)类来声明这一类产品应该具有的基本功能。

    
// 定义操作系统这类产品的抽象产品类
class Body {
    walking() {
        throw new Error('抽象产品方法不允许直接调用,你需要将我重写!');
    }
}

// 定义具体操作系统的具体产品类
class MammalsBody extends Body {
    walking() {
        console.log('我会用哺乳动物的方式行走')
    }
}

class reptilesBody extends Body {
    walking() {
        console.log('我会用爬行动物的方式行走')
    }
}

生产'灵魂'也是同理,这里就不重复了。

// 定义灵魂的抽象类
class Soul {
    // 灵性
    spiritual() {
        throw new Error('抽象产品方法不允许直接调用,你需要将我重写!');
    }
}

// 定义具体操作系统的具体产品类
class MammalsSoul extends Soul {
    spiritual() {
        console.log('我具有哺乳动物的灵性')
    }
}

class reptilesSoul extends Soul {
    spiritual() {
        console.log('我具有爬行动物的灵性')
    }
}

如此一来,当我们需要生产一个哺乳动物时,我们只需要:

// 哺乳动物
const Mammals = new Mammals()

const myMammals = {}
// 让它拥有躯体
myMammals.body =  Mammals.createBody()
// 让它拥有灵魂
myMammals.soul =  Mammals.createSoul()

当之后需要写一个新的物种,则不需要对动物工厂AnimalFactory做任何修改,只需要拓展它的种类:

class 火星某动物 extends AnimalFactory {
    createBody() {
      // 此种动物躯体
    }
    createSoul() {
      // 此种动物灵魂
    }
}

这么个操作,对原有的系统不会造成任何潜在影响所谓的“对拓展开放,对修改封闭”就这么圆满实现了。

抽象工厂和简单工厂有哪些异同?

共同点:在于都尝试去分离一个系统中变与不变的部分

不同点:场景的复杂度

抽象工厂模式的定义,是围绕一个超级工厂创建其他工厂,对一些工作经验少的同学来说可能较难理解,但目前来说在JS世界里也应用得并不广泛,所以大家不必拘泥于细节,只需对“开放封闭原则”形成自己的理解,知道它好在哪,知道执行它的必要性。

创建型-单例模式

保证一个类仅有一个实例,并提供一个访问它的全局访问点,这样的模式就叫做单例模式。

意图: 保证一个类仅有一个实例,并提供一个访问它的全局访问点。

主要解决: 一个全局使用的类频繁地创建与销毁。

如何解决: 判断系统是否已经有这个单例,如果有则返回,如果没有则创建。

单例模式是设计模式中相对较为容易理解、容易上手的一种模式,同时因为其具有广泛的应用场景,也是面试题里的常客

一般情况下,当我们创建了一个类(本质是构造函数)后,可以通过new关键字调用构造函数进而生成任意多的实例对象。像这样:

class SingleDog {
    show() {
        console.log('俺是一个单例对象')
    }
}
const s1 = new SingleDog()
const s2 = new SingleDog()

s1 === s2 // false

楼上我们先 new 创建了一个 s1,又 new 创建了一个 s2, s1与s2显然是没有任何联系的,两者各占一块内存空间,单例模式想要做到的是,无论创建多少次,它都只返回第一次所创建的那个实例。

要做到这一点,就需要构造函数具备判断自己是否已经创建过一个实例的能力。我们现在把这段判断逻辑写成一个静态方法(其实也可以直接写入构造函数的函数体里):

class SingleDog {
    show() {
        console.log('俺是一个单例对象')
    }
    static getInstance() {
        // 判断是否已经new过1个实例
        if (!SingleDog.instance) {
            // 若这个唯一的实例不存在,那么先创建它
            SingleDog.instance = new SingleDog()
        }
        // 如果这个唯一的实例已经存在,则直接返回
        return SingleDog.instance
    }
}
const s1 = SingleDog.getInstance()
const s2 = SingleDog.getInstance()

s1 === s2 // true

除了楼上这种实现方式之外,getInstance的逻辑还可以用闭包来实现:

    SingleDog.getInstance = (function() {
        // 定义自由变量instance,模拟私有变量
        let instance = null
        return function() {
            // 判断自由变量是否为null
            if(!instance) {
                // 如果为null则new出唯一实例
                instance = new SingleDog()
            }
            return instance
        }
    })()

可以看出,在getInstance方法的判断和拦截下,我们不管调用多少次,SingleDog都只会给我们返回一个实例,s1和s2现在都指向这个唯一的实例。

实现一个简易Storage

生产实践:redux、vuex中的Store,或者我们经常使用的Storage都是单例模式。

来实现一下简易Storage:

class Storage{
    static getInstance() {
        if(!Storage.instance) {
            Storage.instance = new Storage();
        }
        return  Storage.instance;
    }
    getItem(key) {
        return localStorage.getItem(key);
    }
    setItem(key, value){
        return localStorage.setItem(key, value);
    }
}

const storage1 = Storage.getInstance()
const storage2 = Storage.getInstance()
storage1.setItem('name', '小明')
storage1.getItem('name') // 小明
storage2.getItem('name') // 小明
storage1 === storage2 // true

优点

  • 划分命名空间,减少全局变量
  • 增强模块性,把自己的代码组织在一个全局变量名下,放在单一位置,便于维护
  • 且只会实例化一次。简化了代码的调试和维护

缺点

  • 由于单例模式提供的是一种单点访问,所以它有可能导致模块间的强耦合 从而不利于单元测试。无法单独测试一个调用了来自单例的方法的类,而只能把它与那个单例作为一个单元一起测试。

场景例子

  • 定义命名空间和实现分支型方法

  • 登录框

  • vuex 和 redux中的store

结构型-装饰器模式

在我们的开发过程中我们会为了一些通用功能在多个不同的组件、接口或者类中使用,这个时候我们这些功能写到每个组件、接口或者类中,但是这样非常不利于维护。

装饰器模式(Decorator Pattern)允许向一个现有的对象添加新的功能,同时又不改变其结构。

理解了装饰器的能解决了什么问题,那我们在什么情况下考虑使用装饰器模式呢?我的理解是:

  • 需要扩展一个类,为这个类附加一个方法或者属性的时候;
  • 需要修改一个类的功能,或者重构这个类中的某个方法;

如何定义装饰器

装饰器本质是一个函数,可以分为带参数和不带参数(也叫装饰器工厂),装饰器使用 @expression这种形式,expression求值后必须为一个函数,它会在运行时被调用,被装饰的声明信息做为参数传入。

@Test()
class Hello {}

function Test(target) {
    console.log("I am decorator.")
}

装饰器类型

类修饰器

类装饰器一般主要应用于类构造函数,可以监视、修改、替换类的定义,装饰器用来装饰类的时候。装饰器函数的第一个参数,就是所要装饰的目标类本身。

a、添加静态属性或方法

@Test()
class Hello {}

function Test(target) {
   target.a = 1;
}

let o = new Hello();

console.log(o.a) ==>1

b、添加实例属性或方法

@Test()
class Hello {}

function Test(target) {
   target.prototype.a = 1;
   target.prototype.f = function(){
       console.log("新增加方法")
   };

}

let o = new Hello();
o.f() ==>"新增加方法"
console.log(o.a) ==>1

c、装饰器工厂(函数柯里化)

@Test('hello')
class Hello {}

function Test(str) {
   return function(){
        target.prototype.a = str;
        target.prototype.f = function(){
            console.log(str)
        };
   }

}

let o = new Hello();
o.f() ==>"hello"
console.log(o.a) ==>"hello"

d、重载构造函数

@Test('hello')
class Hello {
    constructor(){
        this.a= 1
    }
    f(){
         console.log('我是原始方法',this.a)
    }

}

function Test(target) {
  return class extends target{
      f(){
         console.log('我是装饰器方法',this.a)
    }
  }

}

let o = new Hello();
o.f() ==>"我是装饰器方法",1

结构型-适配器模式

适配器模式通过把一个类的接口变换成客户端所期待的另一种接口,可以帮我们解决不兼容的问题。

生活中的适配器

image.png

代码中的适配器

1.最简单的适配器

适配器模式没有想象中的那么复杂,举个最简单的例子。 客户端调用一个方法进行加法计算:

const result = add(1,2);

但是我们没有提供add这个方法,提供了同样类似功能的sum方法:

function sum(v1,v2){
  return v1 + v2;
}

为了避免修改客户端和服务端,我们增加一个包装函数:

function add (v1,v2){
  reutrn sum(v1,v2);
}

这就是一个最简单的适配器模式,我们在两个不兼容的接口之间添加一个包装方法,用这个方法来连接二者使其共同工作。

2.实际应用

如果现有的接口已经能够正常工作,那就永远不会用上适配器模式。适配器模式是一种“亡羊补牢”的模式,没有人会在程序的设计之初就使用它。因为没有人可以完全预料到未来的事情,也许现在好好工作的接口,未来的某天却不再适用于新系统,那么可以用适配器模式把旧接口包装成一个新的接口,使它继续保持生命力。比如在JSON格式流行之前,很多cgi返回的都是XML格式的数据,如果今天仍然想继续使用这些接口,显然可以创造一个XML-JSON的适配器

  下面是一个实例,向googleMap和baiduMap都发出“显示”请求时,googleMap和baiduMap分别以各自的方式在页面中展现了地图:

const googleMap = {
    show: function(){
        console.log( '开始渲染谷歌地图' );
    }
};
const baiduMap = {
    show: function(){
        console.log( '开始渲染百度地图' );
    }
};
const gaodeMap = {
    display: function(){
        console.log( '开始渲染高德地图' );
    }
};
const renderMap = function( map ){
    if ( map.show instanceof Function ){
        map.show();
    }
};


renderMap( googleMap ); // 输出:开始渲染谷歌地图
renderMap( baiduMap ); // 输出:开始渲染百度地图
renderMap( gaodeMap ); // 输出:开始渲染百度地图

  这段程序得以顺利运行的关键是googleMap和baiduMap、gaodeMap提供了一致的show方法,但第三方的接口方法并不在控制范围之内,但如果gaodeMap提供的显示地图的方法名改了,不叫show而改叫display呢?

  gaodeMap这个对象来源于第三方,正常情况下都不应该去改动它。此时可以通过增加gaodeMapAdapter来解决问题:


const googleMap = {
    show: function(){
        console.log( '开始渲染谷歌地图' );
    }
};
const baiduMap = {
    show: function(){
        console.log( '开始渲染百度地图' );
    }
};
const gaodeMap = {
    display: function(){
        console.log( '开始渲染高德地图' );
    }
};
const gaodeMapAdapter = {
    show: function(){
        return gaodeMap.display();

    }
};

renderMap( googleMap ); // 输出:开始渲染谷歌地图
renderMap( baiduMap ); // 输出:开始渲染百度地图
renderMap( gaodeMapAdapter ); // 输出:开始渲染高德地图
    

又比如vue的computed

原有data 中的数据不满足当前的要求,通过计算属性的规则来适配成我们需要的格式,对原有数据并没有改变,只改变了原有数据的表现形式

 <template>
    <div id="example">
        <p>Original message: "{{ message }}"</p>  <!-- Hello -->
        <p>Computed reversed message: "{{ reversedMessage }}"</p>  <!-- olleH -->
    </div>
</template>
<script type='text/javascript'>
    export default {
        name: 'demo',
        data() {
            return {
                message: 'Hello'
            }
        },
        computed: {
            reversedMessage: function() {
                return this.message.split('').reverse().join('')
            }
        }
    }
</script>

总结

适配器模式的原理很简单,就是新增一个包装类,对新的接口进行包装以适应旧代码的调用,避免修改接口和调用代码。

结构型-代理模式

代理模式:为其他对象提供一种代理以控制对这个对象的访问。在某些情况下,一个对象不适合或者不能直接引用另一个对象,而代理对象可以在客户端和目标对象之间起到中介的作用。

在生活中,代理模式的场景是十分常见的,例如我们现在如果有租房、买房的需求,更多的是去找链家等房屋中介机构,而不是直接寻找想卖房或出租房的人谈。此时,链家起到的作用就是代理的作用。链家和他所代理的客户在租房、售房上提供的方法可能都是一致的(收钱,签合同),可是链家作为代理却提供了访问限制,让我们不能直接访问被代理的客户。

事件代理,可能是代理模式最常见的一种应用方式,也是一道实打实的高频面试题。它的场景是一个父元素下有多个子元素,像这样:

<body>
  <div id="father">
    <a href="#">链接1号</a>
    <a href="#">链接2号</a>
    <a href="#">链接3号</a>
    <a href="#">链接4号</a>
    <a href="#">链接5号</a>
    <a href="#">链接6号</a>
  </div>
</body>

我们现在的需求是,希望鼠标点击每个 a 标签,都可以弹出“我是xxx”这样的提示。比如点击第一个 a 标签,弹出“我是链接1号”这样的提示。这意味着我们至少要安装 6 个监听函数给 6 个不同的的元素(一般我们会用循环,代码如下所示),如果我们的 a 标签进一步增多,那么性能的开销会更大。

// 假如不用代理模式,我们将循环安装监听函数
const aNodes = document.getElementById('father').getElementsByTagName('a')
  
const aLength = aNodes.length

for(let i=0;i<aLength;i++) {
    aNodes[i].addEventListener('click', function(e) {
        e.preventDefault()
        alert(`我是${aNodes[i].innerText}`)                  
    })
}

考虑到事件本身具有“冒泡”的特性,当我们点击 a 元素时,点击事件会“冒泡”到父元素 div 上,从而被监听到。如此一来,点击事件的监听函数只需要在 div 元素上被绑定一次即可,而不需要在子元素上被绑定 N 次——这种做法就是事件代理,它可以很大程度上提高我们代码的性能。

事件代理的实现

用代理模式实现多个子元素的事件监听,代码会简单很多:

// 获取父元素
const father = document.getElementById('father')

// 给父元素安装一次监听函数
father.addEventListener('click', function(e) {
    // 识别是否是目标子元素
    if(e.target.tagName === 'A') {
        // 以下是监听函数的函数体
        e.preventDefault()
        alert(`我是${e.target.innerText}`)
    }
} )

在这种做法下,我们的点击操作并不会直接触及目标子元素,而是由父元素对事件进行处理和分发、间接地将其作用于子元素,因此这种操作从模式上划分属于代理模式。

Proxy

Vue2升级到Vue3的核心

es6增建了 MDN Proxy 对象用于创建一个对象的代理,从而实现基本操作的拦截和自定义(如属性查找、赋值、枚举、函数调用等),

总结

  • 代理模式能将代理对象与被调用对象分离,降低了系统的耦合度。代理模式在客户端和目标对象之间起到一个中介作用,这样可以起到保护目标对象的作用
  • 代理对象可以扩展目标对象的功能;通过修改代理对象就可以了,符合开闭原则;
  • 无论是出于什么目的,这种模式的套路就只有一个—— A 不能直接访问 B,A 需要借助一个帮手来访问 B,这个帮手就是代理器。需要代理器出面解决的问题,就是代理模式发光发热的应用场景。

行为型-观察者模式

当对象间存在一对多关系时,则使用观察者模式。让多个观察者对象同时监听某一个主题对象,这个主题对象的状态发生变化时就会通知所有的观察者对象,使它们能够自动更新自己,当一个对象的改变需要同时改变其它对象,并且它不知道具体有多少对象需要改变的时候,就应该考虑使用观察者模式。

生活中的观察者模式

例子1:过年期间,老板说好在大年30当晚发红包,到了当天晚上,大家都已经做好了抢红包的准备,时刻等待着红包的降临。这个观察红包的过程,就是一个典型的观察者模式

例子2:女神朋友圈官宣新男友。各位潜藏备胎纷纷失恋。

模式特征

  1. 一个目标者对象 Subject,拥有方法:添加 / 删除 / 通知 Observer
  2. 多个观察者对象 Observer,拥有方法:接收 Subject 状态变更通知并处理;
  3. 目标对象 Subject 状态变更时,通知所有 Observer

Subject 添加一系列 Observer, Subject 负责维护与这些 Observer 之间的联系,“你对我有兴趣,我更新就会通知你”。

代码实现

// 目标者类
class Subject {
  constructor() {
    this.observers = [];  // 观察者列表
  }
  // 添加
  add(observer) {
    this.observers.push(observer);
  }
  // 删除
  remove(observer) {
    let idx = this.observers.findIndex(item => item === observer);
    idx > -1 && this.observers.splice(idx, 1);
  }
  // 通知
  notify() {
    for (let observer of this.observers) {
      observer.update();
    }
  }
}

// 观察者类
class Observer {
  constructor(name) {
    this.name = name;
  }
  // 目标对象更新时触发的回调
  update() {
    console.log(`她发消息了,我是:${this.name}`);
  }
}

// 实例化目标者
let subject = new Subject();

// 实例化两个观察者
let obs1 = new Observer('男生A');
let obs2 = new Observer('男生B');

// 向目标者添加观察者
subject.add(obs1);
subject.add(obs2);

// 目标者通知更新
subject.notify();  
// 输出:
// 她发消息了,我是男生A
// 她发消息了,我是男生B

优点

  • 目标者与观察者,功能耦合度降低,专注自身功能逻辑;

  • 观察者被动接收更新,时间上解耦,实时接收目标者更新状态。

缺点

  • 过度使用会导致对象与对象之间的联系弱化,会导致程序难以跟踪维护和理解

行为型-迭代器模式

迭代器模式提供一种方法顺序访问一个聚合对象中的各个元素,而又不暴露该对象的内部表示。 ——《设计模式:可复用面向对象软件的基础》

迭代器模式其实就是为了让一切皆可遍历。

模式特点

  • 为遍历不同数据结构的 “集合” 提供统一的接口;

  • 能遍历访问 “集合” 数据中的项,不关心项的数据结构

模式实现

遍历作为一种合理、高频的使用需求,几乎没有语言会要求它的开发者手动去实现。在JS中,本身也内置了一个比较简陋的数组迭代器的实现——Array.prototype.forEach,我们这里来实现一个简易的forEach

// 统一遍历接口实现
var each = function(arr, callBack) {
  for (let i = 0, len = arr.length; i < len; i++) {
    // 将值,索引返回给回调函数callBack处理
    if (callBack(i, arr[i]) === false) {
      break;  // 中止迭代器,跳出循环
    }
  }
}

// 外部调用
each([1, 2, 3, 4, 5], function(index, value) {
    if (value > 3) {
      return false; // 返回false中止each
    }
    console.log([index, value]);
})

// 输出:[0, 1]  [1, 2]  [2, 3]

“迭代器模式的核心,就是实现统一遍历接口。”

模式细分

  1. 内部迭代器 (jQuery 的 $.each / for...of)
  2. 外部迭代器 (ES6 的 yield)

内部迭代器

内部迭代器: 内部定义迭代规则,控制整个迭代过程,外部只需一次初始调用

// jQuery 的 $.each(跟上文each函数实现原理类似)
$.each(['小明', '小红', '小蓝'], function(index, value) {
    console.log([index, value]);
});

// 输出:[0, 小明]  [1, 小红]  [2, 小蓝]

优点:调用方式简单,外部仅需一次调用 缺点:迭代规则预先设置,欠缺灵活性。无法实现复杂遍历需求(如: 同时迭代比对两个数组)

外部迭代器

外部迭代器: 外部显示(手动)地控制迭代下一个数据项

借助 ES6 新增的 Generator 函数中的 yield* 表达式来实现外部迭代器。

// ES6 的 yield 实现外部迭代器
function* generatorEach(arr) {
  for (let [index, value] of arr.entries()) {
    yield console.log([index, value]);
  }
}

let each = generatorEach(['Angular', 'React', 'Vue']);
each.next();
each.next();
each.next();

// 输出:[0, 'Angular']  [1, 'React']  [2, 'Vue']

优点:灵活性更佳,适用面广,能应对更加复杂的迭代需求

缺点:需显示调用迭代进行(手动控制迭代过程),外部调用方式较复杂

适用场景

不同数据结构类型的 “数据集合”,需要对外提供统一的遍历接口,而又不暴露或修改内部结构时,可应用迭代器模式实现。

特点

  • 访问一个聚合对象的内容而无需暴露它的内部表示。
  • 为遍历不同的集合结构提供一个统一的接口,从而支持同样的算法在不同的集合结构上进行操作

总结

对于集合内部结果常常变化各异,不想暴露其内部结构的话,但又想让客户代码透明的访问其中的元素,可以使用迭代器模式