前言

去年的时候先是看了修言open in new window大佬的性能优化掘金小册子,收获良多。

之后紧接着买了这本JavaScript 设计模式核⼼原理与应⽤实践open in new window,刚好最近有小册免费学open in new window的活动,就赶紧把这篇笔记整理出来了,并且补充了小册子中的没有写到的其余设计模式,学习过程中结合 JavaScript 编写的例子,以便于理解和加深印象。

与其说是一篇文章,其实更像是一篇总结性质的学习笔记。

为什么要学习设计模式?

学习之前,先了解什么是设计模式?

设计模式(Design Pattern)是前辈们对代码开发经验的总结,是解决特定问题的一系列套路。它不是语法规定,而是一套用来提高代码可复用性、可维护性、可读性、稳健性以及安全性的解决方案。

简答理解 它是一套被反复使用、多人知晓的、经过分类的、代码设计经验总结。

烹饪有菜谱,游戏有攻略,每个领域都存在一些能够让我们又好又快地达成目标的“套路”。在程序世界,编程的“套路”就是设计模式。

学习它也就是学习这个编程世界的套路,对以后升级打怪打装备有很大的帮助。在瞬息万变的前端领域,设计模式也是一种“一次学习,终生受用”知识。

设计模式的原则

描述一个不断发生的重复的问题,以及该问题的解决方案的核心。 这样,你就能一次又一次的使用该方案而不必做重复劳动。

一大法则:

  • 迪米特法则:又叫最少知识法则,一个软件实体应该尽可能少的语其他实体发生相互作用,每一个软件单位对其他的单位都只有最少的知识,而且局限于那些与本单位密切相关的软件单位。

五大原则:

  • 单一职责原则:一个类,应该仅有一个引起它变化的原因,简而言之,就是功能要单一。
  • 开放封闭原则:对扩展开放,对修改关闭。
  • 里氏替换原则:基类出现的地方,子类一定出现。
  • 接口隔离原则:一个借口应该是一种角色,不该干的事情不敢,该干的都要干。简而言之就是降低耦合、减低依赖。
  • 依赖翻转原则:针对接口编程,依赖抽象而不依赖具体。

JavaScript 中常用的是单一功能和开放封闭原则。

高内聚和低耦合

通过设计模式可以帮助我们增强代码的可重用性、可扩充性、 可维护性、灵活性好。我们使用设计模式最终的目的是实现代码的 高内聚 和 低耦合。

举例一个现实生活中的例子,例如一个公司,一般都是各个部门各司其职,互不干涉。各个部门需要沟通时通过专门的负责人进行对接。

在软件里面也是一样的 一个功能模块只是关注一个功能,一个模块最好只实现一个功能,这个是所谓的内聚

模块与模块之间、系统与系统之间的交互,是不可避免的, 但是我们要尽量减少由于交互引起的单个模块无法独立使用或者无法移植的情况发生, 尽可能多的单独提供接口用于对外操作, 这个就是所谓的低耦合

封装变化

在实际开发过程中,不发生变化的代码基本是不存在的,所以我要将代码的变化最小化。

设计模式的核心就是去观察你整个逻辑里的变与不变,然后将不变分离,达到使变化的部分灵活、不变的地方稳定的目的。

设计模式的种类

常用的可以分为创建型、结构型、行为型三类,一共 23 种模式。

设计模式大纲

创建型:

结构型:

行为型:

创建型

工厂模式

这种类型的设计模式属于创建型模式,它提供了一种创建对象的最佳方式。

在工厂模式中,我们在创建对象时不会对客户端暴露创建逻辑,并且是通过使用一个共同的接口来指向新创建的对象。 在 JS 中其实就是借助构造函数实现。

例子

某个班级要做一个录入系统,录入一个人,就要写一次。

let liMing = {
  name: "李明",
  age: 20,
  sex: "男",
};
1
2
3
4
5

如果多个录入,则可以创建一个类。

class Student {
  constructor(name, age, sex) {
    this.name = name;
    this.age = age;
    this.sex = sex;
  }
}
let zhangSan = new Student("张三", 19, "男");
1
2
3
4
5
6
7
8

工厂模式是将创建对象的过程单独封装,使用使只需要无脑传参就行了,就像一个工厂一样,只要给够原料,就可以轻易的制造出成品。

小结

  • 构造函数和创建者分离,对 new 操作进行封装
  • 符合开放封闭原则

单例模式

单例模式的定义:保证一个类仅有一个实例,并且提供一个访问它的全局变量。

实现的方法为前判断实例是否存在,如果存在直接返回,不存在则创建在返回,这就确保了一个类只有一个实例对象。

比如:Vuex、jQuery

例子

使用场景:一个单一对象,比如:弹窗,无论点击多少次,弹窗只应被创建一次,实现起来也很简单,用一个变量缓存就行了。

如上面这个弹框,只有在第一次点击按钮时才会创建弹框,之后都不会在创建,而是使用之前创建的弹框。

如此,便是实现了一个应用于单例模式的弹框。

小结

  • 维持一个实例,如果已经创建,就直接返回
  • 符合开放封闭原则

原型模式

用原型实例指定创建对象的种类,并且通过拷贝这些原型创建新的对象。

例子

在 JavaScript 中,实现原型模式是在 ECMAscript5 中,提出的 Object.create 方法,使用现有的对象来提供创建的对象__proto__

var prototype = {
  name: "Jack",
  getName: function() {
    return this.name;
  },
};

var obj = Object.create(prototype, {
  job: {
    value: "IT",
  },
});

console.log(obj.getName()); // Jack
console.log(obj.job); // IT
console.log(obj.__proto__ === prototype); //true
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

有原型就有原理性了

构造器模式

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

function Car(model, year, miles) {
  this.model = model;
  this.year = year;
  this.miles = miles;
  // this.info = new CarDetail(model)
  // 属性也可以通过 new 的方式产生
}

// 覆盖原型对象上的toString
Car.prototype.toString = function() {
  return this.model + " has done " + this.miles + " miles";
};

// 使用:
var civic = new Car("Honda Civic", 2009, 20000);
var mondeo = new Car("Ford Mondeo", 2010, 5000);
console.log(civic.toString()); // Honda Civic has done 20000 miles
console.log(mondeo.toString()); // Ford Mondeo has done 5000 miles
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

其实就是利用原型链上被继承的特性,实现了构造器。

抽象工厂模式

抽象工厂模式(Abstract Factory)就是通过类的抽象使得业务适用于一个产品类簇的创建,而不负责某一类产品的实例。

JS 中是没有直接的抽象类的,abstract 是个保留字,但是还没有实现,因此我们需要在类的方法中抛出错误来模拟抽象类,如果继承的子类中没有覆写该方法而调用,就会抛出错误。

const Car = function() {};
Car.prototype.getPrice = function() {
  return new Error("抽象方法不能调用");
};
1
2
3
4

面向对象的语言里有抽象工厂模式,首先声明一个抽象类作为父类,以概括某一类产品所需要的特征,继承该父类的子类需要实现父类中声明的方法而实现父类中所声明的功能:

/**
 * 实现subType类对工厂类中的superType类型的抽象类的继承
 * @param subType 要继承的类
 * @param superType 工厂类中的抽象类type
 */
const VehicleFactory = function(subType, superType) {
  if (typeof VehicleFactory[superType] === "function") {
    function F() {
      this.type = "车辆";
    }
    F.prototype = new VehicleFactory[superType]();
    subType.constructor = subType;
    subType.prototype = new F(); // 因为子类subType不仅需要继承superType对应的类的原型方法,还要继承其对象属性
  } else throw new Error("不存在该抽象类");
};
VehicleFactory.Car = function() {
  this.type = "car";
};
VehicleFactory.Car.prototype = {
  getPrice: function() {
    return new Error("抽象方法不可使用");
  },
  getSpeed: function() {
    return new Error("抽象方法不可使用");
  },
};
const BMW = function(price, speed) {
  this.price = price;
  this.speed = speed;
};
VehicleFactory(BMW, "Car"); // 继承Car抽象类
BMW.prototype.getPrice = function() {
  // 覆写getPrice方法
  console.log(`BWM price is ${this.price}`);
};
BMW.prototype.getSpeed = function() {
  console.log(`BWM speed is ${this.speed}`);
};
const baomai5 = new BMW(30, 99);
baomai5.getPrice(); // BWM price is 30
baomai5 instanceof VehicleFactory.Car; // true
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41

通过抽象工厂,就可以创建某个类簇的产品,并且也可以通过 instanceof 来检查产品的类别,也具备该类簇所必备的方法。

结构型

装饰器模式

装饰器模式,又名装饰者模式。它的定义是“ 在不改变原对象的基础上,通过对其进行包装拓展,使原有对象可以满足用户的更复杂需求 ”。

装饰器案例

有一个弹窗函数,点击按钮后会弹出一个弹框。

function openModal() {
  let div = document.craeteElement("div");
  div.id = "modal";
  div.innerHTML = "提示";
  div.style.backgroundColor = "gray";
  document.body.appendChlid(div);
}
btn.onclick = () => {
  openModal();
};
1
2
3
4
5
6
7
8
9
10

但是忽然产品经理要改需求,要把提示文字由“提示”改为“警告”,背景颜色由 gray 改为 red。

听到这个你是不是立马就想直接改动源函数:

function openModal() {
  let div = document.craeteElement("div");
  div.id = "modal";
  div.innerHTML = "警告";
  div.style.backgroundColor = "red";
  document.body.appendChlid(div);
}



 
 


1
2
3
4
5
6
7

但是如果是复杂的业务逻辑,或者这个代码时上任代码留下来的产物,在考虑到以后的需求变化,每次都这样修改确实很麻烦。

而且,直接修改已有的函数体,有违背了我们的“开放封闭原则”,往一个函数塞这么多的逻辑,也违背了“单一职责原则”,所以上面的方法并不是最佳的。

最省时省力的方式是不去关心它现有得了逻辑,只在此逻辑之上扩展新的功能即可,因此装饰器模式就此而生。

// 新逻辑
function changeModal() {
  let div = document.getElemnetById("modal");
  div.innerHTML = "告警";
  div.style.backgroundColor = "red";
}
btn.onclick = () => {
  openModal();
  changeModal();
};
1
2
3
4
5
6
7
8
9
10

这种通过函数添加新的功能、而又不修改旧逻辑,这就是装饰器的魅力。

ES7 中的装饰器

在最新的 ES7 中有装饰器的提案,但是还未定案,所以语法可能不是最终版,但是思想是一样的。

  1. 装饰类的属性
@tableColor
class Table {
  // ...
}
function tableColor(target) {
  target.color = "red";
}
Table.color; // true
1
2
3
4
5
6
7
8

Table这个类,添加一个tableColor的装饰器,即可改变Tablecolor属性

  1. 装饰类的方法
class Person {
  @readonly
  name() {
    return `${this.first} ${this.last}`;
  }
}
1
2
3
4
5
6

Person类的name方法添加只读的装饰器,使得该方法不可被修改。

其实是借助Object.definePropertywirteable特性实现的。

  1. 装饰函数

    因为 JS 中函数存在函数提升,直接使用装饰器并不可取,但是可以使用高级函数的方式实现。

    function doSomething(name) {
      console.log("Hello, " + name);
    }
    function loggingDecorator(wrapped) {
      return function() {
        console.log("fun-Starting");
        const result = wrapped.apply(this, arguments);
        console.log("fun-Finished");
        return result;
      };
    }
    const wrapped = loggingDecorator(doSomething);
    let name = "World";
    
    doSomething(name); // 装饰前
    // output:
    // Hello, World
    
    wrapped(name); // 装饰后
    // output:
    // fun-Starting
    // Hello, World
    // fun-Finished
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23

    上面的装饰器,是给一个函数在执行开始和执行结束分别打印一个 log。

参考

适配器模式

适配器模式的作用是解决两个软件实体间的接口不兼容问题。使用适配器模式之后,原本由于接口不兼容而不能工作的两个软件实体可以一起工作。

简单来说,就是把一个类的接口变成客户端期待的另一种接口,解决兼容问题

比如:axios

例子:一个渲染地图的方法,默认是调用当前地图对象的 show 方法进行渲染操作,当有多个地图,而每个地图的渲染方法都不一样时,为了方便使用者调用,就需要做适配了。

let googleMap = {
  show: () => {
    console.log("开始渲染谷歌地图");
  },
};
let baiduMap = {
  display: () => {
    console.log("开始渲染百度地图");
  },
};
let baiduMapAdapter = {
  show: () => {
    return baiduMap.display();
  },
};
function renderMap(obj) {
  obj.show();
}
renderMap(googleMap); // 开始渲染谷歌地图
renderMap(baiduMapAdapter); // 开始渲染百度地图
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

这其中对“百度地图”做了适配的处理。

小结

  • 适配器模式主要解决两个接口之间不匹配的问题,不会改变原有的接口,而是由一个对象对另一个对象的包装
  • 适配器模式符合开放封闭原则
  • 把变化留给自己,把统一留给用户。

代理模式

代理模式——在某些情况下,出于种种考虑/限制,一个对象不能直接访问另一个对象,需要一个第三者(代理)牵桥搭线从而间接达到访问目的,这样的模式就是代理模式。

提起代理(Proxy),对于前端很熟悉的,我能联想到一系列的东西,比如:

  • ES6 新增的 proxy 属性
  • 为了解决跨域问题而经常使用的 webpack 的 proxy 配置和 Nginx 代理
  • 还有科学上网所使用的的代理。
  • 等等

事件代理

常见的列表、表格都需要单独处理事件时,使用父级元素事件代理,可以极大的减少代码量。

<div id="father">
  <span id="1">新闻1</span>
  <span id="2">新闻2</span>
  <span id="3">新闻3</span>
  <span id="4">新闻4</span>
  <span id="5">新闻5</span>
  <span id="6">新闻6</span>
  <!-- 7、8... -->
</div>
1
2
3
4
5
6
7
8
9

如上代码,我想点击每个新闻,都可以拿到当前新闻的id,从而进行下一步操作。

如果给每一个span都绑定一个onclick事件,就太耗费性能了,而且写起来也很麻烦。

我们常见的做法是利用事件冒泡的原理,将事件带代理到父元素上,然后统一处理。

let father = document.getElementById("father");
father.addEventListener("click", (evnet) => {
  if (event.target.nodeName === "SPAN") {
    event.preventDefault();
    let id = event.target.id;
    console.log(id); // 拿到id,进行下一步操作
  }
});
1
2
3
4
5
6
7
8

虚拟代理

例如:某个花销很大的操作,可以通过虚拟代理的方式延迟到这种需要它的时候才去创建(例如:使用虚拟代理实现图片懒加载)

图片预加载:先通过一张 loading 图占位,然后通过异步的方式加载图片,等图片加载完成之后在使用原图替换 loading 图。

问什么要使用预加载+懒加载?以淘宝举例,商城物品图片多之又多,一次全部请求过来这么多图片无论是对 js 引擎还是浏览器本身都是一个巨大的工作量,会拖慢浏览器响应速度,用户体验极差,而预加载+懒加载的方式会大大节省浏览器请求速度,通过预加载率先加载占位图片(第二次及以后都是缓存中读取),再通过懒加载直到要加载的真实图片加载完成,瞬间替换。这种模式很好的解决了图片一点点展现在页面上用户体验差的弊端。

须知:图片第一次设置 src,浏览器发送网络请求;如果设置一个请求过的 src 那么浏览器则会从缓存中读取 from disk cache

class PreLoadImage {
  constructor(imgNode) {
    // 获取真实的DOM节点
    this.imgNode = imgNode;
  }

  // 操作img节点的src属性
  setSrc(imgUrl) {
    this.imgNode.src = imgUrl;
  }
}

class ProxyImage {
  // 占位图的url地址
  static LOADING_URL = "xxxxxx";

  constructor(targetImage) {
    // 目标Image,即PreLoadImage实例
    this.targetImage = targetImage;
  }

  // 该方法主要操作虚拟Image,完成加载
  setSrc(targetUrl) {
    // 真实img节点初始化时展示的是一个占位图
    this.targetImage.setSrc(ProxyImage.LOADING_URL);
    // 创建一个帮我们加载图片的虚拟Image实例
    const virtualImage = new Image();
    // 监听目标图片加载的情况,完成时再将DOM上的真实img节点的src属性设置为目标图片的url
    virtualImage.onload = () => {
      this.targetImage.setSrc(targetUrl);
    };
    // 设置src属性,虚拟Image实例开始加载图片
    virtualImage.src = targetUrl;
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35

ProxyImage 帮我们调度了预加载相关的工作,我们可以通过 ProxyImage 这个代理,实现对真实 img 节点的间接访问,并得到我们想要的效果。

在这个实例中,virtualImage 这个对象是一个“幕后英雄”,它始终存在于 JavaScript 世界中、代替真实 DOM 发起了图片加载请求、完成了图片加载工作,却从未在渲染层面抛头露面。因此这种模式被称为“虚拟代理”模式。

缓存代理

缓存代理比较好理解,它应用于一些计算量较大的场景里。在这种场景下,我们需要“用空间换时间”——当我们需要用到某个已经计算过的值的时候,不想再耗时进行二次计算,而是希望能从内存里去取出现成的计算结果。

这种场景下,就需要一个代理来帮我们在进行计算的同时,进行计算结果的缓存了。

例子:对参数求和函数进行缓存代理。

// addAll方法会对你传入的所有参数做求和操作
const addAll = function() {
  console.log("进行了一次新计算");
  let result = 0;
  const len = arguments.length;
  for (let i = 0; i < len; i++) {
    result += arguments[i];
  }
  return result;
};

// 为求和方法创建代理
const proxyAddAll = (function() {
  // 求和结果的缓存池
  const resultCache = {};
  return function() {
    // 将入参转化为一个唯一的入参字符串
    const args = Array.prototype.join.call(arguments, ",");

    // 检查本次入参是否有对应的计算结果
    if (args in resultCache) {
      // 如果有,则返回缓存池里现成的结果
      console.log("无计算-使用缓存的数据");
      return resultCache[args];
    }
    return (resultCache[args] = addAll(...arguments));
  };
})();

let sum1 = proxyAddAll(1, 2, 3); // 进行了一次新计算

let sum2 = proxyAddAll(1, 2, 3); // 无计算-使用缓存的数据
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

第一次进行计算返回结果,并存入缓存。如果再次传入相同的参数,则不计算,直接返回缓存中存在的结果。

在常见在 HTTP 缓存中,浏览器就相当于进行了一层代理缓存,通过 HTTP 的缓存机制控制(强缓存和协商缓存)判断是否启用缓存。

频繁却变化小的的网络请求,比如getUserInfo,可以使用代理请求,设置统一发送和存取。

小结

  • 代理模式符合开放封闭原则。
  • 本体对象和代理对象拥有相同的方法,在用户看来并不知道请求的是本体对象还是代理对象。

桥接模式

桥接模式:将抽象部分和具体实现部分分离,两者可独立变化,也可以一起工作。

在这种模式的实现上,需要一个对象担任“桥”的角色,起到连接的作用。

例子:

JavaScript 中桥接模式的典型应用是:Array对象上的forEach函数。

此函数负责循环遍历数组每个元素,是抽象部分; 而回调函数callback就是具体实现部分

下方是模拟forEach方法:

const forEach = (arr, callback) => {
  if (!Array.isArray(arr)) return;

  const length = arr.length;
  for (let i = 0; i < length; ++i) {
    callback(arr[i], i);
  }
};

// 以下是测试代码
let arr = ["a", "b"];
forEach(arr, (el, index) => console.log("元素是", el, "位于", index));
// 元素是 a 位于 0
// 元素是 b 位于 1
1
2
3
4
5
6
7
8
9
10
11
12
13
14

外观模式

外观模式(Facade Pattern)隐藏系统的复杂性,并向客户端提供了一个客户端可以访问系统的接口。这种类型的设计模式属于结构型模式,它向现有的系统添加一个接口,来隐藏系统的复杂性。

这种模式涉及到一个单一的类,该类提供了客户端请求的简化方法和对现有系统类方法的委托调用。

例子

外观模式即执行一个方法可以让多个方法一起被调用。

涉及到兼容性,参数支持多个格式、环境等等.. 对外暴露统一的 api

比如自己封装的事件对象包含了阻止冒泡和添加事件监听的兼容方法:

const myEvent = {
    stop (e){
        if(typeof e.preventDefault() == 'function'){
            e.preventDefault();
        }
        if(typeof e.stopPropagation() == 'function'){
            e.stopPropagation()
        }
        // IE
        if(typeOd e.retrunValue === 'boolean'){
            e.returnValue = false
        }
        if(typeOd e.cancelBubble === 'boolean'){
            e.returnValue = true
        }
    }
    addEvnet(dom, type, fn){
        if(dom.addEventListener){
            dom.addEventlistener(type, fn, false);
        }else if(dom.attachEvent){
            dom.attachEvent('on'+type, fn)
        }else{
            dom['on'+type] = fn
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

组合模式

组合模式(Composite Pattern),又叫部分整体模式,是用于把一组相似的对象当作一个单一的对象。

组合模式依据树形结构来组合对象,用来表示部分以及整体层次。这种类型的设计模式属于结构型模式,它创建了对象组的树形结构。

这种模式创建了一个包含自己对象组的类。该类提供了修改相同对象组的方式。

例子

想象我们现在手上有多个万能遥控器,当我们回到家中,按一下开关,下列事情将被执行

  • 开门
  • 开电脑
  • 开音乐
// 先准备一些需要批量执行的功能
class GoHome {
  init() {
    console.log("开门");
  }
}
class OpenComputer {
  init() {
    console.log("开电脑");
  }
}
class OpenMusic {
  init() {
    console.log("开音乐");
  }
}

// 组合器,用来组合功能
class Comb {
  constructor() {
    // 准备容器,用来防止将来组合起来的功能
    this.skills = [];
  }
  // 用来组合的功能,接收要组合的对象
  add(task) {
    // 向容器中填入,将来准备批量使用的对象
    this.skills.push(task);
  }
  // 用来批量执行的功能
  action() {
    // 拿到容器中所有的对象,才能批量执行
    this.skills.forEach((val) => {
      val.init();
    });
  }
}

// 创建一个组合器
let c = new Comb();

// 提前将,将来要批量操作的对象,组合起来
c.add(new GoHome()); // 添加'开门'命令
c.add(new OpenComputer()); // 添加'开电脑'命令
c.add(new OpenMusic()); // 添加'开音乐'命令

c.action(); // 执行添加的所有命令
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46

小结

  • 组合模式在对象间形成树形结构
  • 组合模式中对基本对象和组合对象被一致对待
  • 无需关心对象有多少层,调用时只需要在根部进行调用
  • 将多个对象的功能,组装起来,实现批量执行

享元模式

享元模式(Flyweight Pattern)主要用于减少创建对象的数量,以减少内存占用和提高性能。

这种类型的设计模式属于结构型模式,它提供了减少对象数量从而改善应用所需的对象结构的方式。

特点

  • 共享内存(主要是考虑内存,而非效率)
  • 相同的数据(内存),共享使用

例子

比如常见的事件代理,通过将若干个子元素的事件代理到一个父元素,子元素共同使用一个方法。如果都绑定到<span>标签,对内存开销太大 。

<!-- 点击span,拿到当前的span中的内容 -->
<div id="box">
  <span>1</span>
  <span>2</span>
  <span>3</span>
  <span>4</span>
</div>

<script>
  var box = document.getElementById("box");
  box.addEventListener("click", function(e) {
    let target = e.target;
    if (e.nodeName === "SPAN") {
      alert(target.innerHTML);
    }
  });
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

小结

  • 将相同的部分抽象出来
  • 符合开放封闭的原则

行为型

迭代器模式

迭代器模式提供一种方法顺序访问一个聚合对象中的各个元素,而又不暴露对象的对象的内部表示。

迭代器模式可以把迭代的过程从业务逻辑中分离出来,在使用迭代器模式之后,及时不关心对象的内部构造,也可以按照顺序访问其中的每个元素。

简单类说,它的目的就是去遍历一个可遍历的对象。

像 JS 中原生的 forEach、map 等方法都属于是迭代器模式的一种实现,一般来说不用自己去实现迭代器。

在 JS 中有一种类数组的存在,他们没有迭代方法,比如 nodeList、arguments 并不能直接使用迭代方法,需要使用 jQuery 的 each 方法或者将类数组装换为真正的数组在进行迭代。

而在最新的 ES6 中,对有只要有 Iterator 接口的数据类型都可以使用 for..of..进行遍历,而他的底层则是对 next 方法的反复调用,具体参考阮一峰-Iterator 和 for...of 循环open in new window

例子

我们可以借助 Iterator 接口自己实现一个迭代器。

class Creater {
  constructor(list) {
    this.list = list;
  }
  // 创建一个迭代器,也叫遍历器
  createIterator() {
    return new Iterator(this);
  }
}
class Iterator {
  constructor(creater) {
    this.list = creater.list;
    this.index = 0;
  }
  // 判断是否遍历完数据
  isDone() {
    if (this.index >= this.list.length) {
      return true;
    }
    return false;
  }
  next() {
    return this.list[this.index++];
  }
}

var arr = [1, 2, 3, 4];
var creater = new Creater(arr);
var iterator = creater.createIterator();
console.log(iterator.list); // [1, 2, 3, 4]
while (!iterator.isDone()) {
  console.log(iterator.next());
  // 1
  // 2
  // 3
  // 4
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37

小结

  1. JavaScript 中的有序数据集合有 Array,Map,Set,String,typeArray,arguments,NodeList,不包括 Object
  2. 任何部署了[Symbol.iterator]接口的数据都可以使用 for...of 循环遍历
  3. 迭代器模式使目标对象和迭代器对象分离,符合开放封闭原则

订阅/发布模式(观察者)

发布/订阅模式又叫观察者模式,她定义对象间的一种一对多的依赖关系。当一个对象的状态发生改变时,所有依赖他的对象都将得到通知。在 JavaScrtipt 中,我们一般使用时间模型来替代传统的发布/订阅模式。

比如:Vue 中的双向绑定和事件机制。

发布/订阅模式和观察者模式的区别

  • 发布者可以直接处接到订阅的操作,叫观察者模式

  • 发布者不直接触及到订阅者,而是由统一的第三方完成通信操作,叫发布/订阅模式

    发布订阅模式和观察者模式.png

例子

可以自己实现一个事件总线,模拟$emit$on

class EventBus {
  constructor() {
    this.callbacks = {};
  }
  $on(name, fn) {
    (this.callbacks[name] || (this.callbacks[name] = [])).push(fn);
  }
  $emit(name, args) {
    let cbs = this.callbacks[name];
    if (cbs) {
      cbs.forEach((c) => {
        c.call(this, args);
      });
    }
  }
  $off(name) {
    this.callbacks[name] = null;
  }
}
let event = new EventBus();
event.$on("event1", (arg) => {
  console.log("event1", arg);
});

event.$on("event2", (arg) => {
  console.log("event2", arg);
});

event.$emit("event1", 1); // event1 1
event.$emit("event2", 2); // event2 2
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

策略模式

定义一系列的算法,把他们一个个封装起来,并使他们可以替换。

策略模式的目的就是将算法的使用和算法的实现分离开来。

一个策略模式通常由两部分组成:

  • 一组可变的策略类:封装了具体的算法,负责具体的计算过程
  • 一组不变的环境类:接收到请求后,随后将请求委托到某个策略类

说明环境类要维持对某个策略对象的引用。

例子

通过绩效等级计算奖金,可以轻易的写出如下的代码:

var calculateBonus = function(performanceLevel, salary) {
  if (performanceLevel === "S") {
    return salary * 4;
  }
  if (performanceLevel === "A") {
    return salary * 3;
  }
  if (performanceLevel === "B") {
    return salary * 2;
  }
};

calculateBonus("B", 20000); // 输出:40000
calculateBonus("S", 6000); // 输出:24000
1
2
3
4
5
6
7
8
9
10
11
12
13
14

使用策略模式修改代码:

var strategies = {
  S: (salary) => {
    return salary * 4;
  },
  A: (salary) => {
    return salary * 3;
  },
  B: (salary) => {
    return salary * 2;
  },
};
var calculateBonus = function(level, salary) {
  return strategies[level](salary);
};
console.log(calculateBonus("S", 200)); // 输出:800
console.log(calculateBonus("A", 200)); // 输出:600
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

状态模式

状态模式允许一个对象在其内部状态改变的时候改变

状态模式主要解决的是当控制一个对象状态的条件表达式过于复杂时的情况。把状态的判断逻辑转移到表示不同状态的一系列类中,可以把复杂的判断逻辑简化。

例子

实现一个交通灯的切换。

这时候如果在加一个蓝光的话,可以直接添加一个蓝光的类,然后添加 parssBtn 方法,其他状态都不需要变化。

小结

  • 通过定义不同的状态类,根据状态的改变而改变状态的行为,不必把大量的逻辑都写在被操作对象的类中,而且容易增加新的状态。
  • 符合开放封闭原则

解释器模式

**解释器模式(Interpreter):**给定一个语言,定义它的文法的一种表示,并定义一个解释器,这个解释器使用该表示来解释语言中的句子。

用到的比较少,可以参考两篇文章来理解。

小结

  • 描述语言语法如何定义,如何解释和编译
  • 用于专业场景

中介者模式

中介者模式(Mediator Pattern)是用来降低多个对象和类之间的通信复杂性。

这种模式提供了一个中介类,该类通常处理不同类之间的通信,并支持松耦合,使代码易于维护

通过一个中介者对象,其他所有相关对象都通过该对象来通信,而不是相互引用,但其中一个对象发生改变时,只需要通知中介者对象即可。

通过中介者模式可以解除对象与对象之前的耦合关系。

例如:Vuex

middle-parttern.png

参考链接:JavaScript 中介者模式open in new window

小结

  • 将各关联对象通过中介者隔离
  • 符合开放封闭原则
  • 减少耦合

访问者模式

在访问者模式(Visitor Pattern)中,我们使用了一个访问者类,它改变了元素类的执行算法。

通过这种方式,元素的执行算法可以随着访问者改变而改变。

例子

通过访问者调用元素类的方法。

// 访问者
function Visitor() {
  this.visit = function(concreteElement) {
    concreteElement.doSomething(); // 谁访问,就使用谁的doSomething()
  };
}
// 元素类
function ConceteElement() {
  this.doSomething = function() {
    console.log("这是一个具体元素");
  };
  this.accept = function(visitor) {
    visitor.visit(this);
  };
}
// Client
var ele = new ConceteElement();
var v = new Visitor();
ele.accept(v); // 这是一个具体元素
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

小结

  • 假如一个对象中存在着一些与本对象不相干(或者关系较弱)的操作,为了避免这些操作污染这个对象,则可以使用访问者模式来把这些操作封装到访问者中去。
  • 假如一组对象中,存在着相似的操作,为了避免出现大量重复的代码,也可以将这些重复的操作封装到访问者中去。

备忘录模式

备忘录模式(Memento Pattern)保存一个对象的某个状态,以便在适当的时候恢复对象

例子

实现一个带有保存记录功能的”编辑器“,功能包括

  • 随时记录一个对象的状态变化
  • 随时可以恢复之前的某个状态(如撤销功能)
// 状态备忘
class Memento {
  constructor(content) {
    this.content = content;
  }
  getContent() {
    return this.content;
  }
}

// 备忘列表
class CareTaker {
  constructor() {
    this.list = [];
  }
  add(memento) {
    this.list.push(memento);
  }
  get(index) {
    return this.list[index];
  }
}

// 编辑器
class Editor {
  constructor() {
    this.content = null;
  }
  setContent(content) {
    this.content = content;
  }
  getContent() {
    return this.content;
  }
  saveContentToMemento() {
    return new Memento(this.content);
  }
  getContentFromMemento(memento) {
    this.content = memento.getContent();
  }
}

// 测试代码
let editor = new Editor();
let careTaker = new CareTaker();

editor.setContent("111");
editor.setContent("222");
careTaker.add(editor.saveContentToMemento()); // 存储备忘录
editor.setContent("333");
careTaker.add(editor.saveContentToMemento()); // 存储备忘录
editor.setContent("444");

console.log(editor.getContent()); // 444
editor.getContentFromMemento(careTaker.get(1)); // 撤销
console.log(editor.getContent()); // 333
editor.getContentFromMemento(careTaker.get(0)); // 撤销
console.log(editor.getContent()); // 222
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58

小结

  • 状态对象与使用者分开(解耦)
  • 符合开放封闭原则

模板方法模式

在模板模式(Template Pattern)中,一个抽象类公开定义了执行它的方法的方式/模板。

它的子类可以按需要重写方法实现,但调用将以抽象类中定义的方式进行。

感觉用到的不是很多,想了解的可以点击下面的参考链接。

参考:JavaScript 设计模式之模板方法模式open in new window

职责链模式

顾名思义,责任链模式(Chain of Responsibility Pattern)为请求创建了一个接收者对象的链。

这种模式给予请求的类型,对请求的发送者和接收者进行解耦。

在这种模式中,通常每个接收者都包含对另一个接收者的引用。

如果一个对象不能处理该请求,那么它会把相同的请求传给下一个接收者,依此类推。

例子

公司的报销审批流程:组长=》项目经理=》财务总监

// 请假审批,需要组长审批、经理审批、最后总监审批
class Action {
  constructor(name) {
    this.name = name;
    this.nextAction = null;
  }
  setNextAction(action) {
    this.nextAction = action;
  }
  handle() {
    console.log(`${this.name} 审批`);
    if (this.nextAction != null) {
      this.nextAction.handle();
    }
  }
}

let a1 = new Action("组长");
let a2 = new Action("项目经理");
let a3 = new Action("财务总监");
a1.setNextAction(a2);
a2.setNextAction(a3);
a1.handle();
// 组长 审批
// 项目经理 审批
// 财务总监 审批

// 将一步操作分为多个职责来完成,一个接一个的执行,最终完成操作。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

小结

  • 可以联想到 jQuery、Promise 这种链式操作
  • 发起者和处理者进行隔离
  • 符合开发封闭原则

命令模式

命令模式(Command Pattern)是一种数据驱动的设计模式,它属于行为型模式。

请求以命令的形式包裹在对象中,并传给调用对象。

调用对象寻找可以处理该命令的合适的对象,并把该命令传给相应的对象,该对象执行命令。

例子

实现一个编辑器,有很多命令,比如:写入、读取等等。

class Editor {
  constructor() {
    this.content = "";
    this.operator = [];
  }
  write(content) {
    this.content += content;
  }
  read() {
    console.log(this.content);
  }
  space() {
    this.content += " ";
  }
  readOperator() {
    console.log(this.operator);
  }
  run(...args) {
    this.operator.push(args[0]);
    this[args[0]].apply(this, args.slice(1));
    return this;
  }
}

const editor = new Editor();

editor
  .run("write", "hello")
  .run("space")
  .run("write", "zkk!")
  .run("read"); // => 'hello zkk!'

// 输出操作队列
editor.readOperator(); // ["write", "space", "write", "read"]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34

小结

  • 降低耦合
  • 新的命令可以很容易的添加到系统中

综述

(1)面向对象最终的设计目标:

  • A 可扩展性:有了新的需求,新的性能可以容易添加到系统中,不影响现有的性能,也不会带来新的缺陷。

  • B 灵活性:添加新的功能代码修改平稳地发生,而不会影响到其它部分。

  • C 可替换性:可以将系统中的某些代码替换为相同接口的其它类,不会影响到系统。

(2)设计模式的好处:

  • A 设计模式使人们可以更加简单方便地复用成功的设计和体系结构。
  • B 设计模式也会使新系统开发者更加容易理解其设计思路。

(3)学习设计模式有三重境界(网上看到好多次):

  • 第一重: 你学习一个设计模式就在思考我刚做的项目中哪里能用到(手中有刀,心中无刀)

  • 第二重: 设计模式你都学完了,但是当遇到一个问题的时候,你发现有好几种设计模式供你选择,你无处下(手中有刀,心中也有刀)

  • 第三重:也是最后一重,你可能没有设计模式的概念了,心里只有几大设计原则,等用到的时候信手拈来(刀法的最高境界:手中无刀,心中也无刀)

结语

以下是摘抄自掘金小册-JavaScript 设计模式核⼼原理与应⽤实践open in new window的结语。

设计模式的征程,到此就告一段落了。但对各位来说,真正的战斗才刚刚开始。设计模式的魅力,不在纸面上,而在实践中。

学设计模式:

一在多读——读源码,读资料,读好书;

二在多练——把你学到的东西还原到业务开发里去,看看它是否 OK,有没有问题?如果有问题,如何修复、如何优化?没有一种设计模式是完美的,设计模式和人一样,处在动态发展的过程中,并不是只有 GOF 提出的 23 种设计模式可以称之为设计模式。

只要一种方案遵循了设计原则、解决了一类问题,那么它都可以被冠以“设计模式”的殊荣。

在各位从设计模式小册毕业之际,希望大家带走的不止是知识,还有好的学习习惯、阅读习惯。最重要的,是深挖理论知识的勇气和技术攻关的决心。这些东西不是所谓“科班”的专利,而是一个优秀工程师的必须。

参考

上次更新: 11/1/2023, 6:40:42 AM
Contributors: zhangningle