跳到主要内容

前端需要掌握的设计模式

提到设计模式,相信知道的同学都会脱口而出,五大基本原则(SOLID)和 23 种设计模式。SOLID 所指的五大基本原则分别是:单一功能原则、开放封闭原则、里氏替换原则、接口隔离原则和依赖反转原则。逐字逐句诠释着五大基本原则违背了这篇文章的初衷,引用社区大佬的理解,SOLID 可以简单概括为六个字:即「高内聚、低耦合」:

  • 层模块不依赖底层模块,即为依赖反转原则;
  • 部修改关闭,外部扩展开放,即为开放封闭原则;
  • 合单一功能,即为单一功能原则;
  • 知识要求,对外接口简单,即为迪米特法则;
  • 合多个借口,不如独立拆分,即为接口隔离原则;
  • 成复用,子类继承可替换父类,即为里氏替换原则。

23 种设计模式分为「创建型」、「行为型」和「结构型」,具体类型如下图:

设计模式说白了就是「封装变化」,比如「创建型」封装了创建对象的变化过程,「结构型」将对象之间组合的变化封装,「行为型」则是抽离对象的变化行为。接下来,本文将以「单一功能」和「开放封闭」这两大原则作为主线,分别介绍「创建型」、「结构型」和「行为型」中最具代表性的几大设计模式。

创建型

工厂模式

工厂模式根据抽象程度可分为三种,分别为简单工厂、工厂方法和抽象工厂。其核心在于将创建对象的过程封装起来,然后通过同一个接口创建新的对象。简单工厂模式又叫静态工厂方法,用来创建某一种产品对象的实例,用来创建单一对象。

// 简单工厂
class Factory {
constructor(username, pwd, role) {
this.username = username;
this.pwd = pwd;
this.role = role;
}
}

class CreateRoleFactory {
static create(username, pwd, role) {
return new Factory(username, pwd, role);
}
}

const admin = CreateRoleFactory.create("张三", "222", "admin");

在实际工作中,各用户角色所具备的能力是不同的,因此简单工厂是无法满足的,这时候就可以考虑使用工厂方法来代替。工厂方法的本意是将实际创建对象的工作推迟到子类中。

class User {
constructor(name, menuAuth) {
if (new.target === User) throw new Error("User 不能被实例化");
this.name = name;
this.menuAuth = menuAuth;
}
}

class UserFactory extends User {
constructor(...props) {
super(...props);
}
static create(role) {
const roleCollection = new Map([
["admin", () => new UserFactory("管理员", ["首页", "个人中心"])],
["user", () => new UserFactory("普通用户", ["首页"])],
]);

return roleCollection.get(role)();
}
}

const admin = UserFactory.create("admin");
console.log(admin); // {name: "管理员", menuAuth: Array(2)}
const user = UserFactory.create("user");
console.log(user); // {name: "普通用户", menuAuth: Array(1)}

随着业务形态的变化,一个用户可能在多个平台上同时存在,显然工厂方法也无法满足了,这时候就要用到抽象工厂。抽象工厂模式是对类的工厂抽象用来创建产品类簇,不负责创建某一类产品的实例。

class User {
constructor(hospital) {
if (new.target === User) throw new Error("抽象类不能实例化!");
this.hospital = hospital;
}
}
// 浙一
class ZheYiUser extends User {
constructor(name, departmentsAuth) {
super("zheyi_hospital");
this.name = name;
this.departmentsAuth = departmentsAuth;
}
}
// 萧山医院
class XiaoShanUser extends User {
constructor(name, departmentsAuth) {
super("xiaoshan_hospital");
this.name = name;
this.departmentsAuth = departmentsAuth;
}
}

const getAbstractUserFactory = (hospital) => {
switch (hospital) {
case "zheyi_hospital":
return ZheYiUser;
break;
case "xiaoshan_hospital":
return XiaoShanUser;
break;
}
};

const ZheYiUserClass = getAbstractUserFactory("zheyi_hospital");
const XiaoShanUserClass = getAbstractUserFactory("xiaoshan_hospital");

const user1 = new ZheYiUserClass("王医生", ["外科", "骨科", "神经外科"]);
console.log(user1);
const user2 = newXiaoShanUserClass("王医生", ["外科", "骨科"]);
console.log(user2);

小结: 构造函数和创建对象分离,符合开放封闭原则。

使用场景: 比如根据权限生成不同用户。

单例模式

单例模式理解起来比较简单,就是保证一个类只能存在一个实例,并提供一个访问它的全局借口。单例模式又分懒汉模式和饿汉模式,其区别在于懒汉模式在调用的时候创建实例,而饿汉模式则是在初始化就创建好实例,其具体实现如下:

// 懒汉式
class Single {
static getInstance() {
if (!Single.instance) {
Single.instance = new Single();
}
return Single.instance;
}
}

const test1 = Single.getInstance();
const test2 = Single.getInstance();

console.log(test1 === test2); // true
// 饿汉式
class Single {
static instance = new Single();

static getInstance() {
return Single.instance;
}
}

const test1 = Single.getInstance();
const test2 = Single.getInstance();

console.log(test1 === test2); // true

小结: 实例如果存在,直接返回已创建的,符合开放封闭原则。

使用场景: Redux、Vuex 等状态管理工具,还有我们常用的 window 对象、全局缓存等。

原型模式

对于前端来说,原型模式再常见不过了。当新创建的对象和已有对象存在较大共性时,可以通过对象的复制来达到创建新的对象,这就是原型模式。

// Object.create()实现原型模式
const user = {
name: "zhangsan",
age: 18,
};
let userOne = Object.create(user);
console.log(userOne.__proto__); // {name: "zhangsan", age: 18}

// 原型链继承实现原型模式
class User {
constructor(name) {
this.name = name;
}
getName() {
return this.name;
}
}

class Admin extends User {
constructor(name) {
super(name);
}
setName(_name) {
return (this.name = _name);
}
}

const admin = new Admin("zhangsan");
console.log(admin.getName());
console.log(admin.setName("lisi"));

小结: 原型模式最简单的实现方式 —— Object.create()

使用场景: 新创建对象和已有对象无较大差别时,可以使用原型模式来减少创建新对象的成本。

结构型

装饰器模式

将装饰其模式之前,先聊聊高阶函数。高阶函数就是一个函数可以接收另一个函数作为参数。

const add = (x, y, f) => {
return f(x) + f(y);
};
const num = add(2, -2, Math.abs);
console.log(num); // 4

函数 add 就是一个简单的高阶函数,而 add 相对于 Math.abs 就相当于一个装饰器,因此这个例子也可以理解为一个简单的装饰其模式。在 React 中,高阶组件(HOC)也是装饰其模式的一种体现,通常用来不改变原来组件的情况下添加一些属性,达到组件复用的功能。

import React from "react";

const BgHOC = (WrappedComponent) =>
class extends React.Component {
render() {
return (
<div style={{ background: "blue" }}>
<WrappedComponent />
</div>
);
}
};

小结: 装饰器模式将现有对象和装饰器进行分离,两者独立存在,符合开放封闭原则和单一职责模式。

使用场景: es7 装饰器、vue mixins、core-decorators 等。

适配器模式

适配器别名包装器,其作用是解决两个软件实体间接口不兼容的问题。以 axios 源码为例:

function getDefaultAdapter() {
var adapter;
// 判断当前是否是 node 环境
if (typeof process !== 'undefined' && Object.prototype.toString.call(process) === '[object process]') {
// 如果是 node 环境,调用 node 专属的 http 适配器
adapter = require('./adapters/http');
} else if (typeof XMLHttpRequest !== 'undefined') {
// 如果是浏览器环境,调用基于 xhr 的适配器
adapter = require('./adapters/xhr');
}
return adapter;
}

// http adapter
module.exports = function httpAdapter(config) {
return new Promise(function dispatchHttpRequest(resolvePromise, rejectPromise) {
...
}
}
// xhr adapter
module.exports = function xhrAdapter(config) {
return new Promise(function dispatchXhrRequest(resolve, reject) {
...
}
}

其目的就是保证 node 环境和浏览器环境的入参 config 一致,出参 Promise 都是同一个。

小结: 不改变原有接口的情况下,统一接口、统一入参、统一出参、统一规则,符合开放封闭原则。

使用场景: 拥抱变化,兼容代码。

代理模式

代理模式就是为对象提供一个代理,用来控制对这个对象的访问。在我们业务开发中最常见的有四种代理类型:事件代理、虚拟代理、缓存代理和保护代理。本文主要介绍虚拟代理和缓存代理两类。提到虚拟代理,其最具代表性的例子就是图片预加载。预加载主要为了避免网络延迟、或者图片太大引起页面长时间留白的问题。通常的解决方案是先给 img 标签展示一个占位符,然后创建一个 Image 实力,让这个实例的 src 指向真实的图片地址,当真实图片加载完成后,再将 DOM 上的 img 标签的 src 属性指向真实图片地址。

class ProxyImg {
constructor(imgELe) {
this.imgELe = imgELe;
this.DEFAULT_URL = "xxx";
}
setUrl(targetUrl) {
this.imgEle.src = this.DEFAULT_URL;
const image = new Image();

image.onload = () => {
this.imgEle.src = targetUrl;
};
image.src = targetUrl;
}
}

缓存代理常用于一些计算量较大的场景。当计算的值已经被出现过的时候,不需要进行二次重复计算,以传参求和为例:

const countSum = (...arg) => {
console.log("count...");
let result = 0;
arg.forEach((v) => (result += v));
return result;
};

const proxyCountSum = (() => {
const cache = {};
return (...arg) => {
const args = arg.join(",");
if (args in cache) return cache[args];
return (cache[args] = countSum(...arg));
};
})();

proxyCountSum(1, 2, 3, 4); // count... 10
proxyCountSum(1, 2, 3, 4); // 10

小结: 通过修改代理类来增加功能,符合开放封闭模式。

使用场景: 图片预加载、缓存服务器、处理跨域以及拦截器等。

行为型

策略模式

介绍策略模式之前,简单实现一个常见的促销活动规则:

  • 预售活动,全场 9.5 折
  • 大促活动,全场 9 折
  • 返场优惠,全场 8.5 折
  • 限时优惠,全场 8 折

人人喊打的 if-else:

const activity = (type, price) => {
if (type === "pre") {
return price * 0.95;
} else if (type === "onSale") {
return price * 0.9;
} else if (type === "back") {
return price * 0.85;
} else if (type === "limit") {
return price * 0.8;
}
};

以上代码存在肉眼可见的问题:大量 if-else、可扩展性差、违背开放封闭原则等。我们使用策略模式来优化一下:

const activity = new Map([
["pre", (price) => price * 0.95],
["onSale", (price) => price * 0.9],
["back", (price) => price * 0.85],
["limit", (price) => price * 0.8],
]);

const getActivityPrice = (type, price) => activity.get(type)(price);

// 新增新手活动
activity.set("newcomer", (price) => price * 0.7);

小结: 定义一系列算法,将其一一封装起来,并且使它们可相互替换,符合开放封闭原则。

使用场景: 表单验证、存在大量 if-else 场景、各种重构等。

观察者模式

观察者模式或者发布订阅模式,其用来定义对象之间的一对多依赖关系,以便当一个对象更改状态时,将通知其所有依赖关系。通过名称我们知道观察者模式具有两个角色,即「发布者」和「订阅者」。正如我们工作中产品经理就是一个「发布者」,而前后端、测试可以理解为「订阅者」,以一个需求会议为例:

// 定义发布者类
class Publisher {
constructor() {
this.observers = [];
this.prdState = null;
}
// 增加订阅者
add(observer) {
this.observers.push(observer);
}
// 通知所有订阅者
notify() {
this.observers.forEach((observer) => {
observer.update(this);
});
}
// 该方法用于获取当前的 prdState
getState() {
return this.prdState;
}

// 该方法用于改变 prdState 的值
setState(state) {
// prd 的值发生改变
this.prdState = state;
// 需求文档变更,立刻通知所有开发者
this.notify();
}
}

// 定义订阅者类
class Observer {
constructor() {
this.prdState = {};
}
update(publisher) {
// 更新需求文档
this.prdState = publisher.getState();
// 调用工作函数
this.work();
}
// work 方法,一个专门搬砖的方法
work() {
// 获取需求文档
const prd = this.prdState;
console.log(prd);
}
}

// 创建订阅者:前端开发小王
const wang = new Observer();
// 创建订阅者:后端开发小张
const zhang = new Observer();
// 创建发布者:产品经理小曾
const zeng = new Publisher();
// 需求文档
const prd = {
url: "xxxxxxx",
};
// 小曾开始拉人入群
zeng.add(wang);
zeng.add(zhang);
// 小曾发布需求文档并通知所有人
zeng.setState(prd);

经常使用 EventBus(Vue)和 EventEmitter(node)会发现,发布订阅模式和观察者模式还是有细微差别的,即所有事件的发布 / 订阅都不能由发布者和订阅者 私下联系 ,需要委托事件中心处理,以 Vue EventBus 为例:

import Vue from "vue";

const EventBus = new Vue();
Vue.prototype.$bus = EventBus;

// 订阅事件
this.$bus.$on("testEvent", func);
// 发布/触发事件
this.$bus.$emit("testEvent", params);

整个过程都是 this.$bus 这个「事件中心」在处理。

小结: 为解耦而生,为事件而生,符合开放封闭原则。

使用场景: 跨层级通信、事件绑定等。

迭代器模式

迭代器模式号称「遍历专家」,它提供一种方法顺序访问一个聚合对象中的各个元素,且不暴露该对象的内部表示。迭代器又分为内部迭代器( $.each / for...of )和外部迭代器(es 6 yield)。在 es6 之前,直接通过 forEach 遍历 DOM NodeList 和函数的 arguments 对象,都会直接报错,因为它们都是类数组对象,对此 jquery 很好的兼容了这一点。在 es6 中,约定只要数据类型具备 Symbol.iterator 属性,就可以被 for...of 循环和迭代器的 next 方法遍历。

(function (a, b, c) {
const arg = arguments;
const iterator = arg[Symbol.iterator]();

console.log(iterator.next()); // {value: 1, done: false}
console.log(iterator.next()); // {value: 2, done: false}
console.log(iterator.next()); // {value: 3, done: false}
console.log(iterator.next()); // {value: undefined, done: true}
})(1, 2, 3);

通过 es6 内置生成器 Generator 实现迭代器并没什么难度,这里重点通 es5 实现迭代器:

function iteratorGenerator (list) {
var index = 0;
// len 记录传入集合的长度
var len = list.length;
return {
// 自定义 next 方法
next: funciton () {
// 如果索引还没有超出集合长度,done 为 false
var done = index >= len;
// 如果 done 为 false,则可以继续取值
var value = !done ? list[index++] : undefined;

// 将当前值与遍历是否完毕(done)返回
return {
done: done,
value: value
};
}
}
}

var iterator = iteratorGenerator([1, 2, 3]);
console.log(iterator.next()); // {value: 1, done: false}
console.log(iterator.next()); // {value: 2, done: false}
console.log(iterator.next()); // {value: 3, done: false}
console.log(iterator.next()); // {value: undefined, done: true}

小结: 实现统一遍历接口,符合单一功能和开放封闭原则。

使用场景: 有遍历的地方就有迭代器。

写到最后

设计模式的难,在于它的抽象和分散。抽象在于每一设计模式看例子都很好理解,真正使用起来却不知所措;分散则是出现一个场景发现好几种设计模式都能实现。而解决抽象的最好办法就是动手实践,在业务开发中探索使用它们的可能性。本文大致介绍了前端领域常见的 9 种设计模式,相信大家在理解的同时也不难发现,设计模式始终围绕着「封装变化」来提供代码的可读性、扩展性、易维护性。所以当我们工作生活中,始终保持“封装变化”的思想的时候,就已经开始体会到设计模式精髓了。