成为大牛的阶梯——JS设计模式

前言

普通coder 写代码目的就是能跑功能能实现就行,而 大牛coder 写的代码会考虑性能、扩展、维护、兼容,设计模式在两者之间就像是一座阶梯,普通coder 深入理解设计模式并大量应用和练习也能一步步成为 大牛coder 。我们在接到需求时,不要马上动手,而要子弹飞一会~,停下来多思考怎么设计会更适合?

设计模式??听着很高大上,其实就是前人总结的写代码套路,而且套路多达二十多种,本文主要介绍 JS 语言中常用到的8种,有些套路我们可能有用过但对不上名字?,所以阅读时可能会有“哦,原来这个就是xx设计模式啊?”的感慨。为加深读者对套路的理解和记忆,文章结合了大量例子和经典的应用场景,客官请细细品尝。

这边先提一下,有一个代码原则很重要

【开放封闭】原则

什么是【开放封闭】?简单来说就是:当要改变或扩展程序时,可以增加代码,但不要修改原来的逻辑。

阅读文章时可以多留意下,很多设计模式是为了让代码遵循【开放封闭】原则。如果你写的代码遵守这个原则,那 bug 数量将会大大减少喔!

??下面开始套路时间..

1.代理模式

1.1对象代理

限制一个对象不能直接访问,需要第三者(代理)牵桥搭线才能访问,这就是代理模式。

通常使用 ES6 的 Proxy 来实现代理。

来看一个社交 App 的业务

  1. 用户未登录,仅看查看陌生人的头像、昵称等基本信息
  2. 用户为普通用户,可查看陌生人的基本信息外,还可查看学历
  3. 用户为Vip用户,除了以上信息还可查看陌生人的生活照片
// 要代理的陌生人
const stranger = {
  nickname: 'jack ma',
  portrait: '头像',
  educationBackground: '本科',
  lifePhotos: []
}

// 权限组
const baseInfo = ['nickname', 'portrait']
const loginInfo = ['educationBackground']
const vipInfo = ['lifePhotos']

// 登录用户
const user = {
  isLogin: true,
  isVIP: false,
}

// 实现代理
const objProxy = new Proxy(stranger, {
  get(obj, key) {
    if(!user.isLogin && loginInfo.indexOf(key) !== -1) {
      console.log('请先登入')
      return
    }else if(!user.isVIP && vipInfo.indexOf(key) !== -1){
      console.log('请先成为VIP')
      return
    }
    return obj[key]
  },
  set(obj, key, val) {
    return val
  }
})

console.log(objProxy.nickname) // jack ma
console.log(objProxy.educationBackground) // 本科
console.log(objProxy.lifePhotos) // undefined
复制代码

1.2事件代理

当要给多个子元素添加点击事件时,开销比较大

  <div id="father">
    <a href="#">链接1号</a>
    <a href="#">链接2号</a>
    <a href="#">链接3号</a>
    <a href="#">链接4号</a>
    <a href="#">链接5号</a>
    <a href="#">链接6号</a>
  </div>
复制代码

我们可以将事件代理到父元素上

// 获取父元素
const father = document.getElementById('father')

// 给父元素安装一次监听函数
father.addEventListener('click', function(e) {
    // 识别是否是目标子元素
    if(e.target.tagName === 'A') {
        // 以下是监听函数的函数体
        e.preventDefault()
        alert(`我是${e.target.innerText}`)
    }
} )
复制代码

当点击子元素时,通过事件冒泡触发了父元素的点击方法,并通过 e.target 获取到真实被点击的子元素,这也属于一种代理模式。

1.3缓存代理

当有较复杂的运算时,我们可以通过缓存代理将已经计算过的值缓存起来,避免二次运算从而降低开销。

比如我们已实现一个计算所有入参之和的函数,我们可以通过代理将计算过的值缓存起来:

// 对传入的所有参数做求和
const addAll = function() {
    let result = 0
    const len = arguments.length
    for(let i = 0; i < len; i++) {
        result += arguments[i]
    }
    return result
}

// 为求和方法创建代理
const proxyAddAll = (function(){
    // 求和结果的缓存池
    const resultCache = {}
    return function() {
        // 将入参转化为一个唯一的入参字符串
        const args = Array.prototype.join.call(arguments, ',')
        
        // 检查本次入参是否有对应的计算结果
        if(args in resultCache) {
            // 如果有,则返回缓存池里现成的结果
            return resultCache[args]
        }
        return resultCache[args] = addAll(...arguments)
    }
})()
复制代码

以上例子中,resultCache 对象为缓存池,对象 key 为所有入参 arguments 转化的字符串,对象 value 为合计结果。这样一来,通过 proxyAddAll 就可以实现缓存代理。

2.单例模式

单例模式仅允许类或对象具有单个实例,并且它使用全局变量来存储该实例。

实现方法:判断是否存在该对象的实例,如果已存在则不再创建,如果不存在则创建实例并使用闭包将实例存起来

使用场景:适用于业务场景中只能存在一个的实例,比如弹窗,购物车

// 懒汉式

let ShopCar = (function () {
  let instance;
  function init() {
    /*这里定义单例代码*/
    return {
      buy(good) {
        this.goods.push(good);
      },
      goods: [],
    };
  }
  return {
    getInstance: function () {
      if (!instance) {
        instance = init();
      }
      return instance;
    },
  };
})();
let car1 = ShopCar.getInstance();
let car2 = ShopCar.getInstance();
car1.buy("橘子");
car2.buy("苹果");
console.log(car1.goods); //[ '橘子', '苹果' ]
console.log(car1 === car2); // true
复制代码
// 饿汉式

var ShopCar = (function () {
  var instance = init();
  function init() {
    /*这里定义单例代码*/
    return {
      buy(good) {
        this.goods.push(good);
      },
      goods: [],
    };
  }
  return {
    getInstance: function () {
      return instance;
    },
  };
})();
let car1 = ShopCar.getInstance();
let car2 = ShopCar.getInstance();
car1.buy("橘子");
car2.buy("苹果"); //[ '橘子', '苹果' ]
console.log(car1.goods);
console.log(car1 === car2); // true
复制代码

实现效果有两种,懒汉式和饿汉式,各有优劣,按需求使用

  • 懒汉式在类加载时,不创建实例,因此类加载速度快,但运行时获取对象的速度慢;
  • 饿汉式在类加载时就完成了初始化,所以类加载较慢,但获取对象的速度快

3.策略模式

策略模式解难度不大,并且在面试中权重不高,所以能理解会使用即可。

策略模式简单来说就是利用对象映射,避免编写过多 if else

比如有这样一段代码:

// 询价方法,接受价格标签和原价为入参
function askPrice(tag, originPrice) {
  // 处理预热价
  if (tag === "pre") {
    if (originPrice >= 100) {
      return originPrice - 20;
    }
    return originPrice * 0.9;
  }

  // 处理大促价
  if (tag === "onSale") {
    if (originPrice >= 100) {
      return originPrice - 30;
    }
    return originPrice * 0.8;
  }

  // 处理返场价
  if (tag === "back") {
    if (originPrice >= 200) {
      return originPrice - 50;
    }
    return originPrice;
  }
}
复制代码

使用策略模式改造后:

// 价格处理对象
let priceProcessor = {
  prePrice(originPrice) {
    if (originPrice >= 100) {
      return originPrice - 20;
    }
    return originPrice * 0.9;
  },
  salePrice(originPrice) {
    if (originPrice >= 100) {
      return originPrice - 30;
    }
    return originPrice * 0.8;
  },
  backPrice(originPrice) {
    if (originPrice >= 200) {
      return originPrice - 50;
    }
    return originPrice;
  },
};

// 询问价格
function askPrice(tag, originPrice) {
  return priceProcessor[tag](originPrice);
}
复制代码

使用策略模式的优势:

  • 不同逻辑之间隔离开,不会互相影响。比如当修改 prePrice 逻辑后仅需让测试同学回归该类型价格的功能即可
  • 方便增加新的价格,直接 priceProcessor.newPrice 即可

综上可以总结出来策略模式的定义:定义一系列的算法,把它们一个个封装起来, 并且使它们可相互替换。

用笔者自己的话来阐述:

将 if else 不同逻辑都独立到函数中,将这些函数映射到一个对象上,最后对外提供一个接口函数用于外部调用。

4.状态模式

状态模式(State Pattern) :允许一个对象在其内部状态改变时改变它的行为,对象看起来似乎修改了它的类。

class Fox {
  constructor() {
    this.animationType = "default";
    this.foxLevel = 1;
  }
  // 动画处理器
  animationProcessor = {
    that: this,
    default() {
      console.log(`fox level 为 ${this.that.foxLevel} 的默认动画`);
    },
    feed() {
      console.log(`fox level 为 ${this.that.foxLevel} 的喂食动画`);
    },
    touch() {
      console.log(`fox level 为 ${this.that.foxLevel} 的抚摸动画`);
    },
    upLevel() {
      console.log(`fox level 为 ${this.that.foxLevel} 的升级动画`);
    },
    // 喂食、升级组合动画
    feedAndUpLevel() {
      console.log(`fox level 为 ${this.that.foxLevel} 的喂食并且升级动画`);
    },
  };

  // 触发动画
  triggerAnimation(animationType) {
    try {
      this.animationProcessor[animationType]();
    } catch (err) {
      console.error("执行动画出错", err.message);
    }
    this.animationType = animationType;
  }
}

const myFox = new Fox();
myFox.triggerAnimation("feed"); // fox level 为 1 的喂食动画
复制代码

5.工厂模式

什么时候用?

当类过多不方便管理,且需要创建的对象之间存在某些关联(有同一个父类、实现同一个接口等)时,不妨使用工厂模式

作用?

工厂模式提供一种集中化、统一化的方式,避免了分散创建对象导致的代码重复、灵活性差的问题。

5.1简单工厂模式

例如我们使用简单工厂模式:打造一个可制造多品牌汽车的工厂

// 汽车构造函数
function SuzukiCar(color) {
  this.color = color;
  this.brand = "Suzuki";
}

// 汽车构造函数
function HondaCar(color) {
  this.color = color;
  this.brand = "Honda";
}

// 汽车构造函数
function BMWCar(color) {
  this.color = color;
  this.brand = "BMW";
}

// 汽车品牌枚举
const BRANDS = {
  suzuki: 1,
  honda: 2,
  bmw: 3,
};

/**
 * 汽车工厂
 */
function CarFactory() {
  this.create = function (brand, color) {
    switch (brand) {
      case BRANDS.suzuki:
        return new SuzukiCar(color);
      case BRANDS.honda:
        return new HondaCar(color);
      case BRANDS.bmw:
        return new BMWCar(color);
      default:
        break;
    }
  };
}
复制代码

5.2抽象工厂模式

理解抽象工厂,要理清四个概念:

  • 抽象工厂:抽象类,用于声明产品最终目标的共性。
  • 具体工厂:继承自抽象工厂,实现了抽象工厂定义的方法,用于创建最终的产品。
  • 抽象产品:抽象类,用于声明细粒度产品的共性。
  • 具体产品:继承自抽象产品,实现了抽象产品定义的方法,用于创建细粒度的产品。具体工厂中接口的实现依赖于具体产品类。

咋一看概念晕乎乎的,来看个具体应用就清晰了:

// 抽象工厂
class MobileFactory {
  constructor(name) {
    this.brandName = name;
  }

  installOS() {
    throw new Error("当前为抽象工厂,子品牌需实现该功能");
  }
}

// 具体工厂
class HuaWei extends MobileFactory {
  installOS(OS) {
    OS.install(this);
  }
}

// 抽象产品
class OS {
  constructor(name) {
    this.name = name;
  }
  install(phone) {
    throw new Error("当前为抽象产品,需被继承并实现");
  }
}

// 具体产品
class IosOS extends OS {
  constructor() {
    super("IOS");
  }
  install(phone) {
    console.log(`${phone.brandName} install ${this.name} OS success`);
  }
}
class AndroidOS extends OS {
  constructor() {
    super("Android");
  }
  install(phone) {
    console.log(`${phone.brandName} install ${this.name} OS success`);
  }
}

// 生产华为手机 p40
const huaweiP40 = new HuaWei("HuaWei P40");
const androidOSInstance = new AndroidOS();
huaweiP40.installOS(androidOSInstance); // HuaWei P40 install Android OS success
复制代码

从例子可以看出,抽象工厂抽象产品 都是用来制定规范的,而真正干活的是 具体工厂具体产品 。工厂和产品其实是类似的,只是粒度大小划分不同。

抽象工厂通过抽象工厂类和抽象产品类制定了规范,使得复杂业务变得清晰,最后具体工厂将具体产品拼接整合从而实现一个完整的产品。

6.装饰器模式

装饰器模式的原则:在不改动原来代码逻辑的基础上去添加增量代码。

假设已有旧代码:

window.onload = () => {
  console.log(document.getElementByTagName("*").length); // 8
};
复制代码

现有需求,需在页面加载完成后打印 ‘页面加载完毕’ 。

使用装饰器模式来实现:

const fn = window.onload;
window.onload = () => {
  typeof fn === "function" && fn();
  console.log("页面加载完毕");
};
// 8
// 页面加载完毕
复制代码

使用装饰器模式,不会触碰到旧代码,大大降低了 bug 出现的几率。

7.适配器模式

适配器模式主要用来兼容代码。

假设有一个远古项目,数据请求使用的是 ajax 库。

// 发送get请求
Ajax('get', url地址, post入参, function(data){
    // 成功的回调逻辑
}, function(error){
    // 失败的回调逻辑
})
复制代码

现在需求要将数据请求库改为现代请求库 fetch 。

如果我们在调用了 Ajax 的地方都去修改,那修改、测试成本太高,这时候就可以用到适配器模式。

// Ajax适配器函数,入参与旧接口保持一致
async function AjaxAdapter(type, url, data, success, failed) {
    const type = type.toUpperCase()
    let result
    try {
         // 实际的请求全部由新接口发起
         if(type === 'GET') {
            result = await HttpUtils.get(url) || {}
        } else if(type === 'POST') {
            result = await HttpUtils.post(url, data) || {}
        }
        // 假设请求成功对应的状态码是1
        result.statusCode === 1 && success ? success(result) : failed(result.statusCode)
    } catch(error) {
        // 捕捉网络错误
        if(failed){
            failed(error.statusCode);
        }
    }
}

// 用适配器适配旧的Ajax方法
async function Ajax(type, url, data, success, failed) {
    await AjaxAdapter(type, url, data, success, failed)
}
复制代码

使用适配器模式:

  1. 要实现一个适配器让原先调用 Ajax 函数的参数能无缝衔接到新的请求库上
  2. 覆盖 Ajax 函数,调用适配器

这样一来,原本调用 Ajax 函数地方就可以不必需改直接复用。

8.观察者模式

观察者模式是面试的超高频考点。

观察者模式,又称发布订阅模式。发布者主要拥有:添加订阅,发布通知功能,订阅者主要有:接受通知,执行命令功能。

应用场景:

  • Vue.js 的双向绑定
  • Event Bus

观察模式的简单实现:

class Publisher {
  constructor() {
    this.observers = [];
  }

  addObserver(observer) {
    this.observers.push(observer);
  }

  publish() {
    this.observers.forEach((item) => {
      item.receive();
    });
  }
}
class Observer {
  receive() {
    console.log("收到通知后执行命令");
  }
}

const obs = new Observer();
const publish = new Publisher();

publish.addObserver(obs);
publish.publish(); // 收到通知后执行命令
复制代码

要进一步了解观察模式可以前往:

后语

最后祝大家在人际交往中,遇到的套路少一点,写代码时套路运用多一点,希望大家都能写出艺术品般的代码,而你就不是一个代码农民工??‍?,而是一个艺术家!

其他文章

我的前端知识库

经典又常用的JS代码片段

搭建个人脚手架

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享