什么是设计模式?
设计模式,其实就是一种可以在多处地方重复使用的代码设计方案,
只是不同的设计模式所能应用的场景有所不同。通过这种设计模式可以帮助我们提高代码的可读性、可维护性与可扩展性。
前端的设计模式又分为三个大类型,分别是创建型、结构型和行为型,针对这三个大类型,又会有很多种不同的设计模式。
创建型
主要用于对象的创建过程,比如对象的创建、初始化等,它隐藏了对象创建的具体细节,从而解耦客户端和对象的创建过程。
单例模式
主要思想:确保一个类只有一个实例,并且提供一个访问它的全局访问点。
优势: 由于只有一个实例,所以全局唯一性,并且更好地控制共享资源优化性能。
示例:最经典常用的案例:使用 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,符合确保一个类只有一个实例,并且提供一个访问它的全局访问点原则。
工厂模式
主要思想: 对代码逻辑进行封装,只暴露出通用接口直接调用。
优势: 对逻辑进行高度封装,降低耦合度,易于维护代码和提高后续扩展性。
示例:
// 定义一个产品类
class product() {constructor(productName) {this.productName = productName}getName() {console.log(`产品名称: ${this.productName}`);}
}// 定义一个工厂函数
function createProduct() {return new product()
}// 使用工厂函数创建对象
const test1 = createProduct('产品1');
const test2 = createProduct('产品2');// 使用对象
test1.getName(); // 打印:产品名称: 产品1
test2.getName(); // 打印:产品名称: 产品2
工厂模式的重点是封装对象的创建过程,使得不需要知道对象的具体创建细节,对象通过工厂函数暴露的接口来创建。
构造器模式
主要思想:定义一个通用的构造函数,然后方便多次传递参数调用。
优势:减少重复代码、提高可维护性和扩展性。
示例:
class testPerson {constructor(name, age,) {this.name = name;this.age = age;}introduce() {console.log(`姓名: ${this.name}, 年龄: ${this.age}`);}
}const test1 = new testPerson('张三', 30);
const test2 = new testPerson('李四', 25);test1.introduce(); // 姓名: 张三, 年龄: 30
test2.introduce(); // 输出: 姓名: 李四, 年龄: 25
总结
单例模式与其他两个模式最主要的区别是:
- 单例模式限制只能创建一个对象,通过定义一个全局访问点来获取唯一的对象实例,适用于需要全局唯一对象的场景,如配置管理器、线程池等。
工厂模式和构造器模式最主要的区别是:
-
工厂模式将对象的创建过程完全封装在工厂类中,客户端代码通过工厂接口来创建对象,而不需要知道具体的创建逻辑;构造器模式中客户端代码需要知道具体的类,并直接调用其构造函数来创建对象。即工厂模式的解耦性更强。
-
工厂模式更适合需要经过复杂的步骤创建出来的对象。例如当一个对象的创建依赖很多参数做判断时,工厂模式可以在工厂函数内部早早的写好判断代码,创建对象时只需要传入必须的参数;而构造器模式则可能需要传递大量的参数才能正确创建出需要的对象。
-
构造器模式更适合创建一些不需要经过复杂步骤创建出来的对象。因为一个类可以有多个构造函数,如果对象的创建很复杂,你可能就要设计多个构造函数来应对不同的情况,不能很好地做到解耦性。所以归根到底还是因为工厂模式的解耦性更强。
结构型
主要是针对对象之间的组合。大概意思就是通过增加代码复杂度,从而提高扩展性和适配性。例如使代码兼容性更好、使某个方法功能更加强大。
适配器模式
主要思想:顾名思义就是使某个类的接口有更强的适配性,例如本来仅支持 mp3,适配成能支持 mp4。
优势:适配扩展后提高了复用性、降低耦合度并且增强了灵活性。
示例:
// 现有的 MP3 播放接口
class Mp3Player {playMp3(fileName) {console.log(`Playing MP3 file: ${fileName}`);}
}
// 新增的 MP4 播放接口
class Mp4Player {playMp4(fileName) {console.log(`Playing MP4 file: ${fileName}`);}
}
// 适配器
class AdapterPlayer extends Mp3Player {constructor(mp4Player) {// 使用了 extends 关键则必须调用 super() 来初始化父类super();// 给 AdapterPlayer 类的实例添加一个新的属性 mp4Player,并将传入的 mp4Player 对象赋值给这个属性。this.mp4Player = mp4Player;}// 重写父类的方法,使它可以调用 Mp4Player 的方法playMp3(fileName) {if (fileName.endsWith('.mp4')) {this.mp4Player.playMp4(fileName);} else {super.playMp3(fileName); // 如果不是 MP4 文件,则调用原有的playMp3方法}}
}
// 使用适配器// 1. 创建 Mp4Player 实例
const mp4Player = new Mp4Player();// 创建适配器实例
const adapter = new AdapterPlayer(mp4Player);// 测试播放MP3文件
adapter.playMp3('song.mp3'); // 输出: Playing MP3 file: song.mp3// 测试播放MP4文件
adapter.playMp3('movie.mp4'); // 输出: Playing MP4 file: movie.mp4
装饰器模式
主要思想: 允许你在运行时动态地为对象添加功能,而不需要修改对象本身的结构。这种模式通过创建一个包装对象(装饰器)来包裹原始对象,并在需要的时候增加额外的功能。
优势:不改动原函数的情况下方便动态扩展功能
示例:
// 基础函数
function sayAge(age) {console.log(`我今年${age}岁!`);
}// 装饰器函数
function myDecorator(sayAgeFunction) {return function(name) {sayAgeFunction(name)console.log(`忘了说我的生日是1月1号!`);}
}const setAge = myDecorator(sayAge);
setAge(18)
// 输出:我今年18岁!
// 输出:忘了说我的生日是1月1号!
代理模式
主要思想:代理模式通过创建一个代理对象来控制对真实对象的访问,这个代理对象可以提供额外的功能,如延迟加载、权限控制、日志记录等。
优势:代理对象可以很方便实现拦截控制访问,并且不修改原对象。
示例:vue3 的响应式系统就是使用了代理模式
总结
适配器模式的主要目的是使两个不兼容的接口能够一起工作。这个模式通常应用于已经存在的代码中,当需要将新的类与旧的接口配合使用时。
装饰者模式的特点在于增强,为对象添加功能,可以为一个对象添加多个装饰器。
代理模式是控制对对象的访问。当一个对象使用了代理模式,那么访问这个对象时,其实都是在访问代理对象
行为型
观察者模式
主要思想:它定义了一种一对多的依赖关系,让多个观察者对象同时监听某一个主题对象(也叫被观察者)。这个主题对象在状态发生变化时,会通知所有观察者对象,使它们能够自动更新自己。
优势: 在观察者模式中,观察者对象和主题对象之间的依赖关系是通过接口或抽象类来实现的。这种依赖关系是一种弱依赖关系,因为观察者对象并不需要知道主题对象的具体实现细节。这种弱依赖关系使得我们可以轻松地添加、删除或替换观察者对象,而不会对主题对象的代码造成任何影响。
示例:
// 主题类
class Subject {constructor() {this.observers = []; // 存储观察者的数组}// 添加观察者addObserver(observer) {this.observers.push(observer);}// 移除观察者removeObserver(observer) {this.observers = this.observers.filter(item => item != observer);}// 通知所有观察者notify(message) {this.observers.forEach(observer => {observer.update(message);});}
}
// 观察者类
class Observer {constructor(name) {this.name = name;}// 接收消息update(message) {console.log(`观察者名称:${this.name} --- 接收到消息: ${message}`);}
}
// 使用示例// 创建一个主题实例
const subject = new Subject();// 创建两个观察者实例
const observer1 = new Observer('观察员1');
const observer2 = new Observer('观察员2');// 将观察者添加到主题中
subject.addObserver(observer1);
subject.addObserver(observer2);// 通知所有观察者
subject.notify('主题消息');
// 输出:观察者名称:观察员1 --- 接收到消息: 主题消息
// 输出:观察者名称:观察员2 --- 接收到消息: 主题消息// 移除一个观察者
subject.removeObserver(observer1);// 再次通知所有观察者
subject.notify('Observer 1 has been removed.');
// 输出:观察者名称:观察员2 --- 接收到消息: 主题消息
在这个示例中,多个观察者对象(observer1 和 observer2)同时监听某一个主题对象(subject),当主题对象在状态发生变化时(主题对象调用 notify 方法),会通知所有观察者对象(observer1和observer2),使它们能够自动更新(观察者对象调用自己的 update 方法)。
发布订阅模式
主要思想:发布-订阅模式(Publish-Subscribe Pattern,简称 Pub-Sub)是观察者模式的一个变种,它通过一个事件中心(也称为消息代理、中介者或发布者-订阅者中心)来解耦发布者和订阅者之间的依赖关系。在发布-订阅模式中,发布者(Publisher)和订阅者(Subscriber)并不直接通信,而是通过一个中间件(通常是事件总线或消息队列)进行消息传递。
优势:可以多对多,通过引入事件中心,实现了发布者和订阅者之间的完全解耦。
示例:
// 创建一个事件总线(发布订阅中心)
class EventBus {constructor() {this.events = {}; // 初始化一个空对象来存储事件及其订阅者列表}// 注册订阅者// eventName 表示要注册的事件名称// callback 表示当事件发生时应该被调用的回调函数(订阅者)on(eventName, callback) {if (!this.events[eventName]) {this.events[eventName] = []; // 如果事件名称不存在,则创建一个空数组来存储订阅者}this.events[eventName].push(callback); // 将回调函数添加到对应事件的订阅者列表中}// 移除订阅者// 移除指定事件名称下的指定订阅者(回调函数)off(eventName, callback) {if (!this.events[eventName]) return; // 如果事件名称不存在,则直接返回this.events[eventName] = this.events[eventName].filter(cb => cb !== callback); // 过滤掉指定的事件订阅者}// 发布事件// eventName 表示要发布的事件名称// ...args 表示要传递给订阅者的参数列表(可变参数)emit(eventName, ...args) {if (!this.events[eventName]) return; // 如果事件名称不存在,则直接返回// 遍历并通知所有订阅了该事件的订阅者,将参数列表传递给它们this.events[eventName].forEach(callback => callback(eventName, ...args));}
}
// 使用示例// 订阅者函数1
// 当订阅的事件触发时,打印接收到的消息内容
function subscriber1(eventName, message) {console.log(`我是订阅者1,我订阅了事件${eventName},我收到的内容是${message}`);
}// 订阅者函数2
// 当订阅的事件触发时,打印接收到的消息内容
function subscriber2(eventName, ...args) {console.log(`我是订阅者2,我订阅了事件${eventName},我收到的内容是${args}`);
}// 创建一个事件总线实例
const eventBus = new EventBus();// 注册订阅者到事件总线上
// 订阅者1和订阅者2都订阅了'weather'事件
eventBus.on('weather', subscriber1);
eventBus.on('weather', subscriber2);// 通过事件总线发布指定名称的事件,并传递消息内容
// 发布者发布'weather'事件,并传递消息内容'多云'和'转阴'
eventBus.emit('weather', '多云', '转阴');
// 输出:我是订阅者1,我订阅了事件weather,我收到的内容是多云
// 输出:我是订阅者2,我订阅了事件weather,我收到的内容是多云,转阴// 移除'weather'事件的订阅者1
eventBus.off('weather', subscriber1);// 再次发布消息,只有订阅者2会收到
eventBus.emit('weather', '阴天');
// 输出:我是订阅者2,我订阅了事件weather,我收到的内容是阴天
命令模式
主要思想:将请求封装成对象,调用者调用不同的请求对象,使接收者执行对应的操作。
优势:通过引入新的具体命令类,可以很容易地扩展系统,而无需修改现有的代码。通过扩展命令模式,可以支持撤销(undo)操作。
示例:
// 接收者 Receiver:定义了执行一个请求的接口。
// 在这个例子中,电视(TV)类就是接收者,
// 它有两个方法:on() 和 off(),用于打开和关闭电视。
class TV {on() {console.log("电视打开了");}off() {console.log("电视关闭了");}
}// 命令基类 Command:声明了一个执行操作的接口,
// 持有接收者(Receiver)的引用,可以调用接收者的方法来执行请求。
class Command {constructor(receiver) {this.receiver = receiver; // 持有接收者的引用}
}// 打开电视命令 TurnOnTVCommand:具体命令类,实现了Command接口中的execute()方法,
// 该方法调用接收者的on()方法来打开电视。
class TurnOnTVCommand extends Command {execute() {this.receiver.on(); // 调用接收者的on()方法}
}// 关闭电视命令 TurnOffTVCommand:具体命令类,实现了Command接口中的execute()方法,
// 该方法调用接收者的off()方法来关闭电视。
class TurnOffTVCommand extends Command {execute() {this.receiver.off(); // 调用接收者的off()方法}
}// 调用者 Invoker:要求命令对象执行请求,
// 在这个例子中,遥控器(RemoteControl)类就是调用者,它有一个方法onButton()用于执行传入的命令。
class RemoteControl {// 遥控器按钮方法,接受一个命令对象作为参数,并调用该命令的execute()方法来执行操作。onButton(command) {command.execute(); // 执行传入的命令}
}// 创建接收者对象:创建一个TV实例作为接收者。
const tv = new TV();// 创建具体命令对象:创建打开电视和关闭电视的命令对象,并将TV实例作为接收者传递给命令对象。
// "将请求封装成对象"
const turnOnTVCommand = new TurnOnTVCommand(tv);
const turnOffTVCommand = new TurnOffTVCommand(tv);// 创建调用者对象:创建一个RemoteControl实例作为调用者。
const remoteControl = new RemoteControl();// 客户端代码:通过调用者的onButton()方法执行命令,从而间接地操作接收者。
// "调用者调用不同的请求对象"
remoteControl.onButton(turnOnTVCommand); // 输出:电视打开了
remoteControl.onButton(turnOffTVCommand); // 输出:电视关闭了
扩展命令模式,加入撤销功能:
class TV {on() {console.log("电视打开了");}off() {console.log("电视关闭了");}
}class Command {constructor(receiver) {this.receiver = receiver;}// 添加undo方法undo() {throw new Error("undo 方法必须在子类中实现");}
}class TurnOnTVCommand extends Command {execute() {this.receiver.on();}// 实现undo方法,即关闭电视undo() {this.receiver.off();}
}class TurnOffTVCommand extends Command {execute() {this.receiver.off();}// 实现undo方法,即打开电视undo() {this.receiver.on();}
}class RemoteControl {constructor() {this.commands = []; // 存储已执行的命令,用于支持撤销功能}onButton(command) {command.execute();this.commands.push(command); // 记录已执行的命令}undoButton() {if (this.commands.length === 0) {console.log("没有可撤销的操作");return;}const lastCommand = this.commands.pop(); // 取出最后一个命令lastCommand.undo(); // 撤销该命令}
}const tv = new TV();
const turnOnTVCommand = new TurnOnTVCommand(tv);
const turnOffTVCommand = new TurnOffTVCommand(tv);const remoteControl = new RemoteControl();// 模拟用户操作
remoteControl.onButton(turnOnTVCommand); // 输出:电视打开了
remoteControl.onButton(turnOffTVCommand); // 输出:电视关闭了// 撤销上一次操作
remoteControl.undoButton(); // 输出:电视打开了(因为撤销了关闭电视的操作)
为什么命令模式天然适合撤销和重做功能?
因为命令被封装为对象,因此可以很容易地将这些对象存储在一个历史记录列表中。当需要撤销或重做操作时,只需从历史记录列表中取出相应的命令对象并执行其相应的撤销或重做方法。
模板模式
主要思想: 定义好整个操作过程的框架,可以将一些步骤延迟到子类中,也可以将每个步骤的逻辑都在子类中独立处理。使得可以在不改变结构的情况下,重新定义特定步骤
优势:步骤独立分开管理,易于扩展功能维护代码。
示例:游戏从开始到结束
// 抽象类,定义了模板方法和基本方法
class Game {// 模板方法,定义了算法的框架playGame() {this.startGame();this.onGame();this.stopGame();}// 基本方法,由父类实现startGame() {console.log('游戏启动中...');}// 抽象方法,由子类实现onGame() {throw new Error('子类必须实现playGame方法');}// 基本方法,由父类实现stopGame() {console.log('游戏关闭中...');}
}// 具体子类,实现抽象方法
class YuanShen extends Game {onGame() {console.log('此刻,寂灭之时!');}
}// 客户端代码
const yuanShen = new YuanShen();
yuanShen.playGame();// 输出:游戏启动中...
// 输出:此刻,寂灭之时!
// 输出:游戏关闭中...
总结
-
观察者模式是一对多,当一个对象(主题)的状态发生改变时,所有依赖于它的对象(观察者)都会得到通知并自动更新
-
发布订阅模式是一种消息队列模式,它允许消息的发送者(发布者)和接收者(订阅者)通过一个中间对象(通常是事件通道或消息总线)进行通信,而无需彼此了解对方的存在。
-
命令模式将请求封装成对象,可以轻松添加新的命令,可以通过保存命令的历史记录来实现撤销和重做功能。
参考资料
想成为中高级前端,必须理解这10种javascript设计模式