规模化的-JavaScript-二-

35 阅读1小时+

规模化的 JavaScript(二)

原文:zh.annas-archive.org/md5/310075695FB63536AA5B7DE9945E79F9

译者:飞龙

协议:CC BY-NC-SA 4.0

第五章:可寻址性与导航

运行在网络上的应用程序依赖于可寻址资源。URI 是至关重要的互联网技术,它消除了一类复杂性,因为我们可以把关于资源的信息编码到 URI 字符串中。这是策略部分。机制部分则由浏览器或我们的 JavaScript 代码来完成——查找请求的资源并显示它。

过去,处理 URI 是在后端进行的。当用户传递一个 URI 给浏览器时,浏览器的责任是将这个请求发送到后端并显示响应。随着大规模 JavaScript 应用程序的出现,这一责任主要转移到了前端。我们有了在浏览器中实现复杂路由的工具,有了这些工具,对后端技术的依赖就减少了。

然而,前端路由的好处确实是有代价的,一旦我们的软件增加了功能。本章深入探讨了在我们应用程序架构成长和成熟过程中可能遇到的路由场景。大多数路由器组件的底层实现细节并不重要。我们更关心的是我们的路由器组件如何适应规模影响因素。

路由方法

在 JavaScript 中有两种路由方法。第一种是使用基于哈希的 URI。这些 URI 以#字符开头,这是更受欢迎的方法。另一种不太受欢迎的方法是使用浏览器的 history API 生成更传统的 URI,网民们已经习惯了这种 URI。这种技术更加复杂,直到最近才获得足够的浏览器支持,使其变得可行。

哈希 URI

URI 中哈希部分最初的意图是指向文档的特定位置。所以浏览器会查看#字符左侧的所有信息,并将这些信息发送到后端,请求一些页面内容。只有在页面到达并渲染后,#字符右侧才变得相关。这时,浏览器使用 URI 的哈希部分在页面上找到本地相关位置。

如今,URI 中的哈希部分被用于不同的场景。它依旧用于 URI 变更时避免向后端传递无关数据。主要区别在于,现今我们处理的是应用程序和功能,而非网站和静态内容。由于在地址变更时大部分应用程序已经加载到浏览器中,向后端发送不必要的请求是没有意义的。我们只想要对新 URI 必要的数据,这通常通过后台的 API 请求来实现。

当我们谈论在 JavaScript 应用程序中使用哈希方法来改变 URI 时,通常只是哈希部分发生变化。这意味着相关浏览器事件将会触发,通知我们的代码 URI 已经改变。但它不会自动向后端发出请求以获取新页面内容,这是关键。我们实际上可以通过这种方式的前端路由获得很多性能和效率上的提升,这也是我们使用这种方法的原因之一。

它不仅效果良好,而且实施起来也很简单。实现一个哈希变更事件监听器,以执行逻辑来获取相关数据,然后用相关内容更新页面,并没有很多复杂的部件。此外,浏览器历史的变更我们自己也会自动处理。

传统的 URI

对于某些用户和开发者来说,哈希方法看起来就像是黑客技术。更不用说在公共互联网环境中呈现的 SEO 挑战了。他们更喜欢更传统的用斜杠分隔的资源名格式的外观和感觉。现在在所有现代浏览器中,由于对历史 API 的增强,这通常是可能实现的。本质上,路由机制可以监听历史堆栈上推入的状态,当发生这种情况时,它防止请求发送到后端,而是本地处理它。

显然,这种方法需要更多的代码才能工作,也需要考虑更多的边缘情况。例如,后端需要支持前端路由器所支持的所有 URI,因为用户可以将任何有效的 URI 输入到应用程序中。处理这种情况的一种技术是在服务器上使用重写规则,将 404 错误重定向回应用程序的索引页面,我们的真实路由处理就位于那里。

话说回来,大多数 JavaScript 应用框架中找到的路由组件抽象了方法上的差异,并提供了一种无缝地朝一个方向或另一个方向过渡的手段。是使用哪一个更重要,是为了增强功能还是提高可扩展性?实际上并不重要。但在可扩展性方面,重要的是要认识到实际上有两种主要方法,我们不想完全承诺于其中之一。

路由器是如何工作的

现在是我们深入研究路由器的时候了。我们想了解路由器的职责以及当 URI 发生变化时它的生命周期是什么样的。本质上,这相当于路由器取新的 URI 并判断它是否是路由器感兴趣的东西。如果是,那么它就会用解析后的 URI 数据作为参数触发适当的路线事件。

理解路由器在底层的角色对于扩展我们的应用程序很重要,因为我们有越多的 URI 和响应这些路由事件的组件,就有越多的扩展问题潜力。当我们知道路由器生命周期正在发生什么时,我们可以针对扩展影响因素做出适当的扩展权衡。

路由器职责

路由器的简化观点只是一个映射—有路由,字符串或正则表达式模式定义,它们映射到回调函数。重要的是这个过程快速、可预测且稳定。尤其是在我们应用程序中的 URI 数量增长时,正确实现这一过程具有挑战性。以下是任何路由组件需要处理的任何路由的大致概述:

  • 存储路由模式与其相应事件名称的映射

  • 监听 URI 变化事件—哈希变化弹出状态

  • 执行路由模式查找,将新的 URI 与每个映射的模式进行比较

  • 当找到匹配项时,根据模式解析新的 URI

  • 触发映射的路由事件,传递任何解析的数据

    注意

    路由查找过程涉及在路由映射中进行线性搜索以找到匹配项。当定义了大量的路由时,这意味着性能显著下降。当路由映射是一个对象数组时,它也可能导致路由性能不一致。例如,如果一个路由位于数组的末尾,这意味着它最后被检查并且执行缓慢。如果它位于数组的开头,执行效果会更好。

    为了避免频繁访问的 URI 的性能下降,我们可以扩展路由器,使其根据优先级属性对路由映射数组进行排序。另一种方法涉及使用字典结构,以避免线性查找。当然,只有当路由器性能如此差,以至于可以测量出性能下降时,才考虑这样的优化。

当 URI 发生变化时,路由器要做很多事情,这就是理解给定路由的生命周期很重要的原因,从地址栏中 URI 发生变化开始,到完成所有的事件处理函数。从性能角度来看,大量的路由可能会对我们的应用程序产生负面影响。从组成角度来看,跟踪哪些组件创建和响应哪些路由是具有挑战性的。当我们知道任何给定路由的生命周期看起来是什么样的时,处理起来会稍微容易一些。

路由器事件

一旦路由器为改变后的 URI 找到了匹配项,并且一旦根据其匹配模式解析了 URI,它的最后工作就是触发路由事件。触发的事件是映射的一部分。URI 可能编码了变量,这些变量被解析并通过每个路由事件处理程序传递数据。

路由器事件

路由事件提供了一个抽象层,这意味着非路由器组件可以触发路由事件

大多数框架附带可以直接在路由变化时调用函数的路由组件,而不是触发一个路由事件。实际上,这更简单,是一种更直接的方法,适合小型应用程序。通过路由器触发事件机制间接获得的间接性是我们组件与路由器之间松耦合的原因。

这是有益的,因为不同组件之间如果没有相互了解,它们可以监听同一个路由事件。随着我们扩大规模,之前已经设立的同一路由将需要承担新的责任,而添加新处理程序比不断构建相同的函数代码要容易。还有抽象的好处——监听路由事件的组件不知道事件实际上是由路由实例触发的。当需要组件触发类似路由的行为,而不实际依赖路由时,这个特性很有用。

uri 部分和模式

在大型 JavaScript 应用程序中,路由组件需要经过深思熟虑。我们还需要对 URI 本身进行深思熟虑。它们由什么组成?在整个应用程序中它们是否一致?什么是一个糟糕的 URI?在这些考虑上走向错误的方向会使我们难以扩展应用程序的可寻址性。

编码信息

URI 的作用在于,客户端可以将它传递给我们的应用程序,并且它包含了足够的信息,可以据此进行有用的操作。最简单的 URI 只是指向一种资源类型,或者是一个应用内的静态位置——/users/home 是这类 URI 的 respective 例子。利用这些信息,我们的路由器可以触发一个路由事件,并触发一个回调函数。这些回调甚至不需要任何参数——它们知道要做什么,因为不存在变异性。

另一方面,路由回调函数可能需要一些上下文。这时在 URI 中编码信息就变得很重要。最常见的用途是当客户端要求某个资源的具体实例时,使用唯一标识符。例如,users/31729。在这里,路由需要找到与这个字符串匹配的模式,并且该模式还将指定如何提取 31729 变量。然后将其传递给回调函数,现在回调函数有足够的信息来执行其任务。

URI 可能会变得很大且复杂,如果我们试图在它们中编码很多信息。这个例子就是编码显示资源网格的页面的查询参数。尝试在路由模式中指定所有可能性是困难且容易出错的。肯定会有变化,以及关于变量组合使用的不预期的边缘情况。其中一些可能是可选的。

当一个给定的 URI 有如此多的潜在复杂性时,最好将编码选项保持在传递给路由器的 URI 模式之外。相反,让回调函数查看 URI 并进一步解析以确定上下文。这样可以保持路由规格整洁,将奇异的复杂处理程序与其他一切隔离。

对于常见的查询,我们可能希望为用户提供一个简单的 URI,尤其是如果它以链接的形式呈现。例如,最近的帖子链接到/posts/recent。这个 URI 的处理程序需要确定一些事情,否则这些事情需要编码在 URI 中——比如排序和要获取的资源数量。有时这些事情不需要包含在 URI 中,而这些决策对用户体验和代码的可扩展性都有好处。

设计 URI

资源名是我们创建 URI 的好灵感。如果 URI 链接到一个显示事件的页面,它可能应该以events开始。然而,有时后端暴露的资源名并不直观。或者,作为一个组织或行业,我们喜欢缩写某些术语。这些也应该避免,除非应用程序的上下文提供了意义。

反过来说也是正确的——URI 中包含太多意义实际上可能会导致混淆,如果它太冗长。这可以从这个单词的角度看过于冗长,或者从 URI 组件的数量的角度看过于冗长。为了帮助传达结构,并使人类眼睛更容易解析,通常会将 URI 分解为部分。例如,事物类型,后面是事物标识符。实际上,对于用户来说,将分类或其他辅助信息编码在 URI 中并不真正有帮助——尽管它当然可以在 UI 中显示。

我们能够做到的地方,我们应该保持一致。如果我们限制资源名的字符数,它们都应该遵循相同的限制。如果我们使用斜杠来分隔 URI 部分,到处都应该这样做。这个想法的整个出发点是,当有很多 URI 时,它能够很好地扩展,因为用户最终可以猜测出某个东西的 URI,而不必点击链接。

在保持一致性的同时,我们有时希望某些类型的 URI 能够突出显示。例如,当我们访问一个将资源置于不同状态的页面,或需要用户输入的页面时,我们应该用不同的符号前缀动作。假设我们正在编辑一个任务——URI 可能是/tasks/131:edit。在我们应用程序的各个地方保持一致性,用斜杠分隔 URI 组件。所以我们本可以做成类似/tasks/131/edit。然而,这会让它看起来像一个不同的资源,而实际上,它和tasks/131是同一个资源。只是现在,UI 控件处于不同的状态。

下面是一个显示用于测试路由的正则表达式的例子:

// Wildcards are used to match against parameters in URIs...
console.log('second', (/^user\/(.*)/i).exec('user/123'));
//    [ 'user/123', '123' ]

// Matches against the same URI, only more restrictively...
console.log('third', (/^user\/(\d+)/i).exec('user/123'));
//    [ 'user/123', '123' ]

// Doesn't match, looking for characters and we got numbers...
console.log('fourth', (/^user\/([a-z])/i).test('user/123'));
//    false

// Matches, we got a range of characters...
console.log('fifth', (/^user\/([a-z]+)/i).exec('user/abc'));
//    [ 'user/abc', 'abc' ]

将资源映射到 URI

是时候看看 URI 在实际应用中的样子了。我们最常发现 URI 的形式,就是在我们应用程序中的链接。至少,这是理念所在——拥有一个良好互联的应用程序。虽然路由器理解如何处理 URI,但我们还需要查看所有这些链接需要生成并插入 DOM 中的地方。

生成链接有两种方法。第一种是一种相对手动的过程,需要模板引擎和实用函数的帮助。第二种尝试自动化,以扩展许多 URI 的可管理性。

手动构建 URI

如果一个组件在 DOM 中渲染内容,它可能会构建 URI 字符串并将它们添加到链接元素中。当只有少数页面和 URI 时,这样做是足够简单的。这里的扩展问题在于,JavaScript 应用程序中的页面计数和 URI 计数是互补的——大量的 URI 意味着大量的页面,反之亦然。

我们可以使用路由模式映射配置,该结构指定 URI 的外观以及它们被激活时会发生什么,作为实现视图时的参考。借助大多数框架以一种形式或另一种形式使用的模板引擎,我们可以使用模板特性来动态渲染所需的链接。或者,如果缺乏模板复杂性,我们需要一个独立的实用程序来为我们生成这些 URI 字符串。

当有很多 URI 需要链接,有很多模板时,这变得具有挑战性。模板语法为我们提供了一些帮助,使得构建这些链接稍微不那么痛苦。但这仍然耗时且容易出错。此外,我们将开始看到模板内容的重复,感谢我们在模板中构建链接的静态性质。我们至少需要硬编码,在模板中链接到的资源类型。

自动化资源 URI

我们链接的大部分资源都是来自 API 的实际资源,并在我们的代码中由模型或集合表示。既然如此,如果我们不是利用模板工具为这些资源构建 URI,而是可以在每个模型或集合上使用相同的函数来构建 URI,那会很好。这样,因为我们只关心抽象的uri()函数,所以与构建 URI 相关的模板中的任何重复都会消失。

这种方法虽然简化了模板,但引入了与路由器同步模型的挑战。例如,模型生成的 URI 字符串需要与路由器期望看到的模式匹配。所以要么,实现者需要足够自律,以保持模型生成的 URI 与路由器同步,要么模型需要基于模式来生成 URI 字符串。

如果路由器使用某种简化的正则表达式语法来构建 URI 模式,那么可以通过路由定义自动同步模型中实现的uri()函数。那里的挑战是模型需要了解路由器——这可能会导致依赖性规模问题——我们有时希望使用模型,而不一定是路由器。如果我们的模型存储了与路由器注册的 URI 模式呢?然后它可以使用这个模式来生成 URI 字符串,而且它仍然只在一个地方更改。另一个组件然后将模式注册到路由器,所以没有与模型的紧密耦合。

以下是一个示例,展示了如何将 URI 字符串封装在模型中,远离其他组件:

// router.js
import events from 'events.js';

// The router is also an event broker...
export default class Router {

    constructor() {
        this.routes = [];
    }

    // Adds a given "pattern" and triggers event "name"
    // when activated.
    add(pattern, name) {
        this.routes.push({
            pattern: new RegExp('^' +
                pattern.replace(/:\w+/g, '(.*)')),
            name: name
        });
    }

    // Adds any configured routes, and starts listening
    // for navigation events.
    start() {
        var onHashChange = () => {
            for (let route of this.routes) {
                let result = route.pattern.exec(
                    location.hash.substr(1));
                if (result) {
                    events.trigger('route:' + route.name, {
                        values: result.splice(1)
                    });
                    break;
                }
            }
        };

        window.addEventListener('hashchange', onHashChange);
        onHashChange();
    }

}

// model.js
export default class Model {

    constructor(pattern, id) {
        this.pattern = pattern;
        this.id = id;
    }

    // Generates the URI string for this model. The pattern is
    // passed in as a constructor argument. This means that code
    // that needs to generate URI strings, like DOM manipulation
    // code, can just ask the model for the URI.
    get uri() {
        return '#' + this.pattern.replace(/:\w+/, this.id);
    }

}

// user.js
import Model from 'model.js';

export default class User extends Model {

    // The URI pattern for instances of this model is
    // encapsulated in this static method.
    static pattern() {
        return 'user/:id';
    }

    constructor(id) {
        super(User.pattern(), id);
    }

}

// group.js
import Model from 'model.js';

export default class Group extends Model {

    // The "pattern()" method is static because
    // all instances of "Group" models will use the
    // same route pattern.
    static pattern() {
        return 'group/:id';
    }

    constructor(id) {
        super(Group.pattern(), id);
    }

}

// main.js
import Router from 'router.js';
import events from 'events.js';
import User from 'user.js';
import Group from 'group.js';

var router = new Router()

// Add routes using the "pattern()" static method. There's
// no need to hard-code any routes here.
router.add(User.pattern(), 'user');
router.add(Group.pattern(), 'group');

// Setup functions that respond to routes...
events.listen('route:user', (data) => {
    console.log(`User ${data.values[0]} activated`);
});

events.listen('route:group', (data) => {
    console.log(`Group ${data.values[0]} activated`);
});

// Construct new models, and user their "uri" property
// in the DOM. Again, nothing related to routing patterns
// need to be hard-coded here.
var user = new User(1);
document.querySelector('.user').href = user.uri;

var group = new Group(1);
document.querySelector('.group').href = group.uri;

router.start();

触发路由

最常见的路由触发形式是用户在我们的应用程序中点击一个链接。如前一部分所述,我们需要让我们的链接生成机制能够处理许多页面和许多 URI。这种规模影响因素的另一个维度是实际的触发动作本身。例如,对于较小的应用程序,显然链接会较少。这也意味着用户点击事件较少——更多的导航选择意味着更高的事件触发频率。

考虑较少为人所知的导航参与者也很重要。这些包括在某些后端任务完成后重定向用户,或者只是一个直接的绕道,从点 A 到点 B。

用户操作

当用户在我们的应用程序中点击一个链接时,浏览器会捕捉到这一动作并更改 URI。这包括进入我们应用程序的入口点——可能来自另一个网站或书签。正是这种灵活性使得链接和 URI 能够来自任何地方并指向任何事物。在我们能够利用链接的地方是有意义的,因为这意味着我们的应用程序连接良好,而处理 URI 更改是路由器擅长并且能够轻松处理的事情。

但是还有其他触发 URI 更改和随后路由工作流程的方法。例如,假设我们正在一个create事件表单上。我们提交表单,响应回来后成功——我们想让用户留在create事件页面吗?还是想带他们到显示事件列表的页面,这样他们就可以看到他们刚刚添加的事件?在后一种情况下,手动更改 URI 是有意义的,而且实现起来非常简单。

用户操作

我们的应用程序可以改变地址栏的不同方式

重定向用户

在 API 响应成功后重定向用户到一个新的路由是手动触发路由的一个好例子。还有其他几个场景,我们希望能够将用户从他们当前的位置重定向到一个与他们正在执行的活动相符的新页面,或者确保他们只是在观察正确的信息。

并非所有的重处理都需要在后端进行——我们可能会面临一个本地的 JavaScript 组件运行一个进程,完成后,我们想将用户带到我们应用中的另一个页面。

这里的关键思想是效果比原因更重要——我们并不太关心是什么原因导致了 URI 的变化。真正重要的是能够以意想不到的方式使用路由器。随着我们的应用程序扩展,我们通常会面临通过快速简单的路由器黑客手段来解决问题的场景。能够完全控制我们应用程序的导航,让我们对应用程序的扩展方式有了更多的控制权。

Router 配置

我们的路由与它们的事件映射通常比路由实现本身要大。这是因为随着我们的应用程序增长并拥有更多的路由模式,可能性列表会变得更大。很多时候,这是应用程序满足其扩展需求不可避免的后果。关键是不要让大量的路由声明因自身重量而崩溃,而这可以通过多种方式发生。

配置给定路由器实例响应的路线有不止一种方法。根据我们使用的框架,路由器组件在配置上可能比其他组件有更多的灵活性。一般来说,有静态路由方法,或者事件注册方法。我们还想要考虑路由器随时禁用给定路线的能力。

静态路由声明

简单的应用程序通常使用静态声明配置它们的路由器。这意味着在路由创建时,将路由模式映射到回调函数。这种方法的好处是所有路由模式的相对局部性。一眼就能看出我们的路由配置情况,我们不需要去寻找特定的路由。然而,当有大量路由时,这种方法行不通,因为我们必须去搜索它们。此外,这种方法没有关注点的分离,这不利于开发者独立于彼此尝试做他们的事情。

注册事件

当有大量路由需要定义时,应该关注封装的路由——哪些组件需要这些路由,它们是如何告诉路由器的?嗯,大多数路由器会允许我们调用一个让我们添加新路由配置的方法。然后我们只需要包含路由器并从组件中添加路由。

这绝对是正确的方向;它允许我们将路由声明保留在需要它们的组件中,而不是将整个应用程序的路由配置组合成一个对象。然而,我们可以进一步扩展这种可扩展性。

与其让我们的组件直接依赖路由实例,不如触发一个添加路由事件?这将被任何监听该事件的 router 所接收。也许我们的应用程序正在使用多个 router 实例,每个实例都有自己的专业化功能——比如日志记录——它们都可以基于特定条件监听添加的路由。关键是,我们的组件不应该关心路由实例,只需要知道当某个模式与 URI 变化匹配时,会触发路由事件。

注册事件

如何通过使用事件使组件与路由器隔离

禁用路由

在我们配置好一个给定的路由之后,我们是否假设它在会话期间始终是一个有效的路由?或者,路由器是否应该有一种方法来禁用一个给定的路由?这取决于我们从责任角度如何看待具体案例。

例如,如果发生了某些事情,且某个路径不再可访问——尝试它只会得到一个用户友好的错误——路由处理函数可以检查该路径是否可访问。然而,这增加了回调函数本身的复杂性,这种复杂性将散布在应用程序的回调中,而不是集中在某一个地方。

另一种方法可能是有一个检查组件,当组件进入需要这样做的状态时,该组件会禁用路由。当状态变为路由可以处理的内容时,该组件也会启用路由。

第三种方法是在路由首次注册时添加一个守卫函数作为选项。当路由匹配时,它会运行这个函数,如果守卫通过,则正常激活,否则失败。这种方法最适合扩展,因为检查的状态与相关路由紧密耦合,无需为路由启用/禁用状态。将守卫函数视为路由匹配条件的一部分。

下面是一个示例,展示了接受守卫条件函数的路由器。如果存在这个守卫函数并且返回false,则不会触发路由事件:

// router.js
import events from 'events.js';

// The router triggers events in response to
// route changes.
export default class Router {

    constructor() {
        this.routes = [];
    }

    // Adds a new route, with an optional
    // guard function.
    add(pattern, name, guard) {
        this.routes.push({
            pattern: new RegExp('^' +
                pattern.replace(/:\w+/g, '(.*)')),
            name: name,
            guard: guard
        });
    }

    start() {
        var onHashChange = () => {
            for (let route of this.routes) {
                let guard = route.guard;
                let result = route.pattern.exec(
                    location.hash.substr(1));

                // If a match is found, and there's a guard
                // condition, evaluate it. The event is only
                // triggered if this passes.
                if (result) {
                    if (typeof guard === 'function' && guard()) {
                        events.trigger('route:' + route.name, {
                            values: result.splice(1)
                        });
                    }
                    break;
                }
            }
        };

        window.addEventListener('hashchange', onHashChange);
        onHashChange();
    }

}

// main.js
import Router from 'router.js';
import events from 'events.js';

var router = new Router()

// Function that can be used as a guard condition
// with any route we declare. It's returning a random
// value to demonstrate the various outcomes, but this
// could be anything that we want applied to all our routes.
function isAuthorized() {
    return !!Math.round(Math.random());
}

// The first route doesn't have a guard condition,
// and will always trigger a route event. The second
// route will only trigger a route event if the given
// callback function returns true.
router.add('open', 'open');
router.add('guarded', 'guarded', isAuthorized);

events.listen('route:open', () => {
    console.log('open route is always accessible');
});

events.listen('route:guarded', (data) => {
    console.log('made it past the guard function!');
});

router.start();

调试路由器

一旦我们的路由器增长到足够大的规模,我们将不得不解决复杂的情况。如果我们事先知道可能出现的问题,我们将更好地应对它们。我们还可以将故障排除工具集成到我们的路由器实例中,以帮助这个过程。扩展我们架构的可寻址性意味着能够快速、可预测地响应问题。

冲突的路由

冲突路由可能引起巨大的头痛,因为它们可能非常难以追踪。冲突模式是后来添加到路由器中的更具体模式的通用或类似版本。更通用的模式发生冲突,因为它与最具体的 URI 相匹配,这些 URI 应该已经被更具体模式匹配。然而,它们永远不会被测试,因为通用路由是首先执行的。

当这种情况发生时,可能根本看不出来路由存在问题,因为错误的路由处理程序运行得非常好,在 UI 上,一切看起来都很正常——除非有一点不对劲。如果按 FIFO 顺序处理路由,特定性很重要。也就是说,如果首先添加更通用的路由模式,那么当它们被激活时,它们总是与更具体的 URI 字符串匹配。

当有大量 URI 需要排序时,按照这种方式排序的挑战是,这是一项耗时的工作。我们必须比较新添加的路由与现有路由的模式。如果它们都被添加到同一个地方,开发人员之间的承诺也可能存在冲突。这是将路由按组件分离的另一个优点。这样做使得可能发生冲突的路由更容易被发现和处理,因为组件很可能具有少量类似的 URI 模式。

下面是一个显示具有两个冲突路由的路由组件的示例:

// Finds the first matching route in "routes" - tested
// against "uri".
function match() {
    for (let route of routes) {
        if (route.route.test(uri)) {
            console.log('match', route.name);
            break;
        }
    }
}

var uri = 'users/abc';

var routes = [
    { route: /^users/, name: 'users' },
    { route: /^users\/(\w+)/, name: 'user' }
];

match();
//    match users
// Note that this probably isn't expected behavior
// if we look closely at the "uri". This illustrates
// the importance of order, when testing against a
// collection of URIs specs.

routes.reverse();

match();
//    match user

记录初始配置

路由器应该在配置了所有相关路由之后才开始监听 URI 变化事件。例如,如果个别组件用对该组件必要的路由配置路由器,我们不希望路由器在组件有机会配置其路由之前就开始监听 URI 变化事件。

初始化其下级组件的主要应用组件可能会引导这个过程,并在完成后告诉路由器开始工作。当个别组件有自己的路由封装在内时,在开发过程中,理解路由器的整体配置可能很困难。为此,我们需要在我们的路由器中有一个选项,用于记录其整个配置——模式及其触发的事件。这有助于我们进行扩展,因为我们不必牺牲模块化路由就能了解整体情况。

记录路由事件

除了记录初始路由配置之外,如果路由器能够在触发 URI 变化事件的生命周期中进行日志记录也是很有帮助的。这与我们在前一章中讨论的事件机制日志不同——这些事件将在路由触发路由事件之后记录。

如果我们正在构建一个大规模的 JavaScript 架构,拥有许多路由,我们就想了解关于我们的路由器的一切,以及它在运行时是如何行为的。路由器对于我们应用的可扩展性是如此的基础,以至于我们在这里要投入对细节的关注。

例如,了解路由器在遍历可用路由、寻找匹配项时的行为可能很有用。了解路由器从 URI 字符串解析出来的结果也很有用,这样我们就可以将其与下游的路由事件处理程序所看到的内容进行比较。并非所有的路由组件都支持这种级别的日志记录。如果我们发现需要它,一些框架将提供足够的入口点进入它们的组件,并附有优秀的扩展机制。

处理无效资源状态

有时,我们忘记路由是无状态的;它接受一个 URI 字符串作为输入,并根据模式匹配条件触发事件。与可寻址性相关的一个可扩展性问题并不在于路由器状态,而在于监听路由的组件状态。

例如,想象我们从一项资源导航到另一项资源。在我们访问这个新资源时,第一项资源会发生很多变化。很容易出现这样的情况:它以这样一种方式改变,使得这个特定用户无法访问,同时它还保存在用户的历史记录中,他们只需要点击后退按钮。

路由器和可寻址性可能会将这类边缘情况引入我们的应用程序。然而,处理这些边缘情况并不是路由器的责任。这些问题是由许多 URI、许多组件以及将它们全部联系在一起的复杂业务规则共同造成的。路由器只是一个帮助我们应对大规模政策的机制,而不是实施政策的场所。

摘要

本章详细介绍了如何随着应用程序的扩展实现可寻址性这一架构特性。

我们从路由和可寻址性的讨论开始,首先查看了不同的路由方法——hash 变化事件和利用现代浏览器中可用的历史 API。大多数框架为我们抽象了这些差异。接下来,我们探讨了路由器的职责,以及它们应该如何通过触发事件与其它组件解耦。

URI 本身的设计也在我们软件的可扩展性中扮演了角色,因为它们需要保持一致和可预测。用户甚至可以利用这种可预测性来帮助他们扩展对我们软件的使用。URI 编码信息,然后传递给响应路由的事件处理程序;这也需要考虑。

接着,我们查看了路由被触发的各种方式。这里的标准方法是点击一个链接。如果我们的应用程序连接良好,它将到处都是链接。为了帮助我们管理众多链接,我们需要一种自动生成 URI 字符串的方法。接下来,我们将查看组件运行所需要的中间数据。这些包括用户偏好和我们组件的默认值。

第六章:用户偏好和默认值

任何足够大的 JavaScript 应用程序都需要配置其组件。我们组件配置的范围和性质根据应用程序的不同而有所变化。在配置组件时,需要考虑许多扩展因素,我们将在整章中讨论这些因素。

我们将首先确定我们必须处理的偏好类型,然后本章的其余部分将讨论这些偏好相关的具体扩展问题以及如何解决它们。

偏好类型

当我们设计大型 JavaScript 架构时,关心的三种主要偏好类型是:地区、行为和外观。在本节中,我们将为每个偏好类别提供定义。

地区

当今的应用程序不能只支持一个单一的地区,如果它们要在全球范围内取得成功的话。由于全球化以及互联网,来自世界其他地区的应用程序需求已成为新的常态。因此,我们必须以一种能够无缝容纳许多地区的方式设计我们的 JavaScript 架构。一个地区的用户应该能够像其他任何地区的用户一样轻松、自信地使用我们的应用程序。

注意

使组件能够使用任何地区的过程称为国际化。然后,为我们的应用程序创建特定地区的数据的过程称为本地化

国际化/本地化之所以困难,是因为它触及了用户界面的每一个视觉方面。尽管有很多组件不关心地区(如控制器或集合),但这仍然可能相当多。例如,任何原本在模板某处硬编码的字符串标签,现在需要通过一个地区感知翻译机制。

语言翻译本身就已经够困难了。但是地区数据包括与我们软件中使用的任何一种文化相关的任何和一切内容。例如,用于日期/时间或货币值的形式。这些只是最常见和最直接的元素。事物可以一直变化到如何度量数量,或者一直到整个页面的布局。

行为

我们组件的大部分行为都存在于代码中,并且是不变的。由于不同偏好而发生的行为变化是微妙而又重要的。当有多个互动组件时,必然会有一种不兼容的组合会引起问题。

例如,在我们组件的实现中可能有一个函数,它从一个配置值中获取一个值,用这个值来计算某物。这可能是一个用户偏好,或者可能是我们为了可维护性而设置的东西。

注意

在本书的剩余部分,我们将把个别配置值称为偏好。我们将把给定组件内所有偏好的聚合效果称为配置。

行为偏好可能会对用户看到的内容产生不同的影响。一个简单的例子就是关闭组件,或者禁用它。这个偏好会导致组件在 UI 中不再渲染。另一个偏好将决定显示多少元素。一个常见的例子是用户告诉应用程序他们希望在每页看到多少搜索结果。

这些类型的偏好并不总是直接映射到最终用户。也就是说,组件可能有一些不是直接暴露给用户的偏好。这可能是为了提高开发人员的灵活性,减少我们编写的代码量。可配置的组件有多种形式,从这种角度来看,我们需要确保相应地处理它们,以帮助我们的软件实现扩展。

我们需要的不仅仅是前端组件,因为给定偏好可能会改变后端行为。这可能简单到一个查询参数偏好,或者另一个偏好导致使用不同的 API 端点。所有这些看似无害的偏好加起来会产生深远的影响,跨越整个应用程序,可能会影响系统中的其他用户。

外观

如果一个现代 JavaScript 应用程序要跨越受众人口统计数据进行扩展,它的外观需要是可配置的。这一要求可以从可配置的标志,到具有潜在能力彻底改变 UI 外观和感觉的可互换主题不等。

一般来说,外观变化主要围绕 CSS 属性,如字体、颜色、宽度、边框半径等。虽然确实大多数 CSS 实现并未被大多数 JavaScript 开发者触及,但我们仍然需要关注主题边界。

例如,如果我们对外观以及如何配置它持灵活态度,我们可能会让用户在运行时选择自己的主题。因此,我们需要实现一个用户可以与之交互的主题切换机制。此外,主题化的用户界面意味着偏好需要被存储和加载。

那么这就是粗粒度主题——那细粒度外观配置又是怎样的呢?然而,粗粒度更为常见,对特定组件风格的配置并非不可能。外观粒度级别与其他扩展影响因素一致,比如我们的软件部署在哪里,以及我们配置 API 的能力如何。

支持本地化

拥有对我们所有组件的国际化支持是一个好主意。实际上,有很多 JavaScript 工具可以帮助我们完成这项任务。有些工具比较独立,而有些则更针对特定的框架。使用这些工具很简单,但还有很多其他与本地化相关的工作需要考虑,特别是在扩展的情况下。

决定支持哪些区域

一旦我们拥有带有国际化支持并在生产环境中使用的软件,下一步就是决定支持哪些区域。当我们确保所有组件都进行国际化时,我们只支持一个区域——默认区域。一开始这样做是可以的,可能需要数年才能出现对第二个辅助区域支持的需求。

这通常是新软件项目的情况。我们知道国际化应该是我们优先级列表上的重要事项,但在其他所有事情中很容易分心。不花费精力支持区域的主要论点是它目前是不需要的。反对这种心态的观点是,随着组件的增长,事后实施国际化是非常困难的。所以这又是需要考虑的与扩展相关的权衡。我们希望我们的应用程序能够跨文化扩展,还是认为立即上市更重要?

除了特殊情况外,我们假设国际化是必不可少的——我们需要确定我们首先要支持哪些区域,以及哪些可以等待。例如,在实际需要之前就寻求大规模区域支持是一个糟糕的主意。区域占用的物理空间,需要有人来维护这些区域。所以如果没有客户来承担这种增加的扩展复杂性的成本,这是不值得的。

相反,所选的本地化区域应完全基于客户需求。如果我们有一个地方有数百人寻求支持,而只有不到十几个人询问另一个地方,优先级就很明显了。如果我们像优先支持功能一样优先支持区域,这将对我们有帮助。

维护区域

首先,如果我们支持某个区域,我们将需要翻译在 UI 中显示的所有字符消息。其中一些是在模板文件中静态编码的,而其他字符串在我们的 JavaScript 模块中找到。如果只是找到这些字符串,并一次性翻译它们那该多好。但字符串很少会永远保持不变——经常会有微小的调整。此外,随着我们的软件增长和更多组件的添加,需要翻译的字符串也会增加。

仅字符串翻译的缩放因素就是我们支持的区域设置数量——这就是为什么我们需要谨慎地只支持有限数量的区域设置,只要我们能够做到。复杂性并没有就此结束。例如,一些消息字符串可以从源语言映射到目标语言。像语法屈折这样的东西——单词如何根据修改承担不同的意义——并不是那么直接。实际上,这些用例有时需要国际化的专用库。

其他可本地化的数据,如日期/时间格式,不需要太多维护。对于给定区域设置,应用程序中通常使用一两个格式。对于这些格式,客户可能会对他们文化中使用的标准格式感到满意。幸运的是,我们可以在我们的项目中使用通用区域数据仓库CLDR)数据——一个可下载的通用区域数据仓库。这是一个良好的起点,因为大多数时候这些数据都是足够的,并且根据请求容易覆盖。

设置区域设置

一旦我们建立了国际化库,并且有几个区域设置,我们就可以开始测试我们的应用程序在不同文化角度下的行为。对于这种行为,有许多需要考虑的项目。例如,我们需要为用户启用区域设置,并且我们需要跟踪这个选择。

选择区域设置

在 JavaScript 应用程序中选择区域设置有两种常见方法。第一种方法是使用 accept-language 请求头。第二种方法是在用户设置页面上一个选择器小部件。

accept-language 方法的优点在于无需涉及用户输入。我们的应用程序会根据用户的浏览器语言偏好发送,从而我们可以设置区域设置。这种方法的挑战在于,从可用性的角度来看,它可能过于限制性,从实现角度来看也是如此。例如,用户可能无法控制他们的浏览器语言偏好,或者浏览器可能没有支持我们应用程序的区域设置偏好。

注意

使用 accept-language 请求头方法遇到的另一个技术挑战是,没有简单的方法将请求头从浏览器传递到 JavaScript 代码——这有点疯狂,因为它们都在浏览器中!例如,如果我们的 JavaScript 代码需要知道区域设置偏好,以便它可以加载适当的区域设置数据,它将需要访问 accept-language 头部。为此,我们需要后端技巧。

更加灵活的方法是向用户展示一个区域设置选择器小部件,然后从中明确用户想要激活哪个区域设置。然而,我们需要找到一种存储这个区域设置选择的方法,这样用户就不必重复选择他们的区域设置。

存储区域设置偏好

一旦用户选择地区偏好,可以作为 cookie 值存储。下次应用程序在浏览器中加载时,我们将准备好地区偏好。然后我们可以标记选择器为适当的选择,以及加载相关地区数据。

将地区偏好存储在 cookie 中的问题是,如果用户转到另一个浏览器,将需要重复相同的选择过程。这对于当今用户比以往任何时候都更加移动是一个真正的问题——在一个设备上所做的更改应该在任何应用程序被使用的地方反映出来。而这是通过 cookie 办不到的。

如果我们使用后端 API 存储地区偏好,它将无处不在对用户可用。下一个挑战是加载相关地区数据,使其可供我们其他组件使用。通常,我们希望在此开始渲染数据之前准备好这些数据,因此这是我们向后端发出的第一个请求之一。有时,所有地区都作为单一资源一起提供。如果我们支持很多地区,这可能成为问题,因为加载它需要的前期成本很高。

另一方面,一旦我们加载地区偏好,我们只能加载立即需要的地区。这将提高初始加载时间,但权衡是切换到新地区较慢。这可能不会经常发生,所以最好不要加载从未使用过的地区数据。

存储地区偏好

JavaScript 应用程序首先加载地区偏好,然后使用该偏好加载本地数据

在 URI 中使用地区

除了在后台存储本地偏好或作为 cookie 值外,地区还可以编码为 URI 的一部分。通常,它们作为两个字符代码表示,例如enfr,并位于 URI 的开头。使用这种方法的优点是不需要存储偏好。我们仍然可能需要一个选择器让用户选择他们偏好的地区,但这将导致新的 URI,而不是将偏好值存储在某个地方。

像这样在 URI 中编码首选地区的方法与基于 cookie 的方法有相同的缺点。虽然我们可以收藏一个 URI,或将一个 URI 传递给其他人——他们会看到我们相同的地区——问题是这并不是一个永久的偏好。请注意,我们总是可以存储偏好并在应用程序加载时更新 URI。但由于路由和 URI 生成的额外复杂性,这种方法扩展性不佳。

通用组件配置

正如我们在上一节关于地区偏好的内容中看到的那样,我们需要加载一个偏好值,然后我们的每个组件都可以使用这个值。或者在地区的情况下,可能只有一个组件,但这个偏好值间接影响了所有组件。除了地区之外,我们还有许多其他想要在组件中进行配置的事物。本节从通用角度来探讨这个问题。首先我们需要决定给定组件的哪些方面是可配置的,然后是如何在运行时将这些偏好值传递给组件的机制。

决定配置值

组件配置的第一步是决定偏好——组件哪些方面需要配置,哪些方面可以保持静态?这远非一门精确的科学,因为往往后来我们会意识到某些静态内容应该是可配置的。试错是找到可配置偏好的最佳过程,尤其是当我们的软件刚刚起步时。过多的初始可配置性考虑是可扩展性的瓶颈。

当某事物不可配置时,它具有简单性的优势。它更加结构化,且不是活动的部件。这消除了潜在的边缘案例和性能问题。为使值可配置而进行的前期论证并不经常发生。但随着我们的软件成熟,我们将有一个更好的视角,因为我们已经设定了一些偏好,并且我们将更好地了解预期会发生什么。

例如,我们将开始在我们的多个组件中看到重复。它们将基本相同,只有微妙的差异。如果我们继续添加彼此之间只有细微差别的新的组件类型,我们将面临可扩展性问题。我们的代码库将增长到一个无法管理的规模,并且我们会让开发者困惑,因为给定组件的责任将变得模糊。

这就是我们利用可配置性来实现规模的地方。这是通过引入对新组件类型的偏好来实现的。例如,假设我们需要一个新的视图,它除了处理 DOM 事件的方式与另一个已经在多个地方使用的视图相同外,其他方面都相同。我们不是实现一个新的视图类型,而是增强现有的视图,使其能够接受一个新的函数值,用于覆盖这个事件的默认值。

另一方面,我们不能随意引入组件偏好。当我们这样做时,我们用新的瓶颈取代了旧的瓶颈。我们需要考虑性能,因为每增加一个新的可配置偏好都会受到影响。还有代码复杂性——使用偏好并不像使用静态值那么简单。还有可能引入与其他开发者在同一开发周期内引入的其他偏好不一致的偏好。最后,还需要跟踪和文档化给定组件可用的各种偏好。

存储和硬编码的默认值

就组件而言,偏好应尽可能像普通的 JavaScript 变量一样处理。这使得我们的代码具有灵活性——用静态值替换偏好不应该产生很大的影响。普通变量通常声明有一个初始值,偏好也应该声明有一个默认值。这样,如果由于某种原因我们无法获取存储在后台的偏好值,软件将继续使用合理的默认值运行。

对于任何偏好,都应该有一个回退默认值,并且这些值应该在某个地方进行文档化。理想情况下,使用的默认值服务于常见情况,因此不需要为了使用软件而调整每个偏好。如果我们由于某种原因无法从后端访问存储的配置值,硬编码的默认值会让软件继续运行,尽管是使用不那么理想的配置。

提示

有时,无法访问配置值是一个不可逾越的障碍,软件应该快速失败,而不是使用硬编码的默认值。虽然软件完全可以通过默认值正常运行,但根据我们的客户和他们部署的情况,这种模式可能比软件不可用更糟糕。这在部署大规模 JavaScript 应用程序时需要考虑。

默认偏好值的安全性使得在后台删除修改过的偏好值变得可能。把它看作是一个恢复出厂设置的动作。换句话说,如果我们通过调整偏好值引入了软件问题,我们只需删除我们存储的值即可。如果后台不需要存储默认值,那么就没有覆盖默认值的风险。

存储和硬编码的默认值

默认值总是存在,但很容易被后端偏好值覆盖

后端影响

如果我们将在后台存储偏好值以提供用户的便携性,那么我们需要某种机制,使我们能够在配置存储中放入新的值偏好,以及检索我们的偏好。理想情况下,这是一个允许我们定义任意键值偏好,并且让我们用一个请求检索所有配置的 API。

这种方法对前端开发如此宝贵,是因为我们可以在开发组件的同时为其定义新偏好,而不会打扰到后端团队。对于后端 API 来说,前端配置是任意的——无论是否有 UI,API 都能正常工作。

有时,这实际上可能比想象的更有麻烦。如果变化非常小——整个应用程序中只需要少量的配置值呢?如果是这样,我们可能会考虑维护一个静态的 JSON 文件,作为我们的前端配置。它足够任意,我们可以随时定义偏好,对于获取偏好值来说,它与 API 一样好用。

当用户定义的偏好设置不适用时,例如,用户的首选语言。我们的应用程序可能有一个默认语言,直到用户更改它。他们是在为自己更改偏好,而不是系统中的每个用户。这时我们就需要前面提到的配置 API。它存储这些值的方式,很可能是数据库,需要对用户敏感。但并非所有偏好值都需要这样;有些是由部署操作员设置的,用户无法更改这些。

Backend implications

当前用户会话可以用来加载特定于该用户的偏好设置;这些与系统设置不同,不会因用户而异。

Loading configuration values

加载前端所需配置有两种方法。第一种方法是加载所有配置,因为 UI 中会渲染任何内容。这意味着在路由开始处理任何内容之前,我们会等待配置可用。这通常意味着等待一个加载配置数据的承诺。这里的明显缺点是初始加载时间变长。优点是我们拥有了后续所需的一切——不再需要配置请求。

我们可以在浏览器中使用本地存储来缓存偏好值。它们很少变化,这种策略有可能提高初始加载性能。另一方面,它增加了复杂性——所以只有在配置值很多且加载它们的时间明显时才考虑这种方法。

instead of loading all our configuration up-front, preference values can be loaded on demand. That is, when a component is about to be instantiated, a request is made for its configuration. This has the appeal of being efficient, but again, how much configuration could there possibly be to warrant such complexity? Strive toward loading all application configuration up-front where possible.

Loading configuration values

一个与后端通信的配置组件为获取或设置偏好值的任何组件提供了抽象。

配置行为

如果我们实现得当,我们组件的行为很大程度上是自包含的。它们向外部世界暴露的是对它们行为进行微调的偏好。这可能是一些内部关注的内容——比如使用的模型类型,或首选算法。它可能是一些面向用户的内容,比如启用组件,或设置显示模式。正是这些偏好帮助我们使组件能够在各种上下文中工作。

启用和禁用组件

一旦我们的软件达到一定的临界质量,不是所有功能对所有用户都相关。简单地能够在启用/禁用状态之间切换组件是一个强大的工具。对我们作为软件供应商,以及我们的客户来说都是如此。例如,我们知道某些功能是我们软件中某些用户角色所必需的,但它们并不是常见情况。为了更好地优化常见用户,我们可能会选择禁用某些不经常使用的高级功能。这可以清理布局,提高性能等。

另一方面,我们可能会默认开启所有功能,但如果组件有能力被关闭,那么这就让用户决定了哪些对他们来说是相关的。如果他们能够根据自己的喜好安排用户界面,移除对他们没有特别用处的元素,那么这将提升用户体验。

在任何情况下,这对整体布局都有影响。如果我们不花时间设计可扩展的布局,那么切换组件实际上并没有任何价值。在设计我们的布局时,我们需要考虑用户可能会使用,或者我们自己可能会使用的各种配置场景。

启用和禁用组件

在页面上禁用组件有可能更新布局;我们的样式需要能够处理这种情况

更改数量

在 UI 中显示的数量在某一方面只是一个在设计时做出的猜测。我们希望列表中显示的项数量是最优的,用户不需要为此类型的偏好更改而烦恼。问题是数量是非常主观的。它更多的是关于使用我们的应用程序执行任务的个人,以及他们习惯于什么,他们使用我们的软件时正在做什么,还有许多其他因素,数量偏好默认可能不是最优的。

一个常见的数量问题是我想在屏幕上显示多少个实体?这些实体可以是整个应用程序中常用的网格小部件,一个搜索结果页面,或者任何其他渲染事物集合的东西。我们可以选择显示较少数量的效率默认设置,同时允许更多数量以满足用户的需求。

小贴士

始终检查用户提供的偏好是一个好主意。一个保护措施是在放置允许的值的选择,而不是接受任意的用户输入。例如,我们不应该允许网格中渲染 1,000 个实体。尽管如此,返回这些数据的 API 也应该进行检查并限制数量参数。

另一个数量考虑的是我们需要显示哪些实体属性?在网格的情况下,我们可能希望显示某些列而隐藏其他列。这样的偏好应该是持久的,因为如果我们设置了想要看到的数据,我们就不想重复那项工作。

当我们改变数量偏好时,会有后端影响。在决定渲染多少实体的情况下,我们可能希望在获取数据时将这个约束传递给 API——获取我们不打算显示的东西是没有意义的。这也可能涉及到模型或集合的改变。在确定在特定 UI 区域显示哪些数据的情况下,我们可能要求模型或集合只提供他们拥有的一部分数据。

更改顺序

在 UI 中渲染集合的顺序是另一个常见的 behavioral 偏好,我们很可能会支持它。这里最大的影响是配置某物的默认顺序。例如,按修改日期对每个集合进行排序,这样最近的实体首先出现,这是一个好的默认设置。

许多网格组件允许用户在给定列的升序和降序之间切换排序。这些都是操作,不一定偏好。然而,如果默认顺序从来不是我们想要的,它们可能会变得烦人。因此,我们可能需要为用户提供一种方式,为任何给定网格提供默认排序偏好,同时保留点击列标题进行临时排序的能力。

可能的更复杂的排序偏好是,点击列标题并不总是有帮助。例如,如果我们想按不在 UI 中渲染的东西排序,比如相关性或最佳销售?这里可能有一个控制可以用来实现这一点,但这又是另一个可能的偏好——因为它可能有助于提供更好的体验。

// users.js
export default class Users {

    // Accepts a "collection" array, and an "order"
    // string.
    constructor(collection, order) {
        this.collection = collection;
        this.order = order;

        // Creates an iterator so we can iterate over
        // the "collection" array without having to
        // directly access it.
        this[Symbol.iterator] = function*() {
            for (let user of this.collection) {
                yield user;
            }
        };
    }

    set order(order) {

        // When the order break it down into it's parts,
        // the "key" and the "direction".
        var [ key, direction ] = order.split(' ');

        // Sorts the collection. If the property value can be
        // converted to lower case, they it's converted to avoid
        // case inconsistencies.
        this.collection.sort((a, b) => {
            var aValue = typeof a[key].toLowerCase === 'function' ?
                a[key].toLowerCase() : a[key];

            var bValue = typeof b[key].toLowerCase === 'function' ?
                b[key].toLowerCase() : b[key];

            if (aValue < bValue) {
                return -1;
            } else if (aValue > bValue) {
                return 1;
            } else {
                return 0;
            }
        });

        // If the direction is "desc", we need to reverse the sort.
        if (direction === 'desc') {
            this.collection.reverse();
        }
    }

}

// main.js
import Users from 'users.js';

var users = new Users([
    { name: 'Albert' },
    { name: 'Craig' },
    { name: 'Beth' }
], 'name');

console.log('Ascending order...');
for (let user of users) {
    console.log(user.name);
}
//
// Albert
// Beth
// Craig

users.order = 'name desc';

console.log('Descending order...');
for (let user of users) {
    console.log(user.name);
}
//
// Craig
// Beth
// Albert

配置通知

当用户在我们的应用程序中执行某些操作,比如打开或关闭某些功能时,我们需要提供关于该操作状态的反馈。它成功了吗?失败了吗?正在运行吗?这通常通过通知完成,以屏幕角落的短暂弹出窗口或某个面板的形式呈现。

用户可能希望控制他们通知方式的某些方面——没有比收到我们不关心的信息垃圾更让人恼火了。因此,与通知相关的的一个偏好可能就是通知主题的选择。例如,我们可能希望选择不接收不相关实体类型的通知。

另一个可能的偏好可能是给定通知在屏幕上保持活动状态的持续时间。例如,它应该在我们确认它之前一直停留原地,还是应该在三秒后消失?在极端情况下,如果没有什么其他办法能让它们不那么烦人,用户可能想要完全关闭通知。如果需要,以后可以随时方便地浏览操作日志。

内联选项

那么我们如何收集用户偏好输入呢?对于不太活跃的全球应用程序偏好,一个按类别划分的设置页面可能是合适的。然而,必须在设置页面上为个别小部件配置特定事项有点儿烦人。有时,拥有内联选项会更好。

内联意味着用户可以使用与相关 UI 部分相关的元素来设置他们的偏好。例如,在网格中选择特定的列显示。把这样的偏好埋在某个设置页面上并没有多大意义。当偏好控制与它们控制的元素相对位置时,通常需要更少的解释。当控制具有上下文意义时,用户通常更容易理解其含义。

注意

上下文偏好控制的一个缺点是,它们有可能导致界面混乱。如果页面上有许多组件,每个组件上都有偏好控制,那么我们很可能会制造混乱而不是提供便利。

更改外观和感觉

如今,应用程序的外观和感觉很少是静态的、不变的方面。相反,它们通常会附带几套用户可以选择的主题。或者,软件中内置了易于创建主题的支持。这允许我们的客户决定他们的软件应为他们的用户呈现何种外观。除了更新我们应用程序外观和感觉的预设主题外,还可以设置个别样式偏好。

主题工具

如果我们想要我们的应用程序能够根据请求更换主题,我们必须在 CSS 及其使用的标记上投入大量的设计和架构工作。虽然这个话题远远超出了本书的范围,但研究一下有助于生成主题的工具还是值得的。

在我们这一领域中可用的第一个工具是一个 CSS 框架。与 JavaScript 框架类似,CSS 框架定义了一致的模式和约定。接下来,就要我们这些组件作者来弄清楚如何将这些 CSS 模式应用到我们的组件以及它们生成的标记上。可以把一个主题看作是一系列样式偏好。当配置更改时,由于新的偏好值,外观也会发生变化。使一个 CSS 模块成为主题的原因是,它定义了与应用程序中所有其他主题相同的属性——只有这些属性的值会发生变化。

我们可以使用后端构建过程的一部分工具—CSS 编译器。这些工具接收使用 CSS 方言的文件,并预处理它们。关于这些预处理器语言的好处是,我们能够更精确地控制样式偏好的指定方式。例如,CSS 中没有变量这样的东西,但预处理器中有,这是非常方便的可配置性功能。

选择主题

一旦我们有了可定制的用户界面,我们需要一种加载特定主题实例的方法。即使我们不允许用户选择他们喜欢的主题,能够通过更改偏好值来改变设计也是很好的。当我们决定实现新设计时,这当然可以使部署到生产环境变得更加简单。

将来,我们可能会决定让用户选择自己的主题。例如,我们可能已经拥有大量用户,现在有这种需求。我们可以像系统中使用的任何其他偏好值一样创建主题选择器。我们需要有一种主题选择小部件,用户所做的选择可以映射到路径,因为这是交换一个主题到另一个主题所需的一切。

另一种可能性是根据用户角色设置不同的主题作为默认主题。例如,如果管理员登录,具有不同的视觉提示实际上您以特定类型的用户登录是有帮助的。在截图等场景中,这类事情可以帮助。

个人样式偏好

应用程序的外观和感觉可以逐个元素级别进行更改。也就是说,如果我们想改变某物的宽度,我们可以在屏幕上进行更改。也许我们不喜欢正在使用的字体样式,我们也想更改,但其他方面保持不变。

应避免此类细粒度的样式偏好,因为它们扩展性不佳。我们的组件必须了解特定的样式考虑,这通常会在大多数情况下降低组件的真正目的。在某些情况下,为屏幕选择不同的布局不会有害,因为这通常意味着将一个 CSS 类交换为另一个。

另一种可能性是使用拖放交互来设置某物的尺寸。但是,最好是将这些保留为短暂交互,而不是持久偏好。我们希望为常见的配置值优化,而针对个人口味调整元素大小并没有什么共同之处。

性能影响

我们将以概述由各种配置区域引入的性能影响来结束本章。如果我们确实需要在某一区域获取配置值,因为它们增加了价值,它们可能会影响整体性能—因此我们需要以某种方式抵消这种成本。

可配置的区域性能

说到语言环境,最显著的性能瓶颈就是初始加载。这是因为我们必须在实际为用户渲染任何内容之前加载所有语言环境数据。这包括字符串消息翻译,以及所有进行本地化的其他必要数据。当一次性加载多个语言环境时,初始化过程中的性能受到进一步限制。

提高加载性能的最佳方法是只加载用户实际想要的语言环境。一旦他们设置了这个偏好,他们不太可能频繁更改,所以附近有其他语言环境数据并准备好并没有真正的好处。

渲染视图时不可避免地会有减速,因为大量数据需要通过我们使用的本地化机制。单凭这一点不太可能引起性能问题,因为大多数操作都是小而高效的——简单的查找和字符串格式化。尽管如此,额外的开销是存在的,需要予以考虑。

可配置行为性能

改变组件行为的配置对性能影响最小。实际上,可配置行为的性能特性与可配置语言环境的特性相似。最大的挑战是初始配置加载。在那之后,只需执行查找,这是快速的。

需要注意的是,当我们需要配置大量组件时。虽然单个查找很快,但当查找量很大时,性能会受到影响。达到这个点需要一段时间,但风险依然存在。

以下是一个示例,展示了我们可以配置集合何时排序,从而影响具有依赖顺序并且被频繁调用的其他操作的性能:

// users.js
export default class Users {

    // The users collection excepts data, and an
    // "order" property name.
    constructor(collection, order) {
        this.collection = collection;
        this.order = order;
        this.ordered = !!order;
    }

    // Whenever the "order" property is set, we need
    // to sort the internal "collection" array.
    set order(key) {
        this.collection.sort((a, b) => {
            if (a[key] < b[key]) {
                return -1;
            } else if (a[key] > b[key]) {
                return 1;
            } else {
                return 0;
            }
        });
    }

    // Finds the smallest item of the collection. If the
    // collection is ordered, then we can just return the
    // first collection item. Otherwise, we need to iterate
    // over the collection to find the smallest item.
    min(key) {
        if (this.ordered) {
            return this.collection[0];
        } else {
            var result = {};
            result[key] = Number.POSITIVE_INFINITY;

            for (let item of this.collection) {
                if (item[key] < result[key]) {
                    result = item;
                }
            }

            return result;
        }
    }

    // The inverse of the "min()" function, returns the
    // last collection item if ordered. Otherwise, it looks
    // for the largest item.
    max(key) {
        if (this.ordered) {
            return this.collection[this.collection.length - 1];
        } else {
            var result = {};
            result[key] = Number.NEGATIVE_INFINITY;

            for (let item of this.collection) {
                if (item[key] > result[key]) {
                    result = item;
                }
            }

            return result;
        }
    }

}

// main.js
import Users from 'users.js';

var users;

// Creates an "ordered" users collection.
users = new Users([
    { age: 23 },
    { age: 19 },
    { age: 51 },
    { age: 39 }
], 'age');

// Calling "min()" and "max()" doesn't result in
// two iterations over the collection because they're
// already ordered.
console.log('ordered min', users.min());
console.log('ordered max', users.max());
//
// ordered min {age: 19}
// ordered max {age: 51}

// Creates an "unordered" users collection.
users = new Users([
    { age: 23 },
    { age: 19 },
    { age: 51 },
    { age: 39 }
]);

// Every time "min()" or "max()" is called, we
// have to iterate over the collection to find
// the smallest or largest item.
console.log('unordered min', users.min('age'));
console.log('unordered max', users.max('age'));
//
// unordered min {age: 19}
// unordered max {age: 51}

行为偏好可能用于完全交换一个函数与另一个函数。它们可能有相同的接口,但实现不同。在运行时决定使用哪个函数并不昂贵,但还需要考虑内存消耗。例如,如果我们应用程序中有许多支持不同函数的偏好,我们将不得不存储默认实现,以及作为偏好值存储的函数。

可配置主题性能

我们唯一可以预期的可配置主题的延迟就是确定使用哪个主题的初始成本。然后是下载它以及将样式应用到标记的过程——这与只有一个静态样式集的应用程序没有区别。如果我们允许用户切换主题,那么还需要等待新的 CSS 和相关静态资源下载和渲染的额外延迟。

摘要

本章介绍了大规模 JavaScript 应用程序中可配置性的概念。主要的配置类别包括地区、行为和外观。地区是当今网络应用程序的一个重要部分,因为没有什么能阻止世界上任何地方的人使用我们的应用程序。然而,国际化带来了可扩展性的挑战。它增加了我们开发周期的复杂性,以及维护地区的成本。

偏好需要存储在某个地方。将它们存储在浏览器中是可行的,但这种方法缺乏可移植性。将偏好存储在后端并在应用程序初始化时加载它们要更合适得多。扩展许多偏好面临许多挑战,包括区分用户定义和系统偏好。我们是否包含了合理的硬编码默认值并不重要。

我们应用程序的风格是另一个可配置的维度。有框架和构建工具可以帮助我们构建外观和感觉的主题。可配置组件有一些小的性能考虑——下一章将探讨随着我们扩展软件而出现的性能挑战。

第七章: 加载时间和响应性

JavaScript 的可伸缩性包括应用程序的加载时间以及用户与应用程序交互时的响应性。共同地,我们将这两个架构品质称为性能。在用户眼中,性能是质量的主要指标——正确地做到这一点很重要。

随着我们的应用程序获得新功能和用户基础的增长,我们必须找到避免相关性能下降的方法。初始加载受到诸如 JavaScript 工件负载大小等因素的影响。我们 UI 的响应性更多地与代码的运行特性有关。

在本章中,我们将探讨这两个性能维度的各种权衡,以及它们将如何影响系统其他区域。

组件工件

在书的早期部分,我们强调过,大型 JavaScript 应用程序只是组件的集合。这些组件以复杂和精细的方式相互通信——这些通信实现了我们系统的行为。在组件可以通信之前,它们必须被交付到浏览器。了解这些组件是由什么组成的,以及它们实际上是如何被交付到浏览器的,有助于我们推理出应用程序的初始加载时间。

组件依赖

组件是我们应用程序的基石;这意味着我们需要将它们交付给浏览器,并以某种连贯的方式执行它们。组件本身可以从单体的 JavaScript 文件,到分布在几个模块中的东西。所有拼图碎片都是通过依赖图拼在一起的。我们从一个应用程序组件开始,因为这是进入我们应用程序的入口点。它通过要求它们来找到所有它需要的组件。例如,可能只有少数几个顶级组件,它们映射到我们软件的关键功能。这是依赖树的第一层,除非我们的所有功能组件都是单体构成的,否则可能还会有进一步的模块依赖需要解决。

模块加载机制遍历树结构,直到获取所需的所有内容。模块及其依赖关系细化到合理粒度的好处是,很多复杂性都被隐藏了起来。我们不需要在脑海中持有整个依赖图,这对于中等规模的应用来说是一个不切实际的目标。

这种模块化结构和用于加载和处理依赖的机制带来了性能影响。具体来说,初始加载时间会受到影响,因为模块加载器需要遍历依赖图,并为每个资源向后端请求。虽然请求是异步的,但网络开销依然存在——这在初始加载时对我们影响最大。

然而,仅仅因为我们想要一个模块化结构,并不意味着我们必须承担网络开销的后果。尤其是当我们开始扩展大量功能和大量用户时。每个客户端会话需要交付更多内容,随着更多用户请求相同的事物,后端资源争用也会增加。模块依赖关系是可以追踪的,这为我们的构建工具提供了许多选项。

组件依赖关系

如何加载 JavaScript 应用程序模块;依赖项会自动加载。

构建组件

当我们的组件达到一定复杂程度时,它们可能需要不仅仅是几个模块来实现所有功能。随着组件数量的增加,我们给自己制造了网络请求开销问题。即使模块携带的数据量很小,仍然需要考虑网络开销。

我们应该实际上追求更小的模块,因为它们更容易被其他开发者消费——如果它们小,那么它们很可能有更少的运动部件。如前所见,模块及其之间的依赖关系使我们能够实现分而治之。这是因为模块加载器追踪依赖关系图,并在需要时拉入模块。

如果我们想避免向后端发送这么多请求,我们可以将更大的组件工件作为构建工具链的一部分来构建。有许多工具可以直接利用模块加载器来追踪依赖关系,并构建相应的组件,如 RequireJS 和 Browserify。这很重要,因为它意味着我们可以选择适合我们应用程序的模块粒度,同时仍然能够构建更大的组件工件。或者我们也可以切换回实时将较小模块加载到浏览器中。

在网络请求开销方面的扩展含义造成了很大的影响。组件越多,这些组件越大,构建过程就越重要。特别是自从进行了代码压缩以来,这个压缩文件大小的过程经常是构建过程的一部分。能够关闭这些构建步骤,另一方面,对开发团队的可扩展性也有影响。如果我们能够在这之间切换浏览器接收的组件工件类型,那么开发过程可以推进得更快。

构建组件

构建组件会导致请求的工件更少,网络请求也较少。

加载组件

在本节中,我们将探讨负责将我们的源模块和构建组件实际加载到浏览器中的机制。目前有许多第三方工具用于结构化我们的模块并声明它们的依赖关系,但趋势是转向使用这些任务的新浏览器标准。我们还将探讨延迟加载模块以及加载延迟对用户体验的影响。

加载模块

目前在生产中使用的大型应用程序许多都采用了如 RequireJS 和 Browserify 这样的技术。RequireJS 是一个纯粹的 JavaScript 模块加载器,它有可以构建较大组件的工具。Browserify 的目标是使用为 Node.js 编写的代码来构建在浏览器中运行的组件。尽管这两种技术都解决了本章讨论的许多问题,但新的 ECMAScript 6 模块方法才是未来的发展方向。

支持使用基于浏览器的模块加载和依赖管理方法的主要论点是,不再需要另一个第三方工具。如果语言有一个解决扩展问题的特性,走那条路总是更好的,因为我们的工作量会少一些。这肯定不是万能的,但它确实具备我们所需的大部分功能。

例如,我们不再需要依赖发送 Ajax 请求,并在请求到达时评估 JavaScript 代码——这一切都交给浏览器处理。该语法实际上与在其他编程语言中找到的标准import export关键词更加一致。另一方面,原生的 JavaScript 模块仍然是新宠,而仅仅因为这一点就抛弃使用不同模块加载器的代码还不够充分。对于新项目,研究允许我们一开始就使用这些新模块结构的 ES6 转换器技术是值得的。

注意

我们应用程序经历的网络开销的一部分,以及用户最终支付的一部分,与 HTTP 规范有关。该规范最新草稿版 2.0 解决了许多开销和性能问题。这对模块加载意味着什么?如果我们能够以最小的开销获得合理的网络性能,我们可能能够简化我们的构件。编译较大组件的需求可能会被推迟,以便专注于坚实的模块化架构。

懒加载模块

单块编译组件丧失的一个优势是,我们可以推迟到实际需要时才加载某些模块。对于编译组件来说,要么全部加载,要么全部不加载——这在我们整个前端被编译成一个单独的 JavaScript 文件时尤其正确。好处是,当需要时一切都已准备就绪。如果用户决定在初次加载后五分钟与一个功能互动,代码早已在浏览器中,随时待命。

另一方面,懒加载是默认模式。这意味着模块直到另一个组件明确请求它时才被加载到浏览器中。这可能意味着一个require()调用或一个import声明。在这些调用被作出之前,它们不会从后端获取。好处是,初始页面加载应该要快得多,它只拉取初始显示给用户的特性所需的模块。

另一方面,当用户在初始加载五分钟后尝试使用某个功能时,我们的应用程序将首次需要或导入一些模块。这意味着在初始加载后会有一些延迟。请注意,随后的会话中按需加载的模块数量应该很少。因为必然有一些共享模块在用户最初看到的页面中被加载。

我们必须仔细考虑我们系统中的依赖关系。尽管我们可能认为我们推迟了某些模块的加载,但可能存在一些间接依赖,它们会在不需要的时候无意中加载主页面的模块。开发工具中的网络面板为此提供了理想的功能,因为通常很明显我们正在加载我们实际上不需要的东西。如果我们的应用有很多功能,懒加载尤其有帮助。初始加载时间的节省是巨大的,而且很可能有些用户从未使用过这些功能,因此也无需加载。

接下来是一个示例,展示了在实际需要时才加载模块的概念:

// stuff.js
// Export something we can call from another module...
export default function doStuff() {
    console.log('doing stuff');
}

// main.js
// Don't import "doStuff()" till the link
// is clicked.
document.getElementById('do-link')
    .addEventListener('click', function(e) {
        e.preventDefault();

        // In ES6, it's just "System.import()" - which isn't easy
        // to do across environments yet.
        var loader = new traceur.runtime.BrowserTraceurLoader();
        loader.import('stuff.js').then(function(stuff) {
            stuff.default();
        });
    });

模块加载延迟

模块是对事件的响应而加载的,而这些事件几乎总是用户事件。应用被启动。选择了一个标签页。这类事件如果它们尚未被加载,有可能会加载新的模块。挑战在于在这些代码模块还在传输中或被评估时,我们能为此类用户做些什么?因为正是我们在等待的代码,所以我们不能执行那些能提供更好加载体验的代码。

例如,在我们有一个模块被加载,以及它所有的依赖都被加载之前,我们无法执行那些对用户感知到的 UI 响应性至关重要的操作。这些操作包括发起 API 调用,以及操纵 DOM 以提供用户反馈。没有来自 API 的数据,我们只能告诉用户,耐心点,东西正在加载! 如果用户因为我们的模块需要一些时间而且加载指示器没有消失而感到沮丧,他们将会开始随机点击看起来可以点击的元素。如果我们没有为这些元素设置任何事件处理程序,那么 UI 将感觉不响应。

以下是示例,展示了导入运行昂贵代码的模块如何阻塞导入模块中代码的运行:

// delay.js

var i = 10000000;

// Eat some CPU cycles, causing a delay in any
// modules that import this one.
console.log('delay', 'active');
while (i--) {
    for (let c = 0; c < 1000; c++) {

    }
}
console.log('delay', 'complete');

// main.js

// Importing this module will block, because
// it runs some expensive code.
import 'delay.js';

// The link is displayed, and it looks clickable,
// but nothing happens. Because there's no event
// handler setup yet.
document.getElementById('do-link')
    .addEventListener('click', function(e) {
        e.preventDefault();
        console.log('clicked');
    });

网络是不可预测的,我们应用后台所面对的规模化的影响者也是如此。用户众多意味着在加载我们的模块时可能会产生高延迟。如果我们想要扩展,就必须考虑这些情况。这涉及到使用策略。在主应用之后我们需要加载的第一个模块,是能够通知用户的功能。

例如,我们的 UI 有一个默认的加载器元素,但是当我们的第一个模块加载时,它会继续渲染关于正在加载的内容以及可能需要多长时间的更详细信息,或者,它可能只需要传达网络或后端存在问题的坏消息。随着我们的扩展,这类不愉快的事件会发生。如果我们想要继续扩展,我们必须尽早考虑这些问题,并使 UI 始终感觉响应灵敏,即使它实际上不是。

通信瓶颈

当我们的应用程序拥有更多的运动部件时,它会增加更多的通信开销。这是因为我们的组件需要彼此通信以实现特性的更大行为。如果我们愿意,我们可以将组件间的通信开销减少到几乎为零,但那样我们将面临单体和重复代码的问题。如果我们想要模块化的组件,通信是必须的,但这需要付出代价。

本节将探讨在我们扩展软件时可能会遇到的一些与通信瓶颈有关的问题。我们需要寻找在不牺牲模块化的情况下提高通信性能的折衷方案。其中最有效的方法之一是使用我们网络浏览器中可用的分析工具。它们可以揭示用户在与我们的 UI 交互时所经历的同等响应问题。

减少间接性

主要的抽象概念是通过事件经纪人实现的,我们的组件通过他彼此间进行通信。经纪人的职责是维护任何给定事件类型的订阅者列表。我们的 JavaScript 应用程序在两个方面可扩展——给定事件类型的订阅者数量和事件类型数量。在性能瓶颈方面,这可能会迅速变得无法控制。

我们首先想要密切关注的是我们特性的构成。实现一个特性时,我们将遵循现有特性的相同模式。这意味着我们将使用相同的组件类型,相同的事件等。虽然有细微的差异,但跨特性的总体模式是相同的。这是一个好习惯:遵循特性间的相同模式。所使用的模式是了解如何减少开销的良好起点。

例如,说我们应用程序中使用的模式需要 8-10 个组件来实现给定特性。这是开销太大。这些组件中的任何一个都要与几个其他组件通信,其中一些抽象概念并没有那么有价值。它们在我们的脑海中和设计纸上看起来很好,因为这是我们设计起源于架构的模式。现在我们已经实现了这个模式,最初的价值已经有点稀释,现在变成了一个性能问题。

接下来是一个示例,它展示了仅仅添加新组件就足以使通信开销成本呈指数级增加:

// component.js
import events from 'events.js';

// A generic component...
export default class Component {

    // When created, this component triggers an
    // event. It also adds a listener for that
    // same event, and does some expensive work.
    constructor() {
        events.trigger('CreateComponent');
        events.listen('CreateComponent', () => {
            var i = 100000;
            while (--i) {
                for (let c = 0; c < 100; c++) {}
            }
        });
    }

};

// main.js
import Component from 'component.js';

// A place to hold our created components...
var components = [];

// Any time the add button is clicked, a new
// component is created. As more and more components
// are added, we can see a noticeable impact on
// the overall latency of the system.
// Click this button for long enough, and the browser
// tab crashes.
document.getElementById('add')
    .addEventListener('click', function() {
        console.clear();
        console.time('event overhead');
        components.push(new Component());
        console.timeEnd('event overhead');
        console.log('components', components.length);
    });

松耦合的组件是一件好事,因为它们分离了关注点,并且给了我们更少的实现风险和更多的实现自由。我们组件之间的耦合方式建立了一个可重复的模式。在初始实现之后的某个时刻,随着我们软件的成熟,我们会意识到曾经很好地服务于我们的模式现在过于复杂。我们组件的关注点已经被很好地理解,我们不再需要我们曾经认为可能需要的实现自由。解决这个问题的是改变模式。模式是被遵循的,所以它是未来我们代码的样子 ultimate indicator。它是解决通信瓶颈的最佳位置,通过移除不必要的组件。

代码分析

我们只需查看我们的代码,就能直观地感觉到有很多不必要的复杂性。正如我们在上一节所看到的,应用程序中使用的组件间通信模式非常明显。我们可以在逻辑设计层面看到过量的组件,但在运行时物理层面呢?

在我们开始重构代码、改变模式、移除组件等之前,我们需要对代码进行基准测试。这将给我们一个关于我们代码的运行时性能特性的想法,而不仅仅是它看起来如何。配置文件为我们提供了我们需要的信息,以对优化做出有用的决策。最重要的是,通过配置我们的代码,我们可以避免对最终用户体验影响很小或没有影响的微优化。至少,我们可以优先解决我们需要处理的性能问题。我们组件之间的通信开销很可能会优先考虑,因为它对用户的影响最直观,并且是一个巨大的扩展障碍。

我们可以使用的第一个工具是浏览器的内置分析工具。我们可以手动使用开发者工具 UI 来分析整个应用程序,同时与之交互。这对于诊断 UI 的具体响应性问题很有用。我们还可以编写使用相同浏览器内分析机制的代码,针对更小的代码片段,如单个函数,并获得相同的输出。结果配置文件实际上是一个调用堆栈,详细介绍了 CPU 时间是如何花费的。这指向了正确的方向,因此我们可以将精力集中在优化昂贵的代码上。

注意

我们只是触及了分析 JavaScript 应用程序性能的表面。这是一个巨大的主题,你可以在 Google 上搜索“分析 JavaScript 代码”-那里有很多好的资源。这是一个让你入门的好资源:developer.chrome.com/devtools/docs/cpu-profiling

下面是一个示例,展示了如何使用浏览器开发者工具创建一个比较几个函数的配置文件:

// Eat some CPU cycles, and call other functions
// to establish a profilable call stack...
function whileLoop() {
    var i = 100000;

    while (--i) {
        forLoop1(i);
        forLoop2(i);
    }
}

// Eat some CPU cycles...
function forLoop1(max) {
    for (var i = 0; i < max; i++) {
        i * i;
    }
}

// Eat less CPU cycles...
function forLoop2(max) {
    max /= 2;
    for (var i = 0; i < max; i ++) {
        i * i;
    }
}

// Creates the profile in the "profile" tab
// of dev tools.
console.profile('main');
whileLoop();
console.profileEnd('main');
// 1177.9ms 1.73% forLoop1
// 1343.2ms 1.98% forLoop2

存在一些可以在浏览器外部分析 JavaScript 代码的工具。我们使用它们各有不同的目的。例如,benchmark.js 以及与之类似的工具,用于测量我们代码的原始性能。输出结果告诉我们每秒我们的代码可以运行多少次操作。这种方法真正有用的地方在于比较两个或更多函数的实现。分析可以为我们提供哪个函数最快,以及优势有多大的详细 breakdown。归根结底,这是我们最需要的重要分析信息。

组件优化

现在我们已经解决了组件通信性能瓶颈的问题,是时候看看我们组件内部了,具体是在实现细节和它们可能带来的性能问题上。例如,维护状态是 JavaScript 组件的一个常见要求,然而,从性能角度来看,这并不容易扩展,因为需要所有的记账代码。我们还需要注意那些修改其他组件使用的数据的函数引入的副作用。最后,DOM 本身以及我们的代码与它交互的方式,有很多可能导致不响应。

维护状态的组件

我们代码中的大多数组件需要维护状态,这在很大程度上是不可避免的。例如,如果我们的组件由一个模型和一个视图组成,视图需要根据模型的状态来决定何时重新渲染自己。视图还持有一个 DOM 元素的引用——直接或通过选择器字符串——而任何给定的元素在任何时候都具有状态。

所以状态是我们组件中的一个事实——这有什么大不了的?实际上,真的没有什么大不了的。事实上,我们可以写出一些真正的事件驱动的代码,这些代码对状态的变化做出反应,从而改变用户所看到的内容。当然,问题出现在我们进行扩展的时候;我们的组件单独来看,需要维护更多的状态,后端提供的数据模型变得更加复杂,DOM 元素也是如此。所有这些具有状态的东西都相互依赖。随着这些系统的增长,会带来大量的复杂性,并且真的可能会损害性能。

幸运的是,我们使用的框架为我们处理了很多这种复杂性。不仅如此——它们还针对这些类型的状态变更操作进行了大量优化,因为这对于使用它们的应用程序来说是如此基础。不同的框架采取不同的方法来处理组件状态的变化。例如,一些采取了更自动化的方法,这需要更多的开销来监控状态的变化。其他的更明确,状态是显式改变的,因此直接结果是事件被触发。后者的方法要求程序员更加自律,但也需要更少的开销。

为了避免随着组件数量及其复杂性的增加可能出现的性能问题,我们可以采取两件事。首先,我们要确保只维护那些重要事物的状态。例如,如果我们为永远不会发生状态变化设置处理程序,这是浪费的。同样地,如果我们有组件状态发生变化并触发永远不会导致 UI 更新的事件,这也是浪费的。虽然难以发现,但如果能避免这些隐藏的宝藏,我们也将避免与响应性相关的未来扩展问题。

维护状态的组件

视图可以对任何模型属性变化做出相同反应;或者,它们可以对特定属性变化有特殊反应。虚拟 DOM 试图为我们自动化这个过程。

处理副作用

在前一部分,我们探讨了组件维护的状态以及如果我们不小心,它们如何影响性能。那么这些状态变化是如何发生的呢?它们不是自发发生的——必须有什么明确地改变变量的值。这称为副作用,是另外一种可能影响性能且不可避免的东西。副作用是我们在前一部分讨论的状态变化的原因,如果不对它们小心处理,它们也会影响性能。

具有副作用的函数相反的是纯函数。这些函数接收输入并返回输出。中间没有状态变化。这类函数具有所谓的引用透明性——这意味着对于给定的输入,无论我们调用函数多少次,我们都保证有相同的输出。这个属性对于优化和并发性等事情很重要。例如,如果对于给定的输入我们总是得到相同的结果,函数调用的时间地点实际上并不重要。

想想我们应用程序中共享的通用组件和特定功能的组件。这些组件不太可能维护状态——状态更有可能存在于更接近 DOM 的组件中。这些顶级组件中的函数是没有副作用实现的好的候选者。甚至我们的功能组件也可能实现没有副作用的函数。作为一个经验法则,我们应该将我们的状态和副作用推送到尽可能接近 DOM 的地方。

正如我们在第四章组件通信与职责所看到的,在大致的发布/订阅事件系统中,要心理追溯正在发生的事情是困难的。有了事件,我们实际上并不需要追踪这些路径,但有了函数,情况就不同了。挑战在于,如果我们的函数改变了某物的状态,并且这导致了系统其他地方的故障,要追踪这类问题是非常困难的。此外,我们使用越多的无副作用函数,就越不需要进行理智检查的代码。我们经常遇到一些检查某物状态的代码片段,看似无原因。原因就在于——这是它工作的方式。这种方法在开发努力的扩展上只能走那么远。

下面是一个展示有副作用函数与副作用函数的例子:

// This function mutates the object that's
// passed in as an argument.
function withSideEffects(model) {
    if (model.state === 'running') {
        model.state = 'off';
    }

    return model;
}

// This function, on the other hand, does not
// introduce side-effects because instead of
// mutating the "model", it returns a new
// instance.
function withoutSideEffects(model) {
    return Object.assign({}, model, model.state === 'off' ?
        { state: 'running' } : {});
}

var first = { state: 'running' },
    second = { state: 'off' },
    result;

// We can see that "withSideEffects()" causes
// some unexpected side-effects because it
// changes the state of something that's used
// elsewhere.
result = withSideEffects(first);
console.log('with side effects...');
console.log('original', first.state);
console.log('result', result.state);

// By creating a new object, "withoutSideEffects()",
// doesn't change the state of anything. It can't
// possibly introduce side-effects somewhere else in
// our code.
result = withoutSideEffects(second);
console.log('without side effects...');
console.log('original', second.state);
console.log('result', result.state);

DOM 渲染技术

更新 DOM 是昂贵的。优化 DOM 更新的最佳方法是不更新它们。换句话说,尽可能少地更新。我们应用扩展的挑战在于 DOM 操作变得更为频繁,出于必要。需要监视的状态更多,需要通知用户的事情也更多。即便如此,除了我们选择的框架所采用的技术外,我们还可以通过编写代码来减轻 DOM 更新的负担。

那么,为什么 DOM 更新相对于在页面中运行的简单 JavaScript 来说如此昂贵呢?确定显示应该看起来怎样的计算过程,消耗了大量的 CPU 周期。我们可以采取措施减轻浏览器渲染引擎的负载,使用在我们的视图组件中需要的更少工作的技术,从而提高 UI 的响应性。

例如,重排是导致一系列需要进行的计算的渲染事件。本质上,重排发生在我们元素的某些方面发生变化时,这可能导致其他附近元素布局的改变。整个过程在整个 DOM 中级联,所以一个看似廉价的 DOM 操作可能造成相当多的开销。现代浏览器中的渲染引擎很快。我们可以在 DOM 代码中有点粗心,UI 将表现完美。但随着新移动部件的增加,DOM 渲染技术的可扩展性就发挥作用了。

因此,首先要考虑的是,哪些视图更新可能导致重排?例如,改变元素的内容不是什么大问题,很可能永远不会导致性能问题。将新元素插入页面中,或者响应用户交互更改现有元素的样式——这些都可能带来响应性问题。

注意

目前流行的一个 DOM 渲染技术是使用虚拟 DOM。ReactJS 和其他类似库利用了这个概念。想法是,我们的代码可以直接将内容渲染到 DOM 中,就像它是在第一次渲染整个组件一样。虚拟 DOM 拦截这些渲染调用,并找出已经渲染的内容和发生变化的内容之间的差异。虚拟 DOM 的名字来源于事实,即 DOM 的表示形式存储在 JavaScript 内存中,并用于进行比较。这样,只有在绝对必要时才会触摸真实的 DOM。这种抽象允许进行一些有趣的优化,同时保持视图代码的简洁性。

不断地向 DOM 发送更新也不是理想的选择。因为 DOM 会接收到需要执行的更改列表,并按顺序应用它们。对于可能引发多次重排的复杂 DOM 更新,最好先卸载 DOM 元素,进行更新,然后重新挂载。当元素重新挂载时,昂贵的重排计算一次性完成,而不是连续几次执行。

然而,有时问题并不在 DOM 本身——而是在 JavaScript 的单线程特性。当我们的组件 JavaScript 正在运行时,DOM 没有机会渲染任何待处理的更新。如果在某些情况下我们的 UI 无响应,最好设置一个超时,让 DOM 更新。这也给了任何待处理的 DOM 事件一个被处理的机会,这对于用户在 JavaScript 代码运行时尝试做某事来说很重要。

接下来是一个示例,展示了如何在 CPU 密集型计算期间延迟运行 JavaScript 代码,给 DOM 一个更新机会:

// This calls the passed-in "func" after setting a
// timeout. This "defers" the call till the next
// available opportunity.
function defer(func, ...args) {
    setTimeout(function() {
        func(...args[0]);
    }, 1);
}

// Perform some expensive work...
function work() {
    var i = 100000;
    while (--i) {
        for (let c = 0; c < 100; c++) {
            i * c;
        }
    }
}

function iterate(coll=[], pos=0) {
    // Eat some CPU cycles...
    work();

    // Update the progress in the DOM...
    document.getElementById('progress').textContent =
        Math.round(pos / coll.length * 100) + '%';

    // Defer the next call to "iterate()", giving the
    // DOM a chance to display the updated percentage.
    if (++pos < coll.length) {
        defer(iterate, [ coll, pos ]);
    }
}

iterate(new Array(1000).fill(true));

Web Workers 是另一种处理长时间运行的 JavaScript 代码的可能性。因为它们不能接触 DOM,所以它们不会影响 DOM 的响应性。然而,这项技术超出了本书的范围。

API 数据

随着我们继续扩展,性能问题的最后一个主要障碍将是应用程序数据本身。这是我们必须特别注意的一个领域,因为有这么多影响扩展的因素在起作用。更多功能并不一定意味着更多数据,但通常确实如此。这意味着更多类型的数据和更多的数据量。后者主要受我们软件不断增长的用户基础的影响。我们作为 JavaScript 架构师的工作是要找出我们如何扩展应用程序,以应对加载时间增加和数据到达浏览器时的数据量增加。

加载延迟

或许对我们应用程序性能扩展的最大威胁就是数据本身。我们应用程序数据随时间变化和演进的方式 somewhat of a phenomenon。我们前端添加的功能确实影响了我们数据的形状,但我们的 JavaScript 代码不控制用户数量或他们与我们的软件互动的方式。后两者可能导致数据爆炸,如果我们的前端没有准备,它将停止运行。

我们作为前端工程师面临的挑战是,当我们等待数据时,用户没有什么可显示的。我们所能做的就是采取必要的步骤,提供一个可接受的加载用户体验。这引出了一个问题——当我们等待数据加载时,我们是否应该用加载信息遮挡整个屏幕,还是为等待数据的元素逐一显示加载信息?第一种方法,用户很少有风险做不允许的事情,因为我们阻止了他们与 UI 交互。第二种方法,我们需要担心在网络请求未完成时用户与 UI 交互。

这两种方法都不理想,因为数据加载的任何时刻,我们应用程序的响应性都会受到根本性的限制。我们不想完全阻止用户与 UI 交互。所以,也许我们需要对数据加载强制执行严格的超时。好处是,我们保证了响应性,即使响应是告知用户后端正在花费太长时间。缺点是,有时等待是必要的,就用户而言,如果需要完成某事。有时,糟糕的用户体验是可取的——而不是无意中创造出更糟糕的体验。

为了帮助后端数据扩展,前端需要做两件事。首先,尽可能地缓存响应。这减轻了后端的负载,而且使用了缓存数据的客户端响应性也更强,因为它无需再次请求。显然,我们需要一种失效机制,因为我们不想缓存过时的数据。WebSocket 在这里是一个很好的解决方案候选——即使它们只通知前端会话某个特定实体类型已更改,以便清除缓存。第二种帮助处理增长数据集的技术是减少任何给定请求加载的数据量。例如,大多数 API 调用都有选项,让我们限制结果的数量。这需要保持在一个合理的范围内。有助于思考用户首先需要查看什么,并围绕这一点进行设计。

处理大数据集的工作

在前一节中,我们讨论了前端开发中与应用程序数据相关的扩展问题。随着我们应用程序的增长,数据也在增长,这带来了一个加载挑战。一旦我们设法将数据加载到浏览器中,我们仍然有很多数据需要处理,这可能导致用户交互不响应。例如,如果我们有一个 1000 项的集合,并且一个事件将这个结构传递给几个组件进行处理,用户体验就会受到影响。我们需要的是帮助我们将大数据集和难以扩展的数据集转换为仅包含必需品的工具。

这就是低级实用库大显身手的地方——对大数据集进行复杂转换。更大的框架可能暴露出类似的工具——它们很可能在幕后使用低级实用工具。我们将要在数据上执行的转换是映射-减少(map-reduce)类型的。无论如何,这是抽象的模式,函数式编程库如 Underscore/lodash 提供了这个模式的许多变体。这如何帮助我们处理大数据集的扩展呢?我们可以编写干净、可复用的映射和减少功能,同时将许多优化工作推迟到这些库中。

注意

理想情况下,我们的应用程序只加载当前页面渲染所需的数据。很多时候这根本不可能——API 不能为我们的功能所需的每个可能的查询场景都做好准备。所以,我们用 API 进行广泛过滤,然后当数据到达时,我们的组件使用更具体的条件对数据进行过滤。

在这里,扩展问题在于后端过滤的内容和浏览器中过滤的内容之间的混淆。如果一个组件更多地依赖 API,而其他组件则在本地进行大部分过滤,这会导致开发者之间的混淆,以及非直观的代码。如果 API 发生微妙变化,甚至可能导致不可预测的错误,因为我们的组件以不同的方式使用它。

映射或减少的时间越少,UI 对用户的响应性越强。这就是为什么我们要尽早获取用户看到的数据非常重要的原因。例如,我们不想在数据一到达就立即在事件中传递 API 数据。我们需要以这样的方式构建组件通信,即在可能的情况下尽快进行计算密集型的集合过滤。这减轻了所有组件的工作负担,因为它们现在正在处理一个较小的集合。因此,扩展到更多组件并不是什么大问题,因为它们将处理更少的数据。

运行时优化组件

我们的代码应该针对常见情况进行优化。这是一个不错的扩展策略,因为随着更多功能和用户的加入,增长的是常见情况,而不是边缘情况。然而,总是有可能我们会处理两个同样常见的案例。想想将我们的软件部署到多个客户环境中的情况。随着功能的发展以满足客户的需求,对于任何给定的功能,可能会有两到三个常见情况。

如果我们有两个处理常见情况的函数,那么我们需要在运行时确定使用哪个函数。这些常见情况非常粗粒度。例如,一个常见情况可能是“集合很大”或“集合很小”。检查这些条件并不昂贵。因此,如果我们能够适应不断变化的常见情况,那么我们的软件将比如果我们不能适应变化条件时的响应性更强。例如,如果集合很大,函数可以采取不同的过滤方法。

在运行时优化组件

组件可以在运行时根据大型或小型集合等粗分类改变其行为。

总结

从用户的角度来看,响应性是质量的一个强烈指标。不响应的用户界面令人沮丧,并且不太可能需要我们在扩展方面做出任何进一步的努力。应用程序的初始加载是用户对我们应用程序的第一个印象,也是最难快速实现的部分。我们研究了将所有资源加载到浏览器中的挑战。这是模块、依赖项和构建工具的组合。

在 JavaScript 应用程序中,响应性的下一个主要障碍是组件间通信的瓶颈。这通常是由于过多的间接性,以及实现特定功能所需的事件设计。组件本身也可能成为响应性的瓶颈,因为 JavaScript 是单线程的。我们讨论了这一领域的几个潜在问题,包括维护状态的成本,以及处理副作用的成本。

API 数据是用户关心的内容,直到我们拥有这些数据,用户体验才会下降。我们研究了扩展 API 及其内部数据带来的扩展问题。一旦我们拥有了数据,我们的组件需要能够快速地映射和减少它,同时数据集在我们扩展时继续增长。现在我们已经有了如何使我们的架构表现良好的更好想法,是时候考虑如何使其在各种环境中具有可测试性和功能性了。

第八章:可移植性与测试

网络应用已经走了很长的路,仅仅几年前还只是简单地在网页中嵌入 JavaScript 代码。如今,我们在构建 JavaScript 应用程序,如果你在读这本书,那么是在构建可扩展的应用程序。这意味着我们的架构需要考虑到可移植性;后端服务于我们的应用程序并为其提供数据,是可替换的。

与可移植性相伴的是测试性的想法。当我们开发大规模的 JavaScript 代码时,我们不能对后端做出假设,这意味着有能力在没有后端的情况下运行。本章将探讨这两个密切相关的话题以及它们在面对不断变化的扩展影响时对我们意味着什么。

解耦后端

如果我们还需要更多的动机来证明 JavaScript 不再只用于可脚本的网页,那就看看 Node.js 吧。它不需要完整的浏览器环境,只需要 V8 JavaScript 引擎。Node 主要是作为后端服务器环境创建的,但它仍然很好地展示了 JavaScript 语言已经取得了多大的进步。同样,我们希望我们的代码是可移植的,能够与任何我们可以投入的后端基础设施一起运行。

在本节中,我们将探讨为什么我们要松耦合我们前端 JavaScript 代码与其后端 API 之间的联系。然后,我们将介绍模拟 API 的第一步,完全不需要后端。

模拟后端 API

如果我们正在开发一个大规模的 JavaScript 应用程序,我们将有一个后端基础设施的初步构建。那么,为什么我们还要考虑将我们的代码与这个后端分离,使其不再依赖于它呢?在追求可扩展性时,支持松耦合的组件总是好的,这对于 Web 应用程序中前端和后端环境之间的耦合也是正确的。即使后端 API 永远不会改变,我们也不能假设构建 API 所使用的技术和框架永远不会改变。松耦合这种依赖关系还有其他好处——比如能够独立于系统其他部分更新 UI。但模拟后端 API 的主要扩展好处来自于开发和测试的角度。能够快速搭建新的 API 端点并对其进行请求测试是没有替代品的。模拟 API 是我们 JavaScript 代码的碰撞测试假人。

不管喜欢与否,有时我们感觉自己好像在创建演示软件——在开发冲刺中间,我们必须向感兴趣的利益相关者展示我们所拥有的东西。与其让这导致绝望,我们应该从我们的模拟数据中获得信心。演示不再是大问题,而且有了我们模拟数据的信心,我们将开始将这些事件视为对自己的小挑战。当然,我们总是要维护一个英雄程序员的外表——为了管理人员的利益!

考虑到模拟数据有多棒,那么它的缺点是什么呢?就像我们产品中的任何东西一样,它是一种需要维护的软件——这总是伴随着风险。例如,如果模拟 API 与实际 API 不同步,或者它 creates confusion between what's functional in the UI versus what's mocked,那么它的价值就会降低。为了应对这些风险,我们必须制定围绕我们设计和实现功能的过程,我们稍后会讨论这些。

模拟后端 API

模拟 API 位于任何与实际 API 通信的组件之外;当移除模拟时,组件并不会知道更好

前端入口点

前端和后端接口的边界在哪里?这是我们希望进行切换的地方,在模拟数据和 API 正常返回的数据之间。这个边界实际上可能位于 web 服务器后面——在这种情况下,我们仍然在进行真实的 HTTP 请求,只是没有与真实应用程序交互。在其他情况下,我们完全在浏览器中进行模拟,HTTP 请求在离开浏览器之前被模拟库处理程序拦截。

在两种模拟方式中,我们前端应用程序之间都有一个概念上的边界——这是我们试图建立的。一旦我们找到它,这是关键,因为它代表了我们与后端的独立性。在生产中紧密耦合后端并没有什么问题——那就是它的目的。在其他情况下,例如在开发过程中,能够编排我们的组件发送 API 请求时发生的事情,是一种关键的扩展策略。

有可能直接使用模型和集合创建模拟数据模块。例如,如果我们正在运行在模拟模式,我们会导入这个模块,我们就会有模拟数据可以工作。这种方法的问题是,我们的应用程序知道它实际上并没有与后端真正工作。我们不希望这样。因为我们希望我们的代码运行得好像它在生产环境中运行一样。否则,我们将会经历手动实例化模拟的一些副作用——它需要尽可能地远离我们实际的代码。

无论我们决定采用哪种模拟机制,它都需要是模块化的。换句话说,我们需要有能力将其关闭并完全从构建中移除。在生产环境中,不应该有模拟。实际上,我们的模拟代码甚至不应该出现在生产构建中。如果我们通过 web 服务器提供模拟数据,这一点要容易实现一些。如果我们的模拟处理程序存在于浏览器中,我们需要以某种方式将它们移除,这需要某种构建选项。关于构建工具,我们稍后在第章节中会有更多介绍。

前端入口点

在浏览器中模拟 API 请求,拦截 XHR 级别的调用。如果有模拟代码,它会寻找模拟 API。当模拟被移除时,原生 HTTP 请求功能如常。

模拟工具

如前一部分所述,模拟后端 API 主要有两种方法。第一种方法是引入像 Mockjax 这样的库到我们的应用程序中以拦截 XHR 请求。第二种方法是在那里放置一个真实的 HTTP 服务器,但这个服务器实际上并没有接触到真正的应用程序——它像 Mockjax 方法一样提供模拟数据。

Mockjax 的工作方式简单而巧妙。它基于这样的假设:应用程序正在使用 jQuery ajax()调用来进行 HTTP 请求,这是一个相对安全的假设,因为大多数框架都在幕后使用这个。当调用 Mockjax 时,它用自己的功能覆盖了一些核心 jQuery XHR 功能。这是在每次进行 XHR 请求时运行的。它检查是否有与请求 URI 匹配的路由规范,如果找到,就会运行处理程序。否则,它将直接传递并尝试向后端发起请求——如果我们想将真实 API 请求与模拟请求结合起来,这还是挺有用的。我们稍后会深入研究这种结合。

任何给定的处理程序都可以返回 JSON 数据,或者任何其他格式,就像我们真实的 API 一样。关键在于我们的核心代码——我们的模型和集合初始化请求——对 Mockjax 一无所知,因为所有这些都是在更低的层次上发生的。同样的模型和集合代码在没有对生产后端进行修改的情况下运行。我们只需要在部署到真实 API 时,拔掉调用 Mockjax 的模块即可。

我们可以使用模拟 Web 服务器技术实现相同的属性——运行未修改的代码。这实际上是劫持 XHR 请求的完全相同的想法,只是在一个不同的层面上进行。主要优点是我们不需要在部署过程中采取任何特殊步骤。要么是模拟服务器,要么是真实服务器,在生产环境中,不太可能运行模拟服务器。缺点是我们确实需要一个正在运行的服务器,这对我们要求不高——但这确实是一个额外的步骤。而且我们确实失去了一些可移植性。例如,我们可以打包一个模拟构建发送给某人。如果它不需要 Web 服务器,整个应用程序可以在浏览器中演示。

模拟工具

从浏览器或后台 Web 服务器模拟 API;两种方法达到相同的结果——我们的代码不知道它正在与模拟通信。

生成模拟数据集

既然我们已经知道声明模拟 API 端点的选项,那么我们需要数据。假设我们的 API 返回 JSON 数据,我们可以将模拟数据存储在 JSON 文件中。例如,模拟模块可以将这些 JSON 模块作为依赖项引入,模拟处理程序可以将其作为数据源。但是这些数据从哪里来呢?

当我们开始构建模拟数据时,很可能存在一个 API,它正在某个地方运行。使用我们的浏览器,我们可以查看各种 API 端点返回的数据,并手动策划我们的模拟数据。如果 API 有文档,这个过程会简单得多,因为那样我们就会有线索,了解任何给定实体中任何给定字段允许的值。有时我们实际上没有创建模拟数据的起点——我们将在功能设计过程部分讨论这个问题。

手动创建我们的模拟数据集的优点是,我们可以确保它是准确的。也就是说,我们不希望创建与我们要模拟的数据不反映的东西,因为这将是整个目的的失败。更不用说跟上 API 变化的速度瓶颈了。理想的情况是使用一个工具来自动化生成模拟数据集的任务。它只需要知道给定实体的模式,然后就可以处理剩余的工作,接受几个参数并在其中加入一些随机性。

另一个有用的模拟数据生成工具可能是从给定部署中提取真实 API 数据的功能,并将其作为 JSON 文件存储。例如,假设有一个预演环境,我们的代码表现出问题。我们可以针对该环境运行我们的数据提取工具以获取所需的数据。由于我们希望尽量保持预演环境不变,这种方法是安全的,因为我们在诊断过程中对模拟数据造成的任何损害,都在内存中,可以轻松清除。

执行操作

实施模拟 API 的一个挑战性方面是执行操作。这些是除了 GET 以外的请求,通常需要改变某个资源的状态。例如,改变资源属性的值,或者彻底删除资源。我们需要一些通用的代码,我们的处理程序可以利用它来执行这些操作,因为我们的 API 端点在执行它们上的动作时应该遵循相同的模式。

实际上能否容易地实施取决于我们 API 动作工作流的复杂度。一个容易实施的动作可能就是修改资源的一个属性值然后返回200表示成功。然而,我们的应用程序很可能有更复杂的工作流,比如长时间运行的动作。例如,这类动作可能会返回一个新创建的动作资源的 ID,从那里,我们需要监控该动作的状态。我们的前端代码已经做到了这一点,因为那正是它需要与真实 API 一起工作的地方——我们需要在模拟中实现这些应用程序的细微差别。

操作可能会很快变得非常复杂。尤其是如果应用程序很大,有很多实体类型和很多操作。这里的想法是努力实现模拟这些操作的最小可行性成功路径。不要试图详细地模拟应用程序所做的每一件事——这不会扩展。

功能设计流程

我们不是为了好玩而创建模拟 API,我们是为了帮助开发功能。考虑到我们可能有一个相当大的 API,因此有很多要模拟的内容,我们需要一个过程来规范我们做事情的顺序。例如,我们需要等待 API 实施后再开始实现一个功能吗?如果我们能够模拟 API,那么我们就不必等待,但是 API 本身仍然需要设计,而且 API 有许多利益相关者。

在本节中,我们将回顾一些确保我们正确使用模拟,并以与我们的功能开发同步的方式进行操作的必要步骤。

设计 API

一些 API 端点足够通用,可以支持多个功能。这些是我们应用程序中的核心实体。通常,有一小部分实体扮演着至关重要的角色,大多数功能都会使用它们。另一方面,我们开发的大多数新功能将需要扩展我们的 API。这意味着一个新的 API 端点,或者几个。这取决于我们的后端资源是如何组合的,这涉及到一定程度的设计工作。

试图扩展我们的功能开发的问题在于,实现一个新的 API 可能需要花费很长时间。所以如果我们需要在开始开发前端功能之前就有 API,我们最终会推迟功能,这并不是理想的。我们希望在新鲜的时候开始做某件事。如果某件事在待办事项列表中积压,它经常永远留在那里。为拟议的功能实现一个模拟 API 让我们可以不拖延地开始滚动,这对于扩展开发是至关重要的。

当我们实现一个新 API 端点的模拟时,我们进入了绿色地带设计领域。这意味着我们必须考虑到那些可能不一定会进行前端开发的人的考虑。而且我们可能触及也可能不触及真实 API 的实际实现——这完全取决于我们的团队结构。话说回来,无论主题专家是谁,他们都需要透明地访问我们拟议的 API 的设计。他们可以提供建议、进行更改等等。继续走不可能的道路是没有意义的。另一种方法可能是让后端程序员草拟一个可能的 API 规范。这是纯粹的大局观;只包含最基本的端点,带有最小的属性和操作。其他的都是可以在我们模拟和实际代码之后轻易更改的细节。

在接触后端代码之前,使用模拟 API 实现功能可以帮助防止犯下昂贵的错误。例如,假设我们使用模拟 API 在前端实现了一些功能,直到它具有可演示性。这给了具有特定后端领域知识的工程师一个机会来指出功能的不可行性,从而让我们避免在未来犯下昂贵的错误。

设计 API

设计模拟 API 的循环,以及针对它实现功能

实现模拟

现在我们已经接到实现一个功能的任务,第一步是实现一个模拟 API 来支持我们前端代码的开发。正如我们在上一节所看到的,我们应该与最终将实现真实 API 的人紧密合作。第一步是要确定高层次的 API 看起来是什么样的。其余的我们可以随着我们接近实现真实 API 而进行微调。

然而,在开发我们的模拟数据时,我们并不总是必须依赖 API 团队成员的手把手指导。我们可能有一些 API 端点,它们可能已经被我们的一些前端组件使用。话说回来,可能有一个可识别的模式我们可以遵循,尤其是如果模拟只是一个我们碰巧缺失的平凡实体类型的话。如果我们遵循一个好的模式,那么这就是一个好的起点,因为以后进行激进更改的机会更小。

当我们知道我们的模拟 API 看起来是什么样子,以及我们可以对其做些什么时,我们需要用模拟数据来填充它。如果我们已经有一些为其他模拟生成数据的工具,我们需要找出如何扩展这些工具。或者,我们只需要手动创建一些测试实体来开始。我们不想在前面花费太多时间输入数据。我们只需要最少的有效实体数量来证明我们的方法是可行的。

提示

我们可能并不总是想在创建数据之前就启动实际的模拟端点。相反,我们可能更愿意从数据出发,向上设计——设计正确的实体,而不是担心 API 本身的技术细节。这是因为,数据最终需要在某个地方进行存储,这是一个重要的活动。专注于数据让我们以不同的思维方式工作。选择最适合手头任务的处理方法。

我们所创建的模拟并不总是创造全新的东西。也就是说,我们模拟的 API 可能已经存在,或者其实现正在进行中。这实际上使得模拟的实现变得容易得多,因为我们可以向 API 作者请求示例数据,或者寻求帮助,以构建我们的模拟。记住,如果我们想要实现可移植性,我们必须能够将前端从后端中分离出来,这意味着我们需要模拟整个 API。

实施功能

现在我们已经有了我们的模拟 API,是时候受益了。事情并没有结束——模拟 API 经常进行微调。但这足以让我们开始编写真实的前端代码。立即,我们会发现一些问题。这些问题可能是拟议的 API 的问题,或者是与 API 通信的组件的问题。我们不能让这些问题沮丧,因为这正是我们所寻找的——早期发现问题。没有模拟 API 是无法获得这些的。

如果 API 普遍可行,而且我们的组件代码工作正常,我们可能会发现我们设计中的性能瓶颈。如果我们有生成模拟数据的工具,这尤其容易发现,因为生成 100,000 个实体轻而易举,看看我们的前端代码会发生什么。有时这需要快速重构,有时则需要完全改变方法。关键是我们要尽早而不是稍后找到这些问题。

我们可以通过模拟来做一件其他难以实现的事情,那就是经常进行演示。当我们严重依赖具有大量开销的大型后端环境时,这并不容易。如果少于几分钟就能让一个功能运行起来进行演示,我们可以自信地展示我们所做的。也许它是错误的,也许利益相关者会想到一些他们错过的事情,当他们看到他们的想法变为现实时。这就是模拟如何帮助我们通过早期和持续的反馈来扩展特征开发生命周期。

实施功能

正在开发中的组件的内部,与模拟 API 端点通信

将模拟数据与 API 数据协调一致

此时,功能已经实现,我们如何协调为功能创建的模拟数据取决于实际 API 的状态。例如,如果我们只是模拟 API 中已经存在一段时间的东西,那么只要我们模拟和真实数据之间有高保真度,就可以安全地假设什么也不需要发生。然而,如果我们模拟的是一个全新的 API,有很大几率会发生一些变化,哪怕是微小的变化。重要的是我们要捕捉这些变化,确保我们的现有模拟数据在后续版本中保持相关性。

这是模拟过程中难以扩展且通常令人不愉快的一部分。我们的模拟数据有如此多的不同方式与实际 API 中的数据不同步,以至于很难尝试去跟上。如果我们有生成模拟数据的工具,那就容易多了。我们甚至可能能够根据 API 团队创建的规范生成整个 API。但这也存在问题,因为虽然模拟生成可以自动化,但规范本身需要在某个地方、以某种方式创建。因此,最好实现一个可以生成模拟数据的工具,但让我们的代码处理请求。只要我们不要重复自己太多,并且 API 有一个合理的模式,我们应该能够跟上我们的模拟数据。

另一种做法是在关闭某些模拟 API 端点的同时保留其他端点。可以把它看作是一种穿透——在这里,可以指定模拟端点的粒度,而不是只能切换整个模拟 API。例如,这种能力如果在调试应用程序中的特定问题时会非常有用,我们需要引导某些 API 端点返回特定的响应以复制问题。我们可以在 Mockjax 等库中实现这一点,因为不匹配请求路径规的请求只是被转发给本地的 XHR 机制。

将模拟数据与 API 数据协调一致

一个组件使用模拟 API,而另一个使用实际 API

单元测试工具

是时候将注意力转向测试了,我们在学习了大规模模拟 API 端点的基础知识之后。我们模拟 API 的能力对于测试代码非常有用,因为我们可以使用同样的模拟数据或至少是同样的数据来进行测试。这意味着,如果我们的测试失败,我们可以开始与 UI 交互(如果需要的话),使用测试失败的相同数据,试图找出发生了什么。

我们将探讨使用随 JavaScript 框架提供的单元测试工具,并找出它们的价值观所在。我们还将研究使用更通用的独立测试框架,这些框架可以与任何代码一起运行。在本节结束时,我们将看看我们的测试如何自动化,以及这种自动化如何融入我们的开发工作流程。

框架内置工具

如果我们使用的是较大型的、全面的 JavaScript 应用程序框架,那么有很大概率它会自带一些单元测试工具。这些工具并不是要取代框架无关的现有单元测试工具。相反,它们是为了补充这些工具——为编写符合框架口味的测试提供特定支持。

对我们来说,这意味着我们需要编写更少的单元测试代码。如果我们遵循框架的模式,那么已经有很多单元测试工具了解我们的代码。例如,如果它已经知道我们将使用哪些组件来实现我们的功能,那么它可以为我们生成测试。这极大地帮助我们避免重复,并最终使我们的代码获得更全面的测试覆盖。

除了为我们生成测试骨架之外,框架测试设施还可以为我们提供测试中可用的实用函数。这意味着我们无需维护那么多单元测试代码,这之所以可能,是因为框架知道我们将在测试中想要做什么,并以实用函数的形式为我们抽象出这些操作。

依赖框架特定的测试工具的挑战在于,我们将把我们的产品与特定的框架耦合在一起。这对我们来说可能不是一个问题,因为一旦选定了一个框架,我们就会坚持使用它,对吧?嗯,不一定。在今天动荡的 JavaScript 生态系统中更是如此。在可移植性方面的一部分要求我们的架构具有一定程度的灵活性,意味着我们必须适应变化。这或许也是为什么如今越来越多的项目依赖于大型框架,而更多依赖于库的组合。

框架内建的工具

单元测试与框架的组件和单元测试工具有紧密的耦合关系

注意

在大型 JavaScript 应用程序中有很多异步代码,我们的单元测试不应忽略这些异步代码。例如,我们需要确保我们的模型单元能够获取数据并执行操作。这类函数返回承诺,我们需要确保它们如预期般正确解决或失败。

使用模拟 API 可以大大简化这一过程。无论是采用浏览器内方法还是 Web 服务器方法都可以,因为我们的代码仍然将它们视为真正的异步操作。我们可能还需要考虑模拟的是 WebSocket 连接。在浏览器中这样做稍微有些棘手,因为我们必须覆盖内置的 WebSocket 类。如果我们的模拟位于 Web 服务器后面,我们可以使用真实的 WebSocket 连接进行测试。

无论如何,模拟 WebSocket 都是困难的,因为我们必须模拟在某些其他事情发生时触发 WebSocket 消息的逻辑,例如 API 操作。然而,在获得更基本的测试覆盖后,我们仍然可能想要考虑模拟 WebSocket,因为如果我们的应用程序依赖于它们,自动化测试它们是很重要的。

独立的单元测试工具

单元测试工具的另一种方法是使用独立的框架。也就是说,一个不关心我们使用哪个 JavaScript 应用程序框架或库的单元测试工具。Jasmine 是这一目的的标准,因为它为我们提供了一种清晰简洁的方式来声明测试规格。开箱即用,它有一个在浏览器中工作的测试运行器,为我们提供了格式化的测试通过和测试失败的输出。

大多数其他独立的单元测试设施都使用 Jasmine 作为基础,并扩展它以提供额外的功能。例如,有 Jest 项目,它本质上是对 Jasmine 进行了扩展,增加了模块加载和模拟等功能。同样,这种类型的工具是框架无关的;它纯粹关注测试。使用这些独立的工具进行单元测试是一个很好的可移植性策略,因为这意味着,如果我们决定将代码转移到不同的技术,我们的测试仍然有效,并且实际上可以帮助使过渡顺利进行。

Jasmine 并不是市面上唯一的游戏,它只是最通用,给了我们在结构测试方面很大的自由。例如,Qunit 已经存在很长时间了。它适用于任何框架,但最初是为 jQuery 项目设计的测试工具。如果我们觉得现有的测试工具太重,或者不给我们项目所需的灵活性或输出,我们甚至可能想要自己开发测试工具。我们可能不想编写自己的测试运行器。我们的单元测试不是随意运行的, whenever we feel like it。它们通常是我们要自动化的大量任务链的一部分。

注意

有些代码比其他代码更容易测试。这意味着,根据我们的组件是如何组织的,可能很容易将它们分解为可测试的单元,或者可能很难。例如,具有许多活动部件和许多副作用的代码意味着,如果我们想要在组件上获得良好的测试覆盖率,我们必须为这个组件编写相对较大的测试套件。如果我们代码的耦合度较低,副作用较少,那么编写测试就会容易得多。

虽然我们希望编写可测试的代码,以使编写单元测试的过程更容易,但并不总是可能的。所以如果这意味着牺牲覆盖率,有时候这是更好的选择。我们不想为了编写更多的测试而重写代码,或者更糟糕的是,改变我们满意的架构。只有当我们认为我们的组件足够大,值得有更全面的测试覆盖时,我们才应该这样做。如果到了这个地步,我们可能需要重新思考我们的设计。好的代码自然容易测试。

工具链和自动化

随着我们的应用程序变得更大更复杂,有很多事情需要在“离线”状态下进行,作为持续开发过程的一部分。运行单元测试是我们希望自动化的任务之一。例如,在我们甚至运行测试之前,我们可能需要使用一个工具来检查我们的代码,以确保我们没有提交太草率的代码。测试通过后,我们可能需要构建我们的组件工件,以便它们可以被我们应用程序的运行实例使用。如果我们正在生成模拟数据,这可能也是同一过程的一部分。

总的来说,我们有一个工具链可以自动化所有这些任务。这些任务通常是一个更大、更粗粒度任务中的较小步骤,比如构建生产构建开发。更复杂的任务只是我们定义的较小任务的组合。这是一种灵活的方法,因为工具链可以处理任务的顺序,按照它们需要发生的顺序,或者,我们可以单独运行任务。例如,我们可能只想运行测试。

最流行的工具链是一个任务运行器,名为 Grunt。其他类似的工具,如 Gulp,也越来越受欢迎。这些工具的好处在于它们有一个充满插件的活跃生态系统,这些插件能完成我们大部分需要做的事情——我们只需要配置使用这些插件的个别任务以及我们想要组合的更复杂的任务。对于我们来说,设置一个可以自动化我们开发过程大部分步骤的工具链,所需的努力非常小——基本上除了编写代码本身,其他事情都可以自动化。如果没有工具链,要使我们的开发工作扩展到多于几个贡献者,会非常困难甚至是不可能的。

使用工具链进行自动化任务的另一个好处是我们可以随时更改正在构建的工件类型。例如,当我们正中间开发一个功能时,我们不一定希望每次更改都构建生产工件。实际上这样做会大大减慢我们的速度。如果我们的工具可以仅仅部署原始源模块,那也会使调试变得容易很多。然后当我们接近完成时,我们开始构建生产版本,并针对那些版本进行测试。我们的单元测试可以同时针对原始源代码和生成的工件构建运行——因为我们永远不知道编译后可能会引入什么。

测试模拟场景

我们的应用程序规模越大,它需要处理的场景就越多。这是因为更多用户使用更多功能,我们的代码需要处理的复杂性也随之增加。拥有模拟数据和单元测试确实可以帮助我们测试这些场景。在本节中,我们将介绍一些可用于创建这些模拟场景并对其进行测试的选项,包括我们的单元测试以及像用户一样与系统交互。

模拟 API 和测试数据

模拟数据对我们很有价值,其中之一就是单元测试。如果我们模拟 API,我们可以运行我们的单元测试,好像我们的代码正在击中真实的 API。我们对模拟数据中的个别数据点有精细的控制,并且可以随意更改——它是沙盒中的数据,对外部世界没有负面影响。即使我们使用工具生成模拟数据,我们也可以进去调整。

一些单元测试工具接受测试数据,这些数据仅用于运行测试。这和我们在像 Mockjax 这样的 API 模拟工具中使用的数据并没有太大区别。主要区别是,测试数据在我们使用的单元测试框架之外并没有多大用处。

那么,如果我们既能用于测试又能用于模拟呢?例如,假设我们想利用单元测试框架的测试数据功能。它有一些自动化特性,如果我们不提供测试数据,我们就无法使用。另一方面,我们还想为开发目的模拟 API,以便与功能交互,与后端分离等。没有任何东西阻止我们将测试数据既用于单元测试,又用于 API 模拟。这样,我们就可以使用我们创建的任何模拟数据生成器来生成测试和浏览器中用户交互共享的场景。

模拟 API 和测试数据

单元测试可以通过请求击中模拟 API,或者直接使用测试数据;如果模拟 API 提供相同的数据,那么更容易找出失败测试中的问题。

场景生成工具

随着时间的推移,我们将积累新的功能和更多客户使用这些功能的场景。因此,我们工具链中有一个生成模拟数据的工具将非常有帮助。更进一步,这个工具可以接受生成模拟数据的参数。这些参数可能只是粗粒度的,但我们通常只需要将随机生成的模拟数据转换为我们需要的精心策划的场景。

我们将生成的单个模拟场景彼此之间不会有太大差异。这是有点意思的地方——我们需要一个作为基线的东西,这样如果我们确实对我们的场景做出了有趣的发现,我们可以问——这个数据有什么不同? 如果我们开始生成很多场景,因为我们有一个可以让我们这样做工具,我们需要确保我们确实有一个“黄金”模拟数据集——这是我们确信其按预期工作的东西。

我们需要对黄金模拟数据进行的更改类型包括更改集合中实体的数量等。例如,假设我们想看看某事物在给定页面上的表现如何。那么我们创建一百万个模拟实体,看看会发生什么。页面完全崩溃——进一步调查发现了一个reduce()函数,该函数试图对一个大于最大安全整数的数字进行求和。这种情况可以揭示有趣的错误。即使我们使用的场景牵强附会,不太可能在生产中发生,我们仍然应该修复错误,因为其他不那么极端的场景肯定会导致它触发。

场景生成工具

更改场景可能会导致我们的测试失败;通常我们会创建扩展场景,看看我们的代码在哪里崩溃

我们可以模拟大量的可能性。例如,我们可以通过删除实体的属性来扭曲一些数据,确保我们的前端组件对它期望的东西有合理的默认值,或者它以优雅的方式失败。后者实际上非常重要。随着我们扩展 JavaScript 代码,我们有越来越多无法修复的场景,我们只需要确保我们的失败模式是可以接受的。

端到端测试和持续集成

最后一步是将端到端测试组合到我们的功能中,并将其连接到我们的持续集成过程中。单元测试是一回事,它们让我们确信我们的组件是坚固的——当它们通过时。用户不在乎单元测试,端到端测试作为与我们的 UI 交互的用户的代表。例如,可能在我们实施的任何给定功能的规格说明中嵌入了一组用例。端到端测试应该围绕这些用例设计。

像 Selenium 这样的工具使自动化端到端测试成为可能。它们将测试记录为作为用户执行的一系列步骤。同样的步骤可以在我们告诉它时重复。例如,一个端到端测试可能涉及资源的创建、修改和删除。该工具知道在 UI 中寻找成功路径的什么。当这种情况不发生时,我们知道测试失败了,我们需要去修复它。自动化这类测试对于扩展是至关重要的,因为随着我们添加功能,用户与我们的应用程序互动的方式越来越多。

我们再次可以向我们的工具链寻求帮助,因为既然它已经在自动化我们所有的其他任务,它可能也应该自动化我们的端到端测试。工具链对于我们的持续集成过程也是至关重要的。我们可能会共享一个 CI 服务器来构建我们系统的其他方面,只是它们是以不同的方式完成的。工具链使我们容易与 CI 过程集成,因为我们只需要脚本适当的工具链命令即可。

在系统中设置模拟数据可以帮助我们进行端到端的测试,因为如果工具要像用户那样操作,那么它就必须发出后端 API 请求。这样做可以确保我们的一致性,并帮助我们排除测试本身作为问题来源的可能。借助模拟 API,我们可以开发单元测试,并对同一来源进行端到端的测试。

端到端测试和持续集成

工具链、模拟数据以及我们的测试,所有这些都运行在 CI 环境中;我们所开发的代码是输入。

概要

本章介绍了前端 JavaScript 应用程序的可移植性概念。在此上下文中,可移植性意味着与后端不是紧密耦合的。具有可移植性的主要优势在于,我们可以将 UI 视为其独立的应用程序,它不需要任何特定的后端技术。

为了帮助我们的前端实现独立,我们可以模拟它依赖的后端 API。模拟也让我们可以严格专注于 UI 开发——消除了后端问题阻碍我们开发的的可能性。

模拟数据可以帮助我们测试代码。有许多单元测试库,每个库都有自己的方法,我们可以利用它们。如果我们使用相同的模拟数据来运行测试,那么我们可以在浏览器中看到的不一致性中排除不一致性。我们的测试需要自动化,还有其他几个发生在我们开发过程中的任务也需要自动化。

我们所实现的工具链与持续集成服务器完美契合——这是实现规模扩张的关键工具。端到端的测试也是在这里自动完成的,这使我们能更好地了解用户在使用我们的软件时可能会遇到的问题。现在,是时候转换思路,认真审视应用扩展的局限性了。我们不能无限扩展,下一章节将探讨在我们达到一定规模后如何避免撞墙。

第九章:缩减扩展

我们倾向于认为扩展是一个单向问题——我们只能从当前的位置向上扩展。不幸的是,这并不完全有效。我们只能在一条线上扩展这么久,然后基础就会在我们脚下崩溃。关键在于识别扩展限制,并围绕它们进行设计。

在本章中,我们将探讨几乎所有浏览器环境中 JavaScript 架构师面临的根本性扩展限制。我们还将探讨客户作为扩展影响因素,以及新特性与现有特性之间的冲突。从过度设计中缩减也是一项基本活动。

我们整个应用程序的组成决定了通过关闭特性来缩减扩展的难易程度。这一切都取决于耦合,如果我们仔细观察,我们经常会发现我们需要重构我们的组件,以便它们可以稍后轻松移除。

扩展限制

我们的应用程序受到它们运行的环境的限制。这意味着客户机上运行的硬件和浏览器本身。有趣的是,网络应用程序还需要考虑代码本身的传输。例如,如果我们正在编写后端代码,我们可以向任何问题投入更多的代码,这不是问题,因为代码不会移动——它在一个地方运行。

对于 JavaScript 来说,大小很重要。这一点是无法回避的。作为推论,网络带宽也很重要——既包括我们的 JavaScript 工件的交付,也包括从 API 获取我们的应用程序数据。

在本节中,我们将解决浏览器计算环境中对我们施加的硬性扩展限制。随着我们的应用程序的增长,我们感受到这些限制的压力越来越多。在为我们的应用程序规划新特性时,需要考虑这些方面。

JavaScript 工件大小

我们的 JavaScript 工件的累计大小只能增长到一定程度。最终,我们的应用程序的加载时间将会受到严重影响,以至于没有人愿意使用我们的应用程序。巨大的 JavaScript 工件通常意味着其他区域的过度膨胀。例如,如果我们向浏览器交付巨大的文件,我们可能有过多的东西。也许我们不需要那些没有人使用的特性,或者也许我们的组件中有重复的代码。

无论原因是什么,效果都不好。越小越好。我们如何知道我们的 JavaScript 文件大小是否足够小呢?这取决于——没有普遍的“理想”大小。我们的应用程序部署在哪里,是在公共互联网上?还是企业用户背后的 VPN?这些系统的用户可能有不同的接受标准。总的来说,公共互联网用户对我们加载时间的性能和功能膨胀的容忍度较低。另一方面,企业用户通常更欣赏更多功能,并对加载时间的不佳更加宽容。

不断添加到我们产品中的新功能是 JavaScript 文件大小增长的最大贡献者。这些导致了新组件的增加,从而增加了重量。任何给定功能都至少有最小文件集,每个文件都是遵循我们现有功能模式的组件。如果我们的一半模式还可以,那么我们应该能够保持我们组件的大小合理。然而,当截止日期涉及时,重复的代码总是找到进入应用程序的方式。即使我们的代码尽可能精简,当被要求实现功能时,我们仍然必须实现它们。

编译过的文件可以帮助我们解决文件大小的问题。我们可以合并和压缩文件,减少网络请求次数,节省总体带宽。但是,任何特定功能都会使这些编译过的文件持续增长。我们可以在遇到任何问题之前持续增长一段时间。如前所述,问题都是相对的,取决于环境和我们软件的用户。在所有情况下,我们的 JavaScript 文件的大小不能无限增长。

JavaScript 文件大小

JavaScript 文件的大小是组成组件的所有模块的聚合结果。

网络带宽

我们的 JavaScript 文件的大小贡献了我们的应用程序整体网络带宽消耗。尤其是随着更多用户的采用——用户是我们所有架构问题的乘数。与我们的 JavaScript 代码相结合的是我们的应用程序数据。这些 API 调用也贡献了整体网络带宽消耗和用户感知的延迟。

小贴士

随着我们的应用程序跨越地理边界,我们会注意到各种连接问题。在世界上许多地方,高速网络根本不是一个选项。如果我们想要进入这些市场,而且我们应该这么做,那么我们的架构需要能够应对缓慢的互联网连接。使用 CDN 传递我们的应用程序所使用的库可以帮助解决这个问题,因为它们考虑了请求的地理位置。

挑战在于,任何新功能都会增加新的网络带宽消耗。这包括代码的大小,以及新组件引入的新 API 调用。请注意,这些效果并不会立即显现。例如,新组件在页面加载时不会进行 API 调用,只有在用户导航到特定的 URI 时才会进行 API 调用。

尽管如此,新的 API 端点意味着随着时间的推移网络带宽使用会增加。此外,当用户导航到功能页面时,并不是只做一个 API 调用那么简单。有时需要三个或更多的 API 调用,以便构建要呈现的数据。当我们认为一个新的 API 调用不是什么大问题时,我们需要记住,通常这会变成多个调用,这意味着更多的带宽消耗。

是否存在根本的网络带宽限制?从理论上讲,并不存在,就像我们的 JavaScript 资源大小一样——如果我们愿意,可以把它们扩展到每个 10MB。我们可以肯定的是,这不会改善用户体验,而且副作用可能会导致更糟糕的体验。网络带宽消耗也是如此。

网络带宽

组件通过请求 JavaScript 模块和 API 数据来消耗网络带宽

下面是一个例子,展示了我们的应用程序随着更多请求的发出而遭受聚合延迟的痛苦:

// model.js
// A model with a fake "fetch()" method that doesn't
// actually set any data.
export default class Model {

    fetch() {

        // Returns a promise so the caller can work
        // with this asynchronous method. It resolves
        // after 1 second, meant to simulate a real
        // network request.
        var promise = new Promise((resolve, reject) => {
            setTimeout(() => resolve(), 1000);
        });

        return promise;
    }

};

// main.js
import Model from 'model.js';

function onRequestsInput(e) {
    var size = +e.target.value,
        cnt = 0,
        models = [];

    // Create some models, based on the "requests"
    // number.
    while (cnt++ < size) {
        models.push(new Model());
    }

    // Setup a timer, so we can see how long it
    // takes to fetch all these models.
    console.clear();
    console.time(`fetched ${models.length} models`);

    // Use "Promise.all()" to synchronize the fetches
    // of each model. When they're all done, we can stop
    // the timer.
    Promise.all(models.map(item => item.fetch())).then(() => {
        console.timeEnd(`fetched ${models.length} models`);
    });
}

// Setup our DOM listener, so we know how many
// models to create and fetch based on the "requests"
// input.
var requests = document.getElementById('requests');

requests.addEventListener('input', onRequestsInput);
requests.dispatchEvent(new Event('input'));

内存消耗

随着我们实现每一个功能,浏览器消耗的内存也在增长。这听起来像是一个显而易见的陈述,但它很重要。内存问题不仅伤害应用程序的性能,它们可能会导致整个浏览器标签页崩溃。因此,我们需要密切关注我们代码的内存分配特性。浏览器内置的性能分析工具可以记录对象在内存中的分配情况随时间变化。这对于诊断问题,或者观察我们的代码行为非常有用。

注意

频繁创建和销毁对象会导致性能滞后。这是因为不再引用的对象会被垃圾回收。当垃圾回收器运行时,我们的 JavaScript 代码不会运行。因此,我们有一个冲突的需求——我们希望代码运行得快,同时不想浪费内存。

想法是不必要地触发垃圾回收器的运行。例如,有时我们可以把变量提升到更高的作用域。这意味着在整个应用程序的生命周期中,引用并没有被多次创建和销毁。

另一种场景是在短时间内频繁分配,例如在循环中。虽然 JavaScript 引擎在处理这类场景时很聪明,但仍然值得我们关注。最佳资源是考虑垃圾回收器的低级库的源代码,避免不必要的分配。

API 返回的响应也消耗内存,具体取决于返回的数据,可能消耗大量的内存。我们希望确保给定 API 端点可以响应的数据量有限制。许多后端 API 会自动这样做,一次不超过 1000 个实体。如果我们需要遍历集合,那么我们需要提供一个偏移量参数。然而,我们可能还需要进一步限制 API 响应的大小,因为集合中单个实体的大小在浏览器中作为模型占用大量内存。

尽管这些集合通常会在用户从一个页面移动到另一个页面时进行垃圾回收,但我们实施的每个新功能都可能带来微妙的内存泄漏错误。这些微妙的错误难以处理,因为泄漏发生缓慢,并且在不同的环境中表现不同。当内存泄漏很大且明显时,它更容易复现,因此也更容易定位和修复。

接下来是一个例子,展示了内存消耗如何迅速失控:

// model.js
var counter = 0;

// A model that consumes more and more memory,
// with each successive instance.
export default class Model {

    constructor() {
        this.data = new Array(++counter).fill(true);
    }

};

// app.js
// A simple application component that
// pushes items onto an array.
export default class App {

    constructor() {
        this.listening = [];
    }

    listen(object) {
        this.listening.push(object);
    }

};

// main.js
import Model from 'model.js';

function onRequestsInput(e) {
    var size = +e.target.value,
        cnt = 0,
        models = [];

    // Create some models, based on the "requests"
    // number.
    while (cnt++ < size) {
        models.push(new Model());
    }

    // Setup a timer, so we can see how long it
    // takes to fetch all these models.
    console.clear();
    console.time(`fetched ${models.length} models`);

    // Use "Promise.all()" to synchronize the fetches
    // of each model. When they're all done, we can stop
    // the timer.
    Promise.all(models.map(item => item.fetch())).then(() => {
        console.timeEnd(`fetched ${models.length} models`);
    });
}

// Setup our DOM listener, so we know how many
// models to create and fetch based on the "requests"
// input.
var requests = document.getElementById('requests');

requests.addEventListener('input', onRequestsInput);
requests.dispatchEvent(new Event('input'));

CPU 消耗

影响我们用户界面响应性的一个重要因素是客户端的 CPU。如果它能够在有代码需要运行时,例如在点击时运行我们的代码,那么 UI 将感觉是响应性的。如果 CPU 正在处理其他事情,我们的代码将不得不等待。用户也只能等待。显然,在给定的操作系统环境中,有很多软件要求 CPU 的注意力——其中大部分完全超出我们的控制。我们无法减少浏览器之外其他应用程序的使用,但我们可以从我们的 JavaScript 应用程序中减少 CPU 的使用。但首先,我们需要了解这些 JavaScript CPU 周期来自哪里。

在架构层面,我们不考虑使单个组件的小部分更高效的微优化。我们关心的是缩小规模,这在应用运行时对 CPU 消耗有明显的影响。我们在第七章,加载时间和响应性,看到了如何分析我们的代码。这告诉我们 CPU 在我们的代码中花费的时间。用配置文件作为我们的测量标准,我们可以进行更改。

影响 CPU 使用的两个因素是活动的特性数量和这些特性使用的大小。例如,当我们向系统中添加更多组件时,CPU 消耗自然会更多,因为当事情在 UI 中发生时,该特性的组件代码需要以某种方式响应。但这一点本身不太可能产生很大的影响。是实现新特性时伴随的 API 数据使得 CPU 成本变得昂贵。

CPU 消耗

合并消耗 CPU 周期的力量——处理更多数据的更多组件

例如,如果我们不断实施新功能,而数据集保持不变,我们开始感受到 CPU 成本。这是因为有更多的间接性,意味着对于任何给定事件需要运行更多的代码。然而,这种减慢会以冰川般的速度发生——我们可以在不费吹灰之力的情况下,不断增加数百个功能。是变化的数据使这成为一种扩展不可能性。因为如果你将功能数量乘以不断增长的数据集,CPU 成本将呈指数级增长。

好吧,也许并非我们所有的功能都在消耗所有的数据。也许我们的设计中间接性非常少。这仍然是在缩减规模时需要考虑的最大因素。所以如果我们需要降低 CPU 成本,我们就需要移除功能及其处理的数据——这是唯一能产生可测量影响的方法。

下面是一个示例,展示了组件数量和数据项数量的组合如何逐渐消耗更多的 CPU 时间:

// component.js
// A generic component used in an application...
export default class Component {

    // The constructor accepts a collection, and performs
    // a "reduce()" on it, for no other reason than to eat
    // some CPU cycles.
    constructor(collection) {
        collection.reduce((x, y) => x + y, 0);
    }

}
// main.js
import Component from 'component.js';

function onInput() {
    // Creates a new collection, the size
    // is based on the "data" input.
    var collection = new Array(+data.value).fill(1000),
        size = +components.value,
        cnt = 0;

    console.clear();

    // Sets up a timer so we can see how long it
    // takes for x components to process y collection items.
    console.time(`${size} components, ${collection.length} items`);

    // Create the number of components in the "components"
    // input.
    while (cnt++ < size) {
        new Component(collection);
    }

    // We're done processing the components, so stop the timer.
    console.timeEnd(`${size} components, ${collection.length} items`);
}

// Setup out DOM event listeners...
var components = document.getElementById('components'),
    data = document.getElementById('data');

components.addEventListener('input', onInput);
data.addEventListener('input', onInput);

components.dispatchEvent(new Event('input'));

后端能力

我们将要解决的最后一个扩展限制是提供我们静态资源和 API 数据的后端。这是一个限制因素,因为我们的代码不能在到达浏览器之前运行,我们也不能在原始数据到达之前向用户显示信息。这两件事都取决于后端来实现,但在进行前端开发时,关于后端有几点需要注意的。

第一个关注点是我们应用程序的使用情况。正如运行我们 JavaScript 代码的浏览器不能无限扩展一样,我们的后端 API 也不能无限扩展。虽然它们有一些特性使它们能够扩展浏览器不具备的,但它们仍然受到更多请求量的影响。第二个关注点是我们代码与 API 的交互方式。我们必须观察一个用户如何使用我们的应用程序,以及这些交互产生的 API 请求。如果我们能够优化一个用户的请求,增加更多用户对后端的冲击会更小。

例如,我们不想发起我们不需要的请求。这意味着,在实际需要之前不要加载数据。并且,不要反复加载相同的数据。如果一个用户在会话开始五分钟后才开始与一个功能互动,那么在这段时间内后端就可以处理其他请求。有时我们的组件会使用相同的 API 端点。如果它们同时被创建,并且先后发送相同的 API 请求会怎样呢?后端不得不服务这两个请求,这是不必要的,因为它们将具有相同的内容。

我们需要构建组件通信结构,以考虑规模影响因素,如后端产生的负载。在这个特定实例中,第二个组件可以在挂起请求映射中查找并返回那个承诺,而不是生成一个新的请求。

后端能力

新组件应致力于减少带宽消耗;一种方法是使用更少的 API 请求来实现相同的功能。

冲突的功能

随着我们软件的增长,我们功能之间的界限变得模糊。至少会有一些重叠,这是件好事。如果没有至少一点重叠,用户在从我们 UI 的一个区域过渡到另一个区域时会有困难。当达到一个特性阈值时,问题就出现了,因为此时有多个重叠的层次不断叠加。这是一个自我传播的问题,每增加一个新特性就会变得更糟,直到解决这个问题。

这个问题的两个潜在原因包括我们应用程序中随时间变得无关的部分,而它们并没有被退役,而是闲置妨碍。客户需求在这种规模影响中起了很大的作用,因为它决定了产品的未来方向。这也应该让我们了解到现在所拥有的,要么为了满足需求而需要改变,要么在不久的将来需要消失。

重叠功能

在我们应用程序的生命周期中,将会有与现有功能重叠的新功能。这就是软件开发的本质——建立在你已经拥有的基础上,而不是从完全无关的领域开始。当这种重叠不显眼时,作为从现有功能到新功能和增强的桥梁,这是很好的。

当重叠与现有功能冲突时,这种重叠就不那么好了。这就像想在树林里建房子,而不先移走任何树。如果重叠要无缝且可扩展,需要发生两件事之一。要么我们需要调整已经存在的内容以适应即将到来的内容,要么我们需要重新思考新功能,使其更好地适应可用空间。这很有趣,因为考虑到我们所拥有的,有时我们甚至在实现功能之前就必须缩减功能——这通常比实现后更容易。

不合理的功能重叠的最终结果是用户觉得功能繁琐、难以使用,因此我们预计将来会收到一些投诉。这是我们稍后可能需要修复或删除的另一件事。我们实际上经常这样说服自己——这并不是一个很大的增加,但足够满足截止日期。但这种足够好的代价是什么?除了预期的用户烦恼外,还有代码需要担心。我们很少说这样的话——好吧,用户可能不喜欢它,但代码非常棒。通常糟糕的用户体验是功能规划不当和实施不佳的结果。

解决方案其实相当简单,正如我们已经看到的。这是为变化腾出空间,或者修改新功能的问题。我们经常忽视的一点是记录潜在问题。例如,如果我们看到一个计划中的功能与我们的现有代码不匹配,我们需要提出来并概述哪些不匹配以及为什么。拥有这些信息归档并可搜索总比忽视要好。这是我们通过与团队包容来扩展我们的架构理念的方式。

功能重叠

旧功能与新功能之间的重叠是缩减不必要的代码的一个很好的起点

不相关功能

随着时间的推移,一些功能证明了自己的价值。我们的用户非常喜欢它们,并且经常使用。更重要的是——我们几乎不需要维护它们。它们就是能正常工作。另一方面,我们实现的其他一些功能开始生锈的速度比我们预期的要快。可能有无数的迹象表明这种情况正在发生。也许有一小部分用户喜欢这个功能,但它存在 bug 并且难以维护。也许我们的大部分用户喜欢这个功能,但它阻碍了项目中的多项举措的实施。但最常见的情况是,根本没有人使用它。

无论出于何种原因,功能确实变得不相关。我们作为行业的问题是我们喜欢积累代码。有时我们出于必要保留不相关的功能——我们可能会破坏太多东西,或者在需要的地方引入向后不兼容。在其他时候,这实际上更多的是前端问题,我们保留功能是因为我们没有明确的命令来摆脱它。好吧,如果我们想要扩展应用程序,我恐怕这种情况需要发生。

这是一个主动而不是被动的问题。正如我们所知,每个组件都对我们扩展约束有所贡献——无论是网络、内存、CPU 还是其他方面。谁知道呢,也许我们的产品里存在的功能完全可以应付。最好把它解决掉,因为这样它实际上限制我们扩展能力的可能性更小。我们可能认为它是一段无害的代码,但彻底排除不是更好吗?此外,这种态度 Simply put, it's better to scale down the things we don't need, and then think about where to go from there. If we set the precedent with all our stakeholders that we're ready and willing to trim the fat, we're more likely to convince them to ship a leaner product.

无关功能

我们的应用程序可扩展的空间有限;删除无关功能可以释放扩展空间

客户需求

取决于我们正在构建的产品类型,以及它服务的用户类型,客户需求将转化为有序规划实施,或者冲动反应。我们都想让客户开心——这就是我们为什么要开发软件。但正是这些快速决定实施人们尖叫着要的东西,损害了我们的架构。就好像我们把功能当成错误来实施。对于错误,我们尽快实施快速修复,因为我们需要把它们赶出门。

新功能不是错误。尽管用户和管理层怎么说——没有他们所要求的功能他们也能活下去。我们需要找到一种方法,为我们争取必要的时间,将客户想要的新功能纳入我们的架构中。这并不是说我们可以一直推迟——我们必须及时地这样做。也许移除用户不太关心的现有功能是前进最快的途径。

客户需求

确定哪些功能会包含在下一个版本中;这些功能要么是我们已经拥有的,要么是客户希望的新功能

设计失败

缩小规模一方面是通过修复我们现有的代码。例如,通过移除功能,或者通过修改现有组件以适应新计划的功能。但这也只能让我们在未来走得更远。两年前看起来是个好主意的设计想法,是为了我们当时考虑的功能,其中一些可能今天已经不再存在。

要想对我们的架构产生持久的影响,我们必须修复破碎的模式。它们仍然在我们的产品中起作用,因为我们在让它们起作用,尽管它们可能不是完成工作的最佳工具。找出正确的设计不是一个一次性的事件,它发生在我们的软件变化中,以及我们的扩展影响命令中。

在本节中,我们将探讨几种可能解决我们设计中一些缺陷的方法。也许有很多我们不需要的活动部件。也许由于我们组件通信模型的复杂性,我们正在效率低下地处理我们的 API 数据。或者,我们 DOM 元素的结构导致了晦涩的选择器字符串,从而减慢了开发速度。这只是可能性的一小部分——缺陷模式因项目而异。

不必要的组件

当我们最初开始设计我们的架构和构建我们的软件时,我们将利用当时有意义的模式。我们设计组件使其彼此之间松耦合。为了实现这种松耦合,我们通常会做出权衡——增加活动部件。例如,为了保持每个组件职责的专注,我们必须将较大的组件拆分为较小的组件。这些模式决定了我们特征组件的构成。如果我们遵循这种模式,并且它有不必要的部分,我们所开发的新东西也将包含不必要的部分。

很难做到模式正确,因为当我们需要决定使用哪些模式时,我们没有足够的信息。例如,框架中有很多通用的模式,因为它们服务的受众比我们的应用程序广泛得多。因此,虽然我们想要利用框架暴露的相同模式,但我们需要将它们适应到我们的特定功能中。这些是随着客户需求逐渐改变我们产品的性质而逐渐变化的模式。我们可以接受这种自然现象,并投入时间修复我们的模式。或者,我们可以随着问题的出现而解决,保持我们原始的模式不变。对我们曾经认为的基础进行调整,是我们扩展架构的最佳方式。

最常见的模式缺陷是多余的间接性。也就是说,组件是抽象的,并没有真正的价值。虽然它们将组件与另一样东西解耦,但它们所做的也就仅此而已。我们会注意到,随着时间的推移,我们的代码积累了这些相对较小的模块,而且倾向于看起来都一样。它们之所以小,是因为它们不做太多的事情,它们看起来一样,是因为它们是我们承诺在整个代码中一致使用的模式的一部分。在模式被构思的时候,这个组件是完全有意义的。实现几个组件后,它变得不那么有意义了。失去这个组件并不会损害设计,事实上,整个项目现在感觉有点更轻了。有趣的是,模式在纸上的样子与在实际应用中的样子之间的脱节。

下一个例子展示了一个使用控制器的组件,以及另一个不需要控制器且少了一个活动部件的组件版本:

// view.js
// An ultra-simplistic view that updates
// the text of an element that's already in
// the DOM.
export default class View {

    constructor(element, text) {
        element.textContent = text;
    }

};

// controller.js
import events from 'events.js';
import View from 'view.js';

// A controller component that accepts and configures
// a router instance.
export default class Controller {

    constructor(router) {
        // Adds the route, and creates a new "View" instance
        // when the route is activated, to update content.
        router.add('controller', 'controller');
        events.listen('route:controller', () => {
            new View(document.getElementById('content'), 'Controller');
        });
    }

};

// component-controller.js
import Controller from 'controller.js';

// An application that doesn't actually do
// anything accept create a controller. Is the
// controller really needed here?
export default class ComponentController {

    constructor(router) {
        this.controller = new Controller(router);
    }

};

// component-nocontroller.js
import events from 'events.js';
import View from 'view.js';

// An application component that doesn't
// require a component. It performs the work
// a controller would have done.
export default class ComponentNoController {

    constructor(router) {
        // Configures the router, and creates a new
        // view instance to update the DOM content.
        router.add('nocontroller', 'nocontroller');
        events.listen('route:nocontroller', () => {
            new View(document.getElementById('content'), 'No Controller');
        });
    }

};

// main.js
import Router from 'router.js';
import ComponentController from 'component-controller.js';
import ComponentNoController from 'component-nocontroller.js';

// The global router instance is shared by components...
var router = new Router();

// Create our two component type instances,
// and start the router.
new ComponentController(router);
new ComponentNoController(router);

router.start();

低效的数据处理

微优化在效率上并没有给我们带来太多好处。另一方面,重复处理可能会导致巨大的扩展问题。挑战在于,我们可能甚至不会注意到正在进行重复处理,除非我们寻找它。这通常发生在数据从一个组件传递到另一个组件时。第一个组件对 API 数据进行转换。然后,原始数据被传递给第二个组件,它随后执行了完全相同的转换。随着更多组件的添加,这些低效问题开始累积。

我们很少发现这类问题的原因是我们被美丽的设计模式所迷惑。有时候,那些损害用户体验的低效问题被我们的代码所掩盖,因为我们是一致地做事情。也就是说,我们保持组件之间的关联关系是松耦合的,正因为如此,我们的架构在多个方面都能扩展。

大多数时候,一点重复的数据处理是完全可以接受的权衡。这取决于它在处理其他扩展影响方面的灵活性给我们带来了什么。例如,如果我们能够轻松地处理多种不同的配置,并在需要时启用/禁用功能,这是因为我们有数量众多的不统一部署,那么这种权衡可能是有意义的。然而,在一个方面扩展往往意味着在另一个方面不扩展。例如,数据量很可能会增加,这意味着组件之间传递的数据会增加。所以之前没有问题的那些偷偷摸摸的数据转换,现在变成了大问题。当这种情况发生时,我们必须减少数据处理。

再次强调,这并不意味着我们需要开始引入微优化——这意味着我们必须开始寻找大的效率胜利。起点应该始终是网络调用本身,因为前端最大的效率胜利就是一开始就没有获取到数据。要查看的第二件事是组件之间传递的数据。我们需要确保一个组件没有在前一个组件链中做完全相同的事情。

下面是一个示例,展示了每次调用fetch()时都会获取模型数据的组件。它还展示了一个替代实现,当已经有挂起的请求时,不会获取模型:

// model.js
// A dummy model with a dummy "fetch()" method.
export default class Model {

    fetch() {
        return new Promise((resolve) => {
            setTimeout(() => {

                // We want to log from within the model
                // so that we know a fetch has actually
                // been performed.
                console.log('processing model');

                // Sets some dummy data and resolves the
                // promise with the model instance.
                this.first = 'First';
                this.last = 'Last';

                resolve(this);
            }, 1000);
        });
    }

};

// component-duplicates.js
import Model from 'model.js';

// Your standard application component
// with a model.
export default class ComponentDuplicates {

    constructor() {
        this.model = new Model();
    }

    // A naive proxy to "model.fetch()". It's
    // naive because it shouldn't fetch the model
    // while there's outstanding fetch requests.
    fetch() {
        return this.model.fetch();
    }

};

// component-noduplicates.js
import Model from 'model.js';

// Your standard application component with a
// model instance.
export default class ComponentNoDuplicates {

    constructor() {
        this.promise = null;
        this.model = new Model();
    }

    // "Smartly" proxies to "model.fetch()". It avoids
    // duplicate API fetches by storing promises until
    // they resolve.
    fetch() {

        // There's a promise, so there's nothing to do -
        // we can exit early by returning the promise.
        if (this.promise) {
            return this.promise;
        }

        // Stores the promise by calling "model.fetch()".
        this.promise = this.model.fetch();

        // Remove the promise once it's resolved.
        this.promise.then(() => {
            this.promise = null;
        });

        return this.promise;
    }

};

// main.js
import ComponentDuplicates from 'component-duplicates.js';
import ComponentNoDuplicates from 'component-noduplicates.js';

// Create instances of the two component types.
var duplicates = new ComponentDuplicates(),
    noDuplicates = new ComponentNoDuplicates();

// Perform two "fetch()" calls. You can see that
// the fetches are both carried out by the model,
// even though there's no need to.
duplicates.fetch();
duplicates.fetch().then((model) => {
    console.log('duplicates', model);
});

// Here we do the exact same double "fetch() call,
// only this component knows not to carry out
// the second call.
noDuplicates.fetch();
noDuplicates.fetch().then((model) => {
    console.log('no duplicates', model);
});

提示

当我们的组件之间相互独立时,避免进行重复的 API 调用是很困难的。比如说,一个功能创建了一个新的模型并获取它。另一个在同一页上的功能需要相同的模型,但它对第一个组件一无所知——它也创建了一个模型并获取数据。

这些会导致完全相同的 API 调用,这显然是不必要的。不仅对于前端来说效率低下,因为它对于完全相同的数据有两个不同的回调,而且它还损害了整个系统。当我们发起不需要的请求时,我们会在后端堵塞请求队列,影响其他用户。我们必须留心这些重复调用,并相应地调整我们的架构。

过度创新的标记

用于渲染我们 UI 组件的标记可能会变得有些失控。因为我们追求的是特定的外观和感觉,所以我们必须对标记进行一些篡改才能达到目的。然后我们再继续篡改,因为在这个浏览器或那个浏览器上看起来不太对。结果是元素被深深地嵌套在其他元素中,以至于它们失去了任何语义意义。我们应该努力实现标签的语义化使用——测试放入p元素中,可点击的按钮是button元素,页面部分用section元素分割等等。

这里的挑战在于,我们追求的设计通常表现为线框图,我们需要以一种方式实现它,使其可以被切成我们的框架和组件可以使用的部分。因此,随着试图保持事物语义化,简单性就丧失了,同时将事物划分为独立的视图也不总是可行的。

尽管如此,我们还是需要在可能的地方简化 DOM 结构,因为它直接影响到我们 JavaScript 代码的简单性和性能。例如,我们的组件经常需要找到页面上的元素,要么改变它们的状态,要么从它们那里读取值。我们可以编写选择器字符串,查询 DOM 并返回我们需要的元素。这些字符串贯穿于我们的视图代码中,它们反映了我们标记的复杂性。

当我们在代码中遇到复杂的选择器字符串时,即使是我们自己写的,我们也无法弄清楚它实际上在查询什么——因为 DOM 结构和所使用的标签并无帮助。所以结果证明,使用语义化标记实际上对我们的 JavaScript 代码有很大的帮助。还有复杂 DOM 结构的性能影响——如果我们经常遍历深层 DOM 结构,我们就会付出性能上的代价。

过度创新的标记

过度深入的元素嵌套通常可以简化,减少元素的使用。

应用程序组合

我们将以一个关于应用程序组成的章节结束。这是我们对应用程序的 10,000 英尺高空视角,在这里我们可以看到各个功能是如何组合的。第三章中,组件组合我们研究了组件组合,同样的原则在这里也适用。这个想法是我们正在操作一个稍微高层次的东西。

在第六章,用户偏好和默认设置中,我们探讨了可配置性,这也与应用程序组成的想法相关。例如,关闭功能,或者打开默认禁用的功能。我们整个应用程序的组成对缩减某些方面有很大的影响。

功能启用

缩减的便捷方法是关闭功能。困难的部分是让利益相关者同意这是一个好主意。然后我们可以直接删除功能,一切就绪了,对吧?不一定。我们可能需要花些时间来移除功能。例如,如果它涉及到系统的几个入口点,而且没有配置可以关闭这些入口点呢?这不是什么大问题,只是意味着我们需要花更多时间编写移除这些功能的代码。

唯一的问题是测试从系统中移除功能的效果。对于没有配置能完成工作的场景,我们不得不花时间编写代码来做这件事,然后我们才能进行测试。例如,我们可以花五分钟关闭配置值,然后我们就会得到立即的结果。也许我们最早就能了解到,在我们可以安全地将功能从系统中移除之前,有很多工作要做。

除了在删除功能后测试我们应用程序的运行行为外,我们可能还需要一些构建时的选项。如果我们的生产代码被编译成几个 JavaScript 工件,那么我们需要一种完全从构建中移除这些功能的方法。通过配置禁用组件是一回事,这意味着当我们的代码运行时,某些东西不会加载等等。如果我们把功能从我们的源代码仓库中移除,那么这显然不那么令人担忧——我们的工具无法构建不存在的代码。然而,如果我们有数百个可能包含在我们构建工件中的潜在组件,我们需要一种排除它们的方法。

新功能影响

对我们应用程序的下一个重大影响是新功能的添加。是的,这个讨论是关于缩减的,但我们不能忽视新功能对我们应用程序的添加。毕竟,这就是我们最初为什么要缩减的原因。不是为了构建一个做更少的较小应用程序。这是为了给客户想要的功能腾出空间,并随着时间的推移提高我们产品的整体质量。

添加功能和删除功能的过程通常是并行的。例如,在一个开发冲刺期间,一个团队负责实现一个新功能,而另一个团队负责移除一个正在引起问题的问题功能。由于这两项活动都以重大方式影响应用程序,我们必须小心行事,并尽量减少这些影响。

本质上,这意味着确保旧功能的移除不会对新添加的功能造成太大的干扰。例如,如果新功能依赖于旧功能中的某个部分。如果我们的设计是合理的,那么就不会有直接的依赖关系。然而,人类对复杂性理解不足——尤其是通过间接作用的原因和效果。因此,扩大这项操作可能意味着我们根本不能在所有活动中并行执行。

新功能影响

根据我们组件间通信模型的不同,向系统中添加新组件的效果应该是相当温和的。

核心库

影响我们应用组合的最后因素就是我们所使用的框架和库。不言而喻,我们只想要用我们所需要的——所谓“用进废退”。这主要是在我们引入较小库作为依赖时的问题。相比之下,框架在大部分情况下是包含一切的。这意味着你可能需要的所有东西框架里都已经有了。虽然这并不一定正确,但它仍然帮助我们减少了第三方库的依赖。

即便框架如今也是模块化的,这意味着我们可以挑选我们想要的好东西,而把其他的留给别人。即便如此,引入组件(无论是来自框架还是其他地方)仍然很容易,而这些我们可能根本不会使用。这在网站开发中相当常见。我们需要这样的一个功能模块,我们不想亲自编写,因为那边的库已经能实现了。然后它就会迷失在页面混合中。我们应该吸取网站没有学到的教训——我们的应用需要一组专注的依赖关系,这对于完成工作至关重要。

总结

本章介绍了这样一个观念:我们应用中的并非一切都是无限可扩展的。实际上,我们应用中的任何方面都不是无限可扩展的,因为每个方面都受到不同因素的限制。这些因素以独特的方式融合在一起,我们必须要做出必要的权衡。如果我们想要持续扩展,我们必须在其他领域进行缩减。

新功能源于客户需求,它们通常与我们已经实现的其他功能重叠。这可能是因为我们没有对新功能定义得很清楚,或者是因为系统现有的入口点定义得不明确。无论如何,这都可能使得一个具有挑战性的练习;用新功能替换现有功能。我们经常需要去除重叠区域,因为它们在代码层面和可用性层面都会造成混淆。

缩小规模不仅仅是逐个处理的活动——还需要考虑设计模式。移除一个功能后,我们需要审视我们正在使用的模式,并问自己,我们是否希望未来一直重复这样做? 向前发展的更好、更可扩展的方法,是修复这个模式。即使在我们缩减规模之后,仍然存在出错的可能性。在下一章中,我们将更详细地探讨失败的组件以及如何处理它们。