JS设计模式与实践

165 阅读15分钟

单例模式

首先请思考:如何实现一个构造函数F,使得new F() === new F()成立?可以说从代码角度来讲,单例模式就是要实现这个。

// 单例模式基本思想:
function F() {
  this.foo = 'bar';
}
F.prototype.instance = null;
F.getInstance = function () {
  if (!this.instance) this.instance = new F();
  return this.instance;
};
const a = F.getInstance();
const b = F.getInstance();
console.log(a === b); // true

虽然又简单又好理解,但也很明显,这个构造函数不“透明”,即开发者必须要知道这是个单例构造函数,不应该用new来获取实例,且必须要知道用来获取实例的函数叫getInstance。 进一步:

let instance = null;
function F(params) {
  if (instance) return instance;
  this.foo = 'bar';
  instance = this;
  return instance; // 这里不return也可以
}
const a = new F();
const b = new F();
console.log(a === b); // true

可以发现,原理都是将第一次调用new得到的实例存储起来,之后的new操作都是直接返回那个存储起来的实例,再优化一点,将实例变量放在自身的原型对象上:

function F() {
  if (this.__proto__.instance) return this.__proto__.instance;
  this.foo = 'bar';
  this.__proto__.instance = this;
}
var a = new F();
var b = new F();
console.log(a === b); // true

或者,使用闭包+自调用函数:

const F = (function () {
  let instance = null;
  return function (params) {
    if (instance) return instance;
    this.foo = 'bar';
    instance = this;
    return instance; // 这里不return也可以
  };
})();
const a = new F();
const b = new F();
console.log(a === b); // true

至此,就已经实现了一个符合new F() === new F()成立的构造函数F。

继续思考,假如F是一个已经存在了的普通构造函数,我们在另一个场景下要实现F的单例模式,那么我们肯定不能修改F,而要通过某种方式将F进行“代理”(代理模式后面再详细介绍):

function F() {
  this.foo = 'bar';
}
const SingleF = (function () {
  let instance = null;
  return function () {
    if (!instance) instance = new F();
    return instance;
  };
})();
const a = new SingleF(); // SingleF实际上已经是普通函数,所以这里不用new也一样
const b = new SingleF(); // SingleF实际上已经是普通函数,所以这里不用new也一样
console.log(a === b); // true

上面这些单例模式的实现,更多的是接近传统面向对象语言的实现,即对象必须由类创建而来,例如Java。但js是无类语言(虽然es6提出了class,但其底层仍是原型链),我们创建对象更多的是使用对象字面量的方式,很少真的在创建对象前先创建一个构造函数。所以更多的,我们是借助单例模式的思想,解决(或优化)一些前端问题,例如使网站的登录弹窗只被创建一次:

const getSingle = function (fn) {
  // 通用的创建单例函数
  let res = null;
  return function () {
    return res || (res = fn.apply(this, arguments));
  };
};
const createLogin = getSingle(function () {
  // 创建登录弹窗
  const div = document.createElement('div');
  div.className = 'login';
  div.innerHTML = 'this is login';
  document.body.appendChild(div);
  return div;
});
// 只会创建一次div
createLogin();
createLogin();
createLogin();

策略模式

人工智能专家Peter Norvig说:“在函数作为一等对象的语言中,策略模式是隐形的。策略就是值为函数的变量。 ” 确实,若不考虑像Java那样基于类的实现,js的策略模式异常简单:

// 以计算年终奖为例
const strategies = {
  S: salary => salary * 4,
  A: salary => salary * 3,
  B: salary => salary * 2,
};
const calculateBonus = (level, salary) => strategies[level](salary);
console.log(calculateBonus('S', 20000));
console.log(calculateBonus('A', 10000));

代理模式

简单来讲,代理模式就是为对象A创建一个模拟其各项功能的对象B,将原本对A的调用全部换作对B的调用,从而达到对对象A进行保护、缓存、优化的目的。以图片预加载为例:

const myImage = (function () {
    const imgNode = document.createElement('img');
    document.body.appendChild(imgNode);
    return {
      setSrc(src) {
        imgNode.src = src;
      },
    };
})();
myImage.setSrc('远程图片A路径');

假如网络环境不好,或图片很大,则能很明显的看到一段空白时间(可以以故意调低网速,或者像我一样直接将后端改为几秒后再返回的方式来测试)。

其实优化方法也很简单,无非是先加载一个本地图片B,等获取到图片A后再加载图片A,可以改成下面这样:

const myImage = (function () {
    const imgNode = document.createElement('img');
    document.body.appendChild(imgNode);
    const img = new Image();
    img.onload = function () {
      imgNode.src = this.src;
    };
    return {
      setSrc(src) {
        imgNode.src ='本地图片B路径';
        img.src = src;
      },
    };
})();

尽管能实现功能,但很明显,这样的改动违反了“单一职责”原则,myImage既要负责真实图片的渲染,也要负责预加载图片。且假如要获取一个能很快获取的远程图片C,很明显现在这样“闪一下”的用户体验并不好(有没有想到加一个标志位,依据其决定是否启用预加载图片?当然可行,但myImage的职责是不是更多了?)。

所以,最佳的解决方法,是创建一个代理对象,在代理对象中实现预加载图片的功能:

// 恢复原本的myImage
const myImage = (function () {
    const imgNode = document.createElement('img');
    document.body.appendChild(imgNode);
    return {
      setSrc(src) {
        imgNode.src = src;
      },
    };
})();
const proxyImage = (function () {
    const img = new Image();
    img.onload = function () {
      myImage.setSrc(this.src);
    };
    return {
      setSrc(src) {
        myImage.setSrc('本地图片B路径');
        img.src = src;
      },
    };
})();
proxyImage.setSrc('远程图片A路径'); // 改为调用proxyImage的setSrc

像这样,通过添加代理,扩展了“加载图片”这一功能。加载真实图片和预加载图片这两个功能被隔离在了两个模块中,互不影响对方,且若不需要预加载功能,继续调用原本的myImage.setSrc就好。不仅符合“单一职责”原则,也符合“对扩展开放,对修改关闭”。

迭代器模式

迭代器模式是一种相对简单的设计模式,简单到甚至不被认为是一种设计模式。当前大部分编程语言都已内置了迭代器。

需要记住的是,并非只有数组可以被迭代,只要被迭代的对象拥有length且可以通过下标访问,它就可以被迭代,例如arguments、{0: 'foo', 1: 'bar', length: 2}等。

这里实现一个最经典的版本(用法有点类似于es6的Generator函数):

const Iterator = function (obj) {
    let current = 0;
    const isDone = () => {
      return current >= obj.length;
    };
    const next = () => {
      current++;
    };
    const getCurrentItem = () => {
      return obj[current];
    };
    return {
      isDone,
      next,
      getCurrentItem
    };
};
const i1 = Iterator([0, 1, 2, 3, 4]);
while (!i1.isDone()) {
    console.log(i1.getCurrentItem());
    i1.next();
}
const i2 = Iterator({ 0: 'foo', 1: 'bar', length: 2 });
while (!i2.isDone()) {
    console.log(i2.getCurrentItem());
    i2.next();
}

发布-订阅模式(观察者模式)

可以说,发布-订阅模式是前端开发最常用的设计模式,以至于开发者根本意识不到使用了该设计模式。实际上,每次在dom节点上绑定事件,就使用了发布-订阅模式。

稍微复杂一点的地方在于,如何实现“接收离线消息”,即发布消息时,若还没有对象订阅,需要先将消息保存下来,等到有对象订阅时,再将消息发布给订阅者。从代码角度来讲,难点在于如何保存一个未来才会注册的函数。

这里给出一个简单实现:

const Event = (function () {
    const clientLists = {},
      offlineStacks = {}, // 存放发送离线消息的函数
      caches = {}; // 存放接收离线消息的函数
    const _listen = function (key, fn) {
      // 注册接收离线消息的函数至cache
      caches[key] = caches[key] || [];
      caches[key].push(fn);
    };
    const _trigger = function (key, res) {
      for (const cache of caches[key]) cache.call(this, res);
    };
    const listen = function (key, fn) {
      _listen(key, fn);
      clientLists[key] = clientLists[key] || [];
      clientLists[key].push(fn);
      for (const offline of (offlineStacks[key] || [])) offline();
      offlineStacks[key] = []; // 离线消息发送结束后要清空
    };
    const trigger = function (key, res) {
      const self = this;
      const args = arguments;
      offlineStacks[key] = offlineStacks[key] || [];
      offlineStacks[key].push(function () {
        _trigger.apply(self, args);
      });
      for (const client of (clientLists[key] || [])) client.call(this, res);
    };
    return {
      listen,
      trigger
    };
})();
Event.trigger('click', 12345);
Event.trigger('click', 67890);
setTimeout(() => {
    Event.listen('click', function (res) {
      console.log('res:', res);
    });
}, 1000);

命令模式

与策略模式类似,js中的命令模式也是“隐形”的,可以用高阶函数非常方便的实现。在传统的面向对象设计中,命令模式必须保存command对象,同时约定执行命令的操作是调用command.execute,如下:

const setCommand = function (button, command) {
    button.onclick = function () {
      command.execute();
    };
};
const MenuBar = {
    refresh: function () {
      console.log('刷新菜单目录');
    },
};
class RefreshMenuBarCommand {
    constructor(receiver) {
      this.receiver = receiver;
    }
    execute() {
      this.receiver.refresh();
    }
}
setCommand(myBtn, new RefreshMenuBarCommand(MenuBar));

而在js中,函数是一等对象,本身就可被传递,所以上面代码可以简化为:

const setCommand = function (button, fun) {
    button.onclick = fun;
};
const MenuBar = {
    refresh: function () {
      console.log('刷新菜单目录');
    }
};
setCommand(myBtn, MenuBar.refresh);

关于撤销,基本原理并不复杂,即在执行命令时,将执行命令之前的状态保存进栈中,然后在需要撤销时执行undo命令来遍历。需要注意的是,并非所有场景都可以像这样通过undo来撤销,这时候最好的办法是在执行execute时将 execute本身保存进队列中,在需要撤销时先将对象恢复为最初始的状态,再遍历队列,将执行过的命令再全部执行一遍(与其称作撤销,称作重做更合适)。这也是逆转不可逆命令的一个好办法。

组合模式

在命令模式中,可将一组命令整合进一个集合,这个集合被称为宏命令,可通过执行这个宏命令,来一次性执行一组命令,这就是组合模式最简单的体现,如下:

const closeDoorCommand = {
    execute: function () {
      console.log('关门');
    },
};
const openPcCommand = {
    execute: function () {
      console.log('开电脑');
    },
};
const openQQCommand = {
    execute: function () {
      console.log('登录 QQ');
    },
};
const MacroCommand = function () {
    return {
      commandsList: [],
      add: function (command) {
        this.commandsList.push(command);
      },
      execute: function () {
        for (let i = 0, command; (command = this.commandsList[i++]); )
          command.execute();
      },
    };
};
var macroCommand = MacroCommand();
macroCommand.add(closeDoorCommand);
macroCommand.add(openPcCommand);
macroCommand.add(openQQCommand);
macroCommand.execute();

不难想象,上面的代码组成了一个树形结构,一个根节点下面包含3个子节点。组合模式最大的好处在于,子节点也可以包含子节点,即一组命令中的每个命令也可以是包含另一组命令的命令集合,只要它包含execute方法,如下:

const closeDoorCommand = {
    execute: function () {
      console.log('关门');
    },
};
const openPcCommand = {
    execute: function () {
      console.log('开电脑');
    },
};
const openQQCommand = {
    execute: function () {
      console.log('登录 QQ');
    },
};
const MacroCommand = function () {
    return {
      commandsList: [],
      add: function (command) {
        this.commandsList.push(command);
      },
      execute: function () {
        for (let i = 0, command; (command = this.commandsList[i++]); )
          command.execute();
      },
    };
};
// ------------增加这些----------------
const openTvCommand = {
    execute: function () {
      console.log('打开电视');
    },
};
const openSoundCommand = {
    execute: function () {
      console.log('打开音响');
    },
};
const macroCommand1 = MacroCommand();
macroCommand1.add(openTvCommand);
macroCommand1.add(openSoundCommand);
// -------------------------------
var macroCommand = MacroCommand();
macroCommand.add(closeDoorCommand);
macroCommand.add(macroCommand1); // 增加这里
macroCommand.add(openPcCommand);
macroCommand.add(openQQCommand);
macroCommand.execute();

组合模式可以使开发人员忽略组合对象和叶对象的区别,将二者一致对待(从而不必再用条件分支来分别处理),它们各自会做各自的事情。当然,这并不是在所有场景下都是优点,且若通过组合模式创建了过多的对象,系统能否负担也是一个要考虑的问题。

模板方法模式

模板方法模式是一种基于继承的设计模式。引入《Head First设计模式》中经典的咖啡与茶的例子,用Java来实现模板方法模式,大致如下:

public abstract class Beverage { // 饮料抽象类
    final void init(){ // 模板方法
        boilWater();
        brew();
        pourInCup();
        addCondiments();
    }
    void boilWater(){ // 具体方法 boilWater
        System.out.println( "把水煮沸" );
    }
    abstract void brew(); // 抽象方法 brew
    abstract void addCondiments(); // 抽象方法 addCondiments
    abstract void pourInCup(); // 抽象方法 pourInCup
}
// Coffee 类
public class Coffee extends Beverage{ 
    @Override
    void brew() { // 子类中重写 brew 方法
        System.out.println( "用沸水冲泡咖啡" );
    }
    @Override
    void pourInCup(){ // 子类中重写 pourInCup 方法
        System.out.println( "把咖啡倒进杯子" );
    }
    @Override
    void addCondiments() { // 子类中重写 addCondiments 方法
        System.out.println( "加糖和牛奶" );
    }
}
// Tea 类
public class Tea extends Beverage{ 
    @Override
    void brew() { // 子类中重写 brew 方法
        System.out.println( "用沸水浸泡茶叶" );
    }
    @Override
    void pourInCup(){ // 子类中重写 pourInCup 方法
        System.out.println( "把茶倒进杯子" );
    }
    @Override
    void addCondiments() { // 子类中重写 addCondiments 方法
        System.out.println( "加柠檬" );
    }
}

虽然js中没有提供对类的支持(es6中增加的class,其底层仍是原型链),但借助原型链,模拟上面的流程也并非难事。区别在于,在Java中编译器会保证子类一定会重写父类中的抽象方法(若未重写编译会不通过),但js却对这样的情况无能为力。一种好一点的解决方法,是给“父类”的“抽象方法”提供一个抛出错误的具体实现(虽然相比于Java,得到错误信息的时间点已经太靠后了):

const Beverage = function () {};
Object.assign(Beverage.prototype, {
    boilWater() {
      console.log('把水煮沸');
    },
    brew() {
      throw new Error('必须实现 brew 方法');
    },
    pourInCup() {
      throw new Error('必须实现 pourInCup 方法');
    },
    addCondiments() {
      throw new Error('必须实现 addCondiments 方法');
    },
    init() {
      this.boilWater();
      this.brew();
      this.pourInCup();
      this.addCondiments();
    },
});
const Coffee = function () {};
Coffee.prototype = new Beverage();
Object.assign(Coffee.prototype, {
    brew() {
      console.log('用沸水冲泡咖啡');
    },
    pourInCup() {
      console.log('把咖啡倒进杯子');
    },
    addCondiments() {
      console.log('加糖和牛奶');
    },
});
const Tea = function () {};
Tea.prototype = new Beverage();
Object.assign(Tea.prototype, {
    brew() {
      console.log('用沸水浸泡茶叶');
    },
    pourInCup() {
      console.log('把茶倒进杯子');
    },
    addCondiments() {
      console.log('加柠檬');
    },
});
var coffee = new Coffee();
coffee.init();
var tea = new Tea();
tea.init();

简单概括,模板方法模式就是“(抽象)父类负责封装不变,子类负责封装变化”。通过增加新的子类,便可增加新的功能,无需更改父类或其他子类。

职责链模式

一图胜千言:

image.png

一个请求先传送给A,若A不能处理则传送给B,B不能处理再传递给C……作用域链、原型链、事件冒泡,都可以看到职责链模式的影子。

这里用一个判断数字范围程序来举例:

const f1 = function (num) {
    if (num >= 1000) {
      console.log('这个数不小于1000');
    } else return false;
};
const f2 = function (num) {
    if (num >= 100) {
      console.log('这个数不小于100');
    } else return false;
};
const f3 = function (num) {
    if (num >= 10) {
      console.log('这个数不小于10');
    } else return false;
};
const f4 = function (num) {
    console.log('这个数是' + num);
};
const chainfn = (function (arr) {
    return function (num) {
      for (let i = 0; i < arr.length; i++) {
        const fn = arr[i];
        if (fn(num) !== false) break;
      }
    };
})([f1, f2, f3, f4]); // 若需要扩充,给数组中增加函数即可
chainfn(1000);
chainfn(100);
chainfn(10);
chainfn(1);

还可以采用代理模式,使职责链的创建方式更加方便、优雅:

Function.prototype.setNext = function (fn) {
    const self = this;
    return function () {
      const res = self.apply(this, arguments);
      if (res === false) {
        return fn && fn.apply(this, arguments);
      }
      return true;
    };
};
const f = f1.setNext(f2).setNext(f3).setNext(f4);
f(1000);
f(100);
f(10);
f(1);

需要注意的是,因为一般情况下,请求只会被一个节点处理,所以大部分节点并没有起到实质作用,只是为了让请求传递下去(甚至请求根本没有传递到就已经被前面的节点处理了)。因此从性能方面考虑,应该避免过长的职责链。

装饰者模式

在传统的面向对象的语言中,装饰者模式是指在不必修改原始类的情况下,动态地给对象添加功能,如下:

class Plane {
    fire() {
      console.log('发射子弹');
    }
}
class MissileDecorator {
    constructor(plane) {
      this.plane = plane;
    }
    fire() {
      this.plane.fire();
      console.log('发射导弹');
    }
}
let plane = new Plane();
plane = new MissileDecorator(plane);
plane.fire();

很明显,在js中根本不需要这样做,动态修改对象的属性再简单不过了,不需要再创建一个MissileDecorator,直接修改plane.fire函数就好。虽然这样做改变了对象原本的方法,并不完全符合装饰者模式的定义,但js的特色正是如此:

let plane = new Plane();
plane.fire = function () {
    console.log('发射子弹');
    console.log('发射导弹');
};
plane.fire();

所以在js中,装饰者模式更多的是指装饰函数(本来函数在js中就是一等对象),即在不改变函数源代码的情况下扩充函数的功能。

不修改函数源代码扩充函数的功能,一般情况下会这么做:

let a = function () {
    console.log(1);
};
const _a = a;
a = function (params) {
    _a();
    console.log(2);
};
a();

但这样做显然不够优雅,更加完美的方法是,用AOP(面向切面编程)来装饰函数:

Function.prototype.before = function (beforeFn) {
    const self = this;
    return function () {
      beforeFn.apply(this, arguments);
      return self.apply(this, arguments);
    };
};
Function.prototype.after = function (afterFn) {
    const self = this;
    return function () {
      const res = self.apply(this, arguments);
      afterFn.apply(this, arguments);
      return res;
    };
};
// 若不喜欢这种污染原型的方式,也可以这样:
function before(fn, beforeFn) {
    return function () {
      beforeFn.apply(this, arguments);
      return fn.apply(this, arguments);
    };
}
function after(fn, afterFn) {
    return function () {
      const res = fn.apply(this, arguments);
      afterFn.apply(this, arguments);
      return res;
    };
}

这样,扩充函数功能就可以:

let a = function () {
    console.log(1);
};
a = a.after(function (params) {
    console.log(2);
});
a();

日常的开发中,在C端项目收尾阶段做埋点时,往往将埋点代码和业务代码混在一起,从而被迫改动原本已可以正常运行的业务代码。此时用AOP来实现,无疑会方便许多:

const a = function() {
    // 业务代码
}
a = a.after(function() {
    // 埋点代码
})
a();

另一个使用场景是表单验证,与其将表单验证和ajax请求放在同一函数中,更好的方式是将表单验证作为ajax请求的“before”,在表单验证通过后,再进行ajax请求(当然,此时要改写一下before函数,依据beforeFn的执行结果来决定是否继续执行原函数)。

状态模式

首先来看一个很经典的开关电灯的例子:

class Light {
    constructor() {
      this.state = 'off';
    }
    turnSwitch() {
      switch (this.state) {
        case 'off':
          this.state = 'weaklight';
          console.warn('打开弱光');
          break;
        case 'weaklight':
          this.state = 'stronglight';
          console.warn('打开强光');
          break;
        case 'stronglight':
          this.state = 'off';
          console.warn('关灯');
        default:
          break;
      }
    }
}
const light = new Light();
light.turnSwitch();
light.turnSwitch();
light.turnSwitch();

可以发现,每次执行的都是turnSwitch方法,但每次执行的结果都不同,这是因为内部维护了一个状态变量state,turnSwitch函数内部依据state的值来进行不同的操作。虽然没有异常,但缺点也很明显:

  1. 违反了开放-关闭原则,一旦state多一种状态,都要向turnSwitch中增加一种对应的处理,那么未来无法预计turnSwitch会被增大到什么程度(此处举例仅仅是打印一条日志而已,实际项目中当然不会这么简单);
  2. 状态的种类、各状态之间的关系都混在turnSwitch中,十分不明显,无法一目了然的看清全貌,在开发过程中很容易漏掉或错误的进行一些操作。

为了解决这样的问题,状态模式应运而生,其关键就是将每种状态都不再是一条简单的数据(字符串、数字),而是一个封装后的类(对象),跟该状态有关的行为也都封装在这个类(对象)内部,将改变状态的请求都委托给这个状态类(对象)。按照这个模式修改上面的开关电灯,如下:

class Light {
    constructor() {
      const states = {
        off: {
          change() {
            this.state = states.weaklight;
            console.warn('打开弱光');
          }
        },
        weaklight: {
          change() {
            this.state = states.stronglight;
            console.warn('打开强光');
          }
        },
        stronglight: {
          change() {
            this.state = states.off;
            console.warn('关灯');
          }
        }
      };
      this.state = states.off;
    }
    turnSwitch() {
      this.state.change.call(this);
    }
}
const light = new Light();
light.turnSwitch();
light.turnSwitch();
light.turnSwitch();

可以看到,各状态都变成了一个对象,且都有一个改变自身状态的方法,turnSwitch只要调用这个方法就好,即便未来有新状态出现也无需修改。