三、组件组成

大型 JavaScript 应用相当于一系列通信组件。 本章的重点是这些组件的组成,而在下一章中,我们将看看这些组件如何彼此通信。 组合是一个大主题,它与可伸缩的 JavaScript 代码相关。 当我们开始考虑组件的组成时,我们开始注意到设计中的某些缺陷; 限制阻止我们对影响者做出反应。

组件的组合不是随机的——JavaScript 组件有一些流行的模式。 在本章的开始,我们将看看这些通用组件类型,它们封装了每个 web 应用中常见的模式。 理解组件实现模式对于以可伸缩的方式扩展这些通用组件至关重要。

从纯粹的技术角度来看,正确地组合组件是一回事,而轻松地将这些组件映射到特性则是另一回事。 同样的挑战也适用于我们已经实现的组件。 我们编写代码的方式需要提供一定程度的透明度,这样就可以在运行时和设计时分解组件并理解它们在做什么。

最后,我们将了解如何将业务逻辑与组件分离。 这并不是什么新鲜事——分离关注点的想法已经存在很长时间了。 JavaScript 应用的挑战在于它涉及了太多的东西——很难将业务逻辑与其他实现关注点清楚地分开。 我们组织源代码的方式(相对于使用它们的组件而言)会对我们的扩展能力产生显著的影响。

通用组件类型

在这个时代,任何人都不可能在没有库、框架或两者的帮助下着手构建大规模的 JavaScript 应用。 让我们将这些统称为工具,因为我们更感兴趣的是使用那些帮助我们扩展的工具,而不一定是哪些工具比其他工具更好。 在一天结束的时候,要由开发团队来决定哪种工具最适合我们正在构建的应用,个人偏好除外。

在选择我们使用的工具时,指导因素是它们提供的组件类型,以及这些组件的功能。 例如,一个更大的 web 框架可能拥有我们需要的所有通用组件。 另一方面,函数式编程实用程序库可能提供我们需要的许多低级功能。 这些东西是如何组成一个紧密结合的功能,这是我们要弄清楚的。

其思想是寻找能够公开我们需要的组件的通用实现的工具。 通常,我们会扩展这些组件,构建应用特有的特定功能。 本节将介绍大型 JavaScript 应用中最典型的组件。

模块

模块以一种或另一种形式存在于几乎每种编程语言中。 除了 JavaScript。 但这几乎是不正确的——在撰写本文时,ecmascript 6 在其最终草案中引入了模块的概念。 然而,今天有一些工具允许我们模块化代码,而不依赖script标签。 大规模 JavaScript 代码仍然是一个相对较新的事物。 像script标签这样的东西并不是为了解决模块化代码和依赖管理之类的问题。

RequireJS 可能是最流行的模块加载器和依赖解析器。 我们需要一个库来将模块加载到前端应用中,这说明了其中的复杂性。 例如,当需要考虑网络延迟和竞争条件时,模块依赖关系就不是一件小事。

另一种选择是使用像Browserify这样的转译器。 这种方法越来越受欢迎,因为它允许我们使用 CommonJS 格式声明模块。 NodeJS 使用这种格式,即将发布的 ECMAScript 模块规范更接近 CommonJS,而不是 AMD。 这样做的好处是,我们现在编写的代码与后端 JavaScript 代码以及未来的代码都有更好的兼容性。

一些框架,比如 Angular 或 Marionette,对什么是模块有自己的想法——尽管是更抽象的想法。

这些模块更多地是关于组织我们的代码,而不是如何巧妙地将代码从服务器发送到浏览器。 这些类型的模块甚至可以更好地映射到框架的其他特性。 例如,如果有一个集中式的应用实例用于管理我们的模块,那么框架可能提供一种管理来自应用的模块的方法。 看看下面的图表:

Modules

使用模块作为构建块的全局应用组件。 模块可以很小,只包含一个特性,也可以很大,包含多个特性

这让我们可以在模块级别执行更高级别的任务(比如禁用模块或用参数配置它们)。 本质上,模块代表特性。 它们是一种封装机制,允许我们封装应用不关心的给定特性。 模块通过向特性添加高级操作,将特性视为构建块,帮助我们扩展应用。 如果没有模块,我们就无法实现这一目标。

根据用于声明模块的机制,模块的组合看起来不同。 模块可以很简单,提供一个可以导出对象的名称空间。 或者,如果我们使用特定的框架模块风格,可能会有更多的内容。 例如自动事件生命周期,或用于执行样板设置任务的方法。

然而我们将其切片,在可伸缩的 JavaScript 环境中,模块是一种创建更大的构建块的方法,也是一种处理复杂依赖关系的方法:

// main.js
// Imports a log() function from the util.js model.
import log from 'util.js';
log('Initializing...');

// util.js
// Exports a basic console.log() wrapper function.
'use strict';

export default function log(message) {
    if (console) {
        console.log(message);
    }
}

虽然使用模块大小的构建块构建大规模应用更容易,但从应用中分离模块并单独使用它也更容易。 如果我们的应用是单片的,或者我们的模块过于丰富和精细,对于我们来说,从代码中删除问题点或测试正在进行的工作是非常困难的。 我们的组件可以自己完美地运行。 然而,它可能会在系统的其他地方产生负面的副作用。 如果我们能够在不费太多力气的情况下,一次解决一块难题,我们就可以扩大故障排除过程的规模。

路由

任何大型 JavaScript 应用都有大量可能的 uri。 URI 是用户正在查看的页面的地址。 它们可以通过单击链接导航到这个资源,或者它们可能会被我们的代码自动带至一个新的 URI,这可能是对某些用户操作的响应。 早在大规模 JavaScript 应用出现之前,web 就一直依赖于 uri。 uri 指向资源,而资源可以是任何东西。 应用越大,资源就越多,潜在的 uri 就越多。

路由组件是我们在前端使用的工具,用于侦听这些 URI 更改事件并相应地响应它们。 对后端 web 服务器解析 URI 并返回新内容的依赖较少。 大多数网站仍然这样做,但当涉及到构建应用时,这种方法有几个缺点:

Routers

当 URI 发生变化时,浏览器会触发事件,路由组件会响应这些变化。 URI 更改可以从历史 API 或从location.hash触发。

主要的问题是,我们想要的 UI 是可移植的,就像,我们想要能够部署它对任何后端和事情应该工作。 由于我们没有在后端为 URI 组装标记,所以在后端解析 URI 也没有意义。

我们以声明的方式指定路由组件中的所有 URI 模式。 我们一般将这些称为路由。 可以将路由看作蓝图,将 URI 看作蓝图的一个实例。 这意味着当路由接收到一个 URI 时,它可以将它与路由关联起来。 这在本质上是路由组件的职责。 这对于较小的应用来说很容易,但当我们谈到规模时,需要进一步考虑路由的设计。

首先,我们必须考虑我们想要使用的 URI 机制。 这两种选择基本上是侦听散列更改事件,或者利用历史 API。 使用散列 uri 可能是最简单的方法。 另一方面,每个现代浏览器中都有historyAPI,它让我们不用散列就可以格式化 URI——它们看起来像真正的 URI。 我们正在使用的框架中的路由组件可能只支持其中一个或另一个,从而简化了决策。 有些 URI 支持两种方法,在这种情况下,我们需要决定哪一种最适合我们的应用。

关于我们架构中的路由,下一件要考虑的事情是如何对路由更改作出反应。 通常有两种方法。 第一种方法是声明性地将路由绑定到回调函数。 当路由没有很多路由时,这是理想的。 第二种方法是在激活路由时触发事件。 这意味着没有任何东西直接绑定到路由上。 相反,其他组件会监听这样的事件。 当有很多路由时,这种方法是有益的,因为路由不知道组件,只知道路由。

下面是一个路由组件监听路由事件的例子:

// router.js

import Events from 'events.js'

// A router is a type of event broker, it
// can trigger routes, and listen to route
// changes.
export default class Router extends Events {

    // If a route configuration object is passed,
    // then we iterate over it, calling listen()
    // on each route name. This is translating from
    // route specs to event listeners.
    constructor(routes) {
        super();

        if (routes != null) {
            for (let key of Object.keys(routes)) {
                this.listen(key, routes[key]);
            }
        }
    }

    // This is called when the caller is ready to start
    // responding to route events. We listen to the
    // "onhashchange" window event. We manually call
    // our handler here to process the current route.
    start() {
        window.addEventListener('hashchange',
            this.onHashChange.bind(this));

        this.onHashChange();
    }

    // When there's a route change, we translate this into
    // a triggered event. Remember, this router is also an
    // event broker. The event name is the current URI.
    onHashChange() {
        this.trigger(location.hash, location.hash);
    }

};

// Creates a router instance, and uses two different
// approaches to listening to routes.
//
// The first is by passing configuration to the Router.
// The key is the actual route, and the value is the
// callback function.
//
// The second uses the listen() method of the router,
// where the event name is the actual route, and the
// callback function is called when the route is activated.
//
// Nothing is triggered until the start() method is called,
// which gives us an opportunity to set everything up. For
// example, the callback functions that respond to routes
// might require something to be configured before they can
// run.

import Router from 'router.js'

function logRoute(route) {
    console.log('${route} activated');
}

var router = new Router({
    '#route1': logRoute
});

router.listen('#route2', logRoute);

router.start();

注释

清单中省略了运行这些示例所需的一些代码。 例如,events.js模块包含在本书附带的代码包中,它只是与示例不太相关。

同样出于篇幅考虑,代码示例避免使用特定的框架和库。 实际上,我们不会编写自己的路由或事件 api——我们的框架已经这样做了。 相反,我们使用 vanillaES6 JavaScript 来说明与扩展应用相关的要点。

当涉及到路由时,我们要考虑的另一个架构是我们想要一个全局的、单片的路由、每个模块的路由,还是一些其他组件。 使用单片路由的缺点是,当它足够大时,随着我们不断添加功能和路由,它变得难以扩展。 优点是所有的路由都在一个地方声明。 单片路由仍然可以触发所有组件都可以监听的事件。

每个模块的路由方法涉及多个路由实例。 例如,如果我们的应用有 5 个组件,每个组件都有自己的路由。 这里的优点是模块是完全自包含的。 任何使用这个模块的人都不需要在其他地方查看来找出它响应的路由。 使用这种方法,我们还可以在路由定义和响应它们的函数之间有更紧密的耦合,这可能意味着更简单的代码。 这种方法的缺点是,我们失去了将所有路线声明在一个中心位置的统一性。 看看下面的图表:

Routers

左边的路由是全局的——所有模块都使用同一个实例来响应 URI 事件。 右边的模块有自己的路由。 这些实例包含特定于模块的配置,而不是整个应用

根据我们使用的框架的功能,路由组件可能支持也可能不支持多个路由实例。 每个路由可能只有一个回调函数。 路由事件中可能有一些我们还没有意识到的细微差别。

模型/集合

我们的应用与公开实体交互的 API。 一旦这些实体被转移到浏览器,我们将存储这些实体的模型。 集合是一组相关实体,通常是同一类型的。

我们正在使用的工具可能提供也可能不提供通用模型和/或集合组件,或者它们可能具有类似的东西,但名称不同。 建模 API 数据的目标是大致接近 API 实体。 这就像将模型存储为普通 JavaScript 对象,将集合存储为数组一样简单。

将 API 实体简单地存储为数组中的普通对象的挑战在于,其他一些组件随后负责与 API 通信,在数据更改时触发事件,并执行数据转换。 我们希望其他组件能够在需要的地方转换集合和模型,以履行其职责。 但是我们不想要重复的代码,如果我们能够封装常见的东西,如转换、API 调用和事件生命周期,那将是最好的。 看看下一个图表:

Models/Collections

模型封装了与 api 的交互、解析数据和在数据更改时触发事件。 这使得模型之外的代码更简单

隐藏 API 数据如何加载到浏览器或如何发出命令的细节,有助于我们在成长过程中扩展应用。 当我们向 API 中添加更多的实体时,代码的复杂性也会随之增加。 我们可以通过约束模型和集合组件的 API 交互来抑制这种复杂性。

提示

下载示例代码

您可以从您的帐户http://www.packtpub.com下载您所购买的所有 Packt Publishing 书籍的示例代码文件。 如果您在其他地方购买了这本书,您可以访问http://www.packtpub.com/support并注册,直接将文件通过电子邮件发送给您。

我们的模型和集合将面临的另一个可伸缩性问题是它们在整体中的位置。 也就是说,我们的应用实际上只是一个大组件,由更小的组件组成。 我们的模型和集合很好地映射到 API,但不一定映射到特性。 API 实体比特定的特性更通用,并且经常被几个特性使用。 这就给我们留下了一个悬而未决的问题——我们的模型和集合在什么地方适合组件?

下面是一个显示特定视图扩展通用视图的示例。 同样的模型可以传递给这两个:

// A super simple model class.
class Model {
    constructor(first, last, age) {
        this.first = first;
        this.last = last;
        this.age = age;
    }
}

// The base view, with a name method that
// generates some output.
class BaseView {
    name() {
        return '${this.model.first} ${this.model.last}';
    }
}

// Extends BaseView with a constructor that accepts
// a model and stores a reference to it.
class GenericModelView extends BaseView {
    constructor(model) {
        super();
        this.model = model;
    }
}

// Extends GenericModelView with specific constructor
// arguments.
class SpecificModelView extends BaseView {
    constructor(first, last, age) {
        super();
        this.model = new Model(...arguments);
    }
}

var properties = [ 'Terri', 'Hodges', 41 ];

// Make sure the data is the same in both views.
// The name() method should return the same result...
console.log('generic view',
    new GenericModelView(new Model(...properties)).name());
console.log('specific view',
    new SpecificModelView(...properties).name());

一方面,组件可以完全通用于它们使用的模型和集合。 在的另一方面,一些组件是特定于他们的需求-他们可以直接实例化他们的集合。 在运行时使用特定的模型和集合配置泛型组件,只有当组件真正是泛型且在多个地方使用时,才会对我们有好处。 否则,我们还可以将模型封装在使用它们的组件中。 选择正确的方法有助于我们扩大规模。 因为不是所有的组件都是通用的或特定的。

控制器/视图

根据我们使用的框架和我们团队遵循的设计模式,控制器和视图可以代表不同的东西。 有太多的 MV模式和风格的变化提供一个有意义的区别的规模。 微小的差异是相对于相似但不同的 MV方法的权衡。 为了讨论大规模的 JavaScript 代码,我们将把和看作相同类型的组件。 如果我们决定在实现中分离这两个概念,本节中的思想将与这两种类型相关。

现在让我们坚持使用视图这个术语,因为我们在概念上涵盖了视图和控制器。 这些组件与其他几种组件类型交互,包括路由、模型或集合以及模板,这些将在下一节中讨论。 当某件事发生时,用户需要得到通知。 视图的任务是更新 DOM。

这可以像改变一个 DOM 元素的属性一样简单,也可以像渲染一个新模板一样复杂:

Controllers/Views

更新 DOM 以响应路由和模型事件的视图组件

视图可以更新 DOM 以响应几种类型的事件。 路线可能已经改变了。 一个模型可以被更新。 或者更直接的方法,比如对视图组件的方法调用。 更新 DOM 并不像人们想象的那么简单。 我们需要考虑性能——当我们的视图被事件淹没时会发生什么? 需要考虑延迟——在停止并实际允许 DOM 渲染之前,这个 JavaScript 调用堆栈将运行多长时间?

视图的另一个职责是响应 DOM 事件。 这些通常是由用户与我们的 UI 交互触发的。 这种互动可能以我们的观点开始,也可能以我们的观点结束。 例如,根据用户输入或模型的状态,我们可以用消息更新 DOM。 或者我们可能什么也不做,例如,如果事件处理程序是而不是

被撤销的函数将多个调用分组为一个。 例如,在 10 毫秒内调用 20 次foo()可能只导致调用一次foo()的实现。 有关的更详细解释,请参阅:http://drupalmotion.com/article/debounce-and-throttle-visual-explanation。 大多数情况下,DOM 事件被转换成其他东西,或者是方法调用,或者是另一个事件。 例如,我们可以调用模型上的方法,或者转换集合。 大多数情况下,最终结果是我们通过更新 DOM 来提供反馈。

这可以直接做到,也可以间接做到。 在直接 DOM 更新的情况下,很容易伸缩。 在间接更新或通过副作用更新的情况下,扩展变得更有挑战性。 这是因为当应用获得更多的活动部件时,就越难以在大脑中形成因果关系的地图。

下面的例子显示了一个侦听 DOM 事件和模型事件的视图。

import Events from 'events.js';

// A basic model. It extending "Events" so it
// can listen to events triggered by other components.
class Model extends Events {
    constructor(enabled) {
        super();
        this.enabled = !!enabled;
    }

    // Setters and getters for the "enabled" property.
    // Setting it also triggers an event. So other components
    // can listen to the "enabled" event.
    set enabled(enabled) {
        this._enabled = enabled;
        this.trigger('enabled', enabled);
    }

    get enabled() {
        return this._enabled;
    }
}

// A view component that takes a model and a DOM element
// as arguments.
class View {
    constructor(element, model) {

        // When the model triggers the "enabled" event,
        // we adjust the DOM.
        model.listen('enabled', (enabled) => {
            element.setAttribute('disabled', !enabled);
        });

        // Set the state of the model when the element is
        // clicked. This will trigger the listener above.
        element.addEventListener('click', () => {
            model.enabled = false;
        });
    }
}

new View(document.getElementById('set'), new Model());

所有这些复杂性的好处是,我们实际上得到了一些可重用的代码。 视图不知道它正在监听的模型或路由是如何更新的。 它只关心特定组件上的特定事件。 这实际上对我们很有帮助,因为它减少了我们需要实现的特殊情况处理的数量。

DOM 结构是在运行时生成的,这是渲染所有视图的结果,也需要考虑。 例如,如果我们查看一些顶级 DOM 节点,它们在节点中嵌套了结构。 正是这些顶层节点构成了布局的框架。 也许这是由主应用视图呈现的,并且每个视图都与它有子关系。 或者也许等级制度延伸到更低的层次。 我们使用的工具很可能有处理这些亲子关系的机制。 然而,请记住,庞大的视图层次结构是很难伸缩的。

模板

模板引擎通常驻留在后端框架中。 但现在情况不太一样了,这在很大程度上要归功于前端提供的复杂的模板呈现库。 在大型 JavaScript 应用中,我们很少与后端服务讨论 ui 特定的内容。 我们不会说,“这是一个 URL,为我渲染 HTML”。 现在的趋势是给予 JavaScript 组件一定程度的自主权——让它们呈现自己的标记。

将组件标记与呈现它们的组件结合在一起是一件好事。 这意味着我们可以很容易地识别 DOM 中的标记是在哪里生成的。 然后我们可以诊断问题并调整大型应用的设计。

模板帮助我们在每个组件中建立关注点的分离。 浏览器中呈现的标记主要来自于模板。 这将使特定于标记的代码远离 JavaScript。 前端模板引擎不仅仅是替换字符串的工具; 他们通常有其他工具来帮助减少样板 JavaScript 代码的编写量。 例如,我们可以在适合的地方嵌入条件和 For -each 循环。

应用组件

到目前为止,我们讨论的组件类型对于实现可伸缩的 JavaScript 代码非常有用,但它们也非常通用。 不可避免的是,在实现过程中,我们将遇到一个障碍——我们一直遵循的组件组合模式将不适用于某些特性。 这时,我们应该后退一步,考虑是否可能向我们的体系结构中添加一种新的组件类型。

例如,考虑小部件的概念。 这些是主要关注表示和用户交互的通用组件。 假设我们的许多视图都使用完全相同的 DOM 元素和完全相同的事件处理程序。 没有必要在应用的每个视图中重复它们。 如果我们把它分解成一个公共分量,会不会更好呢? 视图可能有点过分了,所以我们可能需要一种新的小部件组件类型?

有时候,我们只为了合成的目的而创建组件。 例如,我们可能有一个组件,它将路由、视图、模型/集合和模板组件粘在一起,形成一个内聚单元。 模块部分地解决了这个问题,但它们并不总是足够的。 有时候,我们会遗漏组件为了进行通信而需要的额外编排。 我们将在下一章讨论通信组件。

扩展通用组件

我们经常在开发过程的后期发现,我们所依赖的组件缺少我们所需要的东西。 如果我们使用的基本组件设计得很好,那么我们可以扩展它,插入我们需要的新属性或功能。 在本节中,我们将介绍一些场景,在这些场景中,我们可能需要扩展应用中使用的通用组件。

如果我们要扩展我们的代码,我们需要尽可能地利用这些基本组件。 我们可能也想开始扩展我们自己的基本组件。 有些工具在促进扩展机制方面优于其他工具,我们通过扩展机制实现了这种专门化的行为。

识别常用数据和功能

在我们讨论扩展特定的组件类型之前,有必要考虑所有组件类型中通用的属性和功能。 其中一些是显而易见的,而另一些则不那么明显。 我们的扩展能力部分依赖于我们识别组件之间的共性的能力。

如果我们有一个全局应用实例(在大型 JavaScript 应用中很常见),全局值和功能可以存在于其中。 不过,随着越来越多的常见事物被发现,这种情况可能会变得难以控制。 另一种方法可能是使用多个全局模块,而不仅仅是一个应用实例。 或两者兼而有之。 但从可理解的角度来看,这并不是可规模性的:

Identifying common data and functionality

理想的组件层次结构不会扩展到三层以上。 顶层通常位于应用所依赖的框架中

根据经验,对于任何给定的组件,我们都应该避免将其向下扩展超过三层。 例如,我们正在使用的工具中的通用视图组件可以被我们的通用版本扩展。 这将包括应用中的每个视图实例都需要的属性和功能。 这只是一个两层的层次结构,并且很容易管理。 这意味着,如果任何给定的组件需要扩展我们的通用视图,它可以在不使事情复杂化的情况下这样做。 三层应该是任何给定类型的最大扩展层次深度。 这足以避免不必要的全局数据,超出这个范围就会出现规模问题,因为层次结构不容易掌握。

扩展路由组件

我们的应用可能只需要一个路由实例。 即使在这种情况下,我们可能仍然需要覆盖通用路由的某些扩展点。 在多个路由实例的情况下,我们不希望重复的公共属性和功能。 例如,如果我们的应用中的每个路由都遵循相同的模式,只有细微的差异,我们可以在基本路由中实现这些工具,以避免重复的代码。

除了声明路由外,当给定的路由被激活时,事件也会发生。 根据应用的架构,需要发生不同的事情。 也许某些事情总是需要发生,不管激活的是哪条路径。 这就是扩展路由以提供我们自己的功能的地方。 例如,我们必须验证给定路由的权限。 对于我们来说,通过单独的组件来处理这个问题没有太大意义,因为在复杂的访问控制规则和大量路由的情况下,这将不能很好地扩展。

扩展模型/集合

我们的模型和集合,不管它们的具体实现是什么样的,都将彼此共享一些共同的属性——特别是如果它们的目标是相同的 API,这是常见的情况。 给定模型或集合的细节围绕 API 端点、返回的数据和可能采取的操作。 很可能我们会针对所有实体使用相同的基本 API 路径,并且所有实体都有一些共享属性。 与其在每个模型或集合实例中重复我们自己,不如抽象公共数据。

除了在模型和集合之间共享属性之外,我们还可以共享共同的行为。 例如,给定的模型很可能没有足够的数据来支持给定的特性。 也许该数据可以通过转换模型得到。 这些类型的转换可以是通用的,并在基本模型或集合中进行抽象。 这取决于我们实现的特性类型以及它们之间的一致性。 如果我们的发展速度很快,并且收到了很多关于“开箱即用”特性的请求,那么我们更有可能在视图中实现数据转换,这需要对他们正在使用的模型或集合进行一次性更改。

大多数框架在执行 XHR 请求获取数据或执行操作时都会注意细微差别。 不幸的是,这并不是全部,因为我们的特性很少与单个 API 实体进行一对一的映射。 更有可能的是,我们会有一个特性,需要几个集合,这些集合以某种方式相互关联,以及一个转换后的集合。 这种类型的操作可能会很快变得复杂,因为我们必须处理多个 XHR 请求。

我们可能会使用 promise 同步这些请求的获取,然后在我们有了所有必要的源之后执行数据转换。

下面的例子显示了一个特定的模型扩展了一个泛型模型,以提供新的抓取行为:

// The base fetch() implementation of a model, sets
// some property values, and resolves the promise.
class BaseModel {
    fetch() {
        return new Promise((resolve, reject) => {
            this.id = 1;
            this.name = 'foo';
            resolve(this);
        });
    }
}

// Extends BaseModel with a specific implementation
// of fetch().
class SpecificModel extends BaseModel {

    // Overrides the base fetch() method. Returns
    // a promise with combines the original
    // implementation and result of calling fetchSettings().
    fetch() {
        return Promise.all([
            super.fetch(),
            this.fetchSettings()
        ]);
    }

    // Returns a new Promise instance. Also sets a new
    // model property.
    fetchSettings() {
        return new Promise((resolve, reject) => {
            this.enabled = true;
            resolve(this);
        });
    }
}

// Make sure the properties are all in place, as expected,
// after the fetch() call completes.
new SpecificModel().fetch().then((result) => {
    var [ model ] = result;
    console.assert(model.id === 1, 'id');
    console.assert(model.name === 'foo');
    console.assert(model.enabled, 'enabled');
    console.log('fetched');
});

扩展控制器/视图

当我们有一个基本模型或基本集合时,我们的控制器或视图之间经常有共享的属性。 这是因为控制器或视图的工作是呈现模型或收集数据。 以为例,如果相同的视图是反复渲染相同的模型属性,我们可能会将该位移动到一个基础视图,并从它扩展。 也许重复的部分就在模板本身中。 这意味着我们可能会考虑在基本视图中有一个基本模板,如下图所示。 扩展此基本视图的视图继承此基本模板。

根据我们使用的库或框架,以这种方式扩展模板可能是不可行的。 或者我们的特征的本质可能使这很难实现。 例如,可能没有一个通用的基本模板,但可能有许多可以插入到较大组件中的较小视图和模板:

Extending controllers/views

扩展基本视图的视图可以填充基本视图的模板,以及继承其他基本视图功能

我们的观点也需要响应用户交互。 它们可以直接响应,或者将事件向上转发到组件层次结构中。 在这两种情况下,如果我们的特性是一致的,那么将会有一些我们想要抽象到公共基础视图中的公共 DOM 事件处理。 这对扩展应用有很大帮助,因为随着我们添加更多特性,添加的 DOM 事件处理代码将被最小化。

将功能映射到组件

现在我们已经掌握了最常见的 JavaScript 组件,以及在应用中扩展它们的方法,现在是时候考虑如何将这些组件粘在一起了。 路由本身并不是很有用。 也不是独立的模型、模板或控制器。 相反,我们希望这些东西一起工作,形成一个内聚单元,实现应用中的一个特性。

为此,我们必须将特性映射到组件。 我们也不能随随便便地这么做——我们需要思考什么是我们的功能的共性,什么是它的独特性。 这些特性属性将指导我们的设计决策,以生产出可伸缩的产品。

通用功能

组件组合最重要的方面可能是一致性和可重用性。 当考虑到规模对我们的应用的影响时,我们会列出所有组件都必须具备的特征:比如用户管理、访问控制和其他应用特有的特征。 这与其他架构视角(在本书的其余部分将进行更深入的探讨)一起,形成了我们通用特性的核心:

Generic features

一个通用组件,由我们框架中的其他通用组件组成

在我们的应用中,每个特性的通用方面都是一个蓝图。 它们告诉我们如何构造更大的积木。 这些通用特性说明了帮助我们扩展的架构因素。 如果我们可以将这些因素编码为聚合组件的一部分,我们将更容易地扩展应用。

使这个设计任务具有挑战性的是,我们不仅必须从可伸缩的架构角度,而且必须从功能完整的角度来看待这些通用组件。 如果每个功能都有同样的表现,我们就万事俱备了。 如果每一个特征都遵循相同的模式,那么当时间来规模时,天空就是极限。

但是 100%一致的特性功能是一种错觉,JavaScript 程序员比用户更容易看到。 这种模式打破了必然性。 它正以一种可扩展的方式应对这种破坏。 这就是为什么成功的 JavaScript 应用将不断地回顾我们的特性的一般方面,以确保它们反映现实。

具体功能

当需要执行一些不符合模式的内容时,我们就会面临规模挑战。 我们必须调整,并考虑将这样一个特性引入我们的体系结构的后果。 当模式被打破时,我们的体系结构需要改变。 这不是一件坏事——这是必需品。 限制我们响应这些新功能的能力的因素在于我们现有功能的通用方面。 这意味着我们不能对通用特性组件过于严格。 如果我们要求太多,我们就会失败。

在做出任何基于非常规特性的仓促架构决策之前,请考虑具体的伸缩后果。 例如,新特性使用不同的布局,需要与所有其他特性组件不同的模板,这真的重要吗? JavaScript 规模艺术的状态是围绕着为我们的组件组合找到一些基本的蓝图。 其他的事情都可以讨论如何进行。

分解组件

组件组合是一个创建顺序的活动; 更大的行为由更小的部分构成。 在开发过程中,我们经常需要朝相反的方向前进。 即使在开发之后,我们也可以通过分解代码并观察它在不同的上下文中运行来了解组件是如何工作的。 组件分解意味着我们能够将系统拆开,并以某种结构化的方法检查各个部分。

组件维护和调试

在应用开发的过程中,我们的组件积累了抽象。 我们这样做是为了更好地支持某个特性的需求,同时支持一些有助于我们扩展的架构属性。 问题是,随着抽象的积累,我们在组件的功能中失去了透明性。 这不仅对于诊断和修复问题非常重要,而且对于代码的易学程度也非常重要。

例如,如果存在大量的间接操作,那么程序员跟踪因果关系需要更长的时间。 在跟踪代码上浪费的时间,降低了我们从开发的角度进行扩展的能力。 我们面临着两个相反的问题。 首先,我们需要抽象来处理真实世界的特性需求和架构约束。 其次,由于缺乏透明度,我们无法掌握自己的代码。

下面是一个显示渲染器组件和特性组件的示例。 该特性使用的渲染器很容易替换:

// A Renderer instance takes a renderer function
// as an argument. The render() method returns the
// result of calling the function.
class Renderer {
    constructor(renderer) {
        this.renderer = renderer;
    }

    render() {
        return this.renderer ? this.renderer(this) : '';
    }
}

// A feature defines an output pattern. It accepts
// header, content, and footer arguments. These are
// Renderer instances.
class Feature {
    constructor(header, content, footer) {
        this.header = header;
        this.content = content;
        this.footer = footer;
    }

    // Renders the sections of the view. Each section
    // either has a renderer, or it doesn't. Either way,
    // content is returned.
    render() {
        var header = this.header ?
                '${this.header.render()}\n' : '',
            content = this.content ?
                '${this.content.render()}\n' : '',
            footer = this.footer ?
                this.footer.render() : '';

        return '${header}${content}${footer}';
    }
}

// Constructs a new feature with renderers for three sections.
var feature = new Feature(
    new Renderer(() => { return 'Header'; }),
    new Renderer(() => { return 'Content'; }),
    new Renderer(() => { return 'Footer'; })
);

console.log(feature.render());

// Remove the header section completely, replace the footer
// section with a new renderer, and check the result.
delete feature.header;
feature.footer = new Renderer(() => { return 'Test Footer'; });

console.log(feature.render());

有一种策略可以帮助我们应对这两种相反的规模影响因素,那就是可替代性。 特别是,我们的一个组件,或子组件,可以轻松地被其他东西替换。 这应该很容易做到。 因此,在我们引入抽象层之前,我们需要考虑用简单的组件替换复杂的组件有多容易。 这可以帮助程序员学习代码,也有助于调试。

例如,如果我们能够从系统中取出一个复杂的组件,并用一个虚拟组件替换它,我们就可以简化调试过程。 如果更换组件后错误消失,我们就找到了有问题的组件。 否则,我们可以排除某个组件,继续在其他地方挖掘。

重构复杂组件

当然,用我们的组件实现可替换性说起来容易做起来难,尤其是在面临截止日期的时候。 一旦容易地用其他组件替换组件变得不切实际,就该考虑重构代码了。 或者至少是那些使可替代性不可行的部分。 这是一个平衡的行动,获得正确级别的封装和正确级别的透明度。

替换在更细的层次上也有帮助。 例如,假设一个视图方法很长很复杂。 如果在该方法的执行过程中有几个阶段,我们希望在其中运行自定义的东西,那么我们就不能。 最好将单个方法重构为几个方法,每个方法都可以被覆盖。

可插拔业务逻辑

并非所有的业务逻辑都需要存在于组件中,从外部封装而来。 相反,如果我们可以将业务逻辑编写为一组函数,那将是最理想的。 理论上,这为我们提供了一个明确的关注点分离。 这些组件用于处理帮助我们扩展的特定体系结构关注点,并且业务逻辑可以插入到任何组件中。 在实践中,从组件中删除业务逻辑并非易事。

扩展与配置

在构建组件时,我们可以采用两种方法。 首先,我们有库和框架提供的工具。 在此基础上,我们可以继续扩展这些工具,随着我们越来越深入地挖掘我们的特性而变得更加具体。 或者,我们可以为组件实例提供配置值。 这些指令指示组件如何操作。

扩展需要配置的内容的好处是,调用者不需要担心它们。 如果我们能通过使用这种方法,那就更好了,因为它会导致更简单的代码——尤其是使用组件的代码。 另一方面,只要它们支持这个配置或那个配置选项,我们就可以拥有可用于特定目的的通用特性组件。 这种方法的优点是使用更简单的组件层次结构和更少的整体组件。

有时候,在可理解的范围内,保持组件尽可能的通用会更好。 这样,当我们需要特定特性的通用组件时,我们可以使用它,而不必重新定义层次结构。 当然,组件的调用者要复杂一些,因为他们需要为组件提供配置值。

这一切都取决于我们,应用的 JavaScript 架构师。 我们是想封装所有的东西,配置所有的东西,还是想在两者之间取得平衡?

无状态业务逻辑

在函数式编程中,函数没有副作用。 在某些语言中,这个属性是强制的,但在 JavaScript 中不是。 然而,我们仍然可以在 JavaScript 中实现无副作用的函数。 如果一个函数接受参数,并且总是基于这些参数返回相同的输出,那么这个函数可以说是无状态的。 它不依赖于一个组件的状态,并且它不改变一个组件的状态。 它只是计算一个值。

如果我们可以建立一个以这种方式实现的业务逻辑库,我们就可以设计一些超级灵活的组件。 我们不是直接在组件中实现这个逻辑,而是将行为传递给组件。 这样,不同的组件就可以利用相同的无状态业务逻辑功能。

棘手的部分是找到可以通过这种方式实现的正确函数,因为提前实现这些函数不是一个好主意。 相反,随着应用开发迭代的进行,我们可以使用这种策略将代码重构为通用的无状态函数,这些函数由任何能够使用它们的组件共享。 这将导致业务逻辑以一种有重点的方式实现,以及组件较小、通用和可在各种上下文中重用。

组织组件代码

除了以一种有助于应用扩展的方式组成组件外,我们还需要考虑源代码模块的结构。 当我们第一次从一个给定的项目开始时,我们的源代码文件倾向于很好地映射到客户端浏览器中运行的内容。 随着时间的推移,当我们积累了更多的特性和组件时,关于如何组织我们的源代码树的早期决策会削弱这种强大的映射。

在跟踪源代码的运行时行为时,越少的脑力劳动越好。 我们可以通过这种方式扩展更稳定的功能,因为我们的努力更多地集中在当前的设计问题上——直接为客户提供价值的东西:

Organizing component code

该关系图显示了映射组件部件到它们的实现构件的过程

在我们的架构上下文中还有另一个代码组织的维度,那就是我们隔离特定代码的能力。 我们应该像对待运行时组件一样对待我们的代码,它们是我们可以打开或关闭的自我维持的单元。 也就是说,我们应该能够找到给定组件所需的所有源代码文件,而不需要搜索它们。 如果一个组件需要 10 个源代码文件(javascript、HTML 和 css),那么理想情况下这些文件都应该在同一个目录中找到。

当然,例外是所有组件共享的通用基本功能。 这些应该尽可能接近表面,这样就很容易跟踪组件依赖关系; 它们都指向层级的顶端。 当我们的组件依赖遍布各处时,扩展依赖图是一个挑战。

小结

本章介绍了组件组合的概念。 组件是可伸缩 JavaScript 应用的构建块。 我们可能遇到的常见组件包括模块、模型/集合、控制器/视图和模板。 虽然这些模式帮助我们实现了一定程度的一致性,但它们本身还不足以让我们的代码在各种影响因素的影响下良好运行。 这就是为什么我们需要扩展这些组件,提供我们自己的通用实现,使我们的应用的特定特性可以进一步扩展和使用。

根据应用遇到的各种规模因素,可以采用不同的方法将通用功能添加到组件中。 一种方法是不断扩展组件层次结构,并将所有内容封装起来,不让外界看到。 另一种方法是在创建组件时将逻辑和属性插入组件。 对于使用组件的代码来说,成本更复杂。

在本章的最后,我们讨论了如何组织我们的源代码; 这样它的结构就能更好地反映我们的逻辑组件设计。 这有助于我们扩展开发工作,并有助于将一个组件的代码与其他组件的代码隔离开来。 在下一章中,我们将更详细地研究组件之间的空间。 制作精良的独立组件是一回事。 实现可伸缩组件通信则完全是另一回事。