前言
本文是在前端开发实践设计模式专栏中的最后一篇,其实还有很多设计模式,没有给大家阐述,倒不是说那些设计模式不重要,而是在现在的前端开发中几乎不会用到。
目前基于MVVM的前端框架已经深入人心了,框架在很多方面都简化了我们的开发,在某种场景下甚至还提高了我们代码的运行效率,如果强行去套用那些不常用的设计模式的话,反而会把代码写的很怪异。
另外,有些设计模式不适合JS,因为JS的语法比较灵活,各位读者一定听说过"函数是JS的一等公民"这样的提法,像我了解过的C#,Java,它们这些语言的方法可不像JS这样既可以执行,又可以像普通变量那样传递的,这就导致了在写法上的差异,也算是践行自己语言的特色进行设计吧,哈哈哈。
模式总结
1、单例模式
对于单例模式,在前端开发中主要的应用场景就是一个类(或者一个对象)确实没有必要多次实例化,不让它多次实例化的原因其中之一是确实用不到那么多个实例。
比如一个扳手,只要你的工具箱里面有,并且又不会出借给别人,那么,我们再买一把放在工具箱里面,除了增加工具箱的重量,没有任何意义。这也是为什么Math
,localStorage
,sessionStorage
这些方法要被设计成单例的原因。
另外一个单例的场景就是使用全局唯一的思想进行限制。因为,我们写的代码,对于后来的维护者来说,总是以最小的成本就能够让别人接受或者使用,这就是较好的设计。或者说,如果你是在造一个面向全世界的开发者的库,总是以最小的心智让开发者们使用,这种场景就比较实用。
因为我们不知道别人会怎么使用,所以在设计的时候出于防御性的考虑,做一些限制而避免产生非预期的问题。
就比如,我在封装公司的业务SDK的时候,有一个Loading动画,当用户需要的时候,再创建,如果用户多次调用show(或hide)的方法,不会有任何影响,但是如果不做这个单例限制,天真的以为用户会按你预期的设计调用API,那就会重复添加大量的DOM节点而造成性能的损失。
2、装饰模式和代理模式
装饰模式和代理模式本质上来说,我个人的观点觉得其实他们没有什么区别。因为严格意义上来说,我们对一个既有类的能力进行控制,这个控制的能力,其实也能算的上是一种装饰。
这就好比我们说话一样,老师从小学的语文就开始教导我们怎么分辨什么是主语,什么是谓语,什么是宾语。但是我们在实际交流的时候,只要我们的表达能够让别人听懂,干嘛还要去搞懂什么主谓宾呢,这对我们改善软件设计是没有任何意义的,毕竟我们是一名工程师,而不是一名学者。
但是如果你在面试的时候确实被问到装饰模式和代理模式的区别的话,那你只能去回答,装饰模式侧重的是对既有类的能力的增强,而代理模式侧重的是对既有类的能力的控制。
3、工厂模式和策略模式(模板方法模式)
工厂模式,在阐述的时候给大家分了3类,分别是简单工厂模式
、工厂方法模式
、抽象工厂模式
。
首先,比较简单的一个就是工厂方法模式
,因为工厂方法模式的目的就是为了将类的实例化过程聚合到一个位置,当这个类的实例化过程需要进行一些调整的时候,而不至于在项目里面每处引用到的地方都去进行修改。就像我在工厂模式那篇文章举的一个例子,本来一个类的参数是一个个参数(剩余参数的形式),现在需要调整成对象的形式,用工厂方法模式进行设计的话,只需要改造工厂方法的具体实现即可。
然后,简单工厂模式
和抽象工厂模式
用法是一样的,唯独就是抽象工厂模式需要多一层抽象,多出来的一层抽象,在我们处理多业务线,或者跨平台设计的时候变的非常有用。
所以,最重要的问题,还是回到了怎么去理解简单工厂模式,也是我们必须要掌握的设计模式。
简单工厂模式一般跟策略模式、模板方法模式一起使用,这是因为简单工厂模式的本质就是生成一系列特征相近的产品。
比如:
ts
interface IStrategy {
say();
run();
eat();
sleep();
}
class PersonStrategy implements IStrategy {
say() {
console.log('你好,世界')
}
run() {
console.log('我们在着急的时候进行奔跑,在正常情况下行走')
}
eat() {
console.log('我们是杂食动物,有时候吃肉,有时候吃素')
}
sleep(){
console.log('8小时的睡眠有助于保持我们的身体健康')
}
}
class DogStrategy implements IStrategy {
say() {
console.log('汪汪汪')
}
run() {
console.log('我们在追逐的时候奔跑')
}
eat() {
console.log('我们是肉食动物')
}
sleep(){
console.log('我们需要为人类看家护院,因此睡得晚')
}
}
function getAnimalStrategy(type: string): IStrategy {
let animal
switch(type) {
case 'person':
animal = new PersonStrategy();
break
case 'dog':
animal = new DogStrategy();
break
default:
animal = new PersonStrategy();
break
}
return animal;
}
class App {
// 实际开发中,这个`person`魔术字符串可能往往来源于配置文件的读取,为了简化代码,我就直接写死了
strategy = getAnimalStrategy('person');
// other business
}
const app = new App();
上述的策略是假设完全没有交集的,假设有共同的交集的话,那我们就可以进一次抽象,将稳定(或共同)的业务进行抽象,进行封装,也就成了模板方法模式。
比如VueRouter的源码就是这样做。
如果这些策略类的目标是为了完成和别的API进行适配,那就是适配器模式了。
所以,还是像之前我们举的主谓宾和说法的例子,大家没有必要去纠结是跟模板方法模式使用还是跟策略模式一起配合使用,具体几个策略类之间有没有共性,还是你的业务说了算,哈哈哈。
4. 状态模式和策略模式
在我之前更文的过程中,有的同学在评论区问过我什么时候该用状态模式,什么时候该用策略模式,尤其是这两个设计模式的UML
图是如此的相近。
在这个小节详细的为大家解读一下,首先,策略模式的策略是各个几乎是独立的个体(就算是不同的策略有共同的基类也是一样),在运行时,只需要根据条件选择一个策略提供给外部即可。
而状态模式则不同,这些状态实现类们它们都预先描述着这个系统的运行情况,比如某个状态的下一个状态是另外一个确定的状态,这个切换的过程在某个具体的状态类里面是需要负责处理的。
如果说到这儿,你还没有懂的话,那我就举一个简单的例子,两个父亲分别监护着各自的孩子,一个父亲永远只需要根据女儿提出的问题给出对应的应对,然后就什么都不管了。另一个父亲尽心尽责,当女儿生病了,需要带女儿去看病,在女儿的病即将好之前,就开始规划着带女儿去健身;某天老师让女儿请家长,说他的女儿成绩不好,然后父亲就给女儿请家教,在每次家教快完成之前,父亲总会为女儿准备好餐食,生怕女儿饿着。
5、享元模式
享元模式主要在前端的实际开发中出于一些性能的考虑而进行一些复用操作进而减少类的创建。因为在类的创建过程中,我们将类收敛到使用工厂模式(这个工厂模式一般体现是工厂方法模式)进行创建,可以提高我们代码的健壮性。
6、适配器模式
适配器模式,在微观上来说,就是将两个或多个不同的代码给他们套上一个转接层,使之都具备一定的规格,从而对外有着一致的API。
但是适配器模式用在宏观上往往有着非常让人惊叹的效果,就比如Axios为什么能够同时在浏览器的环境下面使用,又能够在Nodejs环境下使用,稍微有点儿经验的前端开发者都是知道在浏览器下的网络请求跟Nodejs环境下的网络请求是不一样的。Axios编写了两套适配器,一套适配器负责处理XHR发送请求,一套适配器负责处理利用http模块发送请求,对用户暴露出一致的API接口。而这个两个适配器又像是两个策略,Axios根据用户所在的环境采取对应的策略完成对应的请求。
另外,NestJS提出的平台无关论哲学,NestJS提供一套操作底层网络通信的API接口,自己分别使用Express和Fastify对这套API接口进行实现,若用户不满意,还可以自行编写适配器实现这套API接口实现和底层的网络通信。这种设计在不输性能的前提下,提供了完美的Nodejs架构方案,使得我们可以编写出更容易维护的Nodejs代码。
所以在宏观上,适配器模式往往会和策略模式配合使用。
7、职责链模式
职责链模式在实际开发中能够有效的抹平if-else
语句。
另外,之前答疑过一个同学,在此也为大家分享一下,那个同学的问题是:职责链模式跟策略模式有什么区别?
因为这个同学觉得,不管是策略模式还是职责链模式都是在找一个合适的算法进行相应的处理,所以感觉他们好像是说的一个东西。
从我个人的使用体验来说,策略模式注重的是算法簇的封装,这种封装往往可能是一个很大的封装,选出来的策略类注入到别的模块中是要干很多复杂的活儿的,比如像NestJS的Express
和Fastify
两个适配策略类,和Express交互的这种重活儿都交给这个类(或称模块)在做。
而职责链模式不是要找一个能干的干重活儿的类,职责链模式像是你把你的需求丢到传送带上,目标是要找一个接活儿的人,接活儿的工人觉得自己能干,就接。接活儿的人基本上就帮你做做数据验证,数据转化之类的操作等等,一般会比较轻量。
职责链模式在前端开发复杂的表单验证场景非常有用,比如这是我前两天用职责链编写的一个验证的场景,相比较一堆的if-else-if-else
,这段代码看起来要舒服的多:
js
export default {
name: "CampusCertification",
data() {
return {
showPicker: false,
agree: false,
columns: [
{
code: DIRECTION.eastern,
text: "东部地区",
},
{
code: DIRECTION.southern,
text: "南部地区",
},
{
code: DIRECTION.western,
text: "西部地区",
},
{
code: DIRECTION.northern,
text: "北部地区",
},
{
code: DIRECTION.central,
text: "中部地区",
},
],
detail: cloneDeep(zeroForm),
verifyParams: null,
};
},
methods: {
acceptPolicy() {
return new Promise((resolve, reject) => {
if (this.agree) {
resolve();
return;
}
this.$openDialog(
"JoinMatchPromiseDialog",
{},
{
confirm: (reason) => {
if (reason === "ok") {
this.agree = true;
resolve();
} else {
reject(new Error("您需要同意承诺书方可参与比赛~"));
}
},
}
);
});
},
sendVerifyCode: fastClickPrevent(async function fn() {
// 生成验证的职责链
const result = await this.validator(this.validSlider, this.validPhone);
if (!result) {
return;
}
const resp = await sendVerifyCode({
phone: this.detail.telephone,
aliToken: this.verifyParams.token,
sig: this.verifyParams.sig,
scene: "yuanqisenlin",
sessionid: this.verifyParams.sessionId,
});
if (resp.code === 1) {
this.$toast("验证码已发送,请注意查收~");
} else {
this.$toast(resp.msg || "验证码发送失败,请稍后重试~");
}
}),
// 职责链验证器
async validator(...validNodeList) {
let validResult = true;
for (const node of validNodeList) {
try {
await node();
} catch (exp) {
this.$toast(exp.message);
validResult = false;
break;
}
}
return validResult;
},
validSlider() {
if (!this.verifyParams) {
throw new Error("请先拖动滑块完成验证~");
}
},
validPhone() {
if (!/1[3456789]\d{9}/.test(this.detail.telephone)) {
throw new Error("请填写完整手机号");
}
},
validVCode() {
if (!this.detail.vcode) {
throw new Error("请填写验证码~");
}
},
validArea() {
if (!this.detail.area) {
throw new Error("请选择参赛赛区~");
}
},
validSchool() {
if (!this.detail.school) {
throw new Error("请填写学校~");
} else if (/![\u4e00-\u9fea5]+/.test(this.detail.school)) {
throw new Error("学校名称仅允许汉字~");
}
},
validUsername() {
if (!this.detail.username) {
throw new Error("请填写真实姓名");
} else if (!/[\u4e00-\u9fa5a-z]+/i.test(this.detail.username)) {
throw new Error("真实姓名仅允许汉字或英文");
}
},
validNum() {
if (!this.detail.no) {
throw new Error("请填写学号~");
} else if (!/[a-z\-_0-9]/.test(this.detail.no)) {
throw new Error("学号仅允许数字、字母、下划线、横线~");
}
},
handleVerify(data) {
this.verifyParams = data;
},
toggleAgree() {
this.agree = !this.agree;
},
onConfirm(result) {
this.detail.area = result;
this.showPicker = false;
},
openSelect() {
this.showPicker = true;
},
beforeClose() {
this.agree = false;
Object.assign(this.detail, cloneDeep(zeroForm));
this.$closeDialog();
},
async submitCertify() {
// 生成验证的职责链
const valid = await this.validator(
this.validUsername,
this.validVCode,
this.validSchool,
this.validNum,
this.validArea,
this.acceptPolicy
);
if (!valid) {
return;
}
const { username, school, area, no, vcode, telephone } = this.detail;
const resp = await signUp({
name: username,
school,
department: area.code,
studentId: no,
phone: telephone,
verificationCode: vcode,
});
if (resp.code === 1) {
this.$toast("报名成功~");
this.beforeClose();
} else {
this.$toast(resp.msg || "报名失败,请稍后再试试吧~");
}
},
},
};
上述代码仍然存在优化空间,这个优化我就不做了,大家有兴趣可以自己改写一下,哈哈哈。
8、命令模式
命令模式是实际开发中有着明显特征信号的设计模式,这些信号就是像流程图,像某些业务需要支持重做,历史记录,支持任务队列,并且可以取消等标志。所以基本上不用担心滥用命令模式而导致增加设计的复杂度。
各个命令类之间可以具备相同的特征,对共同的部分进行抽象,这就是模板方法模式的和命令模式的配合使用。
和发布订阅模式一起配合使用,可以实现取消的业务场景。或者在命令执行完成之后,自动通知结果的消费者。可以做到在某些资源有限的场景下,只能受到一些限制去完成任务的场景。
9、发布订阅模式
因为Vue双向绑定实现的过程中使用了发布订阅模式,各位"精通"Vue的同学自然就精通发布订阅模式,哈哈哈,对它是爱不释手。
发布订阅模式是实际开发中最容易滥用的设计模式,尤其是在前端开发中两个隔的很"远"的组件要进行通信,使用发布订阅模式相当于可以跨越空间的距离实现通信。
有些初学者把发布订阅模式当做银弹,因为能解决问题,所以他们就觉得这个东西好。
发布订阅模式对于数据流的管理非常难以控制,这种设计将对后来的维护者来说是灾难,还有一个问题是发布订阅模式如果不支持历史消息订阅的话,需要处理事件订阅和事件触发的先后顺序,这个顺序的问题就会导致代码的稳定性比较脆弱,所以各位同学在使用的时候可以别人的视角反问一下自己,当前设计是最优设计吗?
根据我的7年+的开发经验得出一个比较简单的结论。使用发布订阅模式,将代码控制在一个合理的范围内,就比如说限制在一个模块里面完成事件的触发和事件的监听,这种时候代码的耦合就显著的降低了。
把这个模块拿走(即重构),别的地方不会有任何瓜葛,所以就不用调整代码。甚至将这个模块拿到别的项目里面去立即就能使用。
在我关于发布订阅模式举的实际开发中的例子,用发布订阅模式改善SDK的初始化的方式就是一个比较好的范例,有兴趣的同学可以参考一下。
10、中介者模式
中介者模式算是解决了发布订阅模式那种直接通信而带来的数据流管理混乱问题。
因为把通信抽离到了一个统一的地方进行维护,方便了别人,苦了自己。不过,因为收敛到了一处,一定程度上肯定是增加了系统的可维护性。
在实际场景下,需要进行很复杂的通信业务(复杂度的衡量,我的亲身体会就是自己写着写着可能对数据流管理会犯困这种时候就是能够称得上复杂了,或者当前还不够复杂,但是明显能够感觉到,后面增加需求只能在当前的设计上堆屎),并且已经明显没法用别的设计改进就可以考虑使用中介者模式,不过在写的时候,最好配上相应的注释,比如一个数据流从哪儿来,需要通知到谁,它需要做什么响应等。
结语
以上是我7年开发经验中提炼的设计模式的用法和总结,在我的职业生涯中,我不仅参考了很多书籍,也咨询了很多朋友和同学及同事(其中不乏很多目前就职于知名互联网大厂的前同事)
学习设计模式,不要急于求成,这个事儿是真的印证了古人的老话,心急吃不了热豆腐。
因为没有绝对的开发量支持它,你完全搞不明白为什么需要设计,强行套用反而会带来失败的设计。开源论坛上,有些同学也撰写了关于设计模式的文章,但我个人的观点是觉得他们有点儿囫囵吞枣,举的很多例子没有说服力。
在写代码的时候,要摒弃"遇到了再说"或者"到时候再说"的这种意识形态。拿到一个需求的时候,在心里面大概打一个腹稿(有些公司需要做技术方案,但是从我经历的公司来说,强制将其作为一种制度,我个人觉得这种有点儿形式主义),简单的做一下模块划分,职责划分,可以在草稿纸上大概的写写画画,这些都是我用时间总结出来的经验。
纸上得来终觉浅,绝知此事要躬行,书本上的东西只是作者的经验,你如果不经过你自己的实际转化的话,那份知识可能还真不一定就是属于你的。
给大家的建议就是不用死板,可以结合着书和一些博客看一下,然后体会一下自己项目开发中的得失,然后不断地改进设计。如果很多设计模式你还没有用到过,你的代码已经能够写的很好了,那仅仅需要反思一下自己的业务形态是否过于简单?如果不是,那么我可以负责任的告诉你,你不需要学习设计模式。(还是我文中提到的说话的例子,已经能够很好的表达了,还需要搞懂主谓宾干嘛呢,哈哈哈)。
最后就是,多读开源项目的源代码,而且不要怀着面试的功利心去读,将会对代码能力有质的飞跃,这是我两年来明显能够感受到的进步。
如果大家喜欢我的文章,可以多多点赞收藏加关注,你们的认可是我最好的更新动力,😁。