JavaScript 创建模式详解

在上一章中,我们花了很长时间研究了如何创建类。 在本章中,我们将看看如何创建类的实例。 从表面上看,这似乎是一个简单的问题,但如何创建类的实例可能非常重要。

我们煞费苦心地创建代码,使其尽可能地解耦。 确保类对其他类的依赖性最小,这是构建一个能够随使用软件的用户需求的变化而流畅地进行更改的系统的关键。 允许类之间的关系过于紧密,意味着变化会像涟漪一样波及它们。

一个波纹并不是大问题,但是,当你在混合中加入越来越多的变化时,波纹就会叠加并产生干涉图案。 很快,曾经平静的表面就变成了一堆无法辨认的杂乱的累赘和破坏性的节点。 同样的问题也出现在我们的应用中:更改以意想不到的方式放大和交互。 我们容易忘记耦合的一个地方是在创建对象时:

let Westeros;
(function (Westeros) {
  let Ruler = (function () {
    function Ruler() {
      this.house = new Westeros.Houses.Targaryen();
    }
    return Ruler;
  })();
  Westeros.Ruler = Ruler;
})(Westeros || (Westeros = {}));

您可以在这个类中看到,统治者的房子与类Targaryen是强耦合的。 如果要改变这种情况,那么这种紧密耦合就必须在很多地方发生改变。 本章讨论了一些模式,这些模式最初出现在四本书《设计模式:可重用面向对象软件的元素》中。 这些模式的目标是提高应用中的耦合程度,并增加代码重用的机会。 模式如下:

当然,并不是所有这些都适用于 JavaScript,但我们将在学习创建模式时看到这一点。

这里介绍的第一个模式是在不知道对象的具体类型的情况下创建对象套件的方法。 让我们继续前面介绍的统治王国的系统。

对于所讨论的王国,统治院在一定程度上频繁地变化。 很有可能在换房子的过程中会有一定程度的争斗,但我们暂时忽略这一点。 每个家族统治王国的方式各不相同。 有些人崇尚和平与安宁,以仁慈的领袖统治,而另一些人则以铁腕统治。 王国的统治对于一个人来说太大了,所以国王将他的一些决定交给了一个叫做国王之手的副手。 国王在一些事务上也由议会提供建议,该议会由一些更精明的领主和夫人组成。

我们描述的类的图表如下:

Abstract factory

提示

统一建模语言(Unified Modeling LanguageUML)是对象管理集团(Object Management Group)开发的描述计算机系统的标准化语言。 语言中有用于创建用户交互图、序列图和状态机等的词汇表。 出于本书的目的,我们最感兴趣的是类图,它描述了一组类之间的关系。

整个 UML 类图词汇表非常广泛,超出了本书的范围。 然而,维基百科的文章可以在https://en.wikipedia.org/wiki/Class_diagram作为一个很好的介绍,德里克·巴纳斯的优秀视频教程类图可以在https://www.youtube.com/watch?v=3cmzqZzwNDM

问题是,由于统治家族,甚至是王座上的统治家族成员变化如此频繁,加上坦格利安或兰尼斯特这样具体的家族,使得我们的申请变得脆弱。 在一个不断变化的世界里,脆弱的应用并没有很好的发展。

解决这个问题的一种方法是使用抽象工厂模式。 抽象工厂声明了一个接口,用于创建与统治家族相关的每个类。

这个模式的类图相当令人生畏:

Abstract factory

抽象工厂类可以对每个不同的统治族有多个实现。 这些被称为具体工厂,每个工厂都将实现抽象工厂提供的接口。 作为回报,具体的工厂将返回各个统治类的具体实现。 这些具体的类称为产品。

让我们从查看抽象工厂的接口代码开始。

没有代码? 事实上,情况就是这样。 JavaScript 的动态特性使它不需要接口来描述类。 我们不需要接口,而是直接创建类:

Abstract factory

JavaScript 相信您提供的类实现了所有适当的方法,而不是接口。 在运行时,解释器将尝试调用你请求的方法,如果找到了,就调用它。 解释器只是假设,如果您的类实现了方法,那么它就是那个类。 这被称为鸭打字

注释

鸭子打字

“duck typing”这个名字来自于 Alex Martelli 于 2000 年在comp.lang.python新闻组上发表的一篇文章,他在其中写道:

换句话说,不要检查它是否是一只鸭子:检查它是否像鸭子一样嘎嘎叫,像鸭子一样走路,等等,这取决于你需要用什么鸭子行为子集来玩你的语言游戏。

我很欣赏 Martelli 从Monty Python and the Holy Grail中的猎巫素描中借用这个术语的可能性。 尽管我找不到任何证据,但我认为这很有可能是因为 Python 编程语言的名字来自 Monty Python。

鸭子类型在动态语言中是一个强大的工具,可以大大减少实现类层次结构的开销。 然而,这确实带来了一些不确定性。 如果两个类实现了命名相同的方法,但却有根本不同的含义,那么就没有办法知道被调用的方法是否是正确的方法。 以下面的代码为例:

class Boxer{
  function punch(){}
}
class TicketMachine{
  function punch(){}
}

这两个类都有一个punch()方法,但它们显然有不同的含义。 JavaScript 解释器并不知道它们是不同的类,并且会很高兴地在任何一个类上调用 punch,即使其中一个没有意义。

一些动态语言支持泛型方法,每当调用未定义的方法时都会调用该方法。 例如,Ruby 有missing_method,它已经被证明在许多场景中非常有用。 在撰写本文时,JavaScript 目前还不支持missing_method。 然而,ECMAScript 2016, ECMAScript 2015 的后续版本,定义了一个名为Proxy的新结构,它将支持动态包装对象,使用这个结构可以实现相当于missing_method的功能。

实施

为了演示抽象工厂的实现,我们首先需要的是King类的实现。 这段代码提供了这样的实现:

let KingJoffery= (function () {
  function KingJoffery() {
  }
  KingJoffery.prototype.makeDecision = function () {
    …
  };
  KingJoffery.prototype.marry = function () {
    …
  };
  return KingJoffery;
})();

注释

本代码不包括第 2 章组织代码中建议的模块结构。 在每个例子中都包含模板模块代码是很乏味的,而且你们都是聪明的 cookie,所以如果要使用它,就应该把它放到模块中。 完全模块化的代码可在分布式源代码中获得。

这只是一个普通的具体类,可以包含任何实现细节。 我们还需要一个实现的HandOfTheKing类同样是乏味的:

let LordTywin = (function () {
  function LordTywin() {
  }
  LordTywin.prototype.makeDecision = function () {
  };
  return LordTywin;
})();

具体的工厂方法如下所示:

let LannisterFactory = (function () {
  function LannisterFactory() {
  }
  LannisterFactory.prototype.getKing = function () {
    return new KingJoffery();
  };
  LannisterFactory.prototype.getHandOfTheKing = function ()
  {
    return new LordTywin();
  };
  return LannisterFactory;
})();

这段代码只是实例化每个必需类的新实例并返回它们。 另一种不同统治家族的替代实现将遵循相同的一般形式,可能看起来像:

let TargaryenFactory = (function () {
  function TargaryenFactory() {
  }
  TargaryenFactory.prototype.getKing = function () {
    return new KingAerys();
  };
  TargaryenFactory.prototype.getHandOfTheKing = function () {
    return new LordConnington();
  };
  return TargaryenFactory;
})();

用 JavaScript 实现抽象工厂要比用其他语言容易得多。 然而,这样做的代价是您将失去编译器检查,这将迫使您完全实现工厂或产品。 当我们继续进行其余的模式时,您会注意到这是一个常见的主题。 在静态类型语言中有大量管道的模式要简单得多,但会产生更大的运行时失败风险。 适当的单元测试或 JavaScript 编译器可以改善这种情况。

要使用抽象工厂,我们首先需要一个类,它需要使用一些统治家族:

let CourtSession = (function () {
  function CourtSession(abstractFactory) {
    this.abstractFactory = abstractFactory;
    this.COMPLAINT_THRESHOLD = 10;
  }
  CourtSession.prototype.complaintPresented = function (complaint) {
    if (complaint.severity < this.COMPLAINT_THRESHOLD) {
      this.abstractFactory.getHandOfTheKing().makeDecision();
    } else
    this.abstractFactory.getKing().makeDecision();
  };
  return CourtSession;
})();

我们现在可以调用这个CourtSession类,并根据传入的工厂注入不同的功能:

let courtSession1 = new CourtSession(new TargaryenFactory());
courtSession1.complaintPresented({ severity: 8 });
courtSession1.complaintPresented({ severity: 12 });

let courtSession2 = new CourtSession(new LannisterFactory());
courtSession2.complaintPresented({ severity: 8 });
courtSession2.complaintPresented({ severity: 12 });

尽管静态语言和 JavaScript 之间存在差异,但这种模式在 JavaScript 应用中仍然适用和有用。 创造一套能够协同工作的对象在许多情况下都是有用的; 任何时候,一组对象需要协作来提供功能,但可能需要批量替换。 当试图确保在不进行替换的情况下一起使用一组对象时,它也可能是一个有用的模式。

在我们的虚拟世界中,有时我们需要构造一些相当复杂的类。 这些类包含接口的不同实现,具体取决于它们的构造方式。 为了简化这些类的构建并封装关于远离消费者构建类的知识,可以使用构建器。 多个混凝土构建器降低了实现中构造器的复杂性。 当需要新的构建器时,不需要添加构造器,只需要插入新的构建器。

锦标赛就是一个复杂类的例子。 每个比赛都有一个复杂的设置,包括活动、参与者和奖品。 这些比赛的大部分设置都是相似的:每个比赛都有骑马、射箭和近战。 从代码中的多个位置创建比赛意味着知道如何构建比赛的责任是分散的。 如果需要更改初始化代码,那么必须在许多不同的地方进行。

使用构建器模式可以通过集中构建对象所需的逻辑来避免这个问题。 不同的混凝土建筑工可以插入建筑工来建造不同的复杂物体。 构建器模式中各种类之间的关系如下所示:

Builder

实施

让我们顺便看看的一些代码。 首先,我们将创建一些实用工具类,它们将代表比赛的各个部分,如下面的代码所示:

let Event = (function () {
  function Event(name) {
    this.name = name;
  }
  return Event;
})();
Westeros.Event = Event;

let Prize = (function () {
  function Prize(name) {
    this.name = name;
  }
  return Prize;
})();
Westeros.Prize = Prize;

let Attendee = (function () {
  function Attendee(name) {
    this.name = name;
  }
  return Attendee;
})();
Westeros.Attendee = Attendee;

比赛本身是一个非常简单的类,因为我们不需要显式地分配任何公共属性:

let Tournament = (function () {
  this.Events = [];
  function Tournament() {
  }
  return Tournament;
})();
Westeros.Tournament = Tournament;

我们将实现两个构建器来创建不同的锦标赛。 这可以在以下代码中看到:

let LannisterTournamentBuilder = (function () {
  function LannisterTournamentBuilder() {
  }
  LannisterTournamentBuilder.prototype.build = function () {
    var tournament = new Tournament();
    tournament.events.push(new Event("Joust"));
    tournament.events.push(new Event("Melee"));
    tournament.attendees.push(new Attendee("Jamie"));
    tournament.prizes.push(new Prize("Gold"));
    tournament.prizes.push(new Prize("More Gold"));
    return tournament;
  };
  return LannisterTournamentBuilder;
})();
Westeros.LannisterTournamentBuilder = LannisterTournamentBuilder;

let BaratheonTournamentBuilder = (function () {
  function BaratheonTournamentBuilder() {
  }
  BaratheonTournamentBuilder.prototype.build = function () {
    let tournament = new Tournament();
    tournament.events.push(new Event("Joust"));
    tournament.events.push(new Event("Melee"));
    tournament.attendees.push(new Attendee("Stannis"));
    tournament.attendees.push(new Attendee("Robert"));
    return tournament;
  };
  return BaratheonTournamentBuilder;
})();
Westeros.BaratheonTournamentBuilder = BaratheonTournamentBuilder;

最后,director,或者我们称之为TournamentBuilder,简单地获取一个构建器并执行它:

let TournamentBuilder = (function () {
  function TournamentBuilder() {
  }
  TournamentBuilder.prototype.build = function (builder) {
    return builder.build();
  };
  return TournamentBuilder;
})();
Westeros.TournamentBuilder = TournamentBuilder;

您将再次看到,由于不需要接口,JavaScript 实现要比传统实现简单得多。

构建器不需要返回一个完全实现的对象。 这意味着你可以创建一个构建器,它可以部分地水合物一个对象,然后允许对象被传递给另一个构建器以完成它。 一个很好的现实世界类比可能是汽车的制造过程。 装配线上的每个站只生产汽车的一部分,然后将它送到下一个站去生产另一部分。 这种方法允许将构建对象的工作划分到几个责任有限的类中。 在上面的示例中,我们可以有一个负责填充事件的构建器和另一个负责填充参与者的构建器。

从 JavaScript 的原型扩展模型来看,构建器模式仍然有意义吗? 我相信如此。 仍然有需要根据不同的方法创建复杂对象的情况。

我们已经看过了抽象工厂和建造者。 抽象工厂构建了一系列相关的类,而构造器使用不同的策略创建复杂的对象。 工厂方法模式允许类请求接口的新实例,而不需要类决定使用接口的哪个实现。 工厂可能会使用一些策略来选择返回哪个实现:

Factory method

有时这种策略只是简单地采用一个字符串参数或检查一些全局设置来充当开关。

实施

在我们的维斯特洛世界的例子中,很多时候我们都希望将实现的选择推迟到工厂。 就像现实世界一样,维斯特洛也有充满活力的宗教文化,有数十种相互竞争的宗教,崇拜各种各样的神。 在每个宗教祈祷时,都必须遵守不同的规则。 有些宗教要求牺牲,而有些宗教只要求给予礼物。 祈祷班不想知道所有不同的宗教以及如何建立它们。

让我们从创造一些不同的神开始,可以向他们祈祷。 这段代码创建了三个神,包括一个默认的神,如果没有指定其他神,他的祈祷落在:

let WateryGod = (function () {
  function WateryGod() {
  }
  WateryGod.prototype.prayTo = function () {
  };
  return WateryGod;
})();
Religion.WateryGod = WateryGod;
let AncientGods = (function () {
  function AncientGods() {
  }
  AncientGods.prototype.prayTo = function () {
  };
  return AncientGods;
})();
Religion.AncientGods = AncientGods;

let DefaultGod = (function () {
  function DefaultGod() {
  }
  DefaultGod.prototype.prayTo = function () {
  };
  return DefaultGod;
})();
Religion.DefaultGod = DefaultGod;

我避免了每个上帝的任何实现细节。 您可以想象您想要填充prayTo方法的任何传统。 也没有必要确保每个神都实现了一个IGod接口。 接下来我们需要一个工厂,负责建造每一个不同的神:

let GodFactory = (function () {
  function GodFactory() {
  }
  GodFactory.Build = function (godName) {
    if (godName === "watery")
      return new WateryGod();
    if (godName === "ancient")
      return new AncientGods();
    return new DefaultGod();
  };
  return GodFactory;
})();

你可以看到,在这个例子中,我们用一个简单的字符串来决定如何创造一个神。 它可以通过全局对象或更复杂的对象来实现。 在维斯特洛的一些多神主义宗教中,众神将角色定义为勇敢、美丽或其他方面的神。 一个人祷告的神不仅是由宗教决定的,也是由祷告的目的决定的。 我们可以用一个GodDeterminant类来表示,如下所示:

let GodDeterminant = (function () {
  function GodDeterminant(religionName, prayerPurpose) {
    this.religionName = religionName;
    this.prayerPurpose = prayerPurpose;
  }
  return GodDeterminant;
})();

工厂将被更新以使用这个类而不是简单的字符串。

最后,最后一步是看看如何使用这个工厂。 这很简单,我们只需要传入一个字符串,表示我们希望遵守哪个宗教,工厂就会建造正确的上帝,并返回它。 下面的代码演示了如何调用工厂:

let Prayer = (function () {
  function Prayer() {
  }
  Prayer.prototype.pray = function (godName) {
  GodFactory.Build(godName).prayTo();
  };
  return Prayer;
})();

同样,在 JavaScript 中确实需要这样的模式。 在很多情况下,将实例化与使用分离是很有用的。 测试实例化也非常简单,这要感谢关注点的分离和注入一个假工厂以允许测试Prayer的能力。

继续创建没有接口的更简单模式的趋势,我们可以忽略模式的接口部分,直接处理类型,这要感谢 duck typing。

工厂方法是一种非常有用的模式:它允许类将实例化的实现选择推迟到另一个类。 当有多个类似的实现时,例如策略模式(参见第 5 章行为模式),该模式非常有用,通常与抽象工厂模式一起使用。 Factory 方法用于在抽象工厂的具体实现中构建具体对象。 抽象工厂模式可能包含许多工厂方法。 工厂方法当然是一种在 JavaScript 领域仍然适用的模式。

单例模式可能是最被滥用的模式。 近年来,这种模式已经失宠。 为了了解为什么人们开始反对使用 Singleton,让我们看看这个模式是如何工作的。

当需要全局变量时使用 Singleton,但 Singleton 提供了防止意外创建复杂对象的多个副本的保护。 它还允许将对象实例化延迟到第一次使用。

Singleton 的 UML 图如下所示:

Singleton

这显然是一个非常简单的模式。 Singleton 充当类实例的包装器,而 Singleton 本身作为全局变量存在。 当访问实例时,我们只需向 Singleton 请求包装类的当前实例。 如果这个类在 Singleton 中还不存在,通常会在那个时候创建一个新的实例。

实施

在我们正在进行的维斯特洛世界的例子中,我们需要找到一个只有一种情况的例子。 不幸的是,这片土地上经常发生冲突和竞争,所以我的第一个使用国王作为 Singleton 的想法是行不通的。 这种分裂也意味着我们不能使用任何其他明显的候选者(首都,女王,将军,等等)。 然而,在维斯特洛的最北部,有一堵巨大的城墙将古老的敌人拒之门外。 只有一堵墙,在全球范围内使用它应该没有问题。

让我们继续,在 JavaScript 中创建一个单例:

let Westros;
(function (Westeros) {
  var Wall = (function () {

 function Wall() {

 this.height = 0;

 if (Wall._instance)

 return Wall._instance;

 Wall._instance = this;

 }

    Wall.prototype.setHeight = function (height) {
      this.height = height;
    };
    Wall.prototype.getStatus = function () {
      console.log("Wall is " + this.height + " meters tall");
    };

 Wall.getInstance = function () {

 if (!Wall._instance) {

 Wall._instance = new Wall();

 }

 return Wall._instance;

 };

    Wall._instance = null;
    return Wall;
  })();
  Westeros.Wall = Wall;
})(Westeros || (Westeros = {}));

该代码创建了 Wall 的轻量级表示。 在突出显示的两个部分中演示了 Singleton。 在像 c#或 Java 这样的语言中,我们通常只会将构造函数设置为私有,这样它就只能被静态方法getInstance调用。 然而,我们在 JavaScript 中没有这种能力:构造函数不能是私有的。 因此,我们尽最大努力从构造函数返回当前实例。 这可能看起来很奇怪,但在我们构造类的方式中,构造函数与其他方法没有什么不同,因此可以从它返回一些东西。

在第二个突出显示的部分中,我们设置了一个静态变量_instance,作为 Wall 的一个新实例。 如果那个_instance已经存在,我们返回它。 在 c#和 Java 中,这个函数需要一些复杂的锁定逻辑,以避免两个不同的线程同时试图访问实例时出现竞争条件。 幸运的是,在 JavaScript 中不需要担心这个问题,因为多线程的情况是不同的。

缺点

在过去的几年里,单身人士的名声有些不好。 实际上,它们是美化了的全局变量。 正如我们已经讨论过的,全局变量是考虑不正的,并且是许多 bug 的潜在原因。 它们也很难用单元测试进行测试,因为实例的创建不容易被覆盖,而且测试运行程序中的任何形式的并行性都可能引入难以诊断的竞态条件。 我对他们最大的担忧是单身人士有太多的责任。 他们不仅控制自己,还控制自己的实例化。 这明显违反了单一责任原则。 几乎所有可以通过使用 Singleton 解决的问题都可以通过其他机制更好地解决。

JavaScript 让问题变得更糟。 由于构造函数的限制,不可能创建一个干净的 Singleton 实现。 这一点,再加上围绕 Singleton 的一般问题,让我建议在 JavaScript 中避免使用 Singleton 模式。

本章最后一个创造模式是原型模式。 也许这个名字听起来很熟悉。 当然应该这样:它是支持 JavaScript 继承的机制。

我们研究了用于继承的原型,但原型的适用性不需要局限于继承。 复制现有对象可能是一种非常有用的模式。 在许多情况下,能够复制已构造的对象是很方便的。 例如,通过保存以前通过某种克隆创建的实例,可以很容易地维护对象状态的历史。

实施

在维斯特洛,我们发现家庭成员往往非常相似; 俗话说:“有其父必有其子”。 随着每一代的诞生,通过复制和修改现有的家庭成员来创造新一代比从零开始更容易。

第 2 章组织代码中,我们介绍了如何复制现有对象,并提供了一段非常简单的克隆代码:

function clone(source, destination) {
  for(var attr in source.prototype){
    destination.prototype[attr] = source.prototype[attr];}
}

这段代码可以很容易地修改为在类内部使用,以返回自身的副本:

var Westeros;
(function (Westeros) {
  (function (Families) {
    var Lannister = (function () {
      function Lannister() {
      }

Lannister.prototype.clone = function () {

 var clone = new Lannister();

 for (var attr in this) {

 clone[attr] = this[attr];

 }

 return clone;

 };

      return Lannister;
    })();
    Families.Lannister = Lannister;
  })(Westeros.Families || (Westeros.Families = {}));
  var Families = Westeros.Families;
})(Westeros || (Westeros = {}));

代码中突出显示的部分是修改后的克隆方法。 它可以这样使用:

let jamie = new Westeros.Families.Lannister();
jamie.swordSkills = 9;
jamie.charm = 6;
jamie.wealth = 10;

let tyrion = jamie.clone();
tyrion.charm = 10;
//tyrion.wealth == 10
//tyrion.swordSkill == 9

Prototype 模式允许只构造一次复杂对象,然后将其克隆到任意数量的只略有变化的对象中。 如果源对象不是复杂的,那么采用克隆方法得到的好处就很少。 在使用原型方法考虑依赖对象时必须小心。 克隆应该是深的吗?

Prototype 显然是一种有用的模式,从一开始就是 JavaScript 不可分割的一部分。 因此,这种模式在任何规模可观的 JavaScript 应用中都有一定的用处。

创建模式允许在创建对象时进行专门化行为。 在许多情况下,例如工厂,它们提供可以放置横切逻辑的扩展点。 也就是说,逻辑适用于许多不同类型的对象。 如果您正在寻找一种方法,在整个应用中注入日志记录,那么能够挂钩到工厂是非常有用的。

对于所有这些创建模式的效用,它们不应该被频繁使用。 绝大多数对象实例化仍然应该只是改进对象的普通方法。 虽然当你有了一把新锤子时,你很容易把所有东西都当作钉子来对待,但事实是,每种情况都需要有特定的策略。 所有这些模式都比简单地使用new要复杂得多,而且复杂的代码比简单的代码更容易出现错误。 尽可能使用new

本章介绍了许多创建对象的不同策略。 这些方法在创建对象的典型方法之上提供了抽象。 抽象工厂提供了一种构建可互换的工具包或相关对象集合的方法。 Builder 模式提供了可伸缩参数问题的解决方案。 它使构建大型复杂对象变得更容易。 工厂方法是抽象工厂的有用补充,它允许通过静态工厂创建不同的实现。 单例模式是一种模式,它提供一个类的单一副本,这个副本对整个解决方案都是可用的。 这是到目前为止我们看到的唯一一种模式,它提出了一些有关现代软件适用性的问题。 Prototype 模式是 JavaScript 中用于基于其他现有对象构建对象的常用模式。

在下一章中,我们将通过研究结构模式来继续我们对经典设计模式的研究。

教程来源于Github,感谢apachecn大佬的无私奉献,致敬!

技术教程推荐

持续交付36讲 -〔王潇俊〕

软件工程之美 -〔宝玉〕

Go语言从入门到实战 -〔蔡超〕

编辑训练营 -〔总编室〕

全栈工程师修炼指南 -〔熊燚(四火)〕

检索技术核心20讲 -〔陈东〕

视觉笔记入门课 -〔高伟〕

手把手带你写一个Web框架 -〔叶剑峰〕

Spring Cloud 微服务项目实战 -〔姚秋辰(姚半仙)〕