本篇文章记录一下我在日常开发中用到过的设计模式。
一、外观模式
外观模式(Facade Pattern) 是一种为子系统中的一组接口 提供一个统一的高级接口 ,从而使得子系统更容易使用的设计模式。外观模式定义了一个高层接口,这个接口使得这一子系统更加容易使用。
简而言之,外观模式就是外部使用者与一个子系统的通信必须通过一个统一的外观(Facade)对象进行,屏蔽了子系统的不同。
应用场景
- 当外部调用者与多个子系统之间存在大量的关联时,引入外观模式可以将它们分离,提高子系统的独立性和可移植性。
- 当需要为复杂的子系统提供一个简单接口时,可以使用外观模式。
代码示例
来看两个例子,具体了解一下什么是外观模式。
示例一: 封装事件监听方法
js
// 封装addEvent方法
function addEvent(element, type, func) {
if (window.addEventListener) {
element.addEventListener(type, func, false);
}
else if (window.attachEvent) {
element.attachEvent('on'+type, func);
}
else {
element['on'+type] = func;
}
}
相信绝大部分掘友都见过上面的示例代码,它就是一个最简单的外观模式,通过对外提供一个封装的addEvent
方法,解决了代码在不同浏览器中兼容性问题,使用者不需要关心内部实现,只管使用即可。
示例二:实现一个模块,注册一个用户,注册成功后需要给注册者的邮箱发送一个验证邮件。
js
// 子系统类 - User
class User {
constructor(name, email, password) {
this.name = name;
this.email = email;
this.password = password;
}
register() {
console.log(`用户${this.name}注册的邮箱为:${this.email}`);
}
login() {
console.log(`用户${this.name}已登录`);
}
}
// 子系统类 - EmailService
class EmailService {
sendVerificationEmail(email) {
console.log(`验证电子邮件发送到${email}`);
}
}
// 外观类 - UserFacade
class UserFacade {
constructor() {
this.user = null;
this.emailService = new EmailService();
}
createUser(name, email, password) {
this.user = new User(name, email, password);
this.user.register();
this.emailService.sendVerificationEmail(email);
}
loginUser(name, password) {
if (this.user && this.user.name === name && this.user.password === password) {
this.user.login();
} else {
console.log('用户名或密码无效');
}
}
}
// 外部调用者代码
const facade = new UserFacade();
facade.createUser('张三', 'zhangsan@example.com', '666'); // 输出:用户注册的邮箱为:zhangsan@example.com; 验证电子邮件发送到zhangsan@example.com
facade.loginUser('张三', '666'); // 输出:用户张三已登录
facade.loginUser('李四', '111'); // 输出:用户名或密码无效
上面这个示例,有两个子系统分别为:User
和EmailService
,二者作用不同,前者是用来注册和登录用的方法,后者是用来发送邮件的。
不使用外观模式,外部调用者就需要关联两个子系统,并且还需要处理发送邮件的逻辑,其实这些工作量对于外部调用者来说没有意义。引入外观模式后,将两个子系统类对外提供的方法封装起来,对外提供一个统一的外观类UserFacade
,调用者只需要调用外观类的createUser
方法即可完成注册和发送邮件的功能,不需要关心两个子系统类内部的具体实现,这种做法可以简化外部调用者的使用成本和维护成本。
优缺点
优点
-
简化接口
外观模式可以隐藏子系统的复杂性,只提供一个简单的接口供外部使用。这样外部调用者就不需要了解子系统的内部结构和复杂性,从而简化了外部调用者的使用。
-
降低耦合度
外观模式降低了外部调用者与子系统的耦合度。外部调用者只需要与外观对象交互,而不需要与子系统中的多个对象交互。这样,如果子系统内部发生变化,只需要修改外观对象,而不需要修改所有的外部调用者代码。
-
灵活性
外观模式为子系统的变化提供了灵活性。如果子系统的实现需要更改,那么只需要修改外观对象,而不需要更改所有外部调用者代码。
缺点
-
可能增加新的依赖
如果外观对象对子系统进行了大量的封装,那么外部调用者可能会过度依赖外观对象,从而增加了新的依赖。这可能导致在某些情况下,外部调用者无法直接访问子系统中某些必要的接口或功能。
-
可能隐藏了子系统的某些功能
外观模式可能会隐藏子系统的某些功能,使得外部调用者无法直接使用这些功能。这可能会限制外部调用者的灵活性,并导致在某些情况下需要额外的工作来绕过外观对象以访问子系统的功能。
-
维护成本
如果外观对象的设计不合理或者没有正确地抽象出子系统的功能,那么随着子系统的发展,外观对象可能会变得复杂和难以维护。
总结
根据上面的示例,其实不难看出,我们平时很有可能就用过这种外观模式,只是不知道名字罢了[摊手]。外观模式在JS中是一种非常有用的设计模式,它可以帮助我们简化复杂应用的使用,降低外部调用者与子系统的耦合度,并提高系统的灵活性。但是,也需要注意到它可能带来的依赖问题和隐藏功能的问题,这种问题就需要在设计过程中进行合理的权衡和取舍了。
二、代理模式
代理模式 是一种为其他对象提供一个代理以控制对这个对象的访问的设计模式。在某些业务场景上,一个对象不适合或者不能直接引用另一个对象,代理对象可以在客户端和目标对象之间起到中介的作用,它可以控制对目标对象的访问,并可以添加一些额外的操作,如权限验证、日志记录、事务处理等。
简单来说,代理模式其实就是允许一个类代表另一个类的功能。
代码示例
代理模式的实现方式有很多种,例如:高阶函数
、闭包
、new Proxy()
等都可以实现代理模式。
我们再用两个简单的例子来了解代理模式。
示例一: 使用高阶函数实现代理模式。
js
// 原始的用户接口
function UserInterface() {
this.request = function(url) {
console.log(`Requesting ${url}`);
};
}
// 代理函数
function UserInterfaceProxy(realUI) {
this.request = async function(url) {
console.log('开始记录日志');
await realUI.request(url);
console.log('结束记录日志');
};
}
// 使用代理
let realUI = new UserInterface();
let proxyUI = new UserInterfaceProxy(realUI);
proxyUI.request('https://example.com');
在上面的示例中,UserInterfaceProxy
是一个代理对象,它持有一个对UserInterface
对象实例的引用,当调用proxyUI.request
时,先记录日志,然后才调用request
方法。
示例二: 使用ES6中new Proxy()
方式实现代理模式,这种方式更简单。
js
// 原始对象
const originData = {};
const handlerData = {
get(target, name) {
console.log(`获取${name}`);
return target[name];
},
set(target, name, value) {
console.log(`设置${name} = ${value}`);
target[name] = value;
return true;
}
}
const proxy = new Proxy(originData, handlerData);
proxy.foo = '12315'; // 设置foo = 12315
console.log(proxy.foo); // 获取foo; 12315
应用场景
-
虚拟代理
根据需要创建开销很大的对象,通过它来存放实例化需要很长时间的对象, 例如图片预加载,增强用户体验。
-
缓存代理
为一些开销大的运算结果提供暂时的存储,在下次运算时,如果传递进来的参数跟之前一致,则可以直接返回前面存储的运算结果。
优缺点
优点
-
中间控制 代理模式在客户端和目标对象之间增加一个中间层,这个中间层可以拦截和过滤请求,增加额外的操作或检查。
-
灵活性
代理模式提供了一种灵活的方式来管理对目标对象的访问。可以根据需要动态地添加或移除代理,或者更换不同的代理实现。
-
保护目标对象
代理模式可以在客户端和目标对象之间建立一道屏障,客户端只能通过代理来访问目标对象,不能直接访问目标对象。这样可以隐藏目标对象的实现细节,防止客户端对目标对象进行非法访问或操作。
-
远程代理
对于远程对象,可以使用代理模式来优化性能。代理对象可以缓存远程对象的本地副本,减少对远程服务器的调用次数,提高响应速度。
-
智能引用
代理模式可以用于实现智能引用计数,当对象不再被使用时,自动释放其占用的资源。
缺点
-
性能开销
引入代理对象会增加额外的性能开销,因为每次访问目标对象都需要经过代理对象。如果代理对象实现了复杂的逻辑或进行了大量的计算,这种开销可能会更加明显。
-
代码复杂度
代理模式的实现可能会增加代码的复杂度。需要创建代理类,并在代理类中实现与目标对象相同的接口或方法。此外,还需要在客户端代码中正确地使用代理对象。
-
可能引入错误
如果在代理对象中实现了错误的逻辑或处理不当,可能会导致客户端接收到错误的结果或行为。因此,在实现代理模式时需要特别小心,确保代理对象的正确性。
-
不易于调试
由于代理模式在客户端和目标对象之间增加了一个中间层,当出现问题时,可能会增加调试的难度。需要仔细跟踪代理对象和目标对象之间的交互,以找出问题的根源。
总结
总的来说,代理模式的应用非常灵活,在业务中还是很常见的,例如:在vue
框架3.0及以上的版本中就用了Proxy
。
三、工厂模式
工厂模式是一种用于创建对象的常见设计模式。工厂模式通过封装对象创建的逻辑,使得代码更加清晰、可维护,并提供了更大的灵活性。
工厂模式又根据抽象程度不同,分为三种:简单工厂
、工厂方法
、抽象工厂
,若掘友想具体了解这三种方式可以移步到这篇文章,可读性很强,JavaScript设计模式与实践--工厂模式。
代码示例
封装一个绘制形状的工厂函数
js
// 工厂函数
function createShape(type, ...args) {
let shape;
switch (type) {
case 'circle':
shape = new Circle(...args);
break;
case 'rectangle':
shape = new Rectangle(...args);
break;
default:
throw new Error('Invalid shape type');
}
return shape;
}
// 圆形对象构造函数
function Circle(radius) {
this.radius = radius;
this.draw = function() {
console.log(`画了一个半径为${this.radius}的圆`);
};
}
// 矩形对象构造函数
function Rectangle(width, height) {
this.width = width;
this.height = height;
this.draw = function() {
console.log(`画了一个宽高为${this.width}*${this.height}的矩形`);
};
}
// 使用工厂函数创建对象
let circle = createShape('circle', 5);
circle.draw(); // 输出: 画了一个半径为5的圆
let rectangle = createShape('rectangle', 10, 20);
rectangle.draw(); // 输出:画了一个宽高为10*20的矩形
// 尝试创建一个不存在的形状类型会抛出错误
// createShape('triangle'); // 抛出错误: Invalid shape type
上面这个例子,createShape
工厂函数接受一个type
参数和任意数量的其他参数,根据type
参数的值,createShape
决定创建哪种类型的形状对象。如果type
是'circle'
,则创建一个Circle
对象;如果type
是'rectangle'
,则创建一个Rectangle
对象。如果传入了一个无效的type
,工厂函数会抛出一个错误。
应用场景
-
框架和库的开发
在开发JavaScript框架或库时,工厂模式也经常被使用。通过提供工厂函数来创建和管理对象,框架或库可以提供更灵活和可扩展的API,使得开发者能够更方便地使用和定制功能。比如常见的``、
React.createElement()
等都用到了工厂模式。 -
延迟对象创建
工厂模式允许在需要时才创建对象,而不是提前创建并占用内存。这对于资源密集型对象或按需加载的场景非常有用。通过工厂函数,可以在需要时动态地创建对象,从而节省内存并提高性能。
-
封装对象创建细节
工厂模式可以将对象的创建逻辑封装在工厂函数中,隐藏了对象创建的细节。使用时不需要关心对象是如何创建的,只需要调用工厂函数并传入必要的参数即可。这有助于简化业务代码,提高代码的可读性和可维护性。
-
简化配置和初始化
在某些情况下,对象的创建可能涉及多个步骤和配置选项。使用工厂模式,可以将这些步骤和配置封装在工厂函数中,从而简化对象的创建过程。使用时只需要调用工厂函数并传入必要的配置参数,即可获得一个配置好的对象实例。
优缺点
优点
-
代码复用性高
工厂模式通过创建对象来复用相同的代码,提高了代码的可维护性。当需要创建大量相似对象时,使用工厂模式可以避免重复编写相同的创建代码。
-
封装性好
工厂模式隐藏了对象的实现细节,使得调用方无需了解对象的创建过程。这样,调用方只需关注如何使用对象,而无需关心对象的创建逻辑,从而提高了代码的封装性。
-
可扩展性强
通过在工厂方法中添加参数或修改创建对象的逻辑,可以方便地扩展对象的创建方式。这使得工厂模式在应对需求变化时具有较高的灵活性。
-
符合开闭原则
工厂模式通过将对象的创建和使用分离,使得新对象的添加不需要修改已有代码,符合开闭原则(对扩展开放,对修改封闭)。
缺点
-
工厂类职责过重
在工厂模式中,所有产品的创建都由工厂类负责,这使得工厂类的职责过重。一旦工厂类出现问题,整个系统都可能受到影响。
-
系统扩展困难
当需要添加新产品时,可能需要修改工厂类的源代码,这会导致系统的扩展困难,并可能违反"开闭原则"。
-
类的数量增加
对于每种产品,都需要创建一个具体的工厂类,这可能导致类的数量增加,从而增加系统的复杂度和维护成本。
-
不利于反射
工厂模式通常使用静态工厂方法,这不利于使用反射机制来创建对象。如果需要通过反射来创建对象,可能需要额外编写创建对象的代码。
总结
通过使用工厂模式,我们可以将对象的创建逻辑与使用逻辑分离,使得代码更加清晰和易于管理,不过也要根据自己的业务需求来衡量,有时候,简单的构造函数或字面量对象可能更加适合和简洁。
四、单例模式
单例模式,它确保一个类仅有一个实例,即使它被实例化了多次,也只会返回第一次实例化后的对象。并且它提供一个全局访问点来获取该实例。
代码示例
js
class Logger {
constructor() {
// 如果实例存在,直接返回该实例
if (Logger.instance) {
return Logger.instance;
}
this.logs = [];
Logger.instance = this;
}
log(message) {
this.logs.push(message);
console.log(message);
}
getLogs() {
return this.logs;
}
}
// 获取日志记录器实例
const logger = new Logger();
logger.log('This is a log message.');
// 另一个尝试获取Logger实例的操作将返回同一个实例
const anotherLogger = new Logger();
anotherLogger.log('Another log message.');
// 验证两个实例是否相同,并获取所有日志
console.log(logger === anotherLogger); // 输出: true
console.log(logger.getLogs()); // 输出: ['This is a log message.', 'Another log message.']
应用场景
-
管理全局配置或状态
当你的应用程序需要维护全局配置或状态时,单例模式是一个很好的选择。例如,你可能需要管理应用程序的当前语言设置、主题或用户权限等。通过单例模式,你可以确保这些配置或状态在任何地方都是一致和可访问的。
-
日志记录器
在大型应用程序中,日志记录是一个重要的功能,用于跟踪应用程序的行为和调试问题。通过使用单例模式,可以确保所有的日志消息都通过一个统一的记录器实例进行,这有助于保持日志的一致性和可管理性。
-
弹窗或模态框管理
弹窗或模态框通常用于显示额外的信息或收集用户输入。为了避免多个弹窗或模态框同时显示,可以使用单例模式来管理它们的创建和显示。
-
管理模块
在实际开发过程中的库可能会有多种多样的功能,例如处理ajax请求,操作dom或者处理事件。这个时候单例模式还可以用来管理代码库中的各个模块。
优缺点
优点
-
资源高效利用
单例模式确保一个类只有一个实例,从而避免了重复创建相同对象导致的资源浪费。这对于那些需要频繁访问且创建成本较高的对象特别有用,如数据库连接、文件句柄或配置管理器等。
-
全局状态管理
通过单例模式,你可以方便地在应用程序的各个部分之间共享和管理全局状态或配置信息。这有助于保持数据的一致性,并减少数据传递的复杂性。
-
简化代码
由于只有一个实例,你可以避免在代码中多次创建和销毁对象,从而简化代码结构,提高代码的可读性和可维护性。
-
易于扩展
如果需要添加新的功能或修改现有功能,只需在单例实例上进行操作,而无需修改多个实例的代码。
缺点
-
违反单一职责原则
单例模式可能将多个功能或职责集中在一个类中,导致类的职责过多。这违反了单一职责原则,使得类的可维护性和可扩展性降低。
-
测试困难
由于单例模式全局只有一个实例,这使得在单元测试中模拟和替换单例实例变得困难。测试时可能需要额外的逻辑来重置或清除单例状态。
-
难以扩展和修改
一旦单例模式被实现并广泛应用,如果需要对其进行扩展或修改,可能会涉及到大量的代码更改。这可能导致代码的不稳定性和风险增加。
-
隐藏依赖关系
使用单例模式可能会导致代码中的依赖关系变得不明显,使得其他开发者难以理解和维护代码。这也可能增加代码的耦合度,降低代码的可重用性。
总结
在上面展示的那些应用场景中,单例模式会是一个非常有用的工具,不过,也要注意不要过度使用单例模式,因为它可能会增加代码的耦合度,降低可测试性。
五、策略模式
策略模式定义了一系列算法,并将每个算法封装到一个独立的类中,使它们可以相互替换。这样,我们就可以在运行时动态地选择算法的实现,而不需要在代码中显式地使用条件语句来进行选择。
具体来说,策略模式主要分为两部分:策略类和环境类。策略类主要负责具体算法的实现,而环境类则负责接收请求并将请求分配给某一个具体的策略类。
代码示例
示例一: 定义四则运算策略对象
js
// 定义策略对象
const strategies = {
'+': (num1, num2) => num1 + num2,
'-': (num1, num2) => num1 - num2,
'*': (num1, num2) => num1 * num2,
'/': (num1, num2) => num1 / num2
};
// 定义执行运算的函数
function calculate(type, num1, num2) {
const strategy = strategies[type];
if (!strategy) {
throw new Error('Unsupported operation type');
}
return strategy(num1, num2);
}
// 使用示例
console.log(calculate('+', 5, 3)); // 输出:8
console.log(calculate('*', 5, 3)); // 输出:15
示例二:定义排序策略对象
js
// 定义排序策略
const sortingStrategies = {
'bubble': function(array) { /* 冒泡排序实现 */ },
'quick': function(array) { /* 快速排序实现 */ },
'merge': function(array) { /* 归并排序实现 */ }
};
// 定义执行排序的函数
function sort(strategyName, array) {
const strategy = sortingStrategies[strategyName];
if (!strategy) {
throw new Error('Unsupported sorting strategy');
}
return strategy(array);
}
// 使用示例
const array = [5, 3, 8, 4, 2];
console.log(sort('bubble', array)); // 使用冒泡排序对数组进行排序
上面这两个例子过于简单,就不解释了(偷懒~)。
应用场景
-
动态选择算法
当系统需要根据不同条件动态地选择不同算法时,可以使用策略模式。
-
多行为对象
如果一个对象有很多行为,并且这些行为之间存在一定的逻辑关系,但又不希望使用复杂的多重条件判断来实现,那么可以使用策略模式将这些行为转移到相应的具体策略类里面。
-
隐藏复杂数据结构
当不希望使用者知道复杂的、与算法相关的数据结构时,可以使用策略模式来隐藏这些细节。
优缺点
优点
-
算法可以自由切换
由于策略模式将算法封装在独立的类中,因此可以轻松地替换或添加新的算法,而无需修改现有的代码。
-
避免使用多重条件判断
策略模式可以帮助我们消除大量的条件语句,使代码更易于理解和维护。
-
扩展性、复用性良好
由于每个算法都被封装在一个独立的类中,因此可以轻松地重用这些算法,同时也有利于代码的扩展。
缺点
-
策略类会增多
随着算法的增加,策略类的数量也会相应增加,这可能会导致代码量的增加。
-
所有策略类都需要对外暴露
为了让环境类能够选择并使用具体的策略类,所有的策略类都需要对外暴露,这可能会增加一些额外的复杂性。
总结
可见,策略模式能够帮助我们提高代码的灵活性和可维护性,同时减少不必要的复杂性。
六、迭代器模式
迭代器模式(Iterator Pattern)用于顺序地访问聚合对象内部的元素,又无需知道对象内部结构。使用了迭代器之后,使用者不需要关心对象的内部构造,就可以按序访问其中的每个元素。
代码示例
js
// 自定义集合类
class MyCollection {
constructor(items) {
this.items = items;
this.index = 0;
}
// 实现迭代协议
[Symbol.iterator]() {
return this; // 返回集合实例本身,因为它也是迭代器
}
// 迭代器方法
next() {
if (this.index < this.items.length) {
const currentItem = this.items[this.index];
this.index++;
return { value: currentItem, done: false };
} else {
return { value: undefined, done: true };
}
}
}
// 使用迭代器模式遍历集合
const collection = new MyCollection([1, 2, 3, 4, 5]);
// 获取迭代器
const iterator = collection[Symbol.iterator]();
// 使用迭代器遍历
let result;
while (!(result = iterator.next()).done) {
console.log(result.value); // 输出集合中的每个元素
}
// 使用for...of循环遍历(内置支持迭代器)
for (const item of collection) {
console.log(item); // 输出集合中的每个元素
}
在这个例子中,MyCollection
类既是一个集合,也是一个迭代器。它的[Symbol.iterator]()
方法返回集合实例本身,因为实例对象已经包含了next()
方法。
我们可以通过两种方式使用迭代器:
- 手动调用
next()
方法并检查done
属性来遍历集合。 - 使用
for...of
循环来遍历集合,这是JavaScript语言提供的一种内置支持迭代器的语法糖。
迭代器模式在这个例子中的优点是它使得遍历逻辑与集合本身解耦,集合只需要关注其内部数据的表示和管理,而遍历逻辑则封装在迭代器中。
应用场景
-
数组遍历
迭代器模式可以轻松遍历数组的所有元素。ES6中,数组的
forEach
、map
、filter
等方法都属于迭代器。 -
集合和其他可迭代对象的遍历
除了数组,迭代器模式也可以用于遍历其他集合类型,如对象、Map、Set等。这些集合类型通常也实现了迭代器接口,使得我们可以使用相同的迭代器模式来遍历它们。
-
自定义数据结构遍历
对于自定义的数据结构,如果我们希望提供一种统一的方式来遍历其元素,那么迭代器模式是一个很好的选择。通过实现迭代器接口,我们可以使得自定义数据结构支持迭代操作,从而方便地进行遍历。
-
生成器函数
在JavaScript中,生成器函数返回的是一个迭代器对象,它可以通过
next()
方法手动迭代下一个值。这种迭代器属于外部的迭代器,需要手动获取下一个元素。生成器函数与迭代器模式的结合使用,可以实现更复杂的遍历逻辑和数据处理。
优缺点
优点
-
抽象遍历
迭代器模式将遍历逻辑从聚合对象中分离出来,使得遍历操作可以被抽象和复用。这使得代码更加清晰,因为遍历逻辑不再分散在多个地方,而是集中在一个迭代器对象中。
-
简化聚合对象
由于迭代器负责遍历,聚合对象可以专注于其内部数据的表示和管理,而不必关心遍历的具体实现。这有助于保持聚合对象的简洁性,并减少其承担的职责。
-
支持多种遍历方式
迭代器模式允许定义多个迭代器类,每个迭代器类可以提供不同的遍历方式。这使得可以根据需要灵活地选择遍历算法,满足不同的需求。
-
易于扩展
如果需要添加新的聚合类或迭代器类,迭代器模式使得这种扩展变得相对容易。因为迭代器与聚合对象之间的接口是固定的,所以只需要实现新的迭代器或聚合类,而无需修改现有的代码。
-
封装性
迭代器模式提供了一种统一的方式来访问聚合对象的元素,隐藏了聚合对象的内部表示。这有助于保护聚合对象的封装性,防止外部代码直接访问其内部数据。
缺点
-
增加类的数量
引入迭代器模式可能会增加系统中类的数量。每个聚合对象可能需要一个对应的迭代器类,这可能会导致类的数量增加,从而增加系统的复杂性。
-
可能不是最优解
在某些情况下,使用迭代器模式可能不是最优的解决方案。对于简单的遍历需求,直接使用循环结构可能更加简洁和高效。迭代器模式通常用于需要更复杂遍历逻辑或需要支持多种遍历方式的场景。
-
可能引入性能开销
使用迭代器模式进行遍历可能会引入一些性能开销,因为每次调用
next()
方法都需要执行一些额外的逻辑,如检查是否还有下一个元素等。虽然这种开销通常是可以接受的,但在性能要求非常高的场景下可能需要考虑其他优化手段。
总结
总的来说,迭代器模式在JavaScript中提供了一种灵活且强大的遍历机制,使得我们可以方便地访问聚合对象中的元素,而无需关心其内部表示。这有助于简化代码、提高可维护性,并支持各种遍历需求。
七、观察者模式
观察者模式(又被称为发布-订阅(Publish/Subscribe)模式)是一种行为型设计模式,它允许对象之间建立一种一对多的依赖关系,当一个对象状态发生改变时,它的所有依赖者(观察者)都会自动收到通知并更新。在JavaScript中,这种模式特别适用于实现自定义事件处理、数据订阅/发布等场景。
在观察者模式中,通常包含以下几个角色:
-
被观察者(Subject)
也被称为主题,它维护了一个观察者列表,当主题的状态发生改变时,会通知所有注册的观察者。
-
观察者(Observer)
它们订阅了被观察者的状态,当被观察者的状态发生改变时,观察者会收到通知并执行相应的操作。
-
订阅(Subscribe)
观察者通过订阅操作将自己注册到被观察者的观察者列表中。
-
取消订阅(Unsubscribe)
观察者可以通过取消订阅操作从被观察者的观察者列表中移除自己。
-
通知(Notify)
当被观察者的状态发生改变时,它会遍历观察者列表,并调用每个观察者的更新方法。
关于观察者模式,这篇文章讲的非常好,推荐给大家看看JS设计模式-观察者模式
注意:发布订阅和观察者模式是有不同的
观察者模式: 在软件设计中是一个对象,维护一个依赖列表,当任何状态发生改变自动通知它们。
发布-订阅模式: 消息的发送方,叫做发布者(publishers),消息不会直接发送给特定的接收者,叫做订阅者。
代码示例
js
// 被观察者(Subject)
class Subject {
constructor() {
this.observers = [];
}
// 订阅
subscribe(observer) {
this.observers.push(observer);
}
// 取消订阅
unsubscribe(observer) {
this.observers = this.observers.filter(obs => obs !== observer);
}
// 通知
notify(data) {
this.observers.forEach(observer => observer.update(data));
}
}
// 观察者(Observer)
class Observer {
constructor(name) {
this.name = name;
}
// 更新方法
update(data) {
console.log(`${this.name} received data: ${data}`);
}
}
// 使用示例
const subject = new Subject();
const observer1 = new Observer('Observer 1');
const observer2 = new Observer('Observer 2');
subject.subscribe(observer1);
subject.subscribe(observer2);
subject.notify('Hello, observers!'); // 输出:Observer 1 received data: Hello, observers! 和 Observer 2 received data: Hello, observers!
subject.unsubscribe(observer1);
subject.notify('Another message'); // 只输出:Observer 2 received data: Another message
应用场景
-
DOM事件监听
当用户与页面交互(如点击按钮、输入文本)时,我们可以使用观察者模式来监听这些事件。
-
数据绑定
在前端框架中,如Vue、Angular等,观察者模式被用于保证数据和视图的同步。
-
自定义事件
我们可以通过观察者模式创建自定义事件系统,以便在不同的类或模块之间通信。
优缺点
优点
-
解耦
观察者模式实现了一种一对多的依赖关系,让观察者和被观察者解耦,两者之间的依赖关系变得抽象和灵活。这使得代码更加模块化,提高了可维护性和可扩展性。
-
动态关联
在页面载入后,目标对象与观察者之间可以很容易地建立动态关联。这种动态性增加了代码的灵活性,允许在运行时动态地添加或移除观察者。
-
抽象耦合关系
观察者和被观察者之间的抽象耦合关系可以单独扩展和重用。这种抽象性使得代码更加通用,降低了代码之间的耦合度。
-
自动通知
当被观察者的状态发生变化时,观察者模式可以自动通知所有已经订阅过的对象,这使得程序中的事件处理更加自动化和高效。
缺点
-
通知效率
当被观察者有很多观察者时,通知到所有的观察者可能需要花费很多时间。这可能导致性能问题,特别是在需要实时响应的系统中。
-
循环依赖
如果被观察者与观察者之间存在互相依赖的情况,可能会导致死循环。这需要在设计时需要特别注意,避免创建不必要的依赖关系。
-
观察者缺乏具体信息
观察者只知道状态发生了变化,但可能不知道具体是如何变化的。这可能导致观察者需要处理更多的逻辑,或者需要与被观察者进行额外的通信以获取具体信息。
-
开发和调试复杂性
由于程序中包括一个被观察者和多个观察者,开发和调试可能会变得相对复杂。需要仔细跟踪和理解各个组件之间的交互和依赖关系。
总结
观察者模式在JavaScript中具有广泛的应用场景,它能够帮助我们解耦组件之间的依赖关系、处理事件驱动和数据变化通知、实现灵活的插件系统和响应式编程等功能。
结语
设计模式还有很多种,例如中介者模式
、访问者模式
等等,有一些我在业务中几乎用不到,就不在这里记录了,如果后面在业务中用到了,再来记录~。