设计模式在前端项目中的应用

web前端开发

共 10624字,需浏览 22分钟

 · 2021-05-06

来源 | 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这个地方而已。

迭代器模式是一种相对简单的模式,简单到很多时候我们都不认为它是一种设计模式。目前的绝大部分语言都内置了迭代器。

总结

在将函数作为一等对象的语言中,有许多需要利用对象多态性的设计模式,这些模式的结构与传统面向对象语言的结构大相径庭,实际上已经融入到了语言之中,我们可能经常使用它们,只是不知道它们的名字而已。

深入理解他们,并有意识地去使用设计模式来优化代码,提升效率,使我们的系统有更好的拓展性才是我们追求的。

学习更多技能

请点击下方公众号



浏览 43
点赞
评论
收藏
分享

手机扫一扫分享

举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

举报