前言
通过最近的面试我发现,很多同行小伙伴做了几年的开发竟然连基本的设计模式都不知道,甚至可能连什么是设计模式都说的有点模棱两可。
如果作为几年经验的中高级前端这其实是不应该的,因此才想写一篇文章说明什么是设计模式??设计模式有哪些分类??然后举例??
好了废话不多说,下面让我们直入主题。
什么是前端设计模式
用大白话说前端的设计模式,其实就是一种可以在多处地方重复使用的代码设计方案, 只是不同的设计模式所能应用的场景有所不同。
通过这种设计模式可以帮助我们提高代码的可读性、可维护性与可扩展性。
而且前端的设计模式又分为三个大类型,分别是创建型、结构型和行为型
,针对这三个大类型,又会有很多种不同的设计模式。
前端设计模式分类
如果要说设计模式的话有高达二十多种,但本文章主要针对javascript
相关的设计模式,因此我整理出来10种
设计模式,并且进行分类总结。
javascript
的设计模式分为三大类型,即创建型、结构型和行为型
。
整理后具体分类如下所示
前端设计模式详解
创建型
创建型:顾名思义作用就是用于创建过程。通过确定规则对代码进行封装,减少创建过程中的重复代码,并且对创建制定规则提高规范和灵活性。
1、单例模式
主要思想: 确保一个类只有一个实例,并且提供一个访问它的全局访问点。
优势: 由于只有一个实例,所以全局唯一性,并且更好地控制共享资源优化性能。
示例: 下面看一个最经典常用的案例:使用ES6
模块。
const test = {
name: 'testName',
age: '18',
};
export default test;
import test from './test';
console.log(test.name,test.age); // 打印:testName,18
上述例子定义test
并且export defaul
暴露唯一的实例test
,符合确保一个类只有一个实例,并且提供一个访问它的全局访问点原则。
其实单例模式有很多种实现方式,并且不同的实现方式有不同的适用场景,这种只是为了通过例子去理解这种设计模式的思想。
2、工厂模式
主要思想: 对代码逻辑进行封装,只暴露出通用接口直接调用。
优势: 对逻辑进行高度封装,降低耦合度,易于维护代码和提高后续扩展性。
示例: 定义一个通用的产品类为示例。
// ------ 定义一个产品类 ------
class testProduct {
constructor(productName) {
this.productName = productName;
}
getName() {
console.log(`产品名称: ${this.productName}`);
}
}
// ----- 定义一个工厂函数 -------
function createProduct(name) {
return new testProduct(name);
}
// 使用工厂函数创建对象
const test1 = createProduct('产品1');
const test2 = createProduct('产品2');
// 使用对象
test1.getName(); // 打印: 产品名称: 产品1
test2.getName(); // 打印: 产品名称: 产品2
上述例子定义一个工厂函数,逻辑代码是封装在testProduct
类中,暴露出createProduct
方法,调用时传入不同的参数返回不同的内容。
3、构造器模式
主要思想: 定义一个通用的构造函数,然后方便多次传递参数调用。
优势: 减少重复代码、提高可维护性和扩展性。
示例: 创建用户对象,定义一个构造函数并且使用。
class testPerson {
constructor(name, age,) {
this.name = name;
this.age = age;
}
introduce() {
console.log(`姓名: ${this.name}, 年龄: ${this.age}`);
}
}
const test1 = new testPerson('张三', 30);
test1.introduce(); // 姓名: 张三, 年龄: 30
const test2 = new testPerson('李四', 25);
test2.introduce(); // 输出: 姓名: 李四, 年龄: 25
定义一个testPerson
类,每次传入不同参数即可创建不同的用户对象,后续如果需要修改用户属性只需要调整testPerson
类。
结构型
结构型: 主要是针对对象之间的组合。大概意思就是通过增加代码复杂度,从而提高扩展性和适配性。例如使代码兼容性更好、使某个方法功能更加强大。
1、适配器模式
主要思想: 顾名思义就是使某个类的接口有更强的适配性,例如本来仅支持mp3
,适配成能支持mp4
。
优势: 适配扩展后提高了复用性、降低耦合度并且增强了灵活性。
示例: 把一个只能接收110V
电压的插口,适配成能够接收220V
的插口。
// ------ 本来存在需要被适配的110V接口 ------
class Receptacle {
plugIn() {
console.log("110V 插座");
}
}
// ------ 适配者类 ------
class ForeignReceptacle {
plugIn220V() {
console.log("220V 插座");
}
}
// ------ 用于适配的方法 ------
class VoltageAdapter {
constructor(foreignReceptacle) {
this.foreignReceptacle = foreignReceptacle;
}
plugIn() {
this.foreignReceptacle.plugIn220V();
}
}
使用适配器代码:正常使用Receptacle
类时输出效果是110V
,但我们需要配220V
,那么使用定义的VoltageAdapter
适配器把220V
的ForeignReceptacle
类适配到110V
的Receptacle
类上。
通过这个方式即扩展了Receptacle
类的功能,又不需要修改Receptacle
类。
// 创建110V设备
const receptacle = new Receptacle();
receptacle.plugIn(); // 打印输出: 110V 插座
// 创建220V设备
const foreignReceptacle = new ForeignReceptacle();
// 使用适配器将 220V 设备适配到 110V 插座
const adapter = new VoltageAdapter(foreignReceptacle);
adapter.plugIn(); // 打印输出: 220V 插座
2、装饰器模式
主要思想: 创建一个对象去包裹原始对象,在不修改原始对象本身的情况下,动态给指定原始对象添加新的功能。
优势: 不改动原函数的情况下方便动态扩展功能,可以复用现有函数增强灵活性。
示例: 通过对一个只能输出你好啊,**
的方法添加装饰器,使其能额外输出前缀。
// 基础函数
function getGreet(name) {
console.log(`你好啊,${name}!`);
}
// 装饰器函数
function welcomePrefix(greetFunction) {
return function(name) {
console.log("欢迎啊");
greetFunction(name);
};
}
// 基础函数
getGreet("天天鸭"); // 打印: 你好啊,天天鸭!
// 添加 欢迎啊 前缀
const setWelcome = welcomePrefix(getGreet);
setWelcome("天天"); // 打印: 欢迎啊
// 打印: 你好,天天!
如上代码所示,getGreet
只能输出你好啊,**
,但使用装饰器函数welcomePrefix
装饰后,可以在前面添加"欢迎啊"前缀,通过这个实现思路方式不需要修改基础函数就能添加功能。
3、代理模式
主要思想: 给某个对象加一个代理对象,代理对象起到中介作用,中介对象在不改变原对象情况下添加功能。
优势: 代理对象可以很方便实现拦截控制访问,并且不修改原对象提高代码复用率。
示例: 通过代理函数去控制计数器函数的操作
// 基础函数
function counterEvent() {
let count = 0;
return {
setCount: () => {
count++;
},
getCount: () => {
return count;
}
};
}
// 代理函数
function countProxy() {
const newCounter = counterEvent();
return {
setCount: () => {
newCounter.setCount();
},
getCount: () => {
return newCounter.getCount();
}
};
}
// 创建一个代理对象
const myCounter = countProxy();
// 触发增加
myCounter.setCount();
myCounter.setCount();
myCounter.setCount();
// 获取当前数
console.log(myCounter.getCount()); // 打印: 3
不让用户直接操作counterEvent
函数,而是通过countProxy
代理函数去操作counterEvent
函数
。
这里只是举例这种代理模式的设计思想,如果在真实业务中间代理层其实可以很多逻辑操作。
行为型
行为型: 主要是针对对象之间的交互。针对特定的应用场景,通过封装制定对象之间的交互方式规则,使对象之间协作更加灵活高效健壮。
1、观察者模式
主要思想: 顾名思义就是观察某个对象是否发生变化,如果发生变化就会通知所有订阅者,并做出相应操作,是一对一或一对多
关系。。
优势: 有很强动态灵活性,可以轻松地添加或者移除观察者; 把观察者和被观察者解耦进行逻辑分离易于维护。
示例: 实现基本增加、移除和通知。
// 观察者
class Sub {
constructor() {
this.observers = [];
}
add(observer) { // 添加观察者到列表中
this.observers.push(observer);
}
unadd(observer) { // 从列表中移除观察者
this.observers = this.observers.filter(obs => obs !== observer);
}
notify(msg) { // 通知所有观察者
this.observers.forEach(observer => observer(msg));
}
}
// 用于创建观察者
const createObs = (name) => {
return (msg) => {
console.log(`${name} 收到: ${msg}`);
};
};
使用观察者模式代码: 被观察者Sub
里面有add
(添加)、unadd
(移除)、notify
(通知)观察者的方法,观察者createObs
里面有接收通知的方法。
当我们用sub.add
添加观察者之后,使用sub.notify
发布消息所有的观察者都会收到通知。
sub.unadd
移除一个观察者1后也同理,会不再收到通知。
// 创建一个被观察者
const sub = new Sub();
// 创建观察者
const obs1 = createObs("观察者1");
const obs2 = createObs("观察者2");
// 订阅被观察者
sub.add(obs1);
sub.add(obs2);
// 发布消息
sub.notify("你好鸭!"); // 观察者1和观察者2都收到: 你好鸭!
// 移除观察者1
sub.unadd(obs1);
// 再次发布
sub.notify("你好鸭!"); // 只有观察者2收到: 你好鸭!
2、发布者订阅者模式
主要思想: 这模式有点与观察者模式类似,但观察者模式是一对一或者一对多
关系,而发布订阅模式是多对多
关系,因此应用场景会有所不同。
优势: 多对多关系有很强动态灵活性,可以多个订阅者,一个订阅者可以订阅多个事件; 把发布者和订阅者完全解耦提高灵活性和扩展性。
示例:
// 发布者
class Pub {
constructor() {
this.subobj = {};
}
subscribe(event, callback) { // 订阅事件
if (!this.subobj[event]) {
this.subobj[event] = [];
}
this.subobj[event].push(callback);
}
unsubscribe(event, callback) { // 移除订阅事件
if (this.subobj[event]) {
this.subobj[event] = this.subobj[event].filter(cb => cb !== callback);
}
}
publish(event, data) { // 发布事件
if (this.subobj[event]) {
this.subobj[event].forEach(callback => callback(data));
}
}
}
// 创建一个发布者实例
const pub = new Pub();
// 订阅者回调函数
const subevent1 = (msg) => {
console.log(`订阅者1 收到: ${msg}`);
};
const subevent2 = (msg) => {
console.log(`订阅者2 收到: ${msg}`);
};
// 订阅事件
pub.subscribe("greet", subevent1);
pub.subscribe("greet", subevent2);
// 发布消息
pub.publish("greet", "你好鸭!"); // 订阅者1和订阅者2 收到: 你好鸭!
// 移除一个订阅者
pub.unsubscribe("greet", subevent1);
// 再次发布消息
pub.publish("greet", "你好鸭!"); // 只有订阅者2 收到: 你好鸭!
大概思路是定义一个Pub
类,里面有subscribe
(添加订阅事件)、unsubscribe
(移除订阅事件)、
publish
(通知发布事件)。new Publisher()
创建发布者实例后可以添加、移除和发布事件。
对比上面很相似的观察者模式可以留意到,最主要区别在Pub
类里面的constructor()
中,
这里使用的是this.subobj={}
存放事件映射,而不是使用数组。{}
里面每个事件都存放一个订阅者数组从而实现多对多效果。
3、命令模式
主要思想: 把请求封装在对象里面整个传递给调用对象,使里面参数更加灵活方便扩展。
优势: 使发送和接收者完全解耦独立易于数据维护、逻辑独立方便灵活处理、队列请求可以撤销操作。
示例: 以一个开关灯按钮为示例。
接收者testLight
主要负责执行业务逻辑命令,即决定是否关灯;
LightOnComm
和LightOffComm
继承基类Comm
,实现execute()
方法,
在xecute()
方法中调用接收者的方法,然后分别调用on
和off
方法;
RemoteControl
类负责调用者的方法,即去调用execute()
方法。
// 接收者
class testLight {
on() {
console.log("打开灯了");
}
off() {
console.log("关闭灯了");
}
}
// 命令基类
class Comm {
constructor(receiver) {
this.receiver = receiver;
}
}
// 具体命令
class LightOnComm extends Comm {
execute() {
this.receiver.on();
}
}
class LightOffComm extends Comm {
execute() {
this.receiver.off();
}
}
// 调用者
class RemoteControl {
onButton(comm) {
comm.execute();
}
}
使用示例与解释:
创建一个testlight
实例后,将其传递给LightOnComm
和LightOffComm
的构造函数,
然后普创建了LightOnComm
和LightOffComm
的实例。并将它们传递给RemoteControl
的onButton
方法。
最后调用onButton
方法时,就会调用相应命令的execute
方法,从而执行相应的操作。
// 使用
const testlight = new testLight();
const lightOnComm = new LightOnComm(testlight);
const lightOffComm = new LightOffComm(testlight);
const remoteControl = new RemoteControl();
remoteControl.onButton(lightOnComm); // 输出: 打开灯了
remoteControl.onButton(lightOffComm); // 输出: 关闭灯了
4、模版模式
主要思想: 定义好整个操作过程的框架,框架中把每个步骤的逻辑独立处理。
优势: 步骤独立分开管理,易于扩展功能维护代码。
示例: 游戏从初始化到结束
class Game {
constructor(obj) {
}
initGame() {
console.log('初始化');
}
startGame() {
console.log('游戏开始');
}
onGame() {
console.log('游戏中');
}
endGame() {
console.log('游戏结束');
}
personEntry() {
this.initGame()
this.startGame()
this.onGame()
this.endGame()
}
}
这个Game
类中把每个步骤的逻辑都放在对应步骤的方法中,独立管理互不影响。
添加或者减少步骤,只需要修改对应的方法即可。
小结
只要把这些模式都学习理解后会发现,这些设计模式真的是太精妙了!文章的示例主要学习的是设计模式的思想, 这些思想真的能适用于真实业务的方方面面,特别是对去阅读别人开源的源码时很有帮助。
终于肝完了,如果有那里写的不对或者有更好建议的话,欢迎提出来互相学习!