该系列参考自 《JavaScript 设计模式》

以及 汤姆大叔的博文 深入理解 JavaScript 系列

前言

  • 外观模式
  • 适配器模式
  • 代理模式
  • 装饰者模式
  • 桥接模式
  • 组合模式
  • 享元模式

一. 外观模式

为一组复杂子系统接口提供一个更高级的统一接口,通过这个接口使得对子系统访问更加的容易。

// 使用外观模式注册事件监听
function addEvent(dom, type, fn) {
  if (dom.addEventListener) {
    dom.addEventListener(type, fn, false);
  } else if (dom.attachEvent) {
    dom.attachEvent('on' + type, fn);
  } else {
    dom['on' + type] = fn;
  }
}
// 使用外观模式获取事件对象

var getEvent = function(event) {
  return event || window.event;
};

通过对接口的二次封装,使其简单易用,隐藏起内部的复杂度,外观模式就是对接口的外层包装,以供上层代码调用。因此外观模式封装的接口方法不需要接口的具体实现,只需要按照接口的使用规则使用即可。

二. 适配器模式

将一个类的接口转换为另外一个类的接口以满足用户的需求,使类之间的接口不兼容问题通过适配器得以解决。

我们来举一个例子,鸭子(Dock)有飞(fly)和嘎嘎叫(quack)的行为,而火鸡虽然也有飞(fly)的行为,但是其叫声是咯咯的(gobble)。如果你非要火鸡也要实现嘎嘎叫(quack)这个动作,那我们可以复用鸭子的 quack 方法,但是具体的叫还应该是咯咯的,此时,我们就可以创建一个火鸡的适配器,以便让火鸡也支持 quack 方法,其内部还是要调用 gobble。

首先要先定义鸭子和火鸡的抽象行为,也就是各自的方法函数:

//鸭子
var Duck = function() {};
Duck.prototype.fly = function() {
  throw new Error('该方法必须被重写!');
};
Duck.prototype.quack = function() {
  throw new Error('该方法必须被重写!');
};

//火鸡
var Turkey = function() {};
Turkey.prototype.fly = function() {
  throw new Error(' 该方法必须被重写 !');
};
Turkey.prototype.gobble = function() {
  throw new Error(' 该方法必须被重写 !');
};

//鸭子
var MallardDuck = function() {
  Duck.apply(this);
};
MallardDuck.prototype = new Duck(); //原型是Duck
MallardDuck.prototype.fly = function() {
  console.log('可以飞翔很长的距离!');
};
MallardDuck.prototype.quack = function() {
  console.log('嘎嘎!嘎嘎!');
};

//火鸡
var WildTurkey = function() {
  Turkey.apply(this);
};
WildTurkey.prototype = new Turkey(); //原型是Turkey
WildTurkey.prototype.fly = function() {
  console.log('飞翔的距离貌似有点短!');
};
WildTurkey.prototype.gobble = function() {
  console.log('咯咯!咯咯!');
};

为了让火鸡也支持 quack 方法,我们创建了一个新的火鸡适配器TurkeyAdapter

var TurkeyAdapter = function(oTurkey) {
  Duck.apply(this);
  this.oTurkey = oTurkey;
};
TurkeyAdapter.prototype = new Duck();
TurkeyAdapter.prototype.quack = function() {
  this.oTurkey.gobble();
};
TurkeyAdapter.prototype.fly = function() {
  var nFly = 0;
  var nLenFly = 5;
  for (; nFly < nLenFly; ) {
    this.oTurkey.fly();
    nFly = nFly + 1;
  }
};

该构造函数接受一个火鸡的实例对象,然后使用 Duck 进行 apply,其适配器原型是 Duck,然后要重新修改其原型的 quack 方法,以便内部调用 oTurkey.gobble()方法。其 fly 方法也做了一些改变,让火鸡连续飞 5 次(内部也是调用自身的 oTurkey.fly()方法)。

var oMallardDuck = new MallardDuck();
var oWildTurkey = new WildTurkey();
var oTurkeyAdapter = new TurkeyAdapter(oWildTurkey);

//原有的鸭子行为
oMallardDuck.fly();
oMallardDuck.quack();

//原有的火鸡行为
oWildTurkey.fly();
oWildTurkey.gobble();

//适配器火鸡的行为(火鸡调用鸭子的方法名称)
oTurkeyAdapter.fly();
oTurkeyAdapter.quack();

三. 代理模式

由于一个对象不能直接引用另一个对象,所以需要代理对象在这两个对象之间起到中介的作用

// 先声明美女对象
var girl = function(name) {
  this.name = name;
};

// 这是dudu
var dudu = function(girl) {
  this.girl = girl;
  this.sendGift = function(gift) {
    alert('Hi ' + girl.name + ', dudu送你一个礼物:' + gift);
  };
};

// 大叔是代理
var proxyTom = function(girl) {
  this.girl = girl;
  this.sendGift = function(gift) {
    new dudu(girl).sendGift(gift); // 替dudu送花咯
  };
};

var proxy = new proxyTom(new girl('酸奶小妹'));
proxy.sendGift('999朵玫瑰');

假如 dudu 要送酸奶小妹玫瑰花,却不知道她的联系方式或者不好意思,想委托大叔去送这些玫瑰,那大叔就是个代理

其实在日常开发中,我们遇到很多这种情况,比如跨域,之前总结过跨域的所有东西,其中的 jsonp,window.name 还是 location.hash 都是通过代理模式来实现的。

四. 装饰者模式

在不改变源对象的基础上,通过对其进行包装拓展使原有对象可以满足用户的更复杂需求

这里拿给输入框添加事件举例

var decorator = function(input, fn) {
  //获取时间源
  var input = document.getElementById(input);
  if (typeof input.onclick === 'function') {
    //缓存事件源原有的回调函数
    var oldClickFn = input.onclick;
    input.onclick = function(ev) {
      oldClickFn();
      fn();
    };
  } else {
    input.onclick = fn;
  }
};

装饰着模式很简单,就是对原有对象的属性和方法的添加。相比于之前说的适配器模式是对原有对象的适配,添加的方法和原有的方法功能上大致相似。但是装饰着提供的方法和原有方法功能项则有一定的区别,且不需要去了解原有对象的功能。只要原封不动的去使用就行。不需要知道具体的实现细节。

五. 桥接模式

在系统沿着多个维度变化的时候,不增加起复杂度已达到解耦的目的

场景

在我们日常开发中,需要对相同的逻辑做抽象的处理。桥接模式就是为了解决这类的需求。

桥接模式最主要的特点就是将实现层和抽象层解耦分离,是两部分可以独立变化

比如我们写一个跑步游戏,对于游戏中的人和精灵都是动作单元。而他们的动作也是非常的统一。比如人和精灵和球运动都是 x,y 坐标的改变,球的颜色和精灵的颜色绘制方式也非常的类似。 我们就可以将这些方法给抽象出来。

//运动单元
function Speed(x, y) {
  this.x = x;
  this.y = y;
}
Speed.prototype.run = function() {
  console.log('动起来');
};
// 着色单元
function Color(cl) {
  this.color = cl;
}
Color.prototype.draw = function() {
  console.log('绘制色彩');
};

// 变形单元
function Shape(ap) {
  this.shape = ap;
}
Shape.prototype.change = function() {
  console.log('改变形状');
};
//说话单元
function Speak(wd) {
  this.word = wd;
}
Speak.prototype.say = function() {
  console.log('请开始你的表演');
};

//创建球类,并且它可以运动可以着色
function Ball(x, y, c) {
  this.speed = new Speed(x, y);
  this.color = new Color(c);
}
Ball.prototype.init = function() {
  //实现运动和着色
  this.speed.run();
  this.color.draw();
};

function People(x, y, f) {
  this.speed = new Speed(x, y);
  this.speak = new Speak(f);
}

People.prototype.init = function() {
  this.speed.run();
  this.speak.say();
};
//...

//当我们实例化一个人物对象的时候,他就可以有对应的方法实现了

var p = new People(10, 12, '我是一个人');
p.init();

六. 组合模式

又称部分-整体模式,将对象组合成树形结构以表示成“部分整体”的层次结构。组合模式使得用户对单个对象以及组合对象的使用具有一致性

场景

我们平时开发过程中,一定会遇到这种情况:同时处理简单对象和由简单对象组成的复杂对象,这些简单对象和复杂对象会组合成树形结构,在客户端对其处理的时候要保持一致性。比如电商网站中的产品订单,每一张产品订单可能有多个子订单组合,比如操作系统的文件夹,每个文件夹有多个子文件夹或文件,我们作为用户对其进行复制,删除等操作时,不管是文件夹还是文件,对我们操作者来说是一样的。在这种场景下,就非常适合使用组合模式来实现。

组合模式主要有三个角色:

  1. 抽象组件(Component):抽象类,主要定义了参与组合的对象的公共接口
  2. 子对象(Leaf):组成组合对象的最基本对象
  3. 组合对象(Composite):由子对象组合起来的复杂对象

理解组合模式的关键是要理解组合模式对单个对象和组合对象使用的一致性,我们接下来说说组合模式的实现加深理解。

// 抽象一个虚拟父类
var News = function() {
  this.children = [];
  this.element = null;
};

News.prototype = {
  init: function() {
    throw new Error('请重写你的方法');
  },
  add: function() {
    throw new Error('请重写你的方法');
  },
  getElement: function() {
    throw new Error('请重写你的方法');
  }
};

function iniheritObject(o) {
  function F() {}
  F.prototype = o;
  return new F();
}

function inheritPrototype(subClass, superClass) {
  var p = iniheritObject(superClass.prototype);
  p.constructor = subClass;
  subClass.prototype = p;
}
//容器类
var Container = function(id, parent) {
  News.call(this);
  this.id = id;
  this.parent = parent;
  this.init();
};

//寄生式继承父类原型方法
inheritPrototype(Container, News);

Container.prototype.init = function() {
  this.element = document.createElement('ul');
  this.element.id = this.id;
  this.element.className = 'new-container';
};

Container.prototype.add = function(child) {
  this.children.push(child);
  this.element.appendChild(child.getElement());
  return this;
};

Container.prototype.getElement = function() {
  return this.element;
};

Container.prototype.show = function() {
  this.parent.appendChild(this.element);
};
//同样下一层极的行成员集合类以及后面新闻组合体类
var Item = function(classname) {
  News.call(this);
  this.classname = classname;
  this.init();
};
inheritPrototype(Item, News);
Item.prototype.init = function() {
  this.element = document.createElement('li');
  this.element.className = this.classname;
};
Item.prototype.add = function(child) {
  this.children.push(child);
  this.element.appendChild(child.getElement());
  return this;
};
Item.prototype.getElement = function() {
  return this.element;
};

var NewsGroup = function(className) {
  News.call(this);
  this.classname = classname || '';
  this.init();
};
inheritPrototype(NewsGroup, News);
NewsGroup.prototype.init = function() {
  this.element = document.createElement('div');
  this.element.className = this.classname;
};
NewsGroup.prototype.add = function(child) {
  this.children.push(child);
  this.element.appendChild(child.getElement());
  return this;
};
NewsGroup.prototype.getElement = function() {
  return this.element;
};

所以后面我们在使用的时候,创建新闻类,利用之前定义的组合元素去组合就可以了。

七. 享元模式

运用共享技术有效的支持大量细粒度对象,避免对象之间拥有相同内容造成的不必要开销

主要用来优化程序的性能,适合解决大量类似的对象产生的性能问题。享元模式通过分析应用程序的对象,将其解析为内在数据和外在数据,减少对象数量,从而提高程序的性能。

基础知识

享元模式通过共享大量的细粒度的对象,减少对象的数量,从而减少对象的内存,提高应用程序的性能。其基本思想就是分解现有类似对象的组成,将其展开为可以共享的内在数据和不可共享的外在数据,我们称内在数据的对象为享元对象。通常还需要一个工厂类来维护内在数据。

在 JS 中,享元模式主要有下面几个角色组成:

  • 客户端:用来调用享元工厂来获取内在数据的类,通常是应用程序所需的对象
  • 享元工厂:用来维护享元数据的类
  • 享元类:保持内在数据的类

我们举个例子进行说明:苹果公司批量生产 iphone,iphone 的大部分数据比如型号,屏幕都是一样,少数部分数据比如内存有分 16G,32G 等。未使用享元模式前,我们写代码如下:

function Iphone(model, screen, memory, SN) {
  this.model = model;
  this.screen = screen;
  this.memory = memory;
  this.SN = SN;
}
var phones = [];
for (var i = 0; i < 1000000; i++) {
  var memory = i % 2 == 0 ? 16 : 32;
  phones.push(new Iphone('iphone6s', 5.0, memory, i));
}

这段代码中,创建了一百万个 iphone,每个 iphone 都独立申请一个内存。但是我们仔细观察可以看到,大部分 iphone 都是类似的,只是内存和序列号不一样,如果是一个对性能要求比较高的程序,我们就要考虑去优化它。 大量相似对象的程序,我们就可以考虑用享元模式去优化它,我们分析出大部分的 iphone 的型号,屏幕,内存都是一样的,那这部分数据就可以公用,就是享元模式中的内在数据,定义享元类如下:

function IphoneFlyweight(model, screen, memory) {
  this.model = model;
  this.screen = screen;
  this.memory = memory;
}

我们定义了 iphone 的享元类,其中包含型号,屏幕和内存三个数据。我们还需要一个享元工厂来维护这些数据:

var flyweightFactory = (function() {
  var iphones = {};
  return {
    get: function(model, screen, memory) {
      var key = model + screen + memory;
      if (!iphones[key]) {
        iphones[key] = new IphoneFlyweight(model, screen, memory);
      }
      return iphones[key];
    }
  };
})();

在这个工厂中,我们定义了一个字典来保存享元对象,提供一个方法根据参数来获取享元对象,如果字典中有则直接返回,没有则创建一个返回。 接着我们创建一个客户端类,这个客户端类就是修改自 iphone 类:

function Iphone(model, screen, memory, SN) {
  this.flyweight = flyweightFactory.get(model, screen, memory);
  this.SN = SN;
}

然后我们依旧像之前那样生成多个 iphone

var phones = [];
for (var i = 0; i < 1000000; i++) {
  var memory = i % 2 == 0 ? 16 : 32;
  phones.push(new Iphone('iphone6s', 5.0, memory, i));
}
console.log(phones);

这里的关键就在于 Iphone 构造函数里面的 this.flyweight = flyweightFactory.get(model, screen, memory) 。这句代码通过享元工厂去获取享元数据,而在享元工厂里面,如果已经存在相同数据的对象则会直接返回对象,多个 iphone 对象共享这部分相同的数据,所以原本类似的数据已经大大减少,减少的内存的占用。

在 DOM 中的使用

<ul class="menu">
    <li class="item">选项1</li>
    <li class="item">选项2</li>
    <li class="item">选项3</li>
    <li class="item">选项4</li>
    <li class="item">选项5</li>
    <li class="item">选项6</li>
</ul>

点击菜单项,进行相应的操作,我们通过 jQuery 来绑定事件,一般会这么做:

$('.item').on('click', function() {
  console.log($(this).text());
});

给每个列表项绑定事件,点击输出相应的文本。这样看暂时没有什么问题,但是如果是一个很长的列表,尤其是在移动端特别长的列表时,就会有性能问题,因为每个项都绑定了事件,都占用了内存。但是这些事件处理程序其实都是很类似的,我们就要对其优化。

$('.menu').on('click', '.item', function() {
  console.log($(this).text());
});

通过这种方式进行事件绑定,可以减少事件处理程序的数量,这种方式叫做事件委托,也是运用了享元模式的原理。事件处理程序是公用的内在部分,每个菜单项各自的文本就是外在部分。我们简单说下事件委托的原理:点击菜单项,事件会从 li 元素冒泡到 ul 元素,我们绑定事件到 ul 上,实际上就绑定了一个事件,然后通过事件参数 event 里面的 target 来判断点击的具体是哪一个元素,比如低级第一个 li 元素,event.target 就是 li,这样就能拿到具体的点击元素了,就可以根据不同元素进行不同的处理。

参考:Javascript 设计模式理论与实战:享元模式