JavaScript 高级模式详解

在命名这一章时,我犹豫了一下,高级模式。 这并不是关于比其他模式更复杂或复杂的模式。 它是关于你不会经常使用的模式。 坦率地说,来自静态编程语言的背景,其中一些似乎很疯狂。 尽管如此,它们仍然是完全有效的模式,并且在世界各地的大型项目中都在使用。

在本章中,我们将讨论以下主题:

在这本书中,我们一直在谈论的一个话题是使代码模块化的重要性。 小类更容易测试,提供更好的重用,并促进团队更好的协作。 模块化的、松散耦合的代码更容易维护,因为更改可以受到限制。 你可能还记得我们之前用过的 ripstop 的例子。

在这类模块化代码中,我们看到了大量的控制反转。 类通过其创建者传递额外的类来插入功能。 这将子类的某些部分工作的责任转移给了父类。 对于小型项目,这是一个相当合理的方法。 随着项目变得越来越复杂,依赖关系图变得越来越复杂,手动注入功能变得越来越困难。 我们仍然在代码基础上创建对象,将它们传递到已创建的对象中,所以耦合问题仍然存在,我们只是将它提升了一个级别。

如果我们把对象创建看作一种服务,那么这个问题的解决方案就出现了。 我们可以将对象创建延迟到一个中心位置。 这允许我们简单而容易地在一个地方更改给定接口的实现。 它还允许我们控制对象的生命周期,以便我们可以重用对象或在每次使用它们时重新创建它们。 如果我们需要用另一个实现替换接口的一个实现,那么我们可以确信只需要在一个位置更改它。 因为新的实现仍然履行约定(即接口),所以所有使用该接口的类都可以忽略更改。

更重要的是,通过集中对象创建,可以更容易地构造依赖于其他对象的对象。 如果我们看一下模块(如UserManager变量)的依赖关系图,很明显它有许多依赖关系。 这些依赖关系可能有额外的依赖关系等等。 为了建立一个UserManager变量,我们不仅需要传入数据库,而且还需要传入ConnectionStringProviderCredentialProviderConfigFileConnectionStringReader。 天啊,要创建所有这些的实例需要做很多工作。 相反,如果我们在注册中心中注册这些接口的每个实现,那么我们只需要到注册中心查看如何创建它们。 这可以是自动化的,依赖关系会自动注入到所有依赖关系中,而不需要显式地创建任何依赖关系。 这种解决依赖关系的方法通常被称为“解决传递闭包”。

依赖项注入框架负责构造对象。 在应用设置时,依赖注入框架是由名称和对象的组合准备的。 由此,它创建一个注册表或容器。 当通过容器构造对象时,容器会查看构造函数的签名,并尝试满足构造函数的参数。 下面是一个依赖关系图的说明:

Dependency injection

在更静态类型的语言中,如 c#或 Java,依赖注入框架是很常见的。 它们通常通过使用反射来工作,反射是一种使用代码从其他代码中提取结构信息的方法。 在构建容器时,需要指定一个接口和一个或更多可以满足接口的具体类。 当然,使用接口和反射来执行依赖项注入需要语言同时支持接口和自省。

在 JavaScript 中没有办法做到这一点。 JavaScript 既没有直接的内省,也没有传统的对象继承模型。 一种常见的方法是使用变量名来解决依赖关系问题。 考虑一个具有如下构造函数的类:

var UserManager = (function () {
  function UserManager(database, userEmailer) {
    this.database = database;
    this.userEmailer = userEmailer;
  }
  return UserManager;
})();

构造函数接受两个命名非常明确的参数。 当我们通过依赖项注入构造这个类时,通过查看容器中注册的名称并将它们传递到构造函数中,就可以满足这两个参数。 然而,如果没有自省,我们如何提取参数的名称,以便知道要传递给构造函数什么?

解决方法其实非常简单。 JavaScript 中任何函数的原始文本都可以通过简单地调用toString来获得。 因此,对于前面代码中给出的构造函数,我们可以这样做:

UserManager.toString()

现在我们可以解析返回的字符串来提取参数的名称。 必须小心地正确解析文本,但这是可能的。 流行的 JavaScript 框架 Angular 实际上就是用这个方法来进行依赖注入的。 结果保持相对的预格式。 解析实际上只需要完成一次并缓存结果,因此不会产生额外的惩罚。

我不会详细说明如何实际实现依赖项注入,因为它相当乏味。 在解析函数时,可以使用字符串匹配算法解析它,也可以为 JavaScript 语法构建词法分析器和解析器。 第一个解决方案似乎更简单,但更好的决定可能是尝试为要注入的代码构建一个简单的语法树。 幸运的是,整个方法体可以被视为单个令牌,因此这比构建一个成熟的解析器要容易得多。

如果您愿意对依赖注入框架的用户施加不同的语法,那么您甚至可以创建自己的语法。 Angular 2.0 的依赖注入框架di.js支持一种自定义语法,用来表示应该注入对象的位置,以及表示哪些对象满足某些要求。

使用它作为一个需要注入一些代码的类,看起来像下面的代码,摘自di.js示例页面:

@Inject(CoffeeMaker, Skillet, Stove, Fridge, Dishwasher)
export class Kitchen {
  constructor(coffeeMaker, skillet, stove, fridge, dishwasher) {
    this.coffeeMaker = coffeeMaker;
    this.skillet = skillet;
    this.stove = stove;
    this.fridge = fridge;
    this.dishwasher = dishwasher;
  }
}

CoffeeMaker实例看起来像下面的代码:

@Provide(CoffeeMaker)
@Inject(Filter, Container)
export class BodumCoffeeMaker{
  constructor(filter, container){
  …
  }
}

您可能已经注意到这个示例使用了class关键字。 这是因为该项目非常具有前瞻性,需要使用traceur.js来提供对 ES6 类的支持。 我们将在下一章学习traceur.js文件。

很明显,在 JavaScript 中运行toString函数是执行任务的有效方式。 这看起来很奇怪,但实际上,编写生成其他代码的代码与 Lisp 一样古老,甚至更古老。 当我第一次接触到 AngularJS 中依赖注入的工作方式时,我既对这种 hack 感到厌恶,又对这种巧妙的解决方案印象深刻。

如果可以通过动态解释代码来进行依赖注入,那么我们还可以用它做什么呢? 答案是:相当多。 首先想到的是,您可以编写特定领域的语言。

我们在第 5 章行为模式中谈到了 dsl,甚至创建了一个非常简单的模式。 通过加载和重写 JavaScript 的能力,我们可以利用一种接近 JavaScript 但不完全兼容的语法。 在解释 DSL 时,我们的解释器会写出将代码转换为实际 JavaScript 所需的额外标记。

TypeScript 有一个很好的特性,我一直很喜欢,那就是标记为 public 的构造函数的形参会自动转换为对象的属性。 例如,下面的 TypeScript 代码:

class Axe{
  constructor(public handleLength, public headHeight){}
}

编译为以下代码:

var Axe = (function () {
  function Axe(handleLength, headHeight) {
    this.handleLength = handleLength;
    this.headHeight = headHeight;
  }
  return Axe;
})();

我们可以在 DSL 中做类似的事情。 从以下的Axe定义开始:

class Axe{
  constructor(handleLength, /*public*/ headHeight){}
}

我们在这里使用了注释来表示headHeight应该是公共的。 与 TypeScript 版本不同,我们希望我们的源代码是有效的 JavaScript。 因为注释包含在toString函数中,所以工作得很好。

接下来要做的就是从这里发出新的 JavaScript。 我采用了 naïve 方法并使用了正则表达式。 这种方法很快就会失去控制,可能只适用于Axe类中格式良好的 JavaScript:

function publicParameters(func){
  var stringRepresentation = func.toString();
  var parameterString = stringRepresentation.match(/^function .*\((.*)\)/)[1];
  var parameters = parameterString.split(",");
  var setterString = "";
  for(var i = 0; i < parameters.length; i++){
    if(parameters[i].indexOf("public") >= 0){
      var parameterName = parameters[i].split('/')[parameters[i].split('/').length-1].trim();
      setterString += "this." +  parameterName + " = " + parameterName + ";\n";
    }
  }
  var functionParts = stringRepresentation.match(/(^.*{)([\s\S]*)/);
  return functionParts[1] + setterString + functionParts[2];
}

console.log(publicParameters(Axe));

这里我们提取函数的参数,并检查那些具有public注释的参数。 如果在预处理器中使用此函数,则该函数的结果可以被传递回 eval 以供在当前对象中使用,或者写入文件。 通常不鼓励在 JavaScript 中使用 eval。

使用这种处理方法可以做很多不同的事情。 即使没有字符串后期处理,我们也可以通过包装方法探索一些有趣的编程概念。

软件的模块化是一个伟大的特性,本书的大部分内容都是关于模块化及其优点。 然而,软件的一些特性跨越了整个系统。 安全就是一个很好的例子。

我们希望在应用的所有模块中都有类似的安全代码,以检查人们是否被授权执行某些操作。 如果我们有这样一个函数:

var GoldTransfer = (function () {
  function GoldTransfer() {
  }
  GoldTransfer.prototype.SendPaymentOfGold = function (amountOfGold, destination) {
    var user = Security.GetCurrentUser();
    if (Security.IsAuthorized(user, "SendPaymentOfGold")) {
      //send actual payment
    } else {
      return { success: 0, message: "Unauthorized" };
    }
  };
  return GoldTransfer;
})();

我们可以看到有相当多的代码用于检查用户是否被授权。 同样的样板代码在应用的其他地方使用。 事实上,由于这是一个高安全性的应用,安全检查在每个公共功能中都是适当的。 一切都很好,直到我们需要对公共安全代码进行修改。 这个更改需要发生在应用中的每个公共函数中。 我们可以随心所欲地重构应用,但事实仍然是:我们需要在每个公共方法中至少有一些代码来执行安全检查。 这就是所谓的横切关注点。

在大多数大型应用中还有其他横切关注点的实例。 日志记录就是一个很好的例子,数据库访问和性能检测也是如此。 面向方面编程(AOP)提供了一种通过编织过程最小化重复代码的方法。

方面是一段可以拦截和更改方法调用的代码。 在。net 平台上有一个叫做 PostSharp 的工具来做方面编织,在 Java 平台上有一个叫做 AspectJ 的工具。 这些工具与构建管道挂钩,并在代码转换为指令后对其进行修改。 这允许在任何需要的地方注入代码。 源代码看起来没有改变,但是编译后的输出现在包含了对方面的调用。 方面通过注入现有代码来解决横切问题。 这里你可以看到一个方面通过编织器应用到一个方法:

Aspect oriented programming

当然,在大多数 JavaScript 工作流中,我们没有设计时的编译步骤。 幸运的是,我们已经看到了一些使用 JavaScript 实现横切的方法。 我们需要的第一件事是我们在测试一章中看到的方法的包装。 第二个是本章前面提到的tostring能力。

已经存在一些针对 JavaScript 的 AOP 库,可能是一个很好的选择。 但是,我们可以在这里实现一个简单的拦截器。 首先,让我们决定请求注入的语法。 我们将使用前面注释的相同思想来表示需要拦截的方法。 我们将使方法中的第一行成为注释,读取aspect(<name of aspect>)

首先,我们将对之前的GoldTransfer类进行稍微修改:

class GoldTransfer {
  SendPaymentOfGold(amountOfGold, destination) {
    var user = Security.GetCurrentUser();
    if (Security.IsAuthorized(user, "SendPaymentOfGold")) {
    }
    else {
     return { success: 0, message: "Unauthorized" };
    }
  }
}

我们已经剥离了中过去存在的所有安全内容,并添加了控制台日志,这样我们就可以看到它实际上是工作的。 接下来,我们需要一个方面来编织它:

class ToWeaveIn {
   BeforeCall() {
    console.log("Before!");
  }
  AfterCall() {
    console.log("After!");
  }
}

为此,我们使用一个简单的类,它有一个BeforeCallAfterCall方法,一个在原方法之前调用,一个在原方法之后调用。 在这种情况下,我们不需要使用 eval,因此拦截更安全:

function weave(toWeave, toWeaveIn, toWeaveInName) {
  for (var property in toWeave.prototype) {
    var stringRepresentation = toWeave.prototype[property].toString();
    console.log(stringRepresentation);
    if (stringRepresentation.indexOf("@aspect(" + toWeaveInName + ")")>= 0) {
      toWeave.prototype[property + "_wrapped"] = toWeave.prototype[property];
      toWeave.prototype[property] = function () {
      toWeaveIn.BeforeCall();
      toWeave.prototype[property + "_wrapped"]();
      toWeaveIn.AfterCall();
    };
    }
  }
}

可以很容易地将这个拦截器修改为快捷方式,并在调用 main 方法主体之前返回一些内容。 也可以通过简单地跟踪包装方法的输出,然后在AfterCall方法中修改它,从而修改函数的输出。

这是一个相当轻量级的 AOP 示例。 目前已有一些用于 JavaScript AOP 的框架,但最好的方法可能是使用预编译器或宏语言。

正如我们在本书的中所看到的,JavaScript 的继承模式不同于 c#和 Java 等语言中的典型模式。 JavaScript 使用了原型继承,它允许很容易地从多个源向类添加函数。 原型继承允许以类似于经常被诟病的多重继承的方式添加来自多个源的方法。 对多重继承的主要批评是,很难理解在某种情况下会调用方法的哪个重载。 这个问题在原型继承模型中有所缓解。 因此,我们可以放心地使用从多个来源添加功能的方法,即所谓的 mixins。

mixin 是一段可以添加到现有类中以扩展其功能的代码。 它们在需要在继承关系太强的不同类之间共享函数的情况下最有意义。

让我们想象一个场景,在这个场景中,这种功能将非常方便。 在维斯特洛大陆,死亡并不总是像我们的世界那样永恒。 然而,那些死而复生的人可能和他们活着的时候不完全一样。 虽然大部分功能是在PersonReanimatedPerson之间共享的,但它们并没有紧密到具有继承关系。 在这段代码中,您可以看到下划线的extend函数用于向我们的两个 people 类添加 mixin。 没有underscore也可以做到这一点,但正如前面提到的,在extends周围有一些复杂的边缘情况,这使得使用库非常方便:

var _ = require("underscore");
export class Person{
}
export class ReanimatedPerson{
}
export class RideHorseMixin{
  public Ride(){
    console.log("I'm on a horse!");
  }
}

var person = new Person();
var reanimatedPerson = new ReanimatedPerson();
_.extend(person, new RideHorseMixin());
_.extend(reanimatedPerson, new RideHorseMixin());

person.Ride();
reanimatedPerson.Ride();

Mixins 提供了一种机制来在不同的对象之间共享功能,但确实污染了原型结构。

通过宏对代码进行预处理并不是一个新想法。 对于 C 和 c++来说,它过去很流行,现在可能仍然很流行。 事实上,如果你看一看 Linux Gnu 实用程序的一些源代码,你会发现它们几乎完全是用宏编写的。 宏因为难以理解和调试而臭名昭著。 有一段时间,新创建的语言,如 Java 和 c#,不支持宏正是出于这个原因。

也就是说,甚至像 Rust 和 Julia 这样的最新语言也重新引入了宏的概念。 这些语言受到来自 Scheme 语言(一种 Lisp 方言)的宏的影响。 C 宏和 Lisp/Scheme 宏之间的区别在于 C 版本是文本的,而 Lisp/Scheme 版本是结构的。 这意味着 C 宏只是华丽的查找/替换工具,而 Scheme 宏知道围绕它们的抽象语法树(AST),这让它们变得更加强大。

AST for Scheme 的构造比 JavaScript 的构造简单得多。 然而,有一个非常有趣的项目叫做Sweet.js,它试图为 JavaScript 创建结构宏。

Sweet.js插入 JavaScript 构建管道和修改 JavaScript 源代码使用一个或多个宏。 有许多成熟的 JavaScript 转译器,即发出 JavaScript 的编译器。 这些编译器在多个项目之间共享代码时存在问题。 他们的代码是如此不同,以至于没有真正的方法来共享它。 Sweet.js支持在单个步骤中扩展多个宏 这允许更好的代码共享。 可重用位的大小更小,更容易一起运行。

Sweet.js的一个简单例子如下:

let var = macro {
  rule { [$var (,) ...] = $obj:expr } => {
    var i = 0;
    var arr = $obj;
    $(var $var = arr[i++]) (;) ...
  }

  rule { $id } => {
    var $id
  }
}

这里的宏提供了 ecmascript -2015 风格的解构器,将数组拆分为树字段。 宏匹配数组赋值和常规赋值。 对于常规的赋值,宏简单地返回标识,而对于数组赋值,它将爆炸文本并替换它。

例如,如果你运行以下代码:

var [foo, bar, baz] = arr;

那么,结果将是:

var i = 0;
var arr$2 = arr;
var foo = arr$2[i++];
var bar = arr$2[i++];
var baz = arr$2[i++];

这只是一个示例宏。 宏的功能非常强大。 宏可以创建一种全新的语言或改变非常小的事情。 它们可以很容易地插入,以满足任何方面的要求。

使用基于名称的依赖项注入允许名称之间发生冲突。 为了避免冲突,可能需要在注入参数前加上一个特殊字符。 例如,AngularJS 使用$符号来表示注入的术语。

在本章中,我多次提到 JavaScript 构建管道。 我们不得不构建一种解释语言,这似乎有些奇怪。 然而,构建 JavaScript 可能会导致某些优化和流程改进。 有许多工具可以用来帮助构建 JavaScript。 Grunt 和 Gulp 等工具是专门为执行 JavaScript 和 web 任务而设计的,但您也可以使用 Rake、Ant 甚至 make 等传统构建工具。

在本章中,我们介绍了许多高级 JavaScript 模式。 在这些模式中,我相信依赖项注入和宏对我们最有用。 您不一定要在每个项目中使用它们。 在处理问题时,仅仅意识到可能的解决方案可能会改变你处理问题的方法。

在本书中,我广泛地讨论了 JavaScript 的下一个版本。 但是,您不需要等到将来某个时候才能使用这些工具。 现在,有许多方法可以将更新版本的 JavaScript 编译为当前版本的 JavaScript。 最后一章将探讨一些这些工具和技术。

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

技术教程推荐

TensorFlow快速入门与实战 -〔彭靖田〕

零基础学Java -〔臧萌〕

编译原理之美 -〔宫文学〕

后端存储实战课 -〔李玥〕

物联网开发实战 -〔郭朝斌〕

基于人因的用户体验设计课 -〔刘石〕

如何成为学习高手 -〔高冷冷〕

大厂设计进阶实战课 -〔小乔〕

结构思考力 · 透过结构看思考 -〔李忠秋〕