从工厂模式说起,简单聊聊设计模式在前端中的应用

3,824 阅读8分钟

设计模式是一套可以被反复使用的、多数人知晓的、经过分类编目的、代码设计经验的总结。使用设计模式,是为了可重用代码,让代码更容易被他人理解并且提高代码的可靠性。

设计模式的基本原则

  1. 单一职责原则(Single Responsibility Principle,简称SRP )
  2. 里氏替换原则(Liskov Substitution Principle,简称LSP)
  3. 依赖倒置原则(Dependence Inversion Principle,简称DIP)
  4. 接口隔离原则(Interface Segregation Principle,简称ISP)
  5. 迪米特法则(Law of Demeter,简称LoD)
  6. 开放封闭原则(Open Close Principle,简称OCP)

设计模式的原则并不是一个遵守或者不遵守这样的非黑即白的二元问题,而是遵守程度的问题。举例来说,单一职责原则是大家都习以为常的原则,几乎每个学习过软件工程的人都知道这个原则。简单说,一个函数只做一件事。但是,怎么定义完成一件事的边界,就因每个人的理解不同而有差异了。所以说,实际开发中,并不是每个情况下都能简单地判断是否遵循了某一个原则的。

这些原则,我都觉得更多的还是指导思想和评判的心理准则。我们在设计代码时,只要考虑了这些情况,尽可能地去遵守原则就好了。

设计模式有哪些

经典设计模式

以上设计模式都是基于面向对象语言设计的,并不是所有的都适用于 JavaScript。我们能掌握常见的几种设计模式,也便足够日常开发中使用了。下面简单介绍几种可以快速学习入门的设计模式。

工厂模式

工厂模式的设计思想是通过一个工厂函数,快速批量地建立一系列相同的类。我们也可以用来创建对象、方法。

function PersonFactory(name) { // 工厂函数
  let obj = new Object();
  obj.name = name;    
  obj.sayName = function(){
      return this.name;
  }
  return obj;
}
let person = new PersonFactory("张三");

console.log(person.name); // 张三
console.log(person.sayName()); // 张三

这个例子实现了一个简单的对象工厂,当然日常开发中,我们实际这样使用的情景并不多。

再列举一个实际项目中的场景。跨域是前端开发中经常需要解决的问题,当然解决方案有很多,常用的一种方式是增加 node 中间层,对每个接口做一次转发。截取我们项目中的一段代码如下(代码已做脱敏处理)

async getList() {
  const { ctx, config } = this;
  const params =  ctx.request.query;
  const conf = {
    url: `${config.domain}/list`,
    method: 'get',
    params,
  };
  
  await ctx.fetch(conf)
    .then((res) => {
      ctx.body = res.data;
    })
    .catch((err) => {
      ctx.body = err;
    });
}
async getCount() {
  const { ctx, config } = this;
  const params = ctx.request.query;
  const conf = {
    url: `${config.domain}/count`,
    method: 'get',
    params,
  };
  await ctx.fetch(conf)
    .then((res) => {
      ctx.body = res.data;
    })
    .catch((err) => {
      ctx.body = err;
    });
}

项目中有将近 20 个类似的接口转发函数,开发的时候基本都是复制粘贴之前的一个函数,然后修改一下接口的参数。这样的代码维护性是比价差的,下面我们通过工厂模式来优化一下

function apiFactory(url) {
  return async () => {
    const _self = this;
    const { ctx, config: { domain } } = _self;
    const params = ctx.request.query;
    const conf = {
      url: `${domain}/${url}`,
      method: 'get',
      params,
    };
    await ctx.fetch(conf).then((res) => {
        ctx.body = res.data;
      })
      .catch((err) => {
        ctx.body = err;
      });
  }
}
const getList = apiFactory('list');
const getCount = apiFactory('count');

通过工厂模式,在增加新的接口转发函数时,只需要增加一行代码就可以了。

单例模式

单例模式,即保证一个类有且仅有一个实例。作用就是避免重复创建对象,优化性能。

// 单体模式
let Singleton = function(name){
  this.name = name;
  this.instance = null;
};
Singleton.prototype.getName = function(){
  return this.name;
}
// 获取实例对象
function getInstance(name) {
  if(!this.instance) {
      this.instance = new Singleton(name);
  }
  return this.instance;
}
// 测试单体模式的实例
let a = getInstance("aa");
let b = getInstance("bb");
console.log(a === b); // true

策略模式

策略模式是定义一系列算法,把它们一个个封装起来,这些算法之间地位形同,可以相互替换。

对不同的输入采用不同的策略。有需求变更时,只需要增加或者修改内部的策略就可以了。很常用的场景是用来代替重复的 if…else if…else 代码

function showResult(level) {
  if(level === 'A') {
    return '10 颗星';
  } else if(level = 'B') {
    return '9 颗星';
  } else if(level = 'C') {
    return '8 颗星';
  } else if(level = 'D') {
    return '7 颗星';
  } else if(level = 'E') {
    return '6 颗星';
  } else {
    return ''
  }
}
console.log(showResult('A')); // 10 颗星

// 用策略模式改写上面的代码
function showResultWithStrategyPattern(level) {
  const resultMap = {
    A: '10 颗星',
    B: '9 颗星',
    C: '8 颗星',
    D: '7 颗星',
    E: '6 颗星',
    default: '',
  }
  return resultMap[level] || resultMap.default;
}
console.log(showResultWithStrategyPattern('A')); // 10 颗星

可见,使用策略模式的代码更加清晰易读,也更容易维护。

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

订阅-发布模式,发布者维护一份存有订阅者信息的列表,当满足某个触发条件时,就会通知列表里的所有订阅者。实现起来也很简单:

const event = {
  registerList: [],
  register: (key, fn) => {
    if(typeof fn !== 'function') {
      console.log('请添加函数');
      return;
    }
    if(!this.registerList[key]) {
      this.registerList[key] = [];
    }
    this.registerList.push(fn);
  },
  trigger(key, ...rest) {
    const funList = this.registerList[key];
    if(!(funList &&funList.length)) {
      return false;
    }
    funList.forEach(fn => {
      fn.apply(this.rest);
    });
  }
}

event.register('click', () => {console.log('我订阅了')});
event.register('click', () => {console.log('我也订阅了')});
event.trigger('click');
// output
// 我订阅了
// 我也订阅了

该模式在前端中非常常见,典型的应用比如 onClick 等事件绑定方法,vue 中的 watch 等。即使自己没有实现过,也肯定有所了解。

两点思考

讲设计模式的书和文章有很多,这也是个经久不衰的话题。本文仅仅是列举了几个最入门也是最基础的设计模式,想了解更多,请看文末的参考链接。想说的更多的还是自己的一点点思考

首先,为什么我们平时谈论的不多

日常工作中,同事们很少谈论设计模式的话题。起初我以为是这东西可能太基础,应该是每个人都会的而没必要讨论。后来随着接触的项目变多,发现其实在前端的业务代码中,的确就很少看到设计模式的实践(也可能是见得代码还少,受限于个人视野)。

后来总结出几点可能的原因:

1. 经典设计模式并不都适用于 JavaScript

GOF 与 1995 年提出设计模式,到今天已经20 多年了。当时,JavaScript 还只是个刚刚诞生的脚本语言,和主流编程语言并没有什么关系。它虽然也能实现面向对象,但并不是那么纯粹(连 class 都没有)。而设计模式的提出,要解决的问题也是面向对象语言面临的问题。套用到今天的 JavaScript 上,并不那么适用,总会有些“水土不服”。

2. 设计模式,也只是实现目的的一种方式而已

正所谓条条大路通罗马,也许设计模式代表的是多年实践证明的最佳实践,但并不是唯一实践。在浏览器性能越发强大的今天,差一些的设计并不会在业务表现上有什么巨大的差距。事实上,这种差距在很多时候都感知不出来。那么,用不用设计模式又有什么区别呢?

3. 框架和库替我们做了太多事

今天的前端,已经不是当年刀耕火种的时代了。三大框架、各种 UI 库,极大地提升了前端开发者们的生产效率。我们不需要去处理太过复杂的场景,不需要考虑太多交互的问题。很多时候,我们的开发不过是调调 api,绑定一下数据,或者处理一下数据而已。那用得到设计模式呢?

那么,我们还要学设计模式么

我的答案是肯定的,一定要学,而且要在实践中尽可能地多使用。

首先,使用设计模式能够显著地提升个人的代码质量和可读性。设计模式是经过时间检验的经典套路,甚至于是很多场景下的最佳实践。懂得设计模式的人,看其他人写的符合设计模式的代码也更容易。

其次,虽然业务代码中可能并不一定需要设计模式,但很多框架和库的实践中,大量地应用了设计模式。所以,学习设计模式可以让我们更容易阅读和理解这些框架和库的实现原理。

最后,设计模式是进阶高级职位的基本知识。当我们的视野不只局限于 JavaScript 的时候,我们能看到设计模式在软件工程中的大量应用,它是软件工程的基石之一。

总结

本文只是设计模式的一点入门引导和自己不成熟的一些思考。设计模式虽然不是初级开发的必备知识,但却是通往高级开发的阶梯之一。认真学习和实践设计模式,是每一个对自我有要求的工程师的必修课。

参考资料

  1. 快速理解-设计模式六大原则
  2. 详解 Javascript十大常用设计模式
  3. JavaScript 设计模式
  4. JavaScript设计模式与开发实践
  5. 大话设计模式