2021-10-30 封装代码常用的几种设计模式

1,801 阅读9分钟

为什么要封装代码

程序届有句古话:“写代码要有良好的封装,要高内聚、低耦合”。

封装好的代码有以下好处:

  1. 封装好的代码,内部变量不会污染到外部
  2. 可以作为一个模块供给外部使用,使用者不需要知道其具体细节,只要按照约定的规范使用即可
  3. 对外扩展开放,对内修改关闭,即开闭原则。外部不能修改模块,既保证了模块内部的正确性,又可以留出扩展接口,使用灵活

怎么去封装代码

JS生态极其丰富,有很多模块都封装的非常好,值得借鉴。比如最近几年持续火热的前端框架Vue和React,使用起来非常方面、简洁。如果我们去仔细阅读这些框架源码,会发现它们的封装都是有迹可循的,这些规律总结起来就是设计模式的使用,用于代码封装的设计模式主要有工厂模式创建者模式单例模式原型模式策略模式代理模式迭代器模式发布-订阅模式等等

几种常用的设计原则

单一职责原则(SRP)

就一个类而言,应该仅有一个引起它变化的原因,单一职责原则(SRP)的职责被定义为“引起变化的原因”。如果我们有两个动机去改写一个方法,那么这个方法就具有两个职责。每个职责都是变化的一个轴线,如果一个方法承担了过多的职责,那么在需求的变迁过程中,需要改写这个方法的可能性就会越大,修改代码总是一件危险的事情,特别是当两个职责耦合在一起的时候,一个职责发生变化可能会影响到其他职责的实现,造成意想不到的破坏,这种耦合性得到的是低内聚和脆弱的设计

SRP原则体现为:一个对象(方法)只做一件事情,在设计模式中例如代理模式、迭代器模式、单例模式和装饰者模式都有运用

最少知识原则(LKP)

最少知识原则说的是一个软件实体应当尽可能少地与其他实体发生相互作用。这里的软件实体是一个广义的概念,不仅包括对象,还包括系统、类、模块、函数和变量等

减少对象之间的联系,我们在设计程序时,应当尽量减少对象之间的交互。常见的做法是引入一个第三者对象,来承担这些对象之间的通信作用

在设计模式中体现最多的是中介者模式和外观模式

开放封闭原则(OCP)

软件实体(类、模块、函数)等应该是可以扩展的,但是不可以修改

开放-封闭原则的思想:当需要一个程序的功能或者给这个程序增加新功能的时候,可以使用增加代码的方式,但是不允许改动程序的源代码

用对象的多态性消除条件分支

设计模式中的体现为:发布订阅模式、模板方法模式、策略模式、代理模式、职责链模式

设计模式之工厂模式

工厂模式的名字就很直白,封装的模块就像一个工厂一样批量的产出需要的对象。

工厂模式的一个最显著的特征就是调用的时候不需要使用new操作符,而且传入的参数也很简单。但是调用的次数可能会比较频繁,经常需要产出不同的对象,其结构如下:

function factory(type) {
  switch (type) {
    case "type1":
      return new Type1();
    case "type2":
      return new Type2();
    case "type3":
      return new Type3();
    default:
      return new Type();
  }
}

上述代码就是根据参数的type的不同返回的不同的函数或对象

实例:弹窗组件

假如有如下需求:

我们项目需要一个弹窗,弹窗有几种:消息型弹窗、确认型弹窗、取消型弹窗,它们的颜色和内容可能是不一样的

可以设计代码如下:

// 创建三个消息类
function InfoPopup(content, color) {}
function ConfirmPopup(content, color) {}
function CancelPopup(content, color) {}

// 使用工厂模式
function Popup(type, content, color) {
  switch (type) {
    case "infoPopup":
      return new InfoPopup(content, color);
    case "confirmPopup":
      return new ConfirmPopup(content, color);
    case "cancelPopup":
      return new CancelPopup(content, color);
    default:
      return;
  }
}

// 直接调用Popup函数
let infoPopup = Popup("infoPopup", content, color);

改造成面向对象

上述代码虽然实现了工厂模式,但是switch始终感觉不是很优雅,使用面向对象改造成将其改为一个类,将不同类型的弹窗挂载在这个类上成为工厂方法

function Popup(type, content, color) {
  // 如果是通过new调用的,返回对应类型的弹窗
  if (this instanceof Popup) {
    return new this[type](content, color);
  } else {
    // 如果不是new调用的
    return new Popup(type, content, color);
  }
}

// 各种类型的弹窗全部挂载在原型上成为实例方法
Popup.prototype.InfoPopup = function (content, color) {};
Popup.prototype.ConfirmPopup = function (content, color) {};
Popup.prototype.CancelPopup = function (content, color) {};

封装成模块

可以用IIFE立即执行函数包装成模块,挂在在window

(function () {
  function Popup(type, content, color) {
    if (this instanceof Popup) {
      return new this[type](content, color);
    } else {
      return new Popup(type, content, color);
    }
  }

  Popup.prototype.InfoPopup = function (content, color) {};
  Popup.prototype.ConfirmPopup = function (content, color) {};
  Popup.prototype.CancelPopup = function (content, color) {};

  window.Popup = Popup;
})();

// 外面直接使用Popup模块
let infoPopup = Popup("infoPopup", content, color);

设计模式之创建者模式

创建者模式适用于比较复杂的大型对象的构建,如Vue,Vue内部包含一个功能强大,逻辑复杂的对象,在构建的时候需要传很多参数进去。像这种需要创建的情况不多,创建的对象本身又很复杂的时候就适用创建者模式,其结构一般如下:

function Model1() {} // 模块1
function Model2() {} // 模块2

// 外部使用的类
function Final() {
  this.model1 = new Model1();
  this.model2 = new Model2();
}

// 使用时
const obj = new Final();

上述代码中,最终供外部使用的是Final(),但是其里面的结构比较复杂,有很多个子模块,Final就是将这些子模块组合起来完成功能,这种需要精细化构造的就适用于创建者模式

实例:编辑器插件

例如这样一个需求:

写一个编辑器插件,初始化的时候需要配置大量参数,而且内部的功能很复杂,可以改变字体颜色和大小,也可以前进后退

分析需要哪些模块:

  1. 编辑器本身是一个类,是给外部调用的接口
  2. 需要一个控制参数初始化和页面渲染的类
  3. 需要一个控制字体的类
  4. 需要一个状态管理的类
// 编辑器本身,对外暴露
function Editor() {
  // 编辑器里面就是将各个模块组合起来实现功能
  this.initer = new HtmlInit();
  this.fontController = new FontController();
  this.stateController = new StateController(this.fontController);
}

// 初始化参数,渲染页面
function HtmlInit() {}

HtmlInit.prototype.initStyle = function () {}; // 初始化样式
HtmlInit.prototype.renderDom = function () {}; // 渲染DOM

// 字体控制器
function FontController() {}
FontController.prototype.changeFontColor = function () {}; // 改变字体颜色
FontController.prototype.changeFontSize = function () {}; // 改变字体大小

// 状态控制器
function StateController(fontController) {
  this.states = []; // 一个数组,存储所有状态
  this.currentState = 0; // 一个指针,指向当前状态
  this.fontController = fontController; // 字体控制器
}
StateController.prototype.saveState = function () {}; // 保存状态
StateController.prototype.backState = function () {}; // 后退状态
StateController.prototype.forwardState = function () {}; // 前进状态

上述代码其实就将一个编辑器插件的架子搭起来了,具体实现功能就是往这些方法里写入功能内容就行,其实就是各个模块之间的相互作用,比如实现后退状态的功能就可以写成如下:

StateController.prototype.backState = function () {
  const state = this.states[this.currentState - 1]; // 取出上一个状态
  this.fontController.changeFontColor(state.color); // 改变颜色
  this.fontController.changeFontSize(state.size); // 改变大小
};

设计模式之单例模式

单例模式:保证一个类仅有一个实例,并提供一个访问它的全局访问点

一个最简单的单例模式如下:

function Singleton() {}

Singleton.getInstance = function () {
  if (!this.instance) {
    this.instance = new Singleton();
  }
  return this.instance;
};

我们通过Singleton.getInstance来获取Singleton类的唯一对象,这种种方式相对简单,但有一个问题就是增加了这个类的“不透明性”,Singleton类的使用必须知道这是一个单例类,跟以往通过new XXX()的方式来获取对象不同,这里偏要使用Singleton.getInstance来获取对象

用代理模式实现单例模式:

var Singleton = function () {};

Singleton.prototype.run = function () {
  console.log(1);
};

var ProxySingleton = (function () {
  var instance;
  return function () {
    if (!instance) {
      instance = new Singleton();
    }
    return instance;
  };
})();

var a = ProxySingleton("a");
var b = ProxySingleton("b");

console.log(a === b); // true

通过引入代理类的方式,同样的完成了一个单例模式的编写,跟之前不同的是,现在把负责管理单例的逻辑移到了代理类ProxySingleton中。这样一来,Singleton就变成了普通类,符合单一职责原则,它跟ProxySingleton组合起来可以达到单例模式的效果

惰性单例:指的是在需要的时候才创建对象实例。惰性单例是单例模式的重点,这在实际开发中非常有用,通用的惰性单例代码如下:

var getSingle = function (fn) {
  var result;
  return function () {
    return result || (result = fn.apply(this, arguments));
  };
};

把管理单例的逻辑从原来的代码中抽离出来,这些逻辑封装在getSingle函数内部,创建对象的方法fn被当成参数动态的传入getSingle函数中

设计模式之原型模式

原型模式最具代表的就是JavaScript本身,JavaScript的原型链就是原型模式,其中可以使用Object.create()方法指定一个对象作为原型来创建对象

const obj = {
  x: 1,
  func: () => {}
};

// 以obj为原型创建一个新对象
const newObj = Object.create(obj);
console.log(newObj.__proto__ === obj); // true
console.log(newObj.x); // 1

上述代码将obj作为原型,然后用Object.create()创建的新对象都会拥有这个对象上的属性和方法,这其实就算是一种原型模式,当然还有用继承,如下:

function Parent() {
  this.parentAge = 50;
}

function Child() {}
Child.prototype = new Parent();
Child.prototype.constructor = Child; // 重置constructor

const obj = new Child();
console.log(obj.parentAge); // 50

这里的继承其实就是让子类Child.prototype.__proto__指向父类的prototype,从而获取父类的方法和属性

总结

  1. 很多很好的开源库都是有着良好的封装,封装可以将内部环境和外部环境隔离,外部用起来更顺手
  2. 针对不同的场景可以使用不同的封装方案
  3. 需要大量产生类似实例的组件可以考虑用工厂模式来封装
  4. 内部逻辑复杂,外部使用需要的实例不多,可以考虑用创建者模式来封装
  5. 全局只能有一个实例的使用单例模式
  6. 新老对象之间可能有继承关系的可以使用原型模式
  7. 使用设计模式时不要生搬硬套,更重要的是掌握思想,同一个模式在不同的场景下可以有不同的实现方案