来点设计模式震撼(上)

224 阅读9分钟

music.163.com/outchain/pl…

什么是设计模式

设计模式就是:设计的模式(字面意思)是对用来在特定场景下解决一半设计问题的类和相互通信的对象的描述。也就是说设计模式是针对特定场景下使用特定方式进行类和通信方式的设计的方案。

为什么需要设计模式

事实上,设计模式源于城市建设和建筑模式。Christopher Alexander说过:“每一个模式描述了一个在我们周围不断重复发生的问题,以及该问题的解决方案的核心。这样,你就能一次又一次地使用该方案而不必做重复劳动。”也就是说设计模式实质上是前人对特定情景下处理方式最优解的总结,所以这不得狠狠的白嫖一下?(学了之后就不会被师兄说代码结构差了😭)

下面就聊聊前端开发中使用较多(可能)的设计模式:

创建型模式

创建型模式虽然对于前端来说,在业务上使用频率比较低(模型的搬砖者),不过在更接近底层的设计方面上,使用的会相对较多

Singleton 单例模式

定义与应用场景

单例模式比较好理解,也就是 单个 实例 的模式。在计算机领域单例模式运用相当广泛,最出名的单例就是互斥锁的声明。不难看出,当我们需要一个类只能有一个实例并可以从一个总所周知的访问点对其进行访问的时候,我们可以使用单例模式。

也就是说,当我们需要进行互斥控制(一般是后端需要考虑的😊)或者作为全局唯一的资源、状态挂载点(比如vuex的store)的时候,我们可以采取单例模式。

image.png 单例模式示意图

class Singletion {
    public :
        static Singletion* Instance()
    protected:
        Singletion()
    private:
        static Singletion* _instance
}
来个🌰

用js实现一个简易的mutex:

//mutex.js
type ReleaseFn = () => void;
// 思路:声明一个信号量,一个存储函数的数组,要完成互斥访问的功能
class Mutex {
  // 声明一个布尔值用于表示锁
  private locked = false;
  // 声明一个程序数组用于存储(队列的英文好像打错了?)待执行函数
  private queue = [];
  // 功能实现
  async acquire(): Promise<ReleaseFn> {
    return new Promise((resolve) => {
      // 声明执行函数
      const opreate = function () {
        // 如果队列中有等待的函数,则进行下一个函数
        if (this.queue.length > 0) {
          let next = this.queue.shift();
          if (next) next();
        } else {
          // 此时队列已经清空,释放cpu(即将锁置为false)
          this.locked = false;
        }
      };
      // 如果被锁,即此时不占用CPU,则对程序压入执行队列中
      if (this.locked) {
        this.queue.push(() => {
          resolve(opreate);
        });
        // 否则将locked置为true,即此时占用CPU,并执行执行函数
      } else {
        this.locked = true;
        resolve(opreate);
      }
    });
  }
}
//test.js
export async function doTest() {
  try {
    const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
    const mutex = new Mutex();

    let shareState = [];
    // 创建一个 worker
    // 每个 worker 循环5次处理 globalState
    // 通过锁机制,保证其他 worker 处理的是当前 worker 处理后的 globalState
    const createWorker = async (delay, char) => {
      let workerCopyState;
      // 循环5次
      for (let i = 0; i < 5; i++) {
        const release = await mutex.acquire();
        workerCopyState = shareState.slice();
        await sleep(delay);
        workerCopyState.push(char);
        shareState = workerCopyState.slice();
        await sleep(delay);
        release();
      }
    };
    await Promise.all([createWorker(7, 'a'), createWorker(11, 'b')]);
    assert.equal(shareState.join(''), 'ababababab');
    return true;
  } catch (err) {
    console.error(err);
    return false;
  }
}
再来个🍐

Vuex实现了一个全局的store用来存储应用的所有状态。这个store的实现就是单例模式的典型应用。

// 安装vuex插件
Vue.use(Vuex)
// 将store注入Vue实例
new Vue({
    el:"$app",
    store
})

通过调用Vue.use方法,安装Vuex插件。Vuex插件本质上是一个对象,内部实现了一个install方法,这个方法在插件安装时被调用,从而把store注入到Vue实例中。

let Vue // instance 实例

export function install (_Vue) {
  // 判断传入的Vue实例对象是否已经被install过,保证单例
  if (Vue && _Vue === Vue) {
    if (process.env.NODE_ENV !== 'production') {
      console.error(
        '[vuex] already installed. Vue.use(Vuex) should be called only once.'
      )
    }
    return
  }
  // 若没有,则为这个Vue实例对象install一个唯一的Vuex
  Vue = _Vue
  // 将Vuex的初始化逻辑写进Vue的钩子函数里
  applyMixin(Vue)
}
好在哪儿

下面盘点一下单例模式的优点:

  1. 对唯一实例的受控访问: 他可以严格控制客户何时、如何访问、操作该实例(信号量的互斥、vuex的特殊提交、获取方式)
  2. 减少全局变量命名污染: 把部分的变量放在一个单例里面可不就减少了全局变量的数量嘛😊(其实可以将单例看作是第二个全局变量空间)
  3. 保证访问资源的一致性: 严格上来说,这其实是包含在第一点中的(实现互斥与同步),但是对于前端来说,这就跟你单独拉一个const.js一样,想要达到的效果是相同的。(其实我就是想水字数,嘿嘿)
  4. 允许可变数量的实例: 虽然说,单例模式是严格限制一个类的实例数量,但是这个实例可以存储其他实例的数量(信号量也不一定就是0和1

Protoype 对象创造型模式

定义与应用场景

用原型实例制定创建对象的类型,并且通过拷贝这些原型创建新的对象。换句话说就是声明一个泛用的对象,让其他对象“继承”他的属性与方法,并且能在其基础上进行扩展。其关键就是“克隆”。

康康传统🌰

原型模式通过克隆一个已经存在的对象实例来返回新的实例,而不是通过new去创建对象,多用于创建复杂的或者耗时的实例,因为这种情况下,复制一个已经存在的实例使程序运行更高效;在java中复制对象是通过重写clone()实现的,原型类需要实现Cloneable接口,否则报CloneNotSupportedException异常

public abstract class Dude implements Cloneable{

    private String name;

    public Dude(String name) {
        this.name = name;
    }

    @Override
    protected Object clone() throws CloneNotSupportedException {
        return super.clone();
    }
}
再来点JS🍐

在JS中,原型是一个相当重要的概念,但和传统原型不太相同,JS中的原型和原型链更像是把实例化对象链接在一起的类型锁链,其中包含所有实例对象共享的属性和方法,这也就是为了让该函数所实例化的对象们都可以找到公共的属性和方法(都什么年代,还在用传统原型)。也就是说,在你new一个新的实例的时候,无形之中已经给这个新实例施加了一个“枷锁”。

_new的实现:

function _new(obj, ...rest){
  // 基于obj的原型创建一个新的对象
  const newObj = Object.create(obj.prototype);
  // 添加属性到新创建的newObj上, 并获取obj函数执行的结果.
  const result = obj.apply(newObj, rest);
  // 如果执行结果有返回值并且是一个对象, 返回执行的结果, 否则, 返回新创建的对象
  return typeof result === 'object' ? result : newObj;
}

原型链

image.png

要点:

1、所有的引用类型(数组、函数、对象)可以自由扩展属性(除null以外)。

2、所有的引用类型都有一个"proto"属性(也叫隐式原型,它是一个普通的对象)。

3、所有的函数都有一个’prototype’属性(这也叫显式原型,它也是一个普通的对象)。

4、所有引用类型,它的"proto"属性指向它的构造函数的’prototype’属性。

5、当试图得到一个对象的属性时,如果这个对象本身不存在这个属性,那么就会去它的"proto"属性(也就是它的构造函数的’prototype’属性)中去寻找。

Object.__proto__ === Function.prototype;
Function.prototype.__proto__ === Object.prototype;
Object.prototype.__proto__ === null;

所有构造函数(constructor)都是Function的实例,所有原型对象都是Object的实例除了Object.prototype。

image.png 实例对象的__proto__属性指向其构造函数的prototype,通过这点,我们也可以进行类型判断(沿着原型链进行匹配)

function instance_of(L, R) {//L 表示左表达式,R 表示右表达式 
    var RP = R.prototype;   // 取 R 的显示原型 
    L = L.__proto__;  // 取 L 的隐式原型
    while (true) {    
        if (L === null)      
	           return false;   
        if (RP === L)  // 当RP显式原型严格等于L隐式原型时,返回true
             return true;   
        L = L.__proto__;  
    }
}
//instance_of([1,2],Array)
好在哪儿
  1. 改变值以指定新对象
  2. 改变结构以指定新对象

结构型模式

Adapter 适配器

定义与应用场景

名如其名,适配器做的工作就是把原本不能适配的接口变得适配(电源适配器、耳机插孔转换头),当然,翻译官在某种程度上来说也是适配器。一般来说,只有遇到不匹配的时候才会使用适配器,所以其应用场景更多在原本数据模型的数据结构和当前需要不匹配的时候。

来个🌰

众所周知,有一个东西叫做“历史遗留问题”,你很难想象你面前的这份代码的创造者是在什么精状态下完成创作的。那我们假设我们收到后端同学“没动”传来的了三种不同的数据模型(但他们想要表达的东西是相同的)虽然你很想现在就走过去教他写接口,我们现在需要做的事情就是把这三个数据模型转译成为一个统一的数据模型,以方便下一步的业务处理。

// 模型1
{
  person_id: 510107200408261123
  status: 0,
  create: '2021-10-12 18:10:20',
  update: '2022-01-15 09:10:30',
},

// 模型2
{
  id: 510107200408061121
  status: 0,
  createTime: 16782738393022,
  updateAt: '2022-01-15 09:06:10',
},

// 模型3
{
  person_id: 510107201409261002
  status: 0,
  createTime: 16782738393022,
  updateAt: 16782738393029,
}

为了统一处理,我们先声明一个返回的数据模型,即要把这三个模型转换的目标。

interface personData {
  person_id: number;
  status: number;
  createAt: string; // 时间戳
  updateAt: string; // 时间戳
}

通过适配器进行转换

interface personDataType1 {
  person_id: number;
  status: number;
  create: string;
  update: string;
}

interface personDataType2 {
  id: number;
  status: number;
  createTime: number;
  updateAt: string;
}

interface personDataType3 {
  person_id: number;
  status: number;
  createTime: number;
  updateAt: number;
}

const getTimeStamp = function (str: string): number {
  //.....转化成时间戳
  return timeStamp;
};

//适配器
export const personDataAdapter = {
  adapterType1(list: personDataType1[]) {
    const bookDataList: personData[] = list.map((item) => {
      return {
        person_id: item.person_id,
        status: item.status,
        createAt: getTimeStamp(item.create),
        updateAt: getTimeStamp(item.update),
      };
    });
    return bookDataList;
  },

  adapterType2(list: personDataType2[]) {
    const bookDataList: personData[] = list.map((item) => {
      return {
        person_id: item.id,
        status: item.status,
        createAt: item.createTime,
        updateAt: getTimeStamp(item.updateAt),
      };
    });
    return bookDataList;
  },

  adapterType3(list: personDataType3[]) {
    const bookDataList: personData[] = list.map((item) => {
      return {
        person_id: item.person_id,
        status: item.status,
        createAt: item.createTime,
        updateAt: item.updateAt,
      };
    });
    return personDataList;
  },
};
好在哪儿
  1. 可以使原有逻辑得到更好的复用,有助于避免大规模改写现有代码
哪儿有问题
  1. 说实话,不是很建议使用适配器,这种问题尽量通过与后端同学的友好“切磋”后解决。

Bridge 桥接模式

定义与应用场景

桥接模式是用于把抽象化与实现化解耦,使得二者可以独立变化。它通过提供抽象化和实现化之间的桥接结构,来实现二者的解耦。意图将抽象部分与实现部分分离,使它们都可以独立的变化。

既然能够结偶,那么能够使用到的一般就是能够将抽象和实现分离,互相独立互不影响;在生活中处于标准化生产的、拥有统一接口的产品大多能够使用桥接模式,比如蓝牙耳机之于手机 等。

来个🌰

举个生活中的🌰,某品牌电动车针对某一车型推出了若干产品版本:

  • 标准版汽车采用普通电机、普通内饰、普通中控台
  • 运动版汽车采用运动版电机、普通内饰、升级版中控台
  • 豪华版汽车采用运动版电机、豪华内饰、豪华版中控台

我们把电动汽车机抽象的看作由这三个部分组成,那么可以提取电机、内饰、中控台部件作为抽象维度,在新建电动车实例的时候,把抽象出来的部件桥接起来组成一个完整的电动车实例。在这个电动车系列产品中,产品的部件可以沿着各自维度独立地变化。

electric-car-1633939_640.jpg

我们把这个🌰稍微实现一下:

// 组装电动车
function ElecCar(motorType, decorationType, controlerType) {
    this.motor = new Motor(motorType);
    this.decoration = new Decoration(decorationType);
    this.controler = new Controler(controlerType);
}
ElecCar.prototype.work = function () {
    this.motor.run();
    this.decoration.run();
    this.controler.run();
}
 
// 电机
function Motor(type) {
    this.motorType = type + '电机'
}
Motor.prototype.run = function () {
    console.log(this.motorType + '开始工作')
}
 
// 内饰
function Decoration(type) {
    this.decorationType = type + '内饰'
}
Decoration.prototype.run = function () {
    console.log(this.decorationType + '真舒服')
}
 
// 中控台
function Controler(type) {
    this.controlerType = type + '中控台'
}
Controler.prototype.run = function () {
    console.log(this.controlerType + '开始工作')
}
 
// 新建电动车
const ElecCarStandard = new ElecCar('普通', '普通', '普通');
ElecCarStandard.work();

对于电动车来说,每一个部件的实现其实是一个黑盒状态,分而治之,是桥接模式的一个核心(我管你内部是怎么搞的,只要集成之后能跑就行)

好在哪儿
  1. 分离了抽象和实现部分,将实现层(DOM 元素事件触发并执行具体修改逻辑)和抽象层( 元素外观、尺寸部分的修改函数)解耦,有利于分层;
  2. 提高了可扩展性,多个维度的部件自由组合,避免了类继承带来的强耦合关系,也减少了部件类的数量;
  3. 使用者不用关心细节的实现,可以方便快捷地进行使用
  4. 桥接模式要求两个部件没有耦合关系,否则无法独立地变化,因此要求正确的对系统变化的维度进行识别,使用范围存在局限性;

Proxy 代理模式

定义与应用场景

代理模式(Proxy Pattern)又称委托模式,它为目标对象创造了一个代理对象以控制对目标对象的访问。

代理模式把代理对象插入到访问者和目标对象之间,从而为访问者对目标对象的访问引入一定的间接性。正是这种间接性,给了代理对象很多操作空间,比如在调用目标对象前和调用后进行一些预操作和善后操作,从而实现新的功能或者扩展目标的功能。

来个🌰

代理模式在生活中无处不见,黄牛、中间商等很多受人“爱戴”的职业都是代理模式的在现实中的实战,那就拿作者最喜欢的足球举例。

球员(基本上)都有经纪人,那当俱乐部想和球员协商合同、赞助商想和球员签署赞助协议的时候,都会跟球员经纪人洽谈,球员经济人会筛掉不符合球员预期、违背球员意愿的“橄榄枝”。

// 超级球星
var FootbalStar = {
    name: 'Ronaldo',
    playGame: function(team) {
        console.log(team + 'siu')
    }
}
 
// 经纪人
var ProxyAssistant = {
    name: 'Mendes',
    playGame: function(reward,team) {
        // 如果报酬超过1E
        if (reward > 100000000) {
          //如果球队能踢欧冠
          if(qulifyChampionsLeague(team)){
            console.log('我罗来辣!');
            FootbalStar.playGame(team);
        	} 
        }else{
            console.log('我罗不去,沙超除外');
        }
 
    }
}
 
ProxyAssistant.playGame(100000000000, '利雅得胜利');
// 我罗不去,沙超除外

对于上面的例子,我罗就相当于被代理的目标对象(Target),而经纪人就相当于代理对象(Proxy),希望找明星的人是访问者(Visitor),他们直接找不到我罗,只能找我罗的经纪人来进行业务商洽。主要有以下几个概念:

  1. Target:目标对象,也是被代理对象,是具体业务的实际执行者;

  2. Proxy: 代理对象,负责引用目标对象,以及对访问的过滤和预处理;

ES6 原生提供了 Proxy 构造函数

//其中target是目标对象,handler是处理函数
var proxy = new Proxy(target, handler);

那就用proxy的在对这个例子进行一个修改

// 明星
const FootbalStar = {
    name: 'Ronaldo',
    // 档期标识位,false-没空,true-有空
    scheduleFlag: false,           
    playGame: function(team) {
        console.log(team + 'siu!')
    }
}
const ASEASON = 10000 //一个赛季的时间
// 经纪人
const ProxyAssistant = {
    name: 'Mendes',
    scheduleTime(team) {
        // 在这里监听 scheduleFlag 值的变化
        const schedule = new Proxy(FootbalStar, { 			
            set(obj, prop, val) {
                if (prop !== 'scheduleFlag') {return};
                // 我罗现在有空了
                if (obj.scheduleFlag === false && val === true) {                    
                    obj.scheduleFlag = true;
                    // 安排上了
                    obj.playGame(team);        
                }
            }
        });
        setTimeout(() => {
            // 明星有空了
            schedule.scheduleFlag = true              
        }, ASEASON)
    },
    playGame(reward, team) {
        // 如果报酬超过1E
        if (reward > 100000000) {
          //如果球队能踢欧冠
          if(qulifyChampionsLeague(team)){
            console.log('我罗来辣!');
            ProxyAssistant.scheduleTime(ad);
        	} 
        }else{
            console.log('我罗不去,沙超除外');
        }
    }
}
 
ProxyAssistant.playGame(1000000000, '利雅得胜利');
// 我罗不去,沙超除外
ProxyAssistant.playGame(100000000001, '纽卡斯尔联');
//我罗来辣 
//纽卡斯尔siu!
再来个JS🍐

使用代理模式代理对象的访问的方式,一般又被称为拦截器。拦截器的思想在实战中应用非常多,比如我们在项目中经常使用 Axios 的实例来进行 HTTP 的请求,使用拦截器 interceptor 可以提前对 request 请求和 response 返回进行一些预处理,比如:

  1. request 请求头的设置,和 Cookie 信息的设置;

  2. 权限信息的预处理,常见的比如验权操作或者 Token 验证;

  3. 数据格式的格式化,比如对组件绑定的 Date 类型的数据在请求前进行一些格式约定好的序列化操作;

  4. 空字段的格式预处理,根据后端进行一些过滤操作;

  5. response 的一些通用报错处理,比如使用 Message 控件抛出错误;

当然前端数据响应式也是使用proxy进行拦截,然后进行后续操作的,这也是MVVM结构能够建立起来的三驾马车之一,MVVM

好在哪儿
  1. 代理对象在访问者与目标对象之间可以起到 中介和保护目标对象 的作用;

  2. 代理对象可以扩展目标对象的功能,进行预处理、善后处理

  3. 代理模式能将访问者与目标对象分离,在一定程度上降低了系统的耦合度,如果我们希望适度扩展目标对象的一些功能,通过修改代理对象就可以了,符合开闭原则;

需要注意的是,代理模式是可以取消请求,和修饰器模式还是有一定区别(代理模式会拦截请求、变化,然后根据一定规则进行操作、而修饰器模式更像是在外层进行包装)