JavaScript 设计模式详解

我们遇到的大多数问题都不是新问题。 许多在我们之前的程序员已经解决了类似的问题,通过他们的努力,各种编程模式已经出现。 我们称之为设计模式。

设计模式是代码所在的有用结构、样式和模板。 设计模式可以规定从代码基的整体框架到用于构建表达式、函数和模块的单个语法片段的任何内容。 在构建软件的过程中,我们常常不自觉地处于设计的过程中。 正是通过这个设计过程,我们定义了用户和维护人员在接触我们的代码时所要经历的体验。

为了让我们从设计师而不是程序员的角度来考虑这个问题,让我们考虑一个简单的软件抽象的设计。

在本章中,我们将涵盖以下主题:

让我们从设计师的角度来探讨一个简单的问题。 我们必须构造一个抽象,允许用户给我们两个字符串,一个主题字符串和一个查询字符串。 然后必须计算在主题字符串中找到的查询字符串的计数。

因此,考虑以下查询字符串:

"the"

看看下面的主题字符串:

"the fox jumped over the lazy brown dog"

我们应该收到一个2的结果。

作为设计师,我们关心那些必须使用我们代码的人的体验。 现在,我们不用担心我们的实现; 我们将只考虑界面,因为它主要是我们代码的界面,将驱动我们的程序员同伴的经验。

作为设计人员,我们首先要做的就是定义一个带有精心选择的名称和一组指定参数的函数:

function countNeedlesInHaystack(needle, haystack) { }

该函数接受needlehaystack,并返回Number,表示haystackneedle的计数。 我们代码的消费者会像这样使用它:

countNeedlesInHaystack('abc', 'abc abc abc'); // => 3

We are using the popular idiom of needle-in-a-haystack to describe the problem of looking for a substring within another string. Considering popular idioms is a crucial part of designing code, but we must be wary of idioms being misunderstood.

一段代码的设计应该由我们希望解决的问题领域和我们希望揭示的用户体验来定义。 对于相同的问题域,另一个程序员可能会选择不同的解决方案。 例如,他们可能使用了部分应用来允许以下调用语法:

needleCounter('app')('apple apple'); // => 2

或者,他们可能已经设计了一个更冗长的语法,包括调用一个Haystack构造函数和调用它的count()方法,就像这样:

new Haystack('apple apple'),count('app'); // => 2

这种经典方法在对象(Haystack)和count方法之间具有良好的语义关系。 它与我们在前几章中探讨的 OOP 概念非常契合。 也就是说,一些程序员可能会发现它过于冗长。

还有一种可能性是一个描述性更强的 API,其中参数定义在一个配置对象中(也就是说,一个普通的对象字面量作为唯一的参数传递):

countOccurancesOfNeedleInHaystack({
  haystack: 'abc abc abc',
  needle: 'abc'
}); // => 3

还有一种可能性是,这个计数功能可能会形成一个更大的字符串相关工具集的一部分,因此,可以合并到一个更大的自定义命名模块:

str('omg omg omg').count('omg'); // => 3

我们甚至可以认为修改原生的String.prototype是可以的,尽管这是不可取的,这样我们就可以在所有字符串上使用count方法:

'omg omg omg'.count('omg'); // => 3

在我们的命名约定方面,我们可能希望避免大海捞针的习惯用法,相反,使用更描述性的名称,可能有更小的误解风险,如以下:

  • searchableStringsubString
  • querycontent
  • searchcorpus

即使在这个非常狭窄的问题领域内,我们所能做的选择也是压倒性的。 对于哪种方法和命名约定在这里更好,您可能有许多自己的强烈意见。

事实上,我们可以用这么多不同的方法来解决一个看似简单的问题,这表明我们需要一个决策过程。 这个过程就是软件设计。 有效的软件设计使用设计模式来封装问题域,使其他程序员熟悉并易于理解。

The intent with our exploration of the needle-in-a-haystack problem was not to find a solution, but rather to highlight the difficulty of software design, and to expose our minds to a more user-oriented perspective. It also reminds us that there is very rarely one ideal design.

对于任何问题领域,精心选择的设计模式都具有两个基本特征:

  • :精心选择的设计模式将非常适合问题领域,这样我们就可以流畅地表达问题的本质及其解决方案。
  • :一个精心选择的设计模式对我们的程序员伙伴来说是很熟悉的。 他们如何使用它或对代码进行更改,这将是显而易见的。

设计模式在各种上下文和规模中都很有用。 我们在编写单个操作和函数时使用它们,但在构建整个代码库时也使用它们。 设计模式本身就是分层的。 它们存在于代码库的宏观和微观尺度上。 一个单一的代码库可以很容易地包含许多设计模式。

第二章整洁代码中,我们谈到了熟悉度作为一个关键特征。 汽车修理工打开汽车引擎盖时,会希望看到许多熟悉的模式:从单个部件的布线和焊接到汽缸、阀门和活塞等更大的结构。 他们希望找到一个特定的布局,如果没有,他们就会挠头,不知道该如何解决他们想要解决的问题。

熟悉可以增加我们解决方案的可维护性和可用性。 考虑以下目录结构和显示的logger.js源代码:

我们在这里可以观察到什么设计模式? 让我们来看一些例子:

  • 使用顶级的app/目录来包含所有的源代码
  • model, Views, Controllers(MVC)
  • 将实用程序分离到自己的目录中(utils/)
  • 驼峰式文件命名(例如,binarySearch.js)
  • logger.js中使用常规模块模式(即导出一个简单的方法对象)
  • 使用... && msgs.length来确认非零(即真值)长度
  • 在文件的顶部声明常量(即const ALL_LOGS_LEVEL)
  • (可能是其他…)

设计模式不仅仅是高大的建筑结构。 它们可以存在于代码库的每个部分:目录结构、文件命名和代码的各个表达式。 在每个层次上,我们使用通用模式可以增加我们表达问题域的能力,并增加新来者对我们代码的熟悉程度。 模式存在于模式中。

使用良好的设计模式可以对我们之前提到的干净代码的所有原则——可靠性、效率、可维护性和可用性——产生有益的影响:

  • 可靠性:一个好的设计模式将适合问题领域,并允许您轻松地表达您想要的逻辑和数据结构,而不太复杂。 熟悉您采用的设计模式也将使其他程序员容易理解并随着时间的推移提高代码的可靠性。
  • 效率:一个好的设计模式可以让你少操心如何构建你的代码库或你的个人模块。 它会让你花更多的时间来担心问题领域。 精心选择的设计模式还将有助于使不同代码段之间的接口更加流畅和易于理解。
  • 可维护性:好的设计模式易于适应。 如果有规格的变更或需要修复的 bug,程序员可以很容易地找到需要更改/插入的区域,并毫不费力地进行更改。
  • 可用性:好的设计模式因为熟悉而容易理解。 程序员同事可以很容易地理解代码的流程,并快速正确地断言代码如何工作以及如何使用它。 良好的设计模式还将创建令人愉快的用户体验,无论是通过编程 API 还是 GUI 表示。

您可以看到,许多使设计模式有用的东西只有在我们选择正确的模式时才能实现。 我们将探索一些流行的设计模式,并讨论每种模式适合的情况类型。 希望通过本文的探索,您能够更好地理解如何选择良好的设计模式。

**Be warned**: just as good design proliferates via convention, so does bad design. We discussed the phenomenon of cargo culting in Chapter 3, The Enemies of Clean Code, and so we are not strangers to how such types of bad designs may spread, but it's important to remain mindful of these traps when employing design patterns.

架构设计模式是我们将代码连接在一起的方式。 如果我们有十几个不同的模块,那么这些模块之间的通信方式决定了我们的体系结构。

JavaScript 代码库中使用的架构设计模式近年来发生了巨大的变化。 随着 React 和 Angular 等流行框架的不断涌现,我们看到代码库采用了新的约定。 形势仍在发生很大变化,所以我们不应该期待任何特定的标准很快出现。 尽管如此,大多数框架倾向于遵循相同的广泛架构模式。

An example of a popular architectural pattern is the separation of data logic and rendering logic. This is famously adopted by many different UI frameworks, albeit with different styles. This is likely due to the heritage of software UI and the early established pattern of MVC that eventually became the de facto approach.

在本节中,我们将涵盖两个著名的架构设计模式,MVC 及其分支,模型-视图-视图模型(MVVM)。 总之,这些应该让我们意识到通常分离的关注点类型,并有望激励我们在我们创建的架构中寻求类似的清晰级别。

MVC 的特点是这三个概念之间的分离。 一个 MVC 架构可能包括许多单独的模型、视图和控制器,它们都协同工作以解决给定的问题。 每一部分可以描述如下:

  • 模型:描述数据以及业务逻辑如何改变数据。 数据中的更改将在对视图的更改中显示。
  • :这描述了模型是如何呈现的(它的格式,布局和外观),并且当有一个动作需要发生时将调用控制器,可能是对用户事件的响应。
  • 控制器:它接受来自视图的指令,并通知模型要执行什么操作或更改,这将继续影响通过视图呈现给用户的任何内容。

我们可以在下图中观察到控制流程:

MVC 模式为我们提供了一种分离各种关注点的方法。 它规定了我们应该把关于业务决策的逻辑放在哪里(即,在模型中),以及我们应该把关于向用户显示事物的逻辑放在哪里(即,视图)。 此外,它还提供了 Controller,使这两个关注点能够相互通信。 MVC 所提倡的分离是非常有益的,因为它意味着我们的程序员同事可以很容易地辨别出在哪里进行必要的更改或修复。

MVC was originally posed in 1978 by Trygve Reenskaug while working at Xerox PARC. Its original purpose was to support the user's illusion of seeing and manipulating the domain information directly. At the time, this was quite revolutionary, but we now, as end users, take such UIs (and their transparent relation to their data) for granted.

为了说明 MVC 的实现在 JavaScript 中是什么样子,让我们构建一个非常简单的程序。 它将是一个基本的可变数字应用,呈现一个简单的 UI,用户可以在其中看到当前的数字,并选择通过增加或减少其值来更新它。

首先,我们可以使用模型实现数据的逻辑和包含:

class MutableNumberModel {
  constructor(value) {
    this.value = value;
  }
  increment() {
    this.value++;
    this.onChangeCallback();
  }
  decrement() {
    this.value--;
    this.onChangeCallback();
  }
  registerChangeCallback(onChangeCallback) {
    this.onChangeCallback = onChangeCallback;
  }
}

除了存储值本身,这个类还接受并依赖一个名为onChangeCallback的回调函数。 这个回调函数将由控制器提供,并在值发生变化时被调用。 这是必要的,这样我们就可以在模型改变时重新渲染视图。

接下来,我们需要构建控制器,它将充当viewmodel之间的一个非常简单的桥梁(或胶水)。 它注册必要的回调,以知道用户通过view请求更改或model的底层数据更改:

class MutableNumberController {

  constructor(model, view) {

    this.model = model;
    this.view = view;

    this.model.registerChangeCallback(
      () => this.view.renderUpdate()
    );
    this.view.registerIncrementCallback(
      () => this.model.increment()
    );
    this.view.registerDecrementCallback(
      () => this.model.decrement()
    );
  }

}

我们的view负责从model中检索数据并呈现给用户。 为此,它创建了一个 DOM 层次结构,数据将放在其中。 当点击incrementdecrement按钮时,它还侦听并将用户事件升级到controller:

class MutableNumberView {

  constructor(model, controller) {
    this.model = model;
    this.controller = controller;
  }

  registerIncrementCallback(onIncrementCallback) {
    this.onIncrementCallback = onIncrementCallback;
  }

  registerDecrementCallback(onDecrementCallback) {
    this.onDecrementCallback = onDecrementCallback;
  }

  renderUpdate() {
    this.numberSpan.textContent = this.model.value;
  }

  renderInitial() {

    this.container = document.createElement('div');
    this.numberSpan = document.createElement('span');
    this.incrementButton = document.createElement('button');
    this.decrementButton = document.createElement('button');

    this.incrementButton.textContent = '+';
    this.decrementButton.textContent = '-';

    this.incrementButton.onclick =
      () => this.onIncrementCallback();
    this.decrementButton.onclick =
      () => this.onDecrementCallback();

    this.container.appendChild(this.numberSpan);
    this.container.appendChild(this.incrementButton);
    this.container.appendChild(this.decrementButton);

    this.renderUpdate();

    return this.container;

  }

}

This is quite a lengthy View as we're having to create its DOM representation manually. Many modern frameworks (React, Angular, Svelte, and so on) allow you to declaratively express your hierarchy using either plain HTML or a hybrid syntax such as JSX (a syntax extension to JavaScript itself that permits XML-like tags within JavaScript code).

这个视图有两个渲染方法:renderInitial将执行初始渲染,设置 DOM 元素,然后renderUpdate方法负责在数字发生变化时更新数字。

将这些结合在一起,我们的简单程序将像这样初始化:

const model = new MutableNumberModel(5);
const view = new MutableNumberView(model);
const controller = new MutableNumberController(model, view);

document.body.appendChild(view.renderInitial());

view被赋予访问model的权限,以便它可以检索数据来渲染。 将controller赋给modelview,以便通过设置适当的回调将它们粘在一起。

当用户点击+(increment)按钮时,将启动以下过程:

  1. View 接收到来自incrementButton的 DOM 点击事件
  2. 视图触发它的onIncrementCallback(),由控制器监听
  3. 控制器指示模型increment()
  4. 模型调用它的突变回调,即onChangeCallback,由控制器监听
  5. 控制器指示视图重新呈现

您可能想知道我们为什么要在控制器和模型之间进行分离。 为什么视图不能直接与模型通信,反之亦然? 它可以! 但如果我们这样做,我们就会污染我们的视图模型,使其更有逻辑性,从而使其更复杂。 我们同样可以把所有东西都放在视图中,没有模型,但你可以想象那将会变得多么笨拙。 基本上,分离的程度和数量会随着你所从事的每个项目而变化。 MVC 的核心是,它教会我们如何将问题域从表示中分离出来。 如何处理这种分离取决于我们自己。

自 1978 年 MVC 首次出现以来,对它进行了许多改进,但它的中心主题是分离ModelView,这一主题持续了几十年。 考虑 React 应用的架构设计。 它包括 Components,其中包含呈现状态的逻辑,通常还包括几个特定于域的 reducer,这些 reducer 会采取操作(例如,用户点击了什么! ),并从这些动作中获得状态。

这个架构看起来与传统的 MVC 惊人的相似:

MVC 作为一种通用的指导模式,在过去的几十年里影响了无数框架和代码库的设计,而且还将继续这样做。 并非所有的改编、复制或 MVC 都将遵守 1978 年提出的原始描述,但通常情况下,这些改编将忠实于中心重要的主题,即将模型与其视图分离,并使视图成为模型的反映(甚至派生)。

MVVM 在精神上类似于它的祖先 MVC。 它规定了底层业务逻辑和驱动程序的数据以及数据呈现之间的严格分离:

  • 模型:描述数据以及业务逻辑如何改变数据。 数据的变化将体现在视图的变化中。
  • :这描述了模型如何呈现(其结构、布局和外观),将调用数据绑定机制ViewModel每当有一个动作需要发生,可能在响应用户事件。* 视图模型:这是在模型视图之间的粘合剂,使它们能够通过数据绑定机制相互通信。 这种机制在不同的实现之间有很大的差异。**

**各部分之间的关系如下图所示:

MVVM 架构在前端 JavaScript 中更受欢迎,因为它适合拥有一个不断更新的视图的需要,而传统的 MVC 在后端更受欢迎,因为它很好地满足了大多数 HTTP 响应的简单呈现一次的本质。

在 MVVM,之间的数据绑定 ViewModel视图通常使用 DOM 事件跟踪用户的意图,然后变异数据**模型,然后发出自己的突变事件,可以听ViewModel, 导致视图随着数据的变化而不断更新。**

许多框架都有自己的数据绑定适应。 例如,Angular 允许你在 HTML 模板中指定一个名为ng-model的自定义属性,它将把用户输入元素(如<input>)绑定到给定的数据模型上,允许数据双向流动。 如果模型被更新,<input>将被更新以反映这一点,反之亦然。

在你做 JavaScript 程序员的过程中,你会遇到 MVC 和 MVVM 的变体。 作为模式,它们是无限适用的,因为它们涉及软件系统的非常基本的原则:将数据输入到系统中,对数据进行处理,以及处理后的数据的后续输出。 我们还可以选择一些其他方式来将这些原则构建到代码库中,但最终,几乎每一次,我们都会以一个类似于 MVC(或 MVVM)的精神来描述这些问题的系统。

既然我们已经对如何构建代码库和描述良好设计的体系结构的描述类型有了明确的认识,我们就可以研究代码库的各个部分:模块本身。

在 JavaScript 中,模块这个词多年来一直在变化。 一个模块曾经是任何独立且自包含的代码段。 几年前,你可能会像这样在同一个文件中表达几个模块:

// main.js

// The Dropdown Module
var dropdown = /* ... definition ... */;

// The Data Fetcher Module
var dataFetcher = /* ... definition ...*/;

然而,如今,单词module倾向于指 ECMAScript 规范中规定的模块(大写M)。 这些模块是通过importexport语句在代码库中导入和导出的不同文件。 使用这样的模块,我们可能会有一个DropdownComponent.js文件,看起来像这样:

// DropdownComponent.js
class DropdownComponent {}
export default DropdownComponent;

如您所见,它使用export语句导出它的类。 如果我们希望使用这个类作为依赖项,我们可以像这样导入它:

// app.js
import DropdownComponent from './DropdownComponent.js'; 

ECMAScript Modules are slowly gaining more support across various environments. To make use of them within the browser, you can provide an entry script tag with a type of *modul*e, that is, <script type="module" />. Within Node.js, at the time of writing, ES Modules are still an experimental feature, so you can either rely on the old style of importing (const thing = require('./thing')) or you can enable experimental modules by using the --experimental-modules flag and using the .mjs extension on all of your JavaScript files.

importexport语句都允许多种语法。 它们允许您定义要导出或导入的内容的名称。 在一个 Module 只导出一个条目的场景中,通常使用export default [item],就像我们在DropdownComponent.js中所做的那样。 这确保了该模块的任何依赖项都可以导入它,并按照自己的意愿命名它,如下例所示:

import MyLocallyDifferentNameForDropdown from './DropdownComponent.js';

与此相反,您可以通过在花括号中声明并使用as关键字来指定导出的名称:

export { DropdownComponent as TheDropdown };

这将意味着任何进口商将需要明确指定TheDropdown的名称,如:

import { TheDropdown } from './DropdownComponent.js'; 

或者,你可以通过在你的export语句中加入特定的声明来导出命名的项,比如varconstlet,函数声明,或者类定义:

// things.js
export let x = 1;
export const y = 2;
export var z = 3;
export function myFunction() {}
export class MyClass {}

在导入方面,这样的命名导出可以通过使用花括号导入:

import { x, y, z, myFunction, MyClass } from './things.js'; 

在导入时,您还可以选择使用as关键字指定该导入的本地名称,使其本地名称与导出的名称不同(这在命名冲突的情况下特别有用):

import { MyClass as TheClass } from './things.js';
TheClass; // => The class
MyClass; // ! ReferenceError

常规做法是在提供几个相关抽象的代码区域中汇总导出。 例如,如果您已经组合了一个小型组件库,其中每个组件将自己导出为default,那么您可以使用index.js将所有组件一起公开:

// components/index.js
export {default as DropdownComponent} from './DropdownComponent.js';
export {default as AccordianComponent} from './AccordianComponent.js';
export {default as NavigationComponent} from './NavigationComponent.js';

In Node.js, an index.js/index.mjs file is imported by default if you try to import an entire directory. That is, if you import './components/', it would first look for the index file and, if available, would import it. In the browser, no such convention currently exists. All imports must be fully qualified filenames.

现在,我们可以非常方便地通过使用星号和import语句来导入整个组件集:

// app.js
import * from 'components/index.js';

// Make use of the imported components:
new DropdownComponent();
new AccordianComponent();
new NavigationComponent();

关于 JavaScript 中的模块,还有一些额外的细微差别和复杂性,尤其是考虑到 Node.js 的遗留问题时,不幸的是,我们没有时间深入讨论, 但是,到目前为止,我们所介绍的内容应该足以让您对该主题有足够的了解,从而提高效率,并为我们探索模块化设计模式这一主题铺平了道路。

模块化设计模式是我们用来设计单个模块的结构和语法约定。 我们通常会在不同的 JavaScript 模块中使用这些模式。 每个不同的文件应该提供并导出一个特定的抽象。

If you find yourself using these patterns several times within the same file, then it may be worth splitting them out. The directory and file structure of a given code base should ideally reflect its landscape of abstractions. You shouldn't have several abstractions crammed into a single file.

构造函数模式使用一个单独的构造函数,然后用方法和属性手动填充它的prototype。 这是在类定义语法存在之前在 JavaScript 中创建经典类 oop 的传统方法。

通常,它从定义一个构造函数作为函数声明开始:

function Book(title) {
  // Initialization Logic
  this.title = title;
}

然后,这将为原型分配单独的方法:

Book.prototype.getNumberOfPages = function() { /* ... */ };
Book.prototype.renderFrontCover: function() { /* ... */ };
Book.prototype.renderBackCover: function () { /* ... */ };

或者用一个对象字面量替换整个prototype:

Book.prototype = {
  getNumberOfPages: function() { /* ... */ },
  renderFrontCover: function() { /* ... */ },
  renderBackCover: function () { /* ... */ }
};

后一种方法更受欢迎,因为它更加封装和简洁。 当然,现在如果你想使用构造函数模式,你可能会选择方法定义,因为它们比单独的键值对占用更少的空间:

Book.prototype = {
  getNumberOfPages() { /* ... */ },
  renderFrontCover() { /* ... */ },
  renderBackCover () { /* ... */ }
};

构造函数的实例化是通过new关键字:

const myBook = new Book();

这将创建一个新对象,该对象具有构造函数的prototype(也就是我们的对象,它包含getNumberOfPagesrenderFrontCoverrenderBackCover)的内部[[Prototype]]

If you're struggling to recall the prototypal mechanisms that underlie constructors and instantiation, then please revisit Chapter 6, Primitives and Built-in Types, and, specifically, the section called The prototype.

构造函数模式在您希望使用抽象来封装名词概念的情况下是有用的,也就是说,一个事物的实例是有意义的。 例如:NavigationComponentStorageDevice。 构造器模式允许您创建类似于传统 OOP 类的抽象。 因此,如果您来自经典的 OOP 语言,那么您可以在以前使用类的地方使用构造函数模式。

如果你不确定构造函数模式是否适用,请考虑以下问题是否正确:

  • 这个概念能用名词来表达吗?
  • 这个概念需要构建吗?
  • 概念会因实例而异吗?

如果您正在抽象的概念不满足前面的任何标准,那么您可能需要考虑另一种模块化设计模式。 这方面的一个例子可能是具有各种帮助器方法的实用程序模块。 这样的模块可能不需要构造,因为它本质上是方法的集合,而这些方法及其行为在实例之间不会发生变化。

The Constructor pattern has largely fallen out of favor since the introduction of class definitions into JavaScript, which allow you to declare classes in a fashion much more akin to classical OOP languages (that is, class X extends Y {...}). Skip ahead to The Class pattern section to see this in action!

要用构造函数模式实现继承,你需要手动让你的prototype对象从父构造函数的prototype继承。

冒着过于简化的风险,我们将用Animal超类和Monkey子类的经典例子来说明这一点。 以下是我们对Animal的定义:

function Animal() {}
Animal.prototype = {
  isAnimal: true,
  grow() {}
};

从技术上讲,要实现继承,我们需要创建一个具有Animal.prototype原型的[[Prototype]]对象,然后使用这个新创建的对象作为我们的prototype子类。 我们的最终目标是做出这样的原型树:

Object.prototype
 └── Animal.prototype
      └── Monkey.prototype

使用给定的[[Prototype]]创建对象的最简单方法是使用Object.create(ThePrototype)。 在这里,我们可以使用它来扩展Animal.prototype并将结果赋值给Monkey.prototype:

function Monkey() {}
Monkey.prototype = Object.create(Animal.prototype);

然后我们可以自由地为这个新对象分配方法和属性:

Monkey.prototype.isMonkey = true;
Monkey.prototype.screech = function() {};

如果我们现在尝试实例化Monkey,那么我们不仅可以访问它自己的方法和属性,还可以访问从Animal.prototype继承的那些方法和属性:

new Monkey().isAnimal; // => true
new Monkey().isMonkey; // => true
typeof new Monkey().grow; // => "function"
typeof new Monkey().screech; // => "function"

Remember, this only works because Monkey.prototype (that is, [[Prototype]] of every Monkey instance) does itself have [[Prototype]] of Animal.prototype. And, as we know, if a property cannot be found on a given object, then it'll be looked for on its [[Prototype]] (if available).

每次单独设置一个原型的属性和方法是非常麻烦的,如下例所示:

Monkey.prototype.method1 = ...;
Monkey.prototype.method2 = ...;
Monkey.prototype.method3 = ...;
Monkey.prototype.method4 = ...;

因此,出现了另一种使事情变得更简单的模式:使用Object.assign()。 这允许我们将属性和方法批量设置为对象字面量,这意味着我们也可以使用方法定义语法:

function Monkey() {}
Monkey.prototype = Object.assign(Object.create(Animal.prototype), {
  isMonkey: true, 
  screech() {},
  groom() {}
});

Object.assign这里将把它的第二个(第三个,第四个,等等)参数中的任何属性赋给作为第一个参数传递的对象。 这为向子对象prototype添加属性提供了更简洁的语法。

由于更新的类定义语法,构造函数模式及其继承约定在很大程度上失去了人们的青睐,它允许使用更简洁、更简单的方法在 JavaScript 中利用原型继承。 因此,我们接下来要研究的是 Class 模式,它使用了这种较新的语法。

Reminder: For a more thorough refresher on [[Prototype]] (which is vital to understanding constructors and classes in JavaScript), you should re visit the section on The prototype in Chapter 6, Primitives and Built-in Types. A lot of the design patterns in this chapter make use of the prototype mechanism, so it's useful to have it fresh in your mind.

依赖于较新的类定义语法的 Class 模式已经在很大程度上取代了 Constructor 模式。 它涉及类的创建,类似于经典的 OOP 语言,尽管在幕后它使用与构造器模式相同的原型机制。 所以,可以说,这只是一点额外的语法,使语言更有表现力。

下面是一个抽象名称概念的基本类示例:

class Name {
  constructor(forename, surname) {
    this.forename = forename;
    this.surname = surname;
  }
  sayHello() {
   return `My name is ${this.forename} ${this.surname}`;
  }
}

通过这个语法创建类实际上是创建一个带有附加原型的构造函数,因此下面的代码是完全相同的:

function Name(forename, surname) {
  this.forename = forename;
  this.surname = surname;
}

Name.prototype.sayHello = function() {
  return `My name is ${this.forename} ${this.surname}`;
};

使用 Class 模式当然比笨拙的旧的构造函数模式更美观,但是不要被误导! 在幕后,完全相同的机制在起作用。

类模式很像构造函数模式,当你有一个满足以下条件的自包含概念时很有用:

  • 这个概念可用名词来表达
  • 这个概念需要建构
  • 这个概念在不同的实例中会有所不同

以下是一些遵循这些标准的概念示例,因此可以通过 Class 模式合理地表达:

  • 数据库记录(表示一段数据并允许查询和操作)
  • 一个 todo 项目组件(表示一个 todo 项目并允许它被渲染)
  • 二叉树(表示二叉树数据结构)

通常情况下,这种情况对你来说是很明显的。 如果遇到麻烦,请考虑您的抽象的用例,并尝试编写一些消费者代码,即利用您的抽象的伪代码。 如果它看起来很合理,使用起来也不会太尴尬,那么您可能已经找到了一个很好的模式。

静态方法和属性可以通过使用static关键字声明:

class Accounts {
  static allAccounts = [];
  static tallyAllAccounts() {
    // ...
  }
}

Accounts.tallyAllAccounts();
Accounts.allAccounts; // => []

这些属性和方法也可以很容易地添加到初始类定义之后:

Accounts.countAccounts = () => {
  return Accounts.allAccounts.length;
};

当方法或属性的功能和存在在语义上与整个类(而不是单个实例)相关时,静态方法很有用。

要在实例上声明一个公共字段(即属性),你可以简单地在类定义语法中声明:

class Rectangle {
  width = 100;
  height = 100;
}

这些字段为每个实例初始化,因此在实例本身是可变的。 当您需要为给定的属性定义一些合理的默认值时,它们是最有用的。 这样就可以在构造函数中轻松重写:

class Rectangle {
  width = 100;
  height = 100;

  constructor(width, height) {
    if (width && !isNaN(width)) {
      this.width = width;
    }
    if (height && !isNaN(height)) {
      this.height = height;
    }
  }
}

你也可以用#符号作为前缀来定义私有字段:

class Rectangle {
  #width = 100;
  #height = 100;

  constructor(width, height) {
    if (width && !isNaN(width)) {
      this.#width = width;
    }
    if (height && !isNaN(height)) {
      this.#height = height;
    }
  }
}

Traditionally, JavaScript had no concept of private fields, so programmers opted instead to prefix properties intended as private with one or more underscores (for example, __somePropertyName). This was understood as a social contract where other programmers would not mess with these properties (knowing that doing so might break things in unexpected ways).

私有字段只能由类本身访问。 子类不能访问:

class Super { #private = 123; }
class Sub { getPrivate() { return this.#private; } }

// !SyntaxError: Undefined private field #private:
// must be declared in an enclosing class

应该非常谨慎地使用私有字段,因为它们会严重限制代码的可扩展性,从而增加代码的刚性和灵活性的缺乏。 如果使用私有字段,应该确保已经考虑到了后果。 实际上,您可能需要的只是一个伪私有字段,前缀为下划线(例如,_private)或另一段模糊的标点符号(例如,$_private)。 按照惯例,这样做将确保使用您的接口的其他程序员(希望)明白他们不应该公开使用该字段。 如果他们这样做了,那就意味着他们可能会破坏东西。 如果他们希望用自己的实现扩展您的类,那么他们可以自由地使用您的私有字段。

类模式中的继承可以很简单地通过使用class ... extends语法实现,如下所示:

class Animal {}
class Tiger extends Animal {}

这将确保Tiger的任何实例将具有[[Prototype]],而[[Prototype]]本身具有Animal.prototype[[Prototype]]:

Object.getPrototypeOf(new Tiger()) === Tiger.prototype;
Object.getPrototypeOf(Tiger.prototype) === Animal.prototype;

在这里,我们确认了Tiger的每个新实例都有[[Prototype]]Tiger.prototypeTiger.prototype继承自Animal.prototype.

通常,扩展不仅用于创建语义子类,还用于提供方法的混合。 JavaScript 没有提供本地的混合机制,所以要实现它,你要么需要在定义之后扩充原型,要么从你的混合中有效地继承(就像它们是超类一样)。

用 mixins 增强prototype是最简单的方法。 我们可以通过将 mixins 指定为对象,然后通过方便的Object.assign方法将它们添加到类的prototype中来实现:

const fooMixin = { foo() {} };
const bazMixin = { baz() {} };

class MyClass {}
Object.assign(MyClass.prototype, fooMixin, bazMixin);

然而,这种方法不允许MyClass覆盖它自己的 mixin 方法:

// Specify MyClass with its own foo() method:
class MyClass { foo() {} }

// Apply Mixins:
Object.assign(MyClass.prototype, fooMixin, bazMixin);

// Observe that the mixins have overwritten MyClass's foo():
new MyClass().foo === fooMixin.foo; // true (not ideal)

这是预期的行为,但在某些情况下会让我们感到头痛。 因此,为了实现更一般化的 mixin 方法,我们可以探索不同的机制。 我们可以使用继承,而不是直接将混入方法到现有的prototype对象中。 这最容易通过所谓的子类工厂实现。 这些函数本质上只是函数本身返回一个扩展了指定超类的类:

const fooSubclassFactory = SuperClass => {
 return class extends SuperClass {
   fooMethod1() {}
   fooMethod2() {}
 };
};

下面是一个现实中的例子:

const greetingsMixin = Super => class extends Super {
  hello() { return 'hello'; }
  hi() { return 'hi'; }
  heya() { return 'heya'; }
};

class Human {}
class Programmer extends greetingsMixin(Human) {}

new Programmer().hi(); // => "hi"

我们还可以实现一个 helper 来组合任意数量的子类工厂。 它可以通过构建一个[[Prototype]]链接的链(或树)来实现,只要我们提供的mixins列表:

function mixin(...mixins) {
  return mixins.reduce((base, mixin) => {
    return mixin(base);
  }, Object);
}

Note how we have the default base class of our mixin reduction as Object. This is to ensure that Object is always at the top of our inheritance tree (and that we're not creating pointless intermediary classes).

下面是如何使用我们的mixinhelper:首先,定义我们的子类工厂(即实际的 mixins):

const alpha = Super => class extends Super { alphaMethod() {} };
const bravo = Super => class extends Super { braveMethod() {} };

然后,我们可以通过mixinhelper 使用这两个 mixin 来构造类定义:

class MyClass extends mixin(alpha, bravo) {
  myMethod() {}
};

这意味着结果的MyClass实例将访问它自己的原型(包含myMethod)、alpha 的原型(包含alphaMethod)和 bravo 的原型(包含bravoMethod):

typeof new MyClass().myMethod;    // => "function"
typeof new MyClass().alphaMethod; // => "function"
typeof new MyClass().braveMethod; // => "function"

mixin 可能很难正确使用,所以使用库或经过验证的代码段来为您处理这个问题是有帮助的。 你应该使用的 mixin 机制可能取决于你正在寻找的确切特征。 在本节中,我们已经看到了两个示例:一个是通过Object.assign()将方法组合成单一的[[Prototype]],另一个是创建继承树(即[[Prototypes]]链)来表示 mixin 层次结构。 希望您现在能够更好地选择其中的一个(或者实际上是所有其他在线可用的)最适合您的需求。

使用方法定义语法定义的类中的所有函数都有super绑定可用,它提供了对超类及其属性的访问。 super()函数可以直接调用(它将调用超类的构造函数),并提供对特定方法的访问(super.methodName())。

如果你正在扩展另一个类,并且你正在定义自己的构造函数,你必须调用super(),并且你必须在构造函数中以任何方式修改实例(即this)的任何其他代码之前调用super():

class Tiger extends Animal {
  constructor() {
    super(); // I.e. Call Animal's constructor
  }
}

如果你的构造函数试图在修改实例后调用super(),或者试图避免调用super(),那么你将收到ReferenceError:

class Tiger extends Animal {
  constructor() {
    this.someProperty = 123;
    super(); 
  }
}

new Tiger();
// ! ReferenceError: You must call the super constructor in a derived class
// before accessing 'this' or returning from the derived constructor

super绑定和它的奇怪之处在第 6 章原语和内置类型中有更详细的描述(参见函数绑定章节)。

Prototype 模式涉及使用普通对象作为其他对象的模板。 Prototype 模式直接扩展了这个模板对象,而不需要通过newConstructor.prototype对象进行实例化。 您可以将其视为类似于传统构造函数或减去构造函数的类模式。

通常,您将首先创建一个对象作为模板。 它将拥有与抽象相关联的所有方法和属性。 在一个inputComponent抽象的情况下,它可能看起来像这样:

const inputComponent = {
  name: 'Input Component',
  render() {
    return document.createElement('input');
  }
};

Note how inputComponent starts with a lowercase character. By convention, only constructor functions should be named with an initial capital letter.

使用我们的inputComponent模板,我们可以使用Object.create创建(或实例化)特定的实例:

const inputA = Object.create(inputComponent);
const inputB = Object.create(inputComponent);

正如我们所了解的,Object.create(thePrototype)简单地创建一个新对象,并将其内部的[[Prototype]]属性设置为thePrototype,这意味着在新对象上访问的任何属性都将在thePrototype上查找,如果它们在对象本身上不可用的话。 因此,我们可以像对待任何其他经典实例一样对待得到的对象,就像对更传统的构造函数或类模式产生的实例访问属性一样:

inputA.render();

为了方便起见,我们还可以在inputComponent本身上引入一个方法来完成对象创建工作:

inputComponent.extend = function() {
  return Object.create(this);
};

这意味着我们可以用更少的代码创建单独的实例:

const inputA = inputComponent.extend();
const inputB = inputComponent.extend();

如果我们希望创建其他类型的输入,那么我们可以很容易地扩展inputComponent,就像我们已经做的那样; 向结果对象添加一些方法; 然后把这个新对象提供给其他人来扩展:

const numericalInputComponent = Object.assign(inputComponent.extend(), {
  render() {
    const input = InputComponent.render.call(this);
    input.type = 'number';
    return input;
  }
});

如您所见,要覆盖一个特定的方法并访问它的父方法,我们需要直接引用并调用它(InputComponent.render.call())。 你可能认为我们应该能够使用super.render(),但不幸的是,super仅指定义了包含方法的对象(home)的[[Prototype]]。 而且因为Object.assign()有效地窃取了这些方法从他们的家庭对象,super将参考错误的东西。

The Prototype pattern is rather confusingly named. As we've seen, both the conventional Constructor pattern and the newer Class pattern involve the prototype, so you may want to instead refer to this pattern as the Object Extension Pattern or even the No-Constructor Approach to Prototypal Inheritance. Whatever you decide, it's quite a rare pattern. The classical OOP patterns are usually favored.

当您的抽象在实例(或扩展)之间具有不同的特征,但不需要构造时,Prototype 模式是最有用的。 在其核心,Prototype 模式实际上只涉及扩展机制(即通过Object.create),因此它可以同样地用于任何场景,即您有可能通过继承在语义上与其他对象相关的对象。

想象一个场景,我们需要表示三明治数据。 每个三明治都有一个名字,一个面包类型和三个配料槽。 例如,这是 BLT 的表示:

const theBLT = {
  name: 'The BLT',
  breadType: 'Granary',
  slotA: 'Bacon',
  slotB: 'Lettuce',
  slotC: 'Tomato'
};

我们可能希望创造一个 BLT 的适应性,重复使用它的大部分特征,除了Tomato成分,它将被Avocado取代。 我们可以简单地批量克隆对象,通过使用Object.assign将所有属性从theBLT复制到一个新的对象,然后具体复制(即覆盖)slotC:

const theBLA = Object.assign({}, theBLT, {
  slotC: 'Avocado'
});

但是如果 BLT 的breadType被改变了呢? 让我们来看看:

theBLT.breadType = 'Sourdough';
theBLA.breadType; // => 'Granary'

现在,theBLAtheBLT不同步。 我们已经意识到,我们真正想要的是一个继承模型,使theBLAbreadType始终匹配其母三明治的breadType。 为了实现这一点,我们可以简单地更改theBLA的创建,以便它继承theBLT(使用 Prototype 模式):

const theBLA = Object.assign(Object.create(theBLT), {
  slotC: 'Avocado'
});

如果我们以后改变theBLT的一个特征,将有助于通过遗传在theBLA中反映:

theBLT.breadType = 'Sourdough';
theBLA.breadType; // => 'Sourdough'

如您所见,这种无构造函数的继承模型在某些场景中非常有用。 我们也可以使用简单的类来表示这些数据,但使用这样的基本数据可能有些过分。 Prototype 模式的有用之处就在于它提供了一种简单而显式的继承机制,可以减少笨拙的代码(尽管,同样地,如果应用不当,可能会导致更复杂的代码)。

揭示模块模式是用来封装一些私有逻辑,然后公开公共 API 的模式。 该模式有一些调整,但通常是通过立即调用函数表达式(IIFE)来表示的,该表达式返回一个包含公共方法和属性的对象字面量:

const myModule = (() => {
  const privateFoo = 1;
  const privateBaz = 2;

  // (Private Initialization Logic goes here)

  return {
    publicFoo() {},
    publicBaz() {}
  };
})();

IIFE 返回的任何函数都将围绕其各自的作用域形成一个闭包,这意味着它们将继续访问private作用域。

真实世界中揭示模块的一个例子是这个简单的 DOM 组件,它包含了向用户呈现通知的逻辑:

const notification = (() => {

  const d = document;
  const container = d.body.appendChild(d.createElement('div'));
  const message = container.appendChild(d.createElement('p'));
  const dismissBtn = container.appendChild(d.createElement('button'));

  container.className = 'notification';

  dismissBtn.textContent = 'Dismiss!';
  dismissBtn.onclick = () => {
    container.style.display = 'none';
  };

  return {
    display(msg) {
      message.textContent = msg;
      container.style.display = 'block';
    }
  };
})();

外层作用域中的 notification 变量将引用由 IIFE 返回的对象,这意味着我们可以访问它的公共 API:

notification.display('Hello user! Something happened!');

在以下场景中,揭示模块模式特别有用:您需要在私有和公有之间进行描述,您有特定的初始化逻辑,以及由于某种原因,您的抽象不适合更多面向对象的模式(类或构造器模式)。

在类定义和#private字段存在之前,揭示模块模式是模拟真实隐私的唯一简单方法。 因此,它在某种程度上已经失宠了。 一些程序员仍然在使用它,但通常只是出于审美偏好。

Conventional Module 模式通常表示为带有一组方法的普通对象字面量:

const timeDiffUtility = {
  minutesBetween(dateA, dateB) {},
  hoursBetween(dateA, dataB) {},
  daysBetween(dateA, dateB) {}
};

对于这样的模块,通常还会显示特定的初始化方法,如initializeinitsetup。 或者,我们可能想提供改变整个模块状态或配置的方法(如setConfig):

const timeDiffUtility = {
  setConfig(config) {
    this.config = config;
  },
  minutesBetween(dateA, dateB) {},
  hoursBetween(dateA, dataB) {},
  daysBetween(dateA, dateB) {}
};

传统模块模式非常灵活,因为它只是一个普通的对象。 JavaScript 将函数视为一等公民(也就是说,它们就像任何其他值一样),这意味着你也可以轻松地从别处定义的函数中组合方法对象:

const log = () => console.log(this);

const library = {
  books: [],
  addBook() {},
  log // add log method
};

通常,您可能会考虑使用继承或 mixin 模式在库模块中包含这个log方法,但在这里,我们只是通过直接将其引用并插入到对象中来自己编写它。 这种模式在如何重用 JavaScript 代码方面给了我们很大的灵活性。

如果您只是希望将一组相关的方法或属性打包成具有公共名称的内容,那么在任何场景中,Conventional Module 模式都很有用。 它们通常用于相互关联的常用方法集合,例如日志实用程序:

const logger = {
  log(message) { /* ... */ },
  warn(message) { /* ... */ },
  error(message) { /* ... */ }
};

Conventional Module 模式只是一个对象,所以根本没有必要提及它。 但是,从技术上讲,它是其他抽象定义技术的替代方法,因此将它指定为自己的模式是有用的。

类模式很快成为事实上的模式来创建抽象类型,包括单例对象和效用,因此它可能并不总是你的班级需要用作传统 OOP 类继承和实例化。 例如,我们可能希望用类定义来设置一个实用程序对象,这样我们就可以在构造函数中定义任何初始化逻辑,并在其方法中提供封装的假象:

const utils = new class {
  constructor() {
    this.#privateThing = 123;
    // Other initialization logic here...
  }
  utilityA() {}
  utilityB() {}
  utilityC() {}
};

utils.utilityA(); 

在这里,我们创建并立即实例化一个类。 这在本质上类似于揭示模块模式,在该模式中,我们利用 IIFE 来封装初始化逻辑和公共 API。 这里,我们没有通过作用域(以及围绕私有变量产生的闭包)来实现封装,而是使用简单的构造函数来定义初始化。 然后我们使用常规的实例属性和方法来定义我们的私有变量和公共接口。

当只需要一个类的一个实例时,单例很有用。 产生的奇异实例在性质上与常规或揭示模块模式相似。 它允许您使用私有变量和隐式构造逻辑的选项来包装抽象。 单例的常用用例包括:UtilitiesLoggingCachingGlobal Event buss 等等。

决定使用哪种架构和模块化设计模式可能是一个棘手的过程,因为通常在决定时,项目的所有需求可能都不是很明显。 此外,我们作为程序员并不是无所不知的。 我们是有缺陷的、自私的、通常充满激情的个体。 这种组合,如果不加以控制,就会产生混乱的代码库,从而阻碍我们正在努力培养的生产力、可靠性和可维护性。 要警惕这些陷阱,请记住以下几点:

  • :每个软件项目都会在某些时候涉及到变更。 如果我们在架构和模块化设计中具有前瞻性,那么我们将能够限制这种未来的痛苦,但绝不要在项目开始时就认为您将创建One True Solution。 相反,迭代,质疑你的判断,然后再迭代。
  • :与那些将不得不使用你的代码的涉众交谈。 这可能是您团队中的其他程序员或其他将使用您提供的接口的程序员。 收集意见和数据,然后做出明智的决定。
  • :要意识到货物崇拜和你的自我,如果我们不小心,我们会盲目地继承做事的方式,而没有至关重要地考虑它们的适用性,或者我们会被我们的自我所困: 认为一个特定的设计或方法是完美的,仅仅因为它是我们个人知道和喜欢的。
  • :在设计建筑时,最重要的是寻求和谐。 代码库中总会有许多单独定制的部分,但是太多的内部差异会让维护人员感到困惑,并导致代码库质量和可靠性的分裂。

在本章中,我们探讨了 JavaScript 中设计模式的目的和应用。 这包含了对设计模式的基本反思,以及对一些常见的模块和架构设计模式的探索。 我们已经探索了使用 JavaScript 的原生机制(如类和原型)以及一些更新颖的机制(如揭示模块模式)来声明抽象的各种方法。 我们对这些模式的深入报道将确保,在未来,当我们制作抽象时,我们有足够的选择。

在下一章中,我们将探索 JavaScript 程序员所遇到的现实世界的挑战,比如状态管理和网络通信,并将我们的新知识应用于这些挑战。**

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

技术教程推荐

微服务架构核心20讲 -〔杨波〕

白话法律42讲 -〔周甲徳〕

Python核心技术与实战 -〔景霄〕

Swift核心技术与实战 -〔张杰〕

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

技术管理案例课 -〔许健〕

动态规划面试宝典 -〔卢誉声〕

Python自动化办公实战课 -〔尹会生〕

手把手带你写一门编程语言 -〔宫文学〕