深入 JavaScript 设计模式,从此有了优化代码的理论依据

29,971 阅读17分钟

一、设计模式综述

我想很多和我一样的朋友小时候都看过《天龙八部》,里面的女主角王语嫣是个武学博才,但自己却毫无实战。比如段誉和慕容复交手时,她连连口述指导:"段郎,二龙爪手,抢珠三式,当心你的腰肋,注意你的气户穴。潘月偷心,扶手相望......",虽然看着感觉都是一些最基本的拳脚功夫,但有解说在旁边,到底还是感觉高大上了很多。没错,设计模式其实就和这些招数名差不多,很多模式都给人一种其实平时没少用,可就是不知道原来这是一个专业招术...。但我们确实需要从系统层面深入理解一下这些常用的模式,不仅可以起到发散思维的作用,同时也可以指导我们解决问题的能力。如果之前很少接触过设计模式,那么这篇文章希望可以助力你一下,感谢关注和点赞。

1.1 模式定义

设计模式的定义:在面向对象软件设计过程中针对特定问题的简洁而优雅的解决方案。

说白了,设计模式就是一种理念,通过一些设计思维来解决平时编写底层或业务代码时遇到的场景问题。比如早期业务中的一个封装类,同时带有一些封装方法。如果现在该类不能再满足全部业务场景,且不允许修改原方法,此时就需要装饰器或适配器模式来解决;又比如当设计一个场景,在调用一个固定对象时一定要先执行某些方法,比如验证登录、验证身份ID等场景,此时就应该用到代理模式。这种例子有很多,可以先看一下设计模式的分类。

1.2 模式分类

设计模式,按标准划分,有3大类23种,而由于JavaScript的一些特性,如弱类型语言、无接口编程等特征,故其中只有一些模式是比较重要的。下面给出这23种设计模式名称。

类型 模式名称
创建型 工厂 单例 原型
组合型(结构型) 适配器 装饰器 代理 外观 桥接
行为型 观察者 命令 中介者 状态 策略 解释器 迭代器 访问者 模板方法 职责链 备忘录

是不是觉得这些高逼格的词汇很霸气,下面就先从一些重要的模式开展了解和深入。

二、工厂模式

1.1 基本特征

工厂模式有三种形式:简单工厂模式(Simple Factory)、工厂方法模式(Factory Method)和抽象工厂模式(Abstract Factory)。在js中我们最常见的当属简单工厂模式。工厂模式的设计思想即:

  • 将 new 操作单独封装,只对外提供相应接口;
  • 遇到new 时,就要考虑是否应该使用工厂模式;

1.2 核心作用

工厂模式的核心作用如下:

  • 主要用于隐藏创建实例的复杂度,只需对外提供一个接口;
  • 实现构造函数和创建者的分离,满足开放封闭的原则;

1.3 分类

  • 简单工厂模式:一个工厂对象创建一种产品对象实例。即用来创建同一类对象;
  • 工厂方法模式:建立抽象核心类,将创建实例的实际重心放在核心抽象大类的子类中;
  • 抽象工厂模式:对类的工厂抽象用来创建产品类簇,不负责创建某一类产品的实例。 由于在JS中基本不会使用抽象工厂模式,因此本文探究前两类模式。

1.4 实例演示

先通过一个简单例子最直观感受什么是工厂:

// 定义产品
class Product {
    constructor(name) {
        this.name = name;
    }
    init() {
        console.log('初始化产品')
    }
}

// 定义工厂
class Factory {
    create(name) {
        return new Product(name); // 核心思想
    }
}

let c = new Factory(); 
let p = c.create('p1');
p.init();

工厂模式最直观的地方在于,创建产品对象不是通过直接new 产品类实现,而是通过工厂方法实现。现在再用一个稍微有些好看的例子描述一下简单工厂:

//User类
class User {
  //构造器
  constructor(opt) {
    this.name = opt.name;
    this.viewPage = opt.viewPage;
  }

  static getInstance(role) {
    switch (role) {
      case 'superAdmin':
        return new User({ name: '超级管理员', viewPage: ['首页', '通讯录', '发现页', '应用数据', '权限管理'] });
        break;
      case 'admin':
        return new User({ name: '管理员', viewPage: ['首页', '通讯录'] });
        break;
      default:
        throw new Error('params error')
    }
  }
}

//调用
let superAdmin = User.getInstance('superAdmin');
let admin = User.getInstance('admin');

通过上例,我们可以看到,每次创建新的对象实例时,只需要传入相应的参数,就可以得到指定的对象实例。最直观的例子是如果不用工厂模式,那代码中是不是就会多出好多个new,这样看着也不太舒服。

其实简单工厂模式已经能满足我们前端大部分业务场景了,如果非要说其一个缺陷,那就是每次有新实例时,我们需要重写这个User大类,总归感觉和后面所述的装饰器模式有一些冲突。此时,工厂方法模式就出来了,其核心思想就是独立出一个大的User类,将创建实例对象的过程用其子类来实现:

class User {
  constructor(name = '', viewPage = []) {
    this.name = name;
    this.viewPage = viewPage;
  }
}

class UserFactory extends User {
  constructor(name, viewPage) {
    super(name, viewPage)
  }
  create(role) {
    switch (role) {
      case 'superAdmin': 
        return new UserFactory( '超级管理员', ['首页', '通讯录', '发现页', '应用数据', '权限管理'] );
        break;
      case 'admin':
        return new UserFactory( '管理员', ['首页', '通讯录'] );
        break;
      default:
        throw new Error('params error');
    }
  }
}
let userFactory = new UserFactory();
let superAdmin = userFactory.create('superAdmin');
let admin = userFactory.create('admin');
let user = userFactory.create('user');

这样,虽然也得通过 new 一个实例,但至少我们可以无需修改User类里面的东西,虽说代码量上感觉和简单模式差不了多少,但思想主体确实就是这样。

1.5 应用场景

(1) jQuery的选择器$(selector)

$('div')new $('div') 有何区别? 为什么 $('div') 就能直接实现 new的效果,同时去除了 new $('div') 这种$('div') 去除了 new 书写繁杂的弊端,还能实现完美的链式操作代码简介,就是因为$内置的实现机制是工厂模式。其底层代码如下:

class jQuery {
    constructor(selector) {
        super(selector)
    }
    // ...
}

window.$ = function(selector) {
    return new jQuery(selector)
}

(2) Vue 异步组件

Vue.component('async-example' , (resolve , reject) => {
    setTimeout(function() {
        resolve({
            template: `<div>I am async!</div>`
        })
    }, 1000)
})

除了上述两个常见的实例场景,还有React.createElement() 也是工厂原理。所以,当我们平时遇到要创建实例的时候,就可以想想能否用工厂模式实现了。

三、单例模式

3.1 基本特征

单例模式,顾名思义即保证实例在全局的单一性,概述如下:

  • 系统中被唯一使用
  • 一个类只有一个实例(注意只能有一个实例,必须是强相等===)

在日常业务场景中,我们经常会遇到需要单例模式的场景,比如最基本的弹窗,或是购物车等。因为不论是在单页面还是多页面应用程序中,我们都需要这些业务场景只会同时存在一个。而如果用单例模式,则会避免需要外部变量来判定是否存在的低端方法。

3.2 实例演示

举一个单例模式的例子:

class Modal {
    login() {
        console.log('login...');
    }
}
Modal.create = (function() {
    let instance
    return function() {
        if(!instance) {
           instance = new Modal();
        }
        return instance
    }
})()
let m1 = Modal.create();
let m2 = Modal.create();
console.log(m1 === m2) // true

上述代码是一种简单版单例模式,通过js的立即执行函数和闭包函数,将初始实例确定,之后便可通过判定instance是否存在,果存在则直接返回,反之则创建了再返回,即确保一个类只有一个实例对象。还有一种种“透明版”单例模式:

let Modal = (function(){
    let instance;
    return function(name) {
        if (instance) {
           return instance;
        }
        this.name = name;
        return instance = this;
    }
})();

Modal.prototype.getName = function() {
    return this.name
}

let question = new Modal('问题框');
let answer = new Modal('回答框');

console.log(question === answer); // true
console.log(question.getName());  // '问题框'
console.log(answer.getName());  // '问题框'

所以,单例模式的实现实质即创建一个可以返回对象实例的引用和一个获取该实例的方法。保证创建对象的引用恒唯一。

3.3 应用场景

单例模式应用场景太多了 在Vue 中 我们熟知的Vuex 和 redux 中的 store

三、适配器模式

3.1 定义及特征

适配器模式很好理解,在日常开发中其实不经意间就用到了。适配器模式(Adapter)是将一个类(对象)的接口(方法或属性)转化成适应当前场景的另一个接口(方法或属性),适配器模式使得原本由于接口不兼容而不能一起工作的那些类(对象)可以一些工作。所以,适配器模式必须包含目标(Target)、源(Adaptee)和适配器(Adapter)三个角色。

3.2 应用场景

举个我工作中最生动简单的例子,你就知道原来适配器无处不在。前端通过接口请求来一组数据集,类型分别文章、回答和课程,其中文章类返回的日期类型是2019-08-15 09:00:00格式字符串,回答类是2019/08/15 09:00:00,课程类返回的是时间戳格式,且文章、回答的创建时间字段叫createAt,课程叫createTime(我们真就是这样......)返回数据如下:

let result = [
      {
          id: 1
          type: 'Article',
          createAt: '2019-06-12 08:10:20',
          updateAt: '2019-08-15 09:00:00',
          ......
      },
      {
          id: 2
          type: 'Answer',
          createAt: '2019-04-11 08:11:23',
          updateAt: '2019/08/15 09:00:00',
          ......
      },
      {
          id: 3
          type: 'Course',
          createTime: 1554941483000,
          updateAt: 1565830800000,
          ......
      }
    ]

现在我们要呈现这些实体的格式到移动端。并显示一个统一的时间格式。而一般情况下在遇到时间类型时,我们通常首先想到的就是先 new Date() 一下,再做相应的转换,但是很遗憾,在移动端IOS系统上,2019-08-15这种横杠分隔格式的时间是不被识别的,所以,我们此时就需要做个数据适配器做兼容处理:

 let endResult = result.map(item => adapter(item));
 
 let adapter = function(item) {
    switch(item.type) {
        case 'Article':
          [item.createAt, item.updateAt] = [
             new Date(item.createAt.replace(/-/g,'/')).getTime(),
             new Date(item.updateAt.replace(/-/g,'/')).getTime()
          ]
        break;
        case: 'Answer': 
          item.createAt = new Date(item.createAt.replace(/-/g,'/')).getTime();
        break;
        case: 'Course':
          item.createAt = item.createTime
        break;
    }
 }

恩,没错,这个adapter 也可以叫做数据适配器,有了这个方法,所有实体数据类型的数据就都可适配了。

再看一个基于ES6类的适配器例子:

// 目标
class Target {
    typeGB() {
        throw new Error('This method must be overwritten!');
    }
}

// 源
class Adaptee {
    typeHKB() {
        console.log("香港(Hong Kong)标准配件"); // 港独都是sb
    }
}

// 适配器
class Adapter extends Target {
    constructor(adaptee) {
        super();
        this.adaptee = adaptee;
    }
    typeGB() {
        this.adaptee.typeHKB();
    }
}

let adaptee = new Adaptee();

let adapter = new Adapter(adaptee);
adapter.typeGB(); //香港(Hong Kong)标准配件

上述实例就将 Adaptee 类的实例对象的 typeHKB() 适配了通用的 typeGB() 方法。另外我不想重申官方说过的话,我只想直白一些:港..独都是sb

四、装饰器模式

4.1 定义及特征

装饰器,顾名思义,就是在原来方法的基础上去装饰一些针对特别场景所适用的方法,即添加一些新功能。因此其特征主要有两点:

  • 为对象添加新功能;
  • 不改变其原有的结构和功能,即原有功能还继续会用,且场景不会改变。

直接上个例子:

class Circle {
    draw() {
        console.log('画一个圆形');
    }
}

class Decorator {
    constructor(circle) {
        this.circle = circle;
    }
    draw() {
        this.circle.draw();
        this.setRedBorder(circle);
    }
    setRedBorder(circle) {
        console.log('画一个红色边框');
    }
}

let circle = new Circle();
let decorator = new Decorator(circle);
decorator.draw(); //画一个圆形,画一个红色边框

该例中,我们写了一个Decorator装饰器类,它重写了实例对象的draw方法,给其方法新增了一个setRedBorder(),因此最后为其输出结果进行了装饰。

4.2 装饰器插件

ES7 中就存在了装饰器语法,需要安装相应的babel插件,一起看一下该插件如何用,首先安装一下插件,并做相关的语法配置:

npm i babel-plugin-transform-decorators-legacy 

//.babelrc
{
    "presets": ["es2015", "latest"],
    "plugins": ["transform-decorators-legacy"]
}

给一个Demo类上添加一个装饰器 testDec,此时 Demo类就具有了 装饰器赋予的属性:

@testDec
class Demo {}

function testDec(target) {
   target.isDec = true;
}

alert(Demo.isDec) // true

通过上例可以得出下述代码结论:

@decorator 
class A {}

// 等同于

class A {}
A = decorator(A) || A;

4.3 实力场景

装饰器的实例场景有很多,我们主要拿mixin和属性装饰学习一下。

(1) mixin 示例

function mixins(...list) {
   return function(target) {
      Object.assign(target.prototype, ...list)
   }
}

const Foo = {
    foo() {
        alert('foo');
    }
}

@mixins(Foo)
class MyClass { }

let obj = new MyClass();
obj.foo();

上例中,Foo作为target的实参,MyClass作为 list的实参,最终实现将Foo的所有原型方法(foo)装饰到 MyClass类上,成为了MyClass的方法。最终代码的运行结果是执行了foo()

(2) 属性装饰器

固定语法:

function readonly(target, name, descriptor) {
    // descriptor 属性描述对象(Object.defineProperty 中会用到)
    /*
      {
          value: specifiedFunction,
          enumerable: false,
          configurable: true
          writable: true 是否可改
      }
    */
}

设置类属性只读:

function readonly(target , name , descriptor) {
  descriptor.writable = false;
}

class Person {
    constructor() {
        this.first = '周';
        this.last = '杰伦';
    }

    @readonly
    name() {
        return `${this.first}${this.last}`
    }
}

const p = new Person();
console.log(p.name());  // 打印成功 ,‘周杰伦’

// 试图修改name:
p.name = function() {
    return true;
}
// Uncaught TypeError:Cannot assign to read only property 'name' of object '#<Person>'

可见,再给属性添加了只读的装饰后,代码试图修改属性的命令将会报错。

五、代理模式

5.1 定义及特征

代理模式的定义如下:

为一个对象提供一个代用品或占位符,以便控制对它的访问。

通俗来说,代理模式要突出“代理”的含义,该模式场景需要三类角色,分别为使用者、目标对象和代理者,使用者的目的是直接访问目标对象,但却不能直接访问,而是要先通过代理者。因此该模式非常像明星代理人的场景。其特征为:

  • 使用者无权访问目标对象;
  • 中间加代理,通过代理做授权和控制。

代理模式确实很方便,通常如果面临一些很大开销的操作,就可以并采用虚拟代理的方式延迟到需要它的时候再去创建,比如懒加载操作。或者一些前置条件较多的操作,比如目标操作实现的前提必须是已登录,且Id符合一定特征,此时也可以将这些前置判断写到代理器中。举个加载图片的例子:

class ReadImg {
    constructor(fileName) {
       this.fileName = fileName;
       this.loadFromDisk();
    }

    display() {
        console.log('display...' + this.fileName);
    }

    loadFromDisk() {
        console.log('loading...' + this.fileName);
    }
}

class ProxyImg {
    constructor(fileName) {
       this.readImg = new ReadImg(fileName)
    }

    display() {
        this.readImg.display();
    }
}

let proxyImg = new ProxyImg('1.png');
proxyImg.display();

5.2 实际应用

(1) HTML元素事件代理:

HTML元素代理事件,又名网页代理事件,举例如下:

<body>
    <div id="div1">
        <a href="#">a1</a>
        <a href="#">a2</a>
        <a href="#">a3</a>
        <a href="#">a4</a>
        <a href="#">a5</a>
    </div>

    <script>
       var div1 = document.getElementById('div1');
       div1.addEventListener('click', (e) => {
          var target = e.target;
          if(target.nodeName === 'A') {
             alert(target.innerHTML);
          }
       })
    </script>
</body>

该例中,我们并未直接在元素上定义点击事件,而是通过监听元素点击事件,并通过定位元素节点名称来代理到<a>标签的点击,最终利用捕获事件来实现相应的点击效果。

(2) $.proxy

$.proxyjQuery 提供给我们的一个代理方法,还以上述 html 元素为例,写一个点击事件:

// html如上例
$('#div1').click(function() {
   setTimeout(function() {
      $(this).css('background-color', 'yellow')
   },1000)
})

上述div的点击最终不会实现背景色变化,因为setTimeout的因素,导致内部函数中的this指向的是window而非相应的div。通常我们的做法是在setTimeout方法前获取当前this 指向,代码如下:

$('#div1').click(function() {
   let _this = this;
   setTimeout(function() {
      $(_this).css('background-color', 'yellow')
   },1000)
})

而如果不用上面的方法,我们就可以用$.proxy代理目标元素来实现:

$('#div1').click(function() {
    var fn = $.proxy(function() {
        $(this).css('background-color', 'yellow')
    }, this);
    
    setTimeout(fn , 1000)
})

(3) ES6 proxy

ES6的 Proxy 相信大家都不会陌生,Vue 3.0 的双向绑定原理就是依赖 ES6 的 Proxy 来实现,给一个简单的例子:

let star = {
    name: '菜徐坤',
    song: '~鸡你太美~'
    age: 40,
    phone: 13089898989
}

let agent = new Proxy(star , {
    get(target , key) {
        if(key == 'phone') {
            // 返回经济人自己的电话
            return 15667096303
        }
        if(key == 'price') {
           return 20000000000
        }
        return target[key]
    },
    set(target , key , val) {
       if(key === 'customPrice') {
          if(val < 100000000) {
              throw new Error('价格太低')
          }
          else {
              target[key] = value;
              return true
          }
       }
    }
})

// agent 对象会根据相应的代理规则,执行相应的操作:
agent.phone // 15667096303  
agent.price // 20000000000 

不用多解释了,真不明白他咋火的。。。。。。

七、观察者模式

7.1 定义及特征

观察者模式有多重要?这么说吧,如果上帝告诉你,这辈子你只能学习一种模式,你该毫不犹豫选择观察者模式。观察者模式,也叫订阅-发布模式,熟悉Vue的朋友一定不会陌生,该模式定义了一种1对N的关系(注意:不一定是一对多,所以更准确地描述应该是1对N),使观察者们同时监听某一个对象相应的状态变换,一旦变化则通知到所有观察者,从而触发观察者相应的事件。因此,观察者模式中的角色有两类:观察者(发布者)和被观察者(订阅者)。

我们可直接看一下观察者模式的UML类图:

image

类图解析:

  • 每一个观察者(Observer)都有一个update 方法,并且观察者的状态就是等待被触发;
  • 每一个主题(subject)都可以通过attach方法接纳N个观察者所观察,即观察者们存储在主题的observers数组里,;
  • 主题有初始化状态(init)、获取状态(getState)和设置状态(setState)三个通用型方法;
  • 当主题的状态发生变化时,通过特定的notifyAllObervers方法通知所有观察者。

这下就很明白了,针对如上描述再来个小例子:

// 创建一个主题,保存状态,状态变化之后触发所有观察者对象
class Subject {
    constructor() {
        this.state = 0;
        this.observers = []
    }

    getState() {
        return this.state
    }

    setState(state) {
       this.state = state;
       this.notifyAllObservers()
    }

    notifyAllObservers() {
        this.observers.forEach(observer => {
            observer.update()
        })
    }

    attach(observer) {
       this.observers.push(observer)
    }
}

// 观察者
class Observer {
    constructor(name , subject) {
       this.name = name;
       this.subject = subject;
       this.subject.attach(this);
    }
    update() {
        console.log(`${this.name} update, state: ${this.subject.getState()}`)
    }
}

let s = new Subject();
let o1 = new Observer('o1' , s);
let o2 = new Observer('o2' , s);
let o3 = new Observer('o3' , s);

s.setState(1)
s.setState(2)
s.setState(3)

/*
o1 update, state: 1
 o2 update, state: 1
o3 update, state: 1
o1 update, state: 2
o2 update, state: 2
o3 update, state: 2
o2 update, state: 3
o3 update, state: 3
*/

通过最终结果不能看到,主题每次改变状态后都会触发所有观察者状态更新,主题触发了3次状态,观察者一定update了9次。

7.2 实例场景

其实我们在平时不经意间就使用了很多观察者模式的例子,比如Promise等、Node.js中的 EventEmitter事件监听器、Vue 的 Watch生命周期钩子等等,这些都是观察者模式,比如在Vue组件生命周期Watch,为甚在Watch里设定了数据监听,一旦数据改变了就触发相应事件了?还有Promise,为什么异步操作得到结果后就会进入到then或者catch里呢?这些都依赖于观察者模式。这里我引用一篇很不错的文章《vue的双向绑定原理及实现》

好了,这篇文章的内容就先告一段落,我们已经把23中设计模式中的核心重点都过了一遍,剩下的一些非重点,我会尽快整理出来,欢迎大家关注和点赞。

感谢千阳老师的校验。

参考文章