【青训营】- JavaScript中常见设计模式(中篇)

198 阅读9分钟

结构设计模式

结构型模式封装的是对象之间的组合关系,用于描述“如何将类或对象按某种布局组成更大的结构”。常见的模式包括代理模式(Proxy)组合模式(Composite)享元模式(flyweight)装饰器模式(Decorator)适配器模式(Adapter)

1.代理模式(Proxy)

代理模式是为一个对象提供一个代用品或占位符,以便控制对它的访问。

现实生活中有很多使用代理模式的场景。比如,要请明星来一场商演,这时候你需要去联系他的经纪人;当你和经纪人洽谈好后,经纪人才会把合同交给明星。

代理模式的关键是,当客户不方便直接访问一个对象或者不满足需要的时候,提供一个替身对象来控制对这个对象的访问,客户实际上访问的是替身对象。替身对象对请求做出一些处理之后,再把请求转交给本体对象。

未命名文件.png

代理的类型主要包括保护代理虚拟代理保护代理用于控制不同权限的对象对目标对象的访问,而虚拟代理则把开销大的对象延迟到真正需要它的时候才去创建。在Javascript中更常用的是虚拟代理。

举一个例子,在网页开发中,经常会遇到图片加载的问题。由于图片过大或是网络较差,常常会导致图片未加载出来而显示一片空白。常见的做法是先用一张 loading 图片占位,然后用异步的方式加载图片,等图片加载好了再把它填充到 img 节点里,这种场景就很适合使用虚拟代理。

    var myImage = (function () {
      var imgNode = document.createElement('img');
      document.body.appendChild(imgNode);
      return {
        setSrc: function (src) {
          imgNode.src = src;
        }
      }
    })();
    
    var proxyImage = (function () {
      var img = new Image;
      img.onload = function () {
        myImage.setSrc(this.src);
      }
      return {
        setSrc: function (src) {
          myImage.setSrc('./loading.gif');
          img.src = src;
        }
      }
    })();
    
    proxyImage.setSrc('http://xxxxx.jpg');

2.组合模式(Composite)

组合模式将对象组合成树形结构,以表示“部分-整体”的层次结构。 除了用来表示树形结构之外,组合模式的另一个好处是通过对象的多态性表现,使得用户对单个对象和组合对象的使用具有一致性

未命名文件 (1).png

文件夹和文件之间的关系,非常适合用组合模式来描述。文件夹里既可以包含文件,又可以包含其他文件夹,最终可能组合成一棵树。我们使用组合模式来模拟文件夹扫描的过程,首先分别定义好文件夹Folder和文件File这两个类。

    var Folder = function (name) {
      this.name = name;
      this.files = [];
    };
    Folder.prototype.add = function (file) {
      this.files.push(file);
    };
    Folder.prototype.scan = function () {
      console.log('开始扫描文件夹: ' + this.name);
      for (var i = 0, file, files = this.files; file = files[i++];) {
        file.scan();
      }
    };

    var File = function (name) {
      this.name = name;
    };
    File.prototype.add = function () {
      throw new Error('文件下面不能再添加文件');
    };
    File.prototype.scan = function () {
      console.log('开始扫描文件: ' + this.name);
    };

接下来创建一些文件夹和文件对象,并且让它们组合成一棵树。

  var folder = new Folder('学习资料');
  var folder1 = new Folder('JavaScript');
  var folder2 = new Folder('jQuery');
  var file1 = new File('JavaScript 设计模式与开发实践');
  var file2 = new File('精通jQuery');
  var file3 = new File('重构与模式')
  folder1.add(file1);
  folder2.add(file2);
  folder.add(folder1);
  folder.add(folder2);
  folder.add(file3);

通过这个例子,我们再次看到客户是如何同等对待组合对象和叶对象。在添加一批文件的操作过程中,客户不用分辨它们到底是文件还是文件夹。新增加的文件和文件夹能够很容易地添加到原来的树结构中,和树里已有的对象一起工作。

运用了组合模式之后,扫描整个文件夹的操作也是轻而易举的,我们只需要操作树的最顶端对象。

  folder.scan();

Snipaste_2021-09-15_14-55-37.png

3.享元模式(flyweight)

享元(flyweight)模式是一种用于性能优化的模式,“fly”在这里是苍蝇的意思,意为蝇量级。享元模式的核心是运用共享技术来有效支持大量细粒度的对象。

如果系统中因为创建了大量类似的对象而导致内存占用过高,享元模式就非常有用了。在JavaScript 中,浏览器特别是移动端的浏览器分配的内存并不算多,如何节省内存就成了一件非常有意义的事情。

假设有个内衣工厂,目前的产品有50 种男式内衣和50种女士内衣,为了推销产品,工厂决定生产一些塑料模特来穿上他们的内衣拍成广告照片。 正常情况下需要50个男模特和50个女模特,然后让他们每人分别穿上一件内衣来拍照。不使用享元模式的情况下,在程序里也许会这样写:

    var Model = function (sex, underwear) {
      this.sex = sex;
      this.underwear = underwear;
    };
    Model.prototype.takePhoto = function () {
      console.log('sex= ' + this.sex + ' underwear=' + this.underwear);
    };
    for (var i = 1; i <= 50; i++) {
      var maleModel = new Model('male', 'underwear' + i);
      maleModel.takePhoto();
    };
    for (var j = 1; j <= 50; j++) {
      var femaleModel = new Model('female', 'underwear' + j);
      femaleModel.takePhoto();
    };

要得到一张照片,每次都需要传入sex和underwear参数,如上所述,现在一共有50种男内衣和50种女内衣,所以一共会产生100个对象。如果将来生产了10000种内衣,那这个程序可能会因为存在如此多的对象已经提前崩溃。

下面我们来考虑一下如何优化这个场景。虽然有100种内衣,但很显然并不需要50个男模特和50个女模特。其实男模特和女模特各自有一个就足够了,他们可以分别穿上不同的内衣来拍照。

    var Model = function (sex) {
      this.sex = sex;
    };
    Model.prototype.takePhoto = function () {
      console.log('sex= ' + this.sex + ' underwear=' + this.underwear);
    };

    var maleModel = new Model('male'),
      femaleModel = new Model('female');

    for (var i = 1; i <= 50; i++) {
      maleModel.underwear = 'underwear' + i;
      maleModel.takePhoto();
    };

    for (var j = 1; j <= 50; j++) {
      femaleModel.underwear = 'underwear' + j;
      femaleModel.takePhoto();
    };

享元模式要求将对象的属性划分为内部状态外部状态。享元模式的目标是尽量减少共享对象的数量,关于如何划分内部状态和外部状态,下面的几条经验提供了一些指引。

  • 内部状态存储于对象内部。
  • 内部状态可以被一些对象共享。
  • 内部状态独立于具体的场景,通常不会改变。
  • 外部状态取决于具体的场景,并根据场景而变化,外部状态不能被共享。

这样一来,我们便可以把所有内部状态相同的对象都指定为同一个共享的对象。而外部状态可以从对象身上剥离出来,并储存在外部。

4.装饰器模式(Decorator)

在程序开发中,许多时候都并不希望某个类天生就非常庞大,一次性包含许多职责。那么我们就可以使用装饰器模式。装饰器模式可以动态地给某个对象添加一些额外的职责,而不会影响从这个类中派生的其他对象。

假设我们在编写一个飞机大战的游戏,随着经验值的增加,我们操作的飞机对象可以升级成更厉害的飞机,一开始这些飞机只能发射普通的子弹,升到第二级时可以发射导弹,升到第三级时可以发射原子弹。

下面来看代码实现。

    var Plane = function () { }
    Plane.prototype.fire = function () {
      console.log('发射普通子弹');
    }
    var MissileDecorator = function (plane) {
      this.plane = plane;
    }
    MissileDecorator.prototype.fire = function () {
      this.plane.fire();
      console.log('发射导弹');
    }
    var AtomDecorator = function (plane) {
      this.plane = plane;
    }
    AtomDecorator.prototype.fire = function () {
      this.plane.fire();
      console.log('发射原子弹');
    }

    var plane = new Plane();
    plane = new MissileDecorator(plane);
    plane = new AtomDecorator(plane);
    plane.fire();
    // 分别输出: 发射普通子弹、发射导弹、发射原子弹

这种给对象动态增加职责的方式,并没有真正地改动对象自身,而是将对象放入另一个对象之中,这些对象以一条链的方式进行引用,形成一个聚合对象。这些对象都拥有相同的接口(fire方法),当请求达到链中的某个对象时,这个对象会执行自身的操作,随后把请求转发给链中的下一个对象。

JavaScript语言动态改变对象相当容易,我们可以直接改写对象或者对象的某个方法,并不需要使用“类”来实现装饰者模式。

    var plane = {
      fire: function () {
        console.log('发射普通子弹');
      }
    }
    var missileDecorator = function () {
      console.log('发射导弹');
    }
    var atomDecorator = function () {
      console.log('发射原子弹');
    }
    var fire1 = plane.fire;
    plane.fire = function () {
      fire1();
      missileDecorator();
    }
    var fire2 = plane.fire;
    plane.fire = function () {
      fire2();
      atomDecorator();
    }
    plane.fire();
  // 分别输出: 发射普通子弹、发射导弹、发射原子弹

通过保存原引用的方式就可以改写某个函数,这样的代码当然是符合开放封闭原则的,我们在增加新功能的时候,确实没有修改原来的代码,但是这种方式存在以下两个问题。

  • 必须维护中间变量。
  • this被劫持。

而这可以通过AOP解决:

    Function.prototype.before = function (beforefn) {
      var __self = this; // 保存原函数的引用
      return function () { // 返回包含了原函数和新函数的"代理"函数
        beforefn.apply(this, arguments); // 执行新函数,且保证this不被劫持,新函数接受的参数
        // 也会被原封不动地传入原函数,新函数在原函数之前执行
        return __self.apply(this, arguments); // 执行原函数并返回原函数的执行结果,
        // 并且保证this 不被劫持
      }
    }
    Function.prototype.after = function (afterfn) {
      var __self = this;
      return function () {
        var ret = __self.apply(this, arguments);
        afterfn.apply(this, arguments);
        return ret;
      }
    };

    var fire = function () {
      console.log('发射普通子弹');
    }

    var missileDecorator = function () {
      console.log('发射导弹');
    }
    var atomDecorator = function () {
      console.log('发射原子弹');
    }
    var fire = fire.after(missileDecorator).after(atomDecorator);
    fire();

5.适配器模式(Adapter)

适配器模式的作用是解决两个软件实体间的接口不兼容的问题。使用适配器模式之后,原本由于接口不兼容而不能工作的两个软件实体可以一起工作。

假设我们要用googleMapbaiduMap分别以各自的方式在页面中展现地图。

    var googleMap = {
      show: function () {
        console.log('开始渲染谷歌地图');
      }
    };
    var baiduMap = {
      display: function () {
        console.log('开始渲染百度地图');
      }
    };
    var renderMap = function (map) {
      if (map.show instanceof Function) {
        map.show();
      }
    };

由于两个地图的展示方法不同,所以不能直接调用renderMap函数,因此我们要为百度地图提供一个适配器。这样我们就可以同时渲染2个地图了。

    var baiduMapAdapter = {
      show: function () {
        return baiduMap.display();
      }
    };
    renderMap(googleMap); // 输出:开始渲染谷歌地图
    renderMap(baiduMapAdapter); // 输出:开始渲染百度地图

参考资料

《JavaScript设计模式与开发实践》