四、通信与职责

前一章重点讨论了是什么成分,它们由什么组成,为什么。 本章重点介绍 JavaScript 组件之间的粘合——如何。 如果我们的组件是为特定目的而设计的,那么它们需要与其他组件通信,以实现更大的行为。 例如,路由组件不太可能更新 DOM 或与 API 通信。 我们有擅长这些任务的组件,所以其他组件可以通过与它们通信来要求它们执行这些任务。

我们将从前端开发中流行的通信模型开始这一章。 我们不太可能开发自己的组件间通信框架,因为已经有很多健壮的库在做这件事了。 从 JavaScript 伸缩的角度来看,我们更感兴趣的是应用中选择的通信模型如何阻止我们伸缩,以及对此可以做些什么。

给定组件的职责会影响它与我们自己的组件以及我们无法控制的服务(如后端 api 和 DOM api)通信的方式。 一旦我们开始实现应用的组件,层就开始显示它们自己,如果显式地声明,这些层对于可视化通信流是有用的。 这使我们能够预测未来的组件通信伸缩问题。

通信模式

我们可以使用各种通信模型来实现组件间通信。 最简单的是方法调用或函数调用。 这种方法是最直接和最容易实现的。 然而,一个组件直接调用另一个组件的方法之间也存在深度耦合。 这不能超出几个组件的范围。

相反,我们需要组件之间的间接层次; 从一个组件到另一个组件的通信的中介。 这有助于我们扩展组件间的通信,因为我们不再直接与其他组件通信。 相反,我们依赖于我们的通信机制来完成消息传递。 这种通信机制的两种流行模型是消息传递和事件触发。 让我们比较一下这两种方法。

消息传递模型

消息传递通信模型在 JavaScript 应用中很常见。 例如,消息可以在本地机器上从一个进程传递到另一个进程; 它们可以从一个宿主传递到另一个宿主,也可以在相同的过程中传递。 尽管消息传递有点抽象,但它仍然是一个相当低级的概念——有很大的解释空间。 它是位于两个提供高级抽象的通信组件之间的机制。

例如,发布-订阅是一种更具体的消息传递通信模型。 实现这些消息的机制通常称为代理。 组件将订阅特定主题的消息,而其他组件将发布关于该主题的消息。 关键的设计特性是组件之间不知道彼此。 这促进了组件之间的松散耦合,并帮助我们在有很多组件时进行扩展。

Message-passing models

这显示了一个发布-订阅模型,使用代理将发布的消息传递给订阅者

另一种类型的消息传递抽象是命令响应。 在这里,一个组件向另一个组件发出命令并获得响应。 这个场景中的耦合稍微紧密一些,因为调用者的目标是一个特定的组件来完成命令。

但是,这仍然比直接命令调用更可取,因为我们仍然可以轻松地替换调用方和接收方。

事件模型

我们经常听说用户界面代码是事件驱动的,也就是说,一些事件发生,导致 UI 重新呈现一个部分。 或者,用户在 UI 中执行某些操作,触发代码必须解释和处理的事件。 从交流的角度来看,ui 只是一堆说明性的视觉元素; 被触发的事件,以及响应这些事件的回调函数。

这就是为什么发布-订阅模型非常适合 UI 开发的原因。 我们开发的大多数组件将触发一个或多个事件类型,而其他组件将订阅这种类型的事件,并运行代码以响应它的触发。 这是我们大多数组件之间通信的高级方式—通过事件,实际上就是发布-订阅。

用事件和触发而不是消息和发布-订阅的术语来说是有意义的,因为它是 JavaScript 开发人员更熟悉的术语。 例如,这里有 DOM 和它的整个事件系统。 它们是与 Ajax 调用和Promise对象关联的异步事件,然后是我们的应用所利用的框架所使用的本地事件系统。

Event models

事件由一个组件触发,而监听该事件的另一个组件执行回调; 这个过程是由事件代理机制协调的

不用说,通过我们的应用组件触发事件的独立事件系统,使我们很难在头脑中理解对给定动作的响应实际发生了什么。 这确实是一个可伸缩的问题,本章的各个章节将深入探讨使我们能够伸缩组件通信的解决方案。

通信数据模式

事件数据不是不透明的——它意味着我们的回调函数使用它来决定如何作出反应。 有时,这些数据是不需要的,可以被回调函数安全地忽略。 然而,我们不想决定,在早期,一些回调添加以后将不需要这个数据。 这有助于我们的通信机制扩展——在正确的地方提供正确的数据。

数据不仅需要存在,便于每个回调函数使用,而且还需要具有可预测的结构。 我们将研究为事件名称本身以及传递给处理程序函数的数据建立命名约定的方法。 通过确保提供所需的事件数据并且不可能被误解,我们可以使组件间的通信更透明一点,从而更具有可伸缩性。

命名约定

想出有意义的名称是困难的,特别是当有许多事物需要命名时,比如事件。 一方面,我们希望事件名称具有意义。 这有助于我们进行扩展,因为只查看事件名称而不查看其他内容,就可以找到意义。 另一方面,如果试图使事件名称具有太多的含义,那么快速解析事件名称的好处就会丢失。

拥有良好、简短和有意义的事件名称的主要关注点在于处理这些事件的开发人员。 他们的想法是,当他们的代码对事件作出反应时,他们可以快速地把事件流的心理地图放在一起。 请注意,这只是对整个可伸缩事件体系结构做出贡献的一个小实践,但它仍然是一个重要的实践。

例如,我们可能有一个基本事件类型,以及该事件的更具体版本。 我们可以有几个基本事件类型,以及它们的几个更具体的实例,以覆盖更直接的场景。 如果我们对事件名称和类型有太多的专一性,这意味着我们不能真正地重用它们。 这也意味着有更多的事件需要开发者去思考。

数据格式

除了事件名称本身,还有事件有效负载。 这应该始终包含有关被触发事件的数据,也可能包含有关触发这些事件的组件的数据。 关于事件数据要记住的最重要的一点是,它应该始终具有与订阅这些类型事件的处理程序相关的数据。 通常,一个回调函数可能会决定什么都不做而忽略事件,基于事件数据中的一个属性的状态。

例如,如果在每个回调函数中,我们都必须对组件执行查找,只是为了获得我们需要做出决定或执行进一步操作的数据,那么它就不是真正可伸缩的。 当然,要猜测需要哪些数据并不容易。 如果我们知道这一点,我们将直接调用函数,从而避免使用事件触发机制的麻烦。 其思想是放松耦合,但同时提供可预测的数据。

下面是一个简化的事件数据示例:

var eventData = {
    type: 'app.click',
    timestamp: new Date(),
    target: 'button.next'
};

要想弄清楚在给定事件被触发时哪些数据与它相关,一个有用的练习是考虑处理程序中可以派生出什么,以及处理程序几乎从来不需要什么。 例如,不建议计算事件数据,然后传递它。 如果处理程序可以计算它,它应该承担这个责任。 如果我们开始看到重复的代码,那么情况就不同了,是时候开始考虑公共事件数据了。

常用数据

事件数据将始终包含触发事件的组件的数据—可能是对组件本身的引用。 这总是一个不错的选择,因为我们今天所知道的是事件被触发了——我们不知道以后回调函数要做什么来响应这个事件。 所以给我们的回调函数提供大量的数据是好的,只要它不令人困惑或误导。

因此,如果我们知道相同类型的组件总是会触发相同类型的事件,我们就可以相应地设计回调函数,并期望相同的数据始终存在。 我们可以使用事件数据获得更通用的数据,并向回调提供关于事件本身的数据。 例如,时间戳、事件状态等与组件无关,而与事件有关。

下面的例子展示了一个基本事件,它定义了所有事件的公共数据,这些事件可以用附加属性扩展它:

// click-event.js
// All instances will have "type" and "timestamp"
// properties, plus any passed-in properties. What's
// important is that anything using "ClickEvent"
// knows that "type" and "timestamp" will always be
// there.
export default class ClickEvent {

    constructor(properties) {
        this.type = 'app.click';
        this.timestamp = new Date();
        Object.assign(this, properties);
    }

};

// main.js
import ClickEvent from 'click-event.js';

// Create a new "ClickEvent" and pass it some properties.
// We can override some of the standard properties,
// and pass it new ones.
var clickEvent = new ClickEvent({
    type: 'app.button.click',
    target: 'button.next',
    moduleState: 'enabled'
});

console.log(clickEvent);

再次强调,不要在数据重用上耍小聪明。 让重复发生,然后处理它。 更好的方法是创建一个基本事件结构,这样一旦发现重复属性,就可以很容易地将它们移到公共结构中。

可跟踪组件通信

也许大型 JavaScript 应用最大的挑战是保持一个事件开始和结束的心理模型,换句话说,在事件流经组件时跟踪它。 不可跟踪的代码将我们的软件的可伸缩性置于危险之中,因为我们无法预测对给定事件的响应将会发生什么。

在开发过程中,我们可以使用数量的策略来缓解计算事件流的痛苦,甚至可以修改设计来简化事情。 简单是有尺度的,我们不能简化我们不理解的东西。

订阅事件

发布-订阅消息传递模型的一个优点是,我们可以插入并添加新的订阅。 这意味着如果我们不确定某件事情是如何工作的,我们可以从不同的角度抛出事件回调函数,直到我们对实际发生的事情有更好的想法。 这是一个黑客工具,支持黑客软件的工具可以帮助我们扩大规模,因为我们让开发者能够自己动手。 如果有什么不清楚的地方,当代码很容易被破解时,他们更有可能自己弄清楚。

Subscribing to events

在特定点或按照特定顺序订阅事件,可以改变事件的生命周期。 拥有这种能力很重要,但如果过度使用,就会导致不必要的复杂性

在极端情况下,我们甚至可能需要使用这种订阅者方法来修复生产系统中出现的问题。 例如,假设一个回调函数能够停止一个事件的执行,取消任何进一步的处理程序的运行。 在整个代码中触发的事件中有这些类型的入口点是很好的。

全球日志事件

为响应被触发事件而执行的回调函数可以从内部记录消息。 然而,有时我们需要从事件机制本身的角度进行日志记录。 例如,如果我们在处理一些复杂的代码,我们需要知道我们的回调函数何时被调用,相对于其他回调函数。 事件触发机制应该有一个选项来处理生命周期日志。

这意味着,对于任何被触发的给定事件,我们都可以看到有关该事件的日志信息,这与响应该事件的代码无关。 我们将这些称为元事件——关于事件的事件。 例如,在回调运行之前、在回调运行之后以及不再有回调时的触发器时间。 这为我们在回调中实现的日志提供了跟踪代码所需的上下文。

下面的示例显示了启用日志记录的事件代理:

// events.js
// A simple event broker.
export default class Events {

    // Accepts a "log()" function when created,
    // used when triggering events.
    constructor(log) {
        this.log = log;
        this.listeners = {};
    }

    // Calls all functions listening to event "name", passing
    // "data" to each. If the "log()" function was provided to
    // the broker when created, then it logs BEFORE each callback
    // is called, and AFTER.
    trigger(name, data) {
        if (name in this.listeners) {
            var log = this.log;
            return this.listeners[name].map(function(callback) {
                log && console.log('BEFORE', name);

                var result = callback(Object.assign({
                    name: name
                }, data));

                log && console.log('AFTER', name);

                return result;
            });
        }
    }
};

// main.js
import Events from 'events.js';

// Two event callback functions that log
// data. The second one is async because it
// uses "setTimeout()".
function callbackFirst(data) {
    console.log('CALLBACK', data.name);
}

function callbackLast(data) {
    setTimeout(function() {
        console.log('CALLBACK', data.name);
    }, 500);
}

var broker = new Events(true);

broker.listen('first', callbackFirst);
broker.listen('last', callbackLast);

broker.trigger('first');
broker.trigger('last');

//
// BEFORE first
// CALLBACK first
// AFTER first
// BEFORE last
// AFTER last
// CALLBACK last
//
// Notice how we can trace the event broker
// invocation? Also note that "CALLBACK last"
// is obviously async because it's not in between
// "BEFORE last" and "AFTER last".

事件生命周期

不同的事件触发机制对其事件有不同的生命周期,尝试理解每个事件如何工作以及如何控制它们是值得的。 我们将从 DOM 事件开始。 UI 中的 DOM 节点形成树状结构,其中任何一个节点都可以触发 DOM 事件。 如果有该事件的处理程序函数直接附加到触发节点,则会执行它们。 然后,事件将向上传播,重复查找处理程序函数的过程,然后继续向上,直到到达文档节点。

我们的处理程序函数实际上可以改变 DOM 事件的默认传播行为。

例如,如果我们不想运行 DOM 树中更上一层的处理程序,则树中更低节点中的处理程序可以阻止事件的传播。

Event lifecycle

将来自不同框架的组件事件系统的事件处理方法与浏览器处理的 DOM 事件进行对比

我们要注意的另一个主要事件触发机制是我们正在使用的框架。 作为一种语言,JavaScript 没有通用的事件触发系统,只有专门用于 DOM 树、Ajax 调用和 Promise 对象的事件触发系统。 在内部,它们都使用相同的任务队列; 它们只是以某种方式暴露出来,使它们看起来像是独立的系统。 这就是我们使用框架的步骤,并提供必要的抽象。 这些类型的事件分派器非常简单; 给定事件的订阅者按照 FIFO 顺序执行。 其中一些事件系统支持本节讨论的更高级的生命周期选项,例如全局事件日志记录和早期事件终止。

通信开销

直接调用组件上的方法的一个优点是所涉及的开销非常小。 当所有组件间的通信都通过事件触发机制进行代理时,就无法避免至少一点开销。 事实上,与此间接关联的开销几乎不明显; 可能导致可伸缩性问题的其他开销因素。

在本节中,我们将研究事件触发频率、回调执行和回调复杂度。 这些都有可能降低我们软件的性能,使其无法使用。

事件频率

当我们的软件只有少数几个组件时,事件发生的频率是有基本限制的。 当有很多组件时,事件频率可能很快变成一个问题,其中一些组件会触发事件以响应事件。 这意味着,如果用户正在快速而有效地执行某件事,或者同时有多个 Ajax 响应到达,我们需要一种方法来防止这些事件阻塞 DOM。

JavaScript 的一个挑战是它是单线程的。 当然也有网络工作者,但他们超出了本书的范围,因为他们引入了一个全新的架构问题类别。 假设用户在一秒内点击了 4 次。 在正常情况下,这对我们的事件系统来说不是什么大问题。 但是,假设它们正在这样做,同时运行一个昂贵的 Ajax 响应处理程序。 最终,UI 将变得无响应。

为了避免无响应的 ui,我们可以对事件进行节流。 这意味着对回调的执行频率设置一个上限。 所以,不是做了,而是做了,休息几毫秒后,再做。 像这样调节回调函数的好处是,它给挂起的 DOM 更新或挂起的 DOM 事件回调函数一个运行的机会。 缺点是,我们的事件生命周期可能会由于长时间的更新或其他代码而受到负面影响。

下面的示例显示了一个事件代理,它将触发的事件调整到特定的时间频率:

// events.js
// The event broker. Sets sets the threshold
// for event triggering frequency to 100
// milliseconds.
export default class Events {

    constructor() {
        this.last = null;
        this.threshold = 100;
        this.size = 0;
        this.listeners = {};
    }

    // Triggers the event, but only if the it meets the
    // frequency threshold.
    trigger(name, data) {
        var now = +new Date();

        // If we're passed the wait threshold, or we've never
        // triggered an event, we can call "_trigger()", where
        // the event callback functions are processed.
        if (this.last === null || now - this.last > this.threshold) {
            this._trigger(name, data);
            this.last = now;
        // Otherwise, we've triggered something recently, and we
        // need to set a timeout. The "size" multiplier is
        // for spreading out the lineup of triggers.
        } else {
            this.size ++;
            setTimeout(() => {
                this._trigger(name, data);
                this.size --;
            }, this.threshold * this.size || 1);
        }
    }

    // This is the actual triggering mechanism, called by
    // "trigger()" after it checks the frequency threshold.
    _trigger(name, data) {
        if (name in this.listeners) {
            return this.listeners[name].map(function(callback) {
                return callback(Object.assign({
                    name: name
                }, data));
                return result;
            });
        }
    }

};

//main.js
import Events from 'events.js';

function callback(data) {
    console.log('CALLBACK', new Date().getTime());
}

var broker = new Events(true);

broker.listen('throttled', callback);

var counter = 5;

// Trigger events in a tight loop. This will
// cause the broker to throttle the callback
// processing.
while (counter--) {
    broker.trigger('throttled');
}
//
// CALLBACK 1427840290681
// CALLBACK 1427840290786
// CALLBACK 1427840290886
// CALLBACK 1427840290987
// CALLBACK 1427840291086
//
// Notice how the logged timestamps in each
// callback are spread out?

回调执行时间

虽然事件触发机制对何时执行回调函数有一定程度的控制,但我们并不一定要控制回调将花费多长时间来完成。 从事件系统的角度来看,由于 JavaScript 的单线程特性,每个回调函数都是一个运行到完成的小黑盒。 如果在事件机制中抛出了一个破坏性回调函数,我们如何知道哪个回调出现了故障,以便我们能够诊断和修复它?

有两种技术可以用来解决这个问题。 正如本章前面提到的,事件触发机制应该有一种简单的方法来打开全局事件日志记录。 从这里,我们可以推断任何正在运行的给定回调的持续时间,假设我们有开始和完整的时间戳。 但这并不是执行回调持续时间的最有效方法。

另一种技术是在给定的回调函数开始运行时设置一个超时函数。 当超时函数运行时,比如在1秒之后,它会检查同一个回调函数是否仍在运行。 如果是,它可以显式抛出异常。 这样,当回调花费太长时间时,系统显式地失败。

这种方法仍然存在一个问题——如果回调被困在一个紧密的循环中呢? 我们的监视回调将永远没有机会运行。

Callback execution time

将执行时间不长的短回调与执行时间较长的回调进行比较,后者在更新 DOM 或处理排队 DOM 事件方面没有提供太多的灵活性

回调复杂度

当所有其他方法都失败时,就取决于我们,大型 JavaScript 应用的架构师,来确保事件处理程序的复杂性处于适当的水平。 过多的复杂性意味着潜在的性能瓶颈和 ui 冻结—糟糕的用户体验。 如果回调函数太细粒度,或者事件本身太细粒度,我们仍然会面临性能问题,因为事件触发机制本身增加了开销——更多的进程回调意味着更多的开销。

在大多数支持组件间通信的 JavaScript 框架中,事件系统的优点在于它们非常灵活。 默认情况下,框架会触发它认为重要的事件。 这些可以被忽略,对我们来说没有明显的性能成本。 然而,它们也允许我们根据需要触发自己的事件。 所以,如果我们在一段时间后发现,我们对事件的粒度有些忘乎所以,我们可以稍微缩减一下。

一旦我们掌握了应用的正确事件粒度级别,我们就可以调整回调函数来反映它。 我们甚至可以开始编写较小的回调函数,以便将它们用于组合提供更粗粒度功能的高级函数。

下面是一个例子,显示了触发其他事件的回调函数,以及侦听这些事件的其他更集中的函数:

import Events from 'events.js';

// These callbacks trigger "logic" events. This
// small indirection keeps our logic decoupled
// from event handlers that might have to perform
// other boilerplate activities.
function callbackFirst(data) {
    data.broker.trigger('logic', {
        value: 'from first callback'
    });
}

function callbackSecond(data) {
    data.broker.trigger('logic', {
        value: 'from second callback'
    });
}

var broker = new Events();

broker.listen('click', callbackFirst);
broker.listen('click', callbackSecond);

// The "logic" callback is small, and focused. It
// doesn't have to worry about things like DOM
// access or fetching network resources.
broker.listen('logic', (data) => {
    console.log(data.name, data.value);
});

broker.trigger('click');
//
// logic from first callback
// logic from second callback

通信责任领域

在考虑 JavaScript 组件通信时,看看外部世界以及应用与之接触的边缘是很有帮助的。 到目前为止,我们主要关注组件间的通信——在同一个 JavaScript 应用中,我们的组件如何与其他组件通信? 这种组件间的通信不是自己开始的,也不是在这里结束的。 可伸缩的 JavaScript 代码需要考虑流入和流出应用的事件。

后端 API

最明显的起点是后端 API,因为它定义了应用的领域。 前端实际上只是 API 最终真相的一个门面。 当然,不仅如此,API 数据最终限制了我们的应用能做什么,不能做什么。

就组件和职责而言,考虑哪些组件负责与后端进行直接通信是很有帮助的。 当应用需要数据时,这些组件将启动 API 会话,获取数据,并通知我数据何时到达,以便我可以将它交给另一个组件。 所以实际上有相当多的组件间通信是与与 API 通信的组件间接相关的。

例如,假设我们有一个集合组件,为了填充它,我们必须调用一个方法。 集合是否知道它需要填充自己,或者为此创建自己? 更有可能的情况是,某个其他组件启动了集合的创建,然后要求它从 API 中获取一些数据。 虽然我们知道这个初始化组件并不直接与 API 通信,但我们也知道它在通信中扮演着重要的角色。

当扩展到许多组件时,考虑这一点很重要,因为它们都应该遵循可预测的模式。

Backend API

前端的事件代理直接或间接地将 API 响应及其数据转换为组件可以订阅的事件

Web socket 更新

Web 套接字连接减轻了 Web 应用中长轮询的需要。 由于浏览器对该技术的强大支持,它们现在被更频繁地使用。 后台服务器也有很多库来支持 web 套接字连接。 具有挑战性的部分是簿记,它允许我们检测更改并通过发送消息通知相关的会话。

撇开后端复杂性不谈,web 套接字确实在前端解决了许多软实时更新问题。 Web 套接字是一个与后端的双向通信通道,但它们真正出色的地方是接收更新,即某些模型已更改状态。

这允许任何可能显示来自该模型的数据的组件重新呈现自己。

挑战的部分是,在任何给定的前端会话中,我们只允许一个 web 套接字连接。 这意味着响应这些消息的处理程序函数需要确定如何处理它们。 您可能还记得,在本章的前面,我们讨论过事件数据,以及事件名称的意义和它们的数据结构。 Web 套接字消息事件是说明这一点为何重要的一个很好的例子。 我们需要弄清楚如何处理它,我们得到的 web 套接字消息的类型会有很多变化。

注释

因为 web 套接字连接是有状态的,所以它们可以被丢弃。 这意味着我们将不得不面对实现重新连接掉的套接字连接的代码的额外挑战。

让一个回调函数处理所有这些 web 套接字消息的处理(直接到 DOM)将是一个坏主意。 一种方法可能是有几个处理程序,每个特定类型的 web 套接字更新一个处理程序。 这将很快失去控制,因为许多回调函数将不得不运行,从责任方面来说,许多组件将不得不与 web 套接字连接紧密耦合。

如果组件不关心更新的数据来自 web 套接字连接呢? 它只关心数据的变化。 也许我们需要为关心数据更改的组件引入一种新的事件类型。 然后,我们的 web 套接字处理程序只需要将消息转换为这些类型的事件。 这是一种可扩展的网络套接字通信方法,因为我们可以完全去掉网络套接字,而实际上它不会触及很多系统。

Web socket updates

事件将一种类型的 web 套接字消息转换为实体特定的事件,因此只有感兴趣的组件需要订阅

DOM 更新

我们的组件需要与 DOM 交互。 这是不言而喻的——它是一个运行在浏览器中的 web 应用。 考虑与 DOM 有关的组件和不涉及 DOM 的组件绝对是值得考虑的。 这些通常是视图组件,因为它们将应用的数据转换为用户在浏览器窗口中可以看到的东西。

这些类型的组件实际上更难以扩展,这主要是因为它们的事件流具有双向特性。 这一挑战的另一个事实是,当对某些新代码段的去向有任何疑问时,通常是视图。 然后,当视图重载时,我们开始将代码放入控制器或实用程序中,谁知道还会放在哪里。 肯定有更好的办法。

让我们考虑一下视图事件通信。 首先是传入的事件。 这告诉视图数据发生了变化,它应该更新 DOM。 它确实做到了。 这种方法实际上非常可靠,并且在视图侦听一个组件的事件时工作得很好。 当我们扩展应用以适应更多特性和增强时,我们的视图必须开始解决问题。 视图在愚蠢时工作得更好。

例如,最初负责呈现一个元素以响应数据事件的视图,现在必须做更多的工作。 在完成这些操作之后,它需要计算一些派生值,并更新另一个元素。 这个让视图变得“更聪明”的过程逐渐失去控制,直到我们无法再扩展。

从通信的角度来看,我们希望将视图看作是简单的数据到 DOM 的一对一绑定。 如果不违反这一原则,那么我们就更容易预测数据变化时将发生什么,因为我们知道哪些视图将侦听这些数据,以及它们绑定到的 DOM 元素。

现在来看看另一个方向的绑定——监听 DOM 中的更改。 这里的挑战是,我们倾向于让我们的观点更明智。 当输入数据出现问题时,我们将重载响应 DOM 事件而触发的视图事件处理程序,并使用应该在其他地方完成的职责。 视图在愚蠢时工作得更好。 它们应该将 DOM 事件转换为任何其他组件都可以监听的特定于应用的事件,就像我们处理 web 套接字消息事件一样。 我们的“智能”组件实际上启动了一些业务流程,并不一定关心操作的原因是否来自 DOM。 这有助于我们通过创建更少的通用组件来进行扩展,这些组件实际上并没有做很多事情。

松耦合通信

当组件间的交流松散耦合时,我们可以更容易地适应规模影响者的出现。 首先,一个好的事件驱动组件间通信设计允许我们移动组件。 我们可以取出一个有缺陷或性能不佳的部件,用另一个替换。 不能以这种方式替换组件意味着我们必须在适当的位置固定组件; 从开发的角度来看,这是交付软件的更大风险和可伸缩的瓶颈。

松散耦合组件间通信的另一个有益的副作用是,当出现问题时,我们可以隔离有问题的组件。 我们可以防止一个组件中发生的异常导致其他组件处于糟糕的状态,这只会在用户尝试做其他事情时导致进一步的问题。 隔离这样的问题有助于我们扩展响应,以修复故障组件。

替代组分

基于给定组件触发和响应的事件,我们可以很容易地用不同的版本替换组件。 我们仍然需要弄清楚组件的内部工作方式,因为我们不太可能想要完全改变它。 但这是比较容易的部分——实现组件的困难部分是将它们连接在一起。 可伸缩组件的实现意味着使这种连接尽可能地接近和一致。

但是为什么零部件的可替代性如此重要呢? 我们会认为,由几个连接在一起的组件组成的稳定代码不需要经常更改。 从这个角度来看,可替代性当然是贬值的——如果你不使用它,为什么要担心它呢? 这种心态的唯一问题是,如果我们认真地考虑扩展 JavaScript 代码,我们就不能把原则应用于某些组件而忽略其他组件。

事实上,不愿重构稳定的代码不一定是件好事。 例如,如果我们有一些需要重构稳定组件的新想法,它实际上可能会阻碍我们。 我们所有组件的可替换性给我们带来的是实现新想法的可伸缩性。 如果很容易通过取出稳定的组件并放入新的实现来进行试验,那么我们更有可能将改进的设计思想放入产品中。

替换组件不仅仅是设计时的活动。 我们可以引入可变性,其中有许多可能的组件可以填补空白,并且在运行时将选择正确的组件。 这种灵活性意味着我们可以很容易地扩展特性,以考虑到可伸缩的影响因素,比如新的用户角色。

有些角色得到一个组件,有些角色得到一个不同但兼容的组件,或者根本没有组件。 关键是要支持这种灵活性。

Substituting components

只要组件遵循相同的通信协议(通常带有事件触发和处理),就更容易开发实验技术

处理突发事件

松散耦合组件帮助我们扩展处理有缺陷组件的能力,主要是因为当我们能够将问题的根源隔离到单个组件时,我们可以快速查明问题并修复它。 此外,如果有缺陷的组件在生产环境中运行,我们可以在实现和交付修复时限制负面影响。

缺陷发生了——我们需要接受它并为它设计。 我们想从缺陷中吸取教训,这样我们就不会在将来重蹈覆辙。 考虑到我们的日程安排很紧,所以尽早并且经常发布,漏洞就会从裂缝中溜走。 这些都是我们没有测试的边缘情况,或者在单元测试中遗漏的独特编程错误。 无论如何,我们需要设计组件的失效模式来考虑这些情况。

隔离有缺陷组件的一种方法可能是将任何事件回调函数包装在 try/catch 中。 如果发生任何意外的异常,我们的回调只会通知事件系统组件处于错误状态。 这给其他处理程序一个机会来恢复它们的状态。 如果在事件回调管道中有一个错误组件,我们可以安全地向用户显示一个关于特定操作不工作的错误。 由于其他组件都处于良好状态,由于坏组件的通知,用户可以安全地使用其他特性。

下面是一个能够捕获回调函数错误的事件代理示例:

// events.js
export default class Events {

    constructor() {
        this.listeners = {};
    }

    // Triggers an event...
    trigger(name, data) {
        if (!(name in this.listeners)) {
            return;
        }

        // We need this to keep track of the error state.
        var error = false,
            mapped;

        mapped = this.listeners[name].map((callback) => {
            // If the previous callback caused an error,
            // we don't run any more callbacks. The values
            // in the mapped output will be "undefined".
            if (error) {
                return;
            }

            var result;

            // Catch any exceptions thrown by the callback function,
            // and the result object sets "error" to true.
            try {
                result = callback(Object.assign({
                    name: name,
                    broker: this
                }, data));
            } catch (err) {
                result = { error: true };
            }

            // The callbacks can throw an exception, or just return
            // an object with the "error" property set to true. The
            // outcome is the same - we stop processing callbacks.
            if (result && result.error) {
                error = true;
            }

            return result;
        });

        // Something went wrong, so we let other components know
        // by triggering an error variant of the event.
        if (error) {
            this.trigger('${name}:error');
        }
    }

}

// main.js
import Events from 'events.js';

// Callback fails by returning an error object.
function callbackError(data) {
    console.log('callback:', 'going to return an error');
    return { error: true };
}

// Callback fails by throwing an exception.
function callbackException(data) {
    console.log('callback:', 'going to raise an exception');
    throw Error;
}

var broker = new Events();

// Listens to both the regular events (the happy path),
// and the error variants.
broker.listen('shouldFail', callbackError);
broker.listen('shouldFail:error', () => {
    console.error('error returned from callback');
});

broker.listen('shouldThrow', callbackException);
broker.listen('shouldThrow:error', () => {
    console.error('exception thrown from callback');
});

broker.trigger('shouldFail');
broker.trigger('shouldThrow');
// callback: going to return an error
// error returned from callback
// callback: going to raise an exception
// exception thrown from callback

组件层

在任何足够大的 JavaScript 应用中,都有一个阈值,其中通信组件的数量会带来伸缩性问题。 主要的瓶颈是我们创造的复杂性,以及我们无法理解它。 为了克服这种复杂性,我们可以引入层。 这些是抽象的范畴概念,帮助我们直观地理解运行时发生了什么。

事件流方向

用层来设计代码的第一件事就是从事件流的方向来看组件间通信的复杂性。 例如,假设我们的应用有三个层。 顶层负责路由,以及进入 UI 的其他入口点。 中间层具有遍布各处的数据和业务逻辑。 底层是我们的视图所在的位置。 关键不在于这些层中有多少个组件; 虽然这是一个因素,但它是一个次要因素。 从这个角度来看,重要的是进入其他层的箭头类型。

例如,考虑到上面描述的三层架构,我们可能会注意到最直接的层连接是在路由和数据/业务逻辑层之间。 这是因为事件基本上都是单向的:从上到下,从路由直接到它下面的一层。 从那里开始,一些模型和控制器组件之间可能会发生一些通信,但最终,事件流会继续向下移动。

在数据/逻辑层和视图层之间,通信箭头开始看起来是双向的,令人困惑。 这是因为代码中的事件流也是双向的,而且容易混淆。 这是不可扩展的,因为我们无法轻易掌握我们所触发事件的影响。 对于使用分层设计方法有帮助的是找出一种删除双向事件流的方法。 这可能意味着引入一个间接级别,它负责代理源和目标之间的事件。

如果我们以一种聪明的方式这样做,额外的移动部分将为我们的层图带来清晰而不是混乱,并且性能影响可以忽略不计。

Event flow direction

组件层之间可识别的事件流方向对可伸缩性有很大的影响

映射到开发人员的职责

层是一种辅助工具,而不是一个正式的体系结构规范工件。 这意味着我们可以用它们来做任何可能有用的事情。 不同的人群可能有他们自己的层,他们使用这些层来满足他们理解复杂性的需要。 然而,如果整个开发团队都遵循相同的层,并且它们都非常简单,那么这将更加有用。 超过 4 或 5 个层次就会破坏使用它们的目的。

开发人员可以使用层作为一种自组织方法。 他们了解体系结构,并且他们要为即将到来的 sprint 做一些工作。 假设我们有两个开发人员致力于相同的功能。 它们可以使用我们的组件体系结构的各个层来规划它们的实现,并避免相互干扰工作。 当在更大的画面中有一个参考点时,比如一个图层,事情就会无缝地结合在一起。

在心里映射代码

即使没有图表,只要知道我们正在查看的组件代码属于一个特定的层,就可以帮助我们在心里描绘出它在做什么,以及它对系统其余部分的影响。 当我们编码时,知道我们工作的层给了我们一个潜意识的环境——我们知道哪些组件是我们的邻居,以及我们的事件何时跨层边界。

在一个层的上下文中,相对于现有的组件及其层之间的通信模式,新的组件将会有明显的设计问题。 这些层的存在,以及它们经常被所有开发人员用作非正式辅助工具的事实,可能足以在早期解决设计问题。 或许这并不是一个真正的问题,但这些层次足以促进对设计的讨论。 有些团队可能会从中学到一些东西,而有些团队可能会自信地认为设计是可靠的。

小结

JavaScript 应用的构建块是组件。 将它们连接在一起的粘合剂是所使用的通信模型。 在较低的层次上,组件间通信包括一个组件通过某种代理机制将消息传递给另一个组件。 这通常被抽象和简化为事件系统。

我们研究了以事件数据的形式从一个组件传递到下一个组件的实际内容。 这些数据需要是一致的、可预测的和有意义的。 我们还研究了可跟踪事件。 也就是说,当事件触发机制触发时,我们是否可以全局记录事件?

我们的 JavaScript 代码的边界是通信端点。 我们研究了负责与外部系统通信的各种组件,如 DOM、Ajax 调用或本地存储。 我们需要将智能组件与系统的边缘隔离开来。

可替换性和层是伸缩的关键概念。 替换组件可以帮助我们在低风险的情况下快速开发新代码。 层次在许多方面都有帮助,因为它可以让你看到更广阔的图景。 不正确的设计假设在层的前面就暴露出来了。

现在是时候考虑扩展应用的可寻址性了,我们将看看最后两章的经验教训是否有价值。