设计模式在前端项目中的应用
来源 | https://juejin.cn/post/6956825832073461768
前端的设计模式是什么
前端的设计模式的基本准则
单一职责原则:每个类只需要负责自己的那部分,类的复杂度降低。 开闭原则:一个实体,如类、模块和函数应该对扩展开放,对修改关闭,让程序更稳定更灵活。 里式替换原则:所有引用基类的地方必须能透明地使用其子类的对象,也就是说子类对象可以替换其父类对象,而程序执行效果不变。便于构建扩展性更好的系统。 依赖倒置原则:上层模块不应该依赖底层模块,它们都应该依赖于抽象;抽象不应该依赖于细节,细节应该依赖于抽象。这可以让项目拥有变化的能力。 接口隔离原则:多个特定的客户端接口要好于一个通用性的总接口,系统有更高的灵活性。 迪米特原则(最少知识原则):一个类对于其他类知道的越少越好,也就是说一个对象应当对其他对象有尽可能少的了解。
设计模式的种类
1、 创建型模式
包括:单例模式,工厂方法模式,抽象工厂模式,建造者模式,原型模式。
2、结构型模式
包括:适配器模式,桥接模式,组合模式,装饰器模式,外观模式,享元模式,代理模式,过滤器模式。
3、行为型模式
包括:命令模式,解释器模式,迭代器模式,中介者模式,备忘录模式,观察者模式,状态模式,策略模式,模板方法模式,访问者模式,责任链模式。
前端常用的计模式应用实例
1、单例模式
class MessageBox {
show() {
console.log("show");
}
hide() {}
static getInstance() {
if (!MessageBox.instance) {
MessageBox.instance = new MessageBox();
}
return MessageBox.instance;
}
}
let box3 = MessageBox.getInstance();
let box4 = MessageBox.getInstance();
console.log(box3 === box4); // true
上面这种是比较常见的单例模式实现,但是这种方式存在一些弊端。因为它需要让调用方了解到通过Message.getInstance来获取单例。
又或者假设需求变更,可以通过存在二次弹窗,则需要改动不少地方,因为MessageBox除了实现常规的弹窗逻辑之外,还需要负责维护单例的逻辑。
因此,可以将初始化单例的逻辑单独维护,实现一个通用的、返回某个类对应单例的方法。
function getSingleton(ClassName) {
let instance;
return () => {
if (!instance) {
instance = new ClassName();
}
return instance;
};
}
const createMessageBox = getSingleton(MessageBox);
let box5 = createMessageBox();
let box6 = createMessageBox();
console.log(box5 === box6);
这样,通过createMessageBox返回的始终是同一个实例。如果在某些场景下需要生成另外的实例,则可以重新生成一个createMessageBox方法,或者直接调用new MessageBox(),这样就对之前的逻辑不会有任何影响。
2、工厂模式
工厂模式提供了一种创建对象的方法,对使用方隐藏了对象的具体实现细节,并使用一个公共的接口来创建对象。
前端本地存储目前最常见的方案就是使用localStorage,为了避免在业务代码中各种getItem和setItem,我们可以做一下最简单的封装。
let themeModel = {
name: "local_theme",
get() {
let val = localStorage.getItem(this.name);
return val && jsON.parse(val);
},
set(val) {
localStorage.setItem(this.name, jsON.stringify(val));
},
remove() {
localStorage.removeItem(this.name);
},
};
themeModel.get();
themeModel.set({ darkMode: true });
这样,通过themeModel暴露的get、set接口,我们无需再维护local_theme。但上面的封装也存在一些可见的问题,如果需要新增多个 name,那么上面的模板代码需要重新写多遍吗?为了解决这个问题,我们可以创建Model对象的逻辑进行封装。
const storageMap = new Map()
function createStorageModel(key, storage = localStorage) {
// 相同key返回单例
if (storageMap.has(key)) {
return storageMap.get(key);
}
const model = {
key,
set(val) {
storage.setItem(this.key, JSON.stringify(val););
},
get() {
let val = storage.getItem(this.key);
return val && JSON.parse(val);
},
remove() {
storage.removeItem(this.key);
},
};
storageMap.set(key, model);
return model;
}
const themeModel = createStorageModel('local_theme', localStorage)
const utmSourceModel = createStorageModel('utm_source', sessionStorage)
这样,我们就可以通过createStorageModel这个公共的接口来创建各种不同本地存储的对象,而无需关注创建对象的具体细节。
3、策略模式
策略模式,可以针对不同的状态,给出不同的算法或者结果。将层级相同的逻辑封装成可以组合和替换的策略方法,减少if...else代码,方便扩展后续功能。
表单校验是我们最常见的场景了,我们一般都会想到用if...else来判断。
function onFormSubmit(params) {
if (!params.name) {
return showError("请填写昵称");
}
if (params.name.length > 6) {
return showError("昵称最多6位字符");
}
if (!/^1\d{10}$/.test(params.phone))
return showError("请填写正确的手机号");
}
// ...
sendSubmit(params)
}
将所有字段的校验规则都堆叠在一起,代码量大,排查问题也是一个大麻烦。在遇见错误时,直接通过 return 跳过了后面的判断;如果我们希望直接展示每个字段的错误呢,那么改动的工作量又不少。
不过,在antd、ELementUI等框架盛行的年代,我们已经不再需要写这些复杂的表单校验,但是对于他们的实现原理,我们可以简单模拟一下。
// 定义一个校验的类,主要暴露了构造参数和validate两个接口
class Schema {
constructor(descriptor) {
this.descriptor = descriptor; // 传入定义的校验规则
}
// 拆分出一些更通用的规则,比如required(必填)、len(长度)、min/max(最值)等,可以尽可能地复用
handleRule(val, rule) {
const { key, params, message } = rule;
let ruleMap = {
required() {
return !val;
},
max() {
return val > params;
},
validator() {
return params(val);
},
};
let handler = ruleMap[key];
if (handler && handler()) {
throw message;
}
}
validate(data) {
return new Promise((resolve, reject) => {
let keys = Object.keys(data);
let errors = [];
for (let key of keys) {
const ruleList = this.descriptor[key];
if (!Array.isArray(ruleList) || !ruleList.length) continue;
const val = data[key];
for (let rule of ruleList) {
try {
this.handleRule(val, rule);
} catch (e) {
errors.push(e.toString());
}
}
}
if (errors.length) {
reject(errors);
} else {
resolve();
}
});
}
}
// 声明每个字段的校验逻辑
const descriptor = {
nickname: [
{ key: "required", message: "请填写昵称" },
{ key: "max", params: 6, message: "昵称最多6位字符" },
],
phone: [
{ key: "required", message: "请填写电话号码" },
{
key: "validator",
params(val) {
return !/^1\d{10}$/.test(val);
},
message: "请填写正确的电话号码",
},
],
};
// 开始对数据进行校验
const validator = new Schema(descriptor);
const params = { nickname: "", phone: "123000" };
validator.validate(params).then(() => {
console.log("success");
}).catch((e) => {
console.log(e);
});
Schema主要暴露了构造参数和validate两个接口,是一个通用的工具类,而params是表单提交的数据源,因此主要的校验逻辑实际上是在descriptor中声明的。将常见的校验规则都放在ruleMap中,比之前各种不可复用的if..else判断更容易维护和迭代。
4、状态模式
状态模式允许一个对象在其内部状态改变的时候改变它的行为。状态模式的思路是:首先创建一个状态对象保存状态变量,然后封装好每种动作对应的状态,然后状态对象返回一个接口对象,它可以对内部的状态修改或者调用。
常见的使用场景,比如滚动加载,包含了初始化加载、加载成功、加载失败、滚动加载等状态,任意时间它只会处于一种状态。
// 定义一个状态机
class rollingLoad {
constructor() {
this._currentState = 'init'
this.states = {
init: { failed: 'error' },
init: { complete: 'normal' },
normal: { rolling: 'loading' },
loading: { complete: 'normal' },
loading: { failed: 'error' },
}
this.actions = {
init() {
console.log('初始化加载,大loading')
},
normal() {
console.log('加载成功,正常展示')
},
error() {
console.log('加载失败')
},
loading() {
console.log('滚动加载')
}
// .....
}
}
change(state) {
// 更改当前状态
let to = this.states[this._currentState][state]
if(to){
this._currentState = to
this.go()
return true
}
return false
}
go() {
this.actions[this._currentState]()
return this
}
}
// 状态更改的操作
const rollingLoad = new rollingLoad()
rollingLoad.go()
rollingLoad.change('complete')
rollingLoad.change('loading')
这样,我们就可以通过状态变更,运行相应的函数,且状态之间存在联系。那么,看起来是不是和策略模式很像呢?其实不然,策略类的各个属性之间是平等平行的,它们之间没有任何联系。而状态机中的各个状态之间存在相互切换,且是被规定好了的。
5、发布-订阅模式
发布—订阅模式又叫观察者模式,它定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都将得到通知。
发布订阅模式大概是前端同学最熟悉的设计模式之一了,常见的事件监听addEventListener,各种属性方法onload、onchange,vue响应式数据,组件通信redux、eventBus等。
常见的获取登录信息,假设我们开发一个商城网站,网站里有 header 头部、nav 导航、消息列表、购物车等模块。
这几个模块的渲染有一个共同的前提条件,就是必须先用 ajax 异步请求获取用户的登录信息。
比如用户的名字和头像要显示在 header 模块里,而这两个字段都来自用户登录后返回的信息。异步的问题通常也可以用回调函数来解决:
login.succ(function(data){
header.setAvatar( data.avatar); // 设置 header 模块的头像
nav.setAvatar( data.avatar ); // 设置导航模块的头像
message.refresh(); // 刷新消息列表
cart.refresh(); // 刷新购物车列表
});
我们还必须了解 header 模块里设置头像的方法叫setAvatar、购物车模块里刷新的方法叫refresh,这种强耦合性会使程序变得不易拓展。
那么回头看看我们的发布—订阅模式,这种模式下,对用户信息感兴趣的业务模块可以自行订阅登录成功的消息事件。
当登录成功时,登录模块只需要发布登录成功的消息,而业务方接受到消息之后,就会开始进行各自的业务处理,登录模块并不关心业务方究竟要做什么。
// 发布登录成功的消息
$.ajax( 'http://xxx.com?login', function(data){ // 登录成功
login.trigger( 'loginSucc', data); // 发布登录成功的消息
});
// 各模块监听登录成功的消息
var header = (function(){ // header 模块
login.listen( 'loginSucc', function(data){
header.setAvatar( data.avatar );
});
return {
setAvatar: function( data ){
console.log( '设置 header 模块的头像' );
}
}
})();
var nav = (function(){ // nav 模块
login.listen( 'loginSucc', function( data ){
nav.setAvatar( data.avatar );
});
return {
setAvatar: function( avatar ){
console.log( '设置 nav 模块的头像' );
}
}
})();
发布—订阅模式可以广泛应用于异步编程中,这是一种替代传递回调函数的方案。比如,我们可以订阅ajax请求的error、succ等事件。
或者如果想在动画的每一帧完成之后做一些事情,那我们可以订阅一个事件,然后在动画的每一帧完成之后发布这个事件。
在异步编程中使用发布—订阅模式,我们就无需过多关注对象在异步运行期间的内部状态,而只需要订阅感兴趣的事件发生点。
6、迭代器模式
迭代器模式是指提供一种方法顺序访问一个聚合对象中的各个元素,而又不需要暴露该对象的内部表示。
迭代器模式可以把迭代的过程从业务逻辑中分离出来,在使用迭代器模式之后,即使不关心对象的内部构造,也可以按顺序访问其中的每个元素。
JS 也内置了多种遍历数组的方法如forEach、reduce等。对于数组的循环大家都轻车熟路了,在实际开发中,也可以通过循环来优化代码。
一个常见的开发场景是:通过 ua 判断当前页面的运行平台,方便执行不同的业务逻辑,最基本的写法当然是if...else。
const PAGE_TYPE = {
app: "app", // app
wx: "wx", // 微信
tiktok: "tiktok", // 抖音
bili: "bili", // B站
kwai: "kwai", // 快手
};
function getPageType() {
const ua = navigator.userAgent;
let pageType;
// 移动端、桌面端微信浏览器
if (/xxx_app/i.test(ua)) {
pageType = app;
} else if (/MicroMessenger/i.test(ua)) {
pageType = wx;
} else if (/aweme/i.test(ua)) {
pageType = tiktok;
} else if (/BiliApp/i.test(ua)) {
pageType = bili;
} else if (/Kwai/i.test(ua)) {
pageType = kwai;
} else {
// ...
}
return pageType;
}
参考策略模式的思路,我们可以减少分支判断的出现,将每个平台的判断拆分成单独的策略:
function isApp(ua) {
return /xxx_app/i.test(ua);
}
function isWx(ua) {
return /MicroMessenger/i.test(ua);
}
function isTiktok(ua) {
return /aweme/i.test(ua);
}
function isBili(ua) {
return /BiliApp/i.test(ua);
}
function isKwai(ua) {
return /Kwai/i.test(ua);
}
let platformList = [
{ name: "app", validator: isApp },
{ name: "wx", validator: isWx },
{ name: "tiktok", validator: isTiktok },
{ name: "bili", validator: isBili },
{ name: "kwai", validator: isKwai },
];
function getPageType() {
// 每个平台的名称与检测方法
const ua = navigator.userAgent;
// 遍历
for (let { name, validator } in platformList) {
if (validator(ua)) {
return name;
}
}
}
这样,整个getPageType方法就变得非常简洁:按顺序遍历platformList,返回第一个匹配上的平台名称作为pageType。
这样即使后面需要增加或移除平台判断,需要修改的仅仅也只是platformList这个地方而已。
迭代器模式是一种相对简单的模式,简单到很多时候我们都不认为它是一种设计模式。目前的绝大部分语言都内置了迭代器。
总结
在将函数作为一等对象的语言中,有许多需要利用对象多态性的设计模式,这些模式的结构与传统面向对象语言的结构大相径庭,实际上已经融入到了语言之中,我们可能经常使用它们,只是不知道它们的名字而已。
深入理解他们,并有意识地去使用设计模式来优化代码,提升效率,使我们的系统有更好的拓展性才是我们追求的。
学习更多技能
请点击下方公众号