唠唠设计模式,一次学习,终生受用

889 阅读17分钟

分享目标

  • 初学or未学习过的同学-可以入门

  • 注意到日常开发中正在使用的一些设计模式

  • 啃过理论,不知道如何实际运用的同学-可以掌握一些套路

  • 不能保证你听完代码就能写的飞起,毕竟设计模式是一种思想,要结合具体的业务场景。

  • 万法皆通的大佬-🍉吃瓜愉快

  • 温故而知新

广告

这里非常推荐各位同学去看之前分享过的:从工厂模式说起,简单聊聊设计模式在前端中的应用

里面讲了工厂模式、单例模式、策略模式、观察者模式/订阅-发布模式在前端的应用,非常详细和例子也贴近日常开发

什么是设计模式?

官方解释 :设计模式是一种可复用的解决方案,用于解决软件设计中遇到的常见问题。

说白了设计模式就是一种套路。像做菜的菜谱,游戏的攻略一样,在软件设计、开发过程中,我们可以针对特定问题、特定场景套用特定的设计模式来更优的解决问题。

设计模式的核心思想——封装变化

设计模式出现的背景,是软件设计越来越复杂,造成复杂的原因大多是变化。

举个🌰

写一个业务,初始版本v1.0。用完即仍或不接受任何迭代和优化,过了几十年还是v1.0。那么这个业务几乎可以随便写主要目标是实现功能,一定程度上可以不考虑可维护性、可扩展性。

But,实际开发中,不发生变化的项目可以说是不存在的,毕竟我们的日常是:新需求,版本迭代,升级优化

我们能做的就是在开发的过程中,将这个变化造成的影响最小化 —— 去观察你整个完整逻辑里面的变与不变,然后将变与不变分离,确保变化的部分灵活、不变的部分稳定。

这个过程,就叫“封装变化”;这样写出来的代码,就是我们所谓的“健壮”的代码,它可以经得起变化的考验。而设计模式出现的意义,就是帮我们写出这样的代码。

二十年前一本神书 《设计模式》横空出世,经久不衰。书中提出的最经典的23种设计模式

妈呀!有这么多?大家别慌。有些设计模式可以只存在于耳熟

说回封装变化,无论是创建型、结构型还是行为型,这些具体的设计模式都是在用自己的方式去封装不同类型的变化

创建型模式封装了创建对象过程中的变化

结构型模式封装的是对象之间组合方式的变化,目的在于灵活地表达对象间的配合与依赖关系

行为型模式则将是对象千变万化的行为进行抽离,确保我们能够更安全、更方便地对行为进行更改。

创建型:构造器模式、工厂模式

构造器模式

先了解构造器模式,其实这个模式几乎我们天天都在用。

function User(name, age, career){
  this.name = name
  this.age = age
  this.career = career
}
const user = New User('霸王', 18, '前端工程师')

像 User 这样用来初始化新建对象的构造函数,就叫构造器。在 JS 中,我们使用构造函数去初始化对象,就是用了构造器模式。

在创建一个user过程中,谁变了,谁不变?

  • 不变:每个用户都具有姓名、年龄、工种这些属性,这是共性

  • 变化:姓名、年龄、工种这些值,这是用户的个性

构造器属性赋值给对象的过程封装,确保了每个对象都具备这些属性,确保共性的不变,同时将 name、age、career 各自的取值操作开放,确保了个性的灵活。

简单工厂模式

如果要实现不同工种不同职责,那么要给每个工种的用户加上个性化的字段,来描述他们的工作内容。这时候如果写多个构造器,显然是不合理的

function Coder(name , age) {
    this.name = name
    this.age = age
    this.career = 'coder' 
    this.work = ['写代码','需求评审', '修Bug']
}
function ProductManager(name, age) {
    this.name = name 
    this.age = age
    this.career = 'product manager'
    this.work = ['订会议室', '写PRD', '催更']
}

在创建一个Coder和ProductManager过程中,谁变了,谁不变?

  • 不变:每个用户仍然具有姓名、年龄、工种、职责这些属性

  • 变化:每个属性值不同,以及work 字段需要随 career 字段取值的不同而改变

把相同的对象赋值逻辑封装到User类里,将变的部分抽离成工厂函数。

function User(name , age, career, work) {
    this.name = name
    this.age = age
    this.career = career
    this.work = work
}

// 工厂函数 (将变的部分抽离出一个函数)
function Factory(name, age, career) {
    let work
    switch(career) {
        case 'coder':
            work =  ['写代码','需求评审', '修Bug']
            break
        case 'product manager':
            work = ['订会议室', '写PRD', '催更']
            break
        case 'boss':
            work = ['喝茶', '看报', '见客户']
        // 其它工种的职责分配

    return new User(name, age, career, work)
}
const user = New Factory('霸王', 18, 'coder')
//user. work = ['写代码','需求评审', '修Bug']

好处:不用写无数个构造函数,不用操心给不同工种分配不同构造函数,只关心传参。

再来理解 创建型模式封装了创建对象过程中的变化

对于工厂函数,在创建一个User过程中

  • 不变:每个用户仍然具有姓名、年龄、工种、职责这些属性,以及不同工种有对应的职责

  • 变化:姓名、年龄、工种这些属性值

工厂模式就像一个加工厂,传入不同的参数(原料),获得特定的对象(产品)

结构型:装饰器模式、适配器模式、代理模式

装饰器模式

装饰者模式的作用是为了给对象增加功能
在不改变原对象的基础上,通过对其进行包装拓展,使原有对象可以满足用户的更复杂需求

举个🌰

有个按钮,点击可以跳转。现在需要增加点击上报数据功能

const btnClick = function() {
  this.jump = function() {
    console.log('页面跳转');
  };
};

普通写法

const btnClick = function() {
  this.jump = function() {
    console.log('页面跳转');
  };
  this.log = function() {
    console.log('赋予日志上报功能');
  };
};

装饰器模式做法

const btnClick = function() {
  this.jump = function() {
    console.log('页面跳转');
  };
};
//装饰器
const Decorator = function(old) {
  this.jump = old.jump;
  
  this.log = function() {
    console.log('赋予日志上报功能');
  };
  this.newClick = function() {
    this.jump();
    this.log();
  };
};

const oldBtnClick = new btnClick();
const decorator = new Decorator(oldBtnClick);
//decorator.newClick();

适配器模式

把一个类的接口变换成我们想要的接口,可以帮我们解决不兼容的问题

电脑只支持type-c口,所以需要转接头来连接我们的显示器、鼠标等。转换器就是一个适配器

应用场景

  • 后端返回的数据格式与页面渲染要用的不一致

    [ { "day": "周一", "uv": 6300 }, { "day": "周二", "uv": 7100 }, { "day": "周三", "uv": 4300 }, { "day": "周四", "uv": 3300 }, { "day": "周五", "uv": 8300 }, { "day": "周六", "uv": 9300 }, { "day": "周日", "uv": 11300 } ]

    Echarts图表图形需要的数据格式: ["周二", "周二", "周三", "周四", "周五", "周六", "周日"] //x轴的数据

    [6300. 7100, 4300, 3300, 8300, 9300, 11300] //坐标点的数据

    //x轴适配器 function echartXAxisAdapter(res) { return res.map(item => item.day); }

    //坐标点适配器 function echartDataAdapter(res) { return res.map(item => item.uv); }

创建两个函数分别对数据按照需要的数据格式进行格式化处理即可解决问题;这两个方法其实就是一个适配器,把指定的数据丢进去即可按照指定规则输出我们期待得到的数据格式;

  • axios 里也用到了适配器模式 源码指路->核心逻辑

    function getDefaultAdapter() { var adapter; // 判断当前是否是node环境 if (typeof XMLHttpRequest !== 'undefined') { // For browsers use XHR adapter 如果是浏览器环境,调用基于xhr的适配器 adapter = require('./adapters/xhr'); } else if (typeof process !== 'undefined' && Object.prototype.toString.call(process) === '[object process]') { // For node use HTTP adapter 如果是node环境,调用node专属的http适配器 adapter = require('./adapters/http'); } return adapter; }

    //http 适配器: module.exports = function httpAdapter(config) { return new Promise(function dispatchHttpRequest(resolvePromise, rejectPromise) { // 具体逻辑 } } //xhr 适配器: module.exports = function xhrAdapter(config) { return new Promise(function dispatchXhrRequest(resolve, reject) { // 具体逻辑 } }

适配器具体实现逻辑:适配器源码

入参 config 一致,出参 Promise 一致。所有关于 http 模块、关于 xhr 的实现细节,全部被 Adapter 封装进了自己复杂的底层逻辑里,暴露出来的都是十分简单的统一的东西——统一的接口,统一的入参,统一的出参,统一的规则

好的适配器把变化留给自己,把统一留给用户。不过,有一点一定要明确,适配器不是银弹,那些繁琐的代码始终是存在的,只不过你把他们集合在了一起,在写具体业务的时候不会干扰到你。

代理模式

代理模式是为了控制对象的访问。
在某些情况下,出于种种考虑/限制,我们不直接操作原有对象,而是委托代理者去进行。代理者会对请求预先进行处理或转接给实际对象。

常见的三种代理类型:保护代理和虚拟代理、缓存代理。

保护代理

帮助主体过滤掉一些请求

🌰:点击报名活动。不直接调用报名活动的方法,而是委托代理者去进行,由代理决定是否能够报名

虚拟代理

把一些开销很大的对象,延迟到真正需要它的时候才去创建。

实现图片预加载

var myImage = (function(){ // 主体函数
    var imageNode = document.createElement('img');
    document.body.appendChild(imageNode);
    
    return {
        setSrc: function(src){
            imageNoode.src = src;
        }
    }
})();

var proxySetImage = (function(){ // 代理
    var img = new Image();
    img.onload = function(){ // 图片加载成功后执行
        myImage.setSrc(this.src);
    }
    return {
        setSrc: function(src){
            myImage.setSrc('loading.gif'); // 先设置加载图片
            img.src = src;
        }
    }
})();

proxySetImage('xxxxx');

缓存代理

可以为一些开销很大的运算结果提供暂时存储,在下次运算时,如果传递进来的参数跟之前一致,则可以直接返回前面存储的运算结果。

var createProxyFactory = function(fn){
    var cache = {}; // 缓存
    return function(){
        var args = Array.prototype.join.call(arguments, ','); // 取参数,并连接成字符串作为key
        if(args in cache){ // 判断是否存在于缓存中
            return cache[args];
        }
        return cache[args] = fn.apply(this, arguments); // 加入缓存
    }
}

var proxyA = createProxyFactory(A);
proxyA(1,2,3);
proxyA(1,2,3); // 取缓存中的结果

行为型:职责链模式、观察者模式

指责链模式

职责链模式是个链式结构,请求在链中的节点之间依次传递,直到有一个对象能处理该请求为止。如果没有任何对象处理该请求的话,那么请求就会从链中离开。

应用场景:我们申请设备之后,接下来要选择收货地址,然后选择责任人,而且必须是上一个成功,才能执行下一个~

普通写法

function applyDevice(data) {
  // 处理...
  let devices = {};
  let nextData = Object.assign({}, data, devices);
  // 执行选择收货地址
  selectAddress(nextData);
}

function selectAddress(data) {
  // 处理...
  let address = {};
  let nextData = Object.assign({}, data, address);
  // 执行选择责任人
  selectChecker(nextData);
}

function selectChecker(data) {
  // 处理...
  let checker = {};
  let nextData = Object.assign({}, data, checker);
  // 还有更多
}

Ctrl C + Ctrl V 大法,感觉也不太好

职责链模式实现

//各函数处理自己模块的业务逻辑
const applyDevice = function() {
    if(xxx){
    //具体逻辑
    }else{
    //如果某个节点不能处理请求,则返回一个特定的字符串
    //nextSuccessor来表示该请求需要继续往后面传递
       return "nextSuccessor";
    }
}
const selectAddress = function() {}
const selectChecker = function() {}

//编写职责链模式的封装构造函数方法
const Chain = function(fn) {
  this.fn = fn;
  this.successor = null;//定义一个属性表示在链中的下一个节点
   //指定在链中的下一个节点
  this.setNext = function(successor) {
    return (this.successor = successor);
  }; 
 //将请求转移到职责链的下一个节点
  this.run = function() {
    var ret = this.fn.apply(this, arguments);
    if (ret === 'nextSuccessor') {
      return this.successor && this.successor.passRequest.apply(this.successor, arguments);
    }
    return ret;
  }; 
};
//把函数分别包装成职责链节点
const chainApplyDevice = new Chain(applyDevice);
const chainSelectAddress = new Chain(selectAddress);
const chainSelectChecker = new Chain(selectChecker);

// 运用职责链模式实现上边功能

// 先指定节点在职责链中的顺序
chainApplyDevice.setNext(chainSelectAddress).setNext(chainSelectChecker);
//最后把请求传递给第一个节点:
chainApplyDevice.run();

优点

  1. 解耦了各节点关系,各自的处理函数互不影响;之前是 A 里边要写 B,B 里边写 C,但是这里不同了,你可以在 B 里边啥都不写。

  2. 链中的节点对象可以灵活地拆分重组,定义执行顺序。新增流程也很灵活

    const applyLincense = function() {} const chainApplyLincense = new Chain(applyLincense);

    // 运用职责链模式实现上边功能 chainApplyLincense.setNext(chainSelectChecker); chainApplyLincense.run();

缺点

  1. 不能保证某个请求一定会被链中的某个节点处理,因此需要在链尾增加一个保底的接收者节点来处理这种即将离开链尾的请求;

  2. 可能在某一次的请求传递过程中,大部分节点并没有起到实质性作用,它的作用仅仅是让请求传递下去,从性能方面考虑,我们要避免过长的职责链带来的性能损耗;

观察者模式

观察者模式定义了一种一对多的依赖关系,让多个观察者对象同时监听某一个目标对象,当这个目标对象的状态发生变化时,会通知所有观察者对象,使它们能够自动更新

举个🌰

产品经理拉了个飞书群

  1. 周一,前端开发某同学被产品拉进了一个飞书群-“xxx需求开发”,群里还有后端、测试两位同学

  2. 大家瞅见群名,准备开始干活,发现还没有需求。产品说,大家先各忙各,需求之后奉上

  3. 大家做好了开发新需求的准备,等待产品召唤

  4. 周三,产品往“xxx需求开发”群大吼一声:“需求文档来了!”,随后甩出了"需求文档"链接,同时@所有人。群里的技术同学立刻点开群进行群消息和群文件查收,随后根据群消息和群文件提供的需求信息,进入各自开发状态。

上述这个过程,就是一个典型的观察者模式,也叫发布 - 订阅模式

需求文档(目标对象)的发布者只有一个——产品经理

需求信息的接受者有多个——前端、后端、测试同学,他们需要根据需求信息开展自己后续的工作、因此不得不时刻关注着群的群消息提醒,他们就是订阅者,即观察者对象。

上文这个飞书群里,一个需求信息对象对应了多个观察者(技术同学),当需求信息对象的状态发生变化(从无到有)时,产品经理通知了群里的所有同学,以便这些同学接收信息进而开展工作:

角色划分 --> 状态变化 --> 发布者通知到订阅者,这就是观察者模式的“套路”。

代码理解

发布者类

需要 拉群(增加订阅者),踢人(移除订阅者)@所有人(通知订阅者)

// 定义发布者类
class Publisher {
  constructor() {
    this.observers = []
    console.log('Publisher created')
  }
  // 增加订阅者
  add(observer) {
    console.log('Publisher.add invoked')
    this.observers.push(observer)
  }
  // 移除订阅者
  remove(observer) {
    console.log('Publisher.remove invoked')
    this.observers.forEach((item, i) => {
      if (item === observer) {
        this.observers.splice(i, 1)
      }
    })
  }
  // 通知所有订阅者
  
  notify() {
    console.log('Publisher.notify invoked')
    this.observers.forEach((observer) => {
      observer.update(this)
    })
  }
}

订阅者类

被通知去执行(被调用,需要定义方法)

// 定义订阅者类
class Observer {
    constructor() {
        console.log('Observer created')
    }

    update() {
        console.log('Observer.update invoked')
    }
}

需求文档(prd)的发布类

// 定义一个具体的需求文档(prd)发布类
class PrdPublisher extends Publisher {
    constructor() {
        super()
        // 初始化需求文档
        this.prdState = null
        // 还没有拉群,开发群目前为空
        this.observers = []
        console.log('PrdPublisher created')
    }
    
    // 该方法用于获取当前的prdState
    getState() {
        console.log('PrdPublisher.getState invoked')
        return this.prdState
    }
    
    // 该方法用于改变prdState的值
    setState(state) {
        console.log('PrdPublisher.setState invoked')
        // prd的值发生改变
        this.prdState = state
        // 需求文档变更,立刻通知所有开发者
        this.notify()
    }
}

作为订阅方,开发者的任务也变得具体起来:接收需求文档、并开始干活:

class DeveloperObserver extends Observer {
    constructor() {
        super()
        // 需求文档一开始还不存在,prd初始为空对象
        this.prdState = {}
        console.log('DeveloperObserver created')
    }
    
    // 重写一个具体的update方法
    update(publisher) {
        console.log('DeveloperObserver.update invoked')
        // 更新需求文档
        this.prdState = publisher.getState()
        // 调用工作函数
        this.work()
    }
    
    // work方法,一个专门搬砖的方法
    work() {
        // 获取需求文档
        const prd = this.prdState
        // 开始基于需求文档提供的信息搬砖。。。
        ...
        console.log('996 begins...')
    }
}

new 一个 PrdPublisher 对象(产品经理),她可以通过调用 setState 方法来更新需求文档。需求文档每次更新,都会紧接着调用 notify 方法来通知所有开发者

// 创建各个订阅者
const liLei = new DeveloperObserver()
const A = new DeveloperObserver()
const B = new DeveloperObserver()
//产品出现了
const hanMeiMei = new PrdPublisher()
// 需求文档出现了
const prd = {
    // 具体的需求内容
    ...
}// 产品开始拉群
hanMeiMei.add(liLei)
hanMeiMei.add(A)
hanMeiMei.add(B)
hanMeiMei.setState(prd) // 产品发送了需求文档,并@了所有人

观察者模式≠发布-订阅模式

观察者模式:产品拉群,把文档给每个群成员,发布者直接触及到订阅者

发布-订阅模式:产品没拉群,把文档上传飞书平台,飞书平台感知文件的变化、自动通知了每一位订阅了该文件的开发者,发布者不直接触及到订阅者、而是由统一的第三方来完成实际的通信

既生瑜,何生亮?既然有了观察者模式,为什么还需要发布-订阅模式呢?

观察者模式,解决的是模块间的耦合问题,有它在,即便是两个分离的、毫不相关的模块,也可以实现数据通信。但仅仅是减少了耦合,并没有完全地解决耦合问题

被观察者需要维护观察者列表

观察者必须实现统一的方法供被观察者调用

而发布-订阅模式,则是快刀斩乱麻了——发布者完全不用感知订阅者,不用关心它怎么实现回调方法,只负责通知变化。事件的注册和触发都发生在独立于双方的第三方平台(事件中心)上。发布-订阅模式下,实现了完全地解耦。

这并不意味着,发布-订阅模式就比观察者模式“高级”。在实际开发中,我们的模块解耦诉求并非总是需要它们完全解耦。如果两个模块之间本身存在关联,且这种关联是稳定的、必要的,那么我们使用观察者模式就足够了。而在模块与模块之间独立性较强、且没有必要单纯为了数据通信而强行为两者制造依赖的情况下,我们往往会倾向于使用发布-订阅模式。

思考

  1. 设计模式有必要学吗,好像完全不了解的时候也能照常写代码?
    1. 很多优秀的框架、库都多多少少会用到设计模式的思想。你不了解的话,看源码会一头雾水,不得要领
    2. 设计模式是技术提高过程中一定要克服的一道关。越是复杂的业务逻辑越是需要想想能否套用某种设计模式,避免一上来就直接苏哈。写完发现耦合强逻辑混乱,bug贼多。更别提后期维护、和开发迭代
  2. 设计模式有很多,而且大多定义抽象,该怎么学,怎么用
    1. 本质是一套解决不同问题的方案的集合,模式与模式间存在共性与关联,要学会触类旁通
    2. 不是每一种都必须学。也不是每种都要生背下来这么用,更重要的是掌握思想
    3. 针对常用的设计模式,总结他们都解决了什么问题、使用的场景、经典案例。让问题和设计模式(解决方案)在你脑海里形成一种映射关系。像数学题一样,看到一种问题能想到可以用哪种设计模式思想,熟的话直接写,不熟再Chrome翻一下书
  3. 要避免为了设计模式而设计模式
    1. 合适的问题运用设计模式的思想来解决,可以提升代码质量、增强可读性。
    2. 设计模式也只是人们在实践过程中总结的某些问题的一些优秀解决方案。不要迷信,不要硬套到不合适的场景,增强代码复杂性和学习成本。