七、加载时间和响应

JavaScript 可伸缩性包括应用的加载时间,以及用户与应用交互时的响应时间。 我们将这两种架构质量统称为性能。 在用户眼中,性能是质量的重要指标——正确的性能非常重要。

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

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

构件

在本书的前面,我们已经强调过,大规模的 JavaScript 应用只是组件的集合。 这些组件以复杂而复杂的方式相互通信——这些通信实现了我们系统的行为。 在组件能够通信之前,它们必须被交付给浏览器。 它有助于理解这些组件是由什么组成的,以及它们实际上是如何交付给浏览器的。 然后我们可以推断应用的初始加载时间。

组件依赖

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

模块加载机制在树中进行,直到它拥有所需的所有东西。 将模块和依赖项分解到合理的粒度级别的好处在于,大量的复杂性被掩盖了。 我们不必把整个依赖关系图都记在脑子里,即使是中等规模的应用,这也是一个不合理的目标。

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

然而,仅仅因为我们想要模块化结构,并不意味着我们必须承受网络开销的后果。 特别是当我们开始扩展到许多功能和许多用户时。 每个客户端会话都有更多的内容需要交付,并且随着越来越多的用户要求相同的内容,后端会出现更多的资源争用。 模块依赖关系是可跟踪的,这给我们的构建工具提供了许多选项。

Component dependencies

如何加载 JavaScript 应用模块; 依赖关系被自动加载

建筑构件

当我们的组件达到一定程度的复杂性时,它们可能需要不止几个模块来实现所有功能。 再乘以越来越多的组件,我们就有了网络请求开销问题。 即使这些模块的有效载荷很小,仍然需要考虑网络开销。

实际上,我们应该争取更小的模块,因为它们更容易被其他开发人员使用——如果它们很小,它们可能有更少的活动部件。 正如我们在前一节中看到的,模块和它们之间的依赖关系使我们能够分而治之。 这是因为模块加载器跟踪依赖关系图,并在需要时拉入模块。

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

网络请求开销方面的可伸缩性影响有很大的不同。 组件越多,这些组件越大,构建过程就越重要。 特别是由于丑化,缩小文件大小的过程,往往是这个过程的一部分。 另一方面,能够关闭这些构建步骤,对开发团队也有可伸缩的影响。 如果我们能够在交付给浏览器的组件构件类型之间来回切换,那么开发过程将会进展得更快。

Building components

构建组件会导致更少的请求工件和更少的网络请求

加载元件

在这一节中,我们将以的方式查看负责实际加载源模块和内置组件到浏览器中的机制。 现在有许多第三方工具可以用来构建我们的模块并声明它们的依赖关系,但趋势是使用更新的浏览器标准来完成这些任务。 我们还将研究延迟加载模块以及负载延迟的可用性含义。

加载模块

今天生产中的许多大型应用都使用 RequireJS 和 Browserify 等技术。 RequireJS 是一个纯 JavaScript 模块加载器,它拥有可以构建更大组件的工具。 Browserify 的目的是使用为 Node.js 编写的代码,构建在浏览器中运行的组件。 虽然这两种技术都解决了本章到目前为止讨论的许多问题,但新的 ECMAScript 6 模块方法是前进的方向。

支持使用基于浏览器的方法进行模块加载和依赖项管理的主要理由是,不再需要另一个第三方工具。 如果语言有一个特性可以解决可伸缩性问题,那么最好还是走那条路,因为我们的工作量更少。 这当然不是一个银弹,但它确实有很多我们需要的功能。

例如,我们不再依赖于发送 Ajax 请求,以及在 JavaScript 代码到达时评估它——这都取决于浏览器。 语法本身实际上与其他编程语言中的标准import export关键字更一致。 另一方面,原生 JavaScript 模块仍然是新热点,这并没有足够的理由抛弃使用不同模块加载器的代码。 对于新项目,有必要看看 ES6 转译器技术,它允许我们从一开始就使用这些新的模块构造。

注释

我们的应用体验到的一部分网络开销,以及最终由用户支付的一部分开销,都与 HTTP 规范有关。 规范的最新版本 2.0 解决了大量开销和性能问题。 这对加载模块意味着什么? 好吧,如果我们能以最小的开销获得合理的网络性能,我们也许能够简化我们的工件。 编译更大的组件的需求可以被取消优先级,以专注于可靠的模块化体系结构。

惰性模块加载

对于单片编译组件,我们失去了的一个优势,那就是在实际需要时才加载某些模块。 对于已编译组件,要么全部要么没有——如果我们的整个前端被编译成一个 JavaScript 工件,这一点尤其正确。 从好的方面来说,当需要的时候,一切都在那里。 如果用户决定在初始加载 5 分钟后与某个特性进行交互,那么代码已经在浏览器中,可以运行了。

另一方面,惰性加载是默认模式。 这仅仅意味着模块不会被加载到浏览器中,直到其他组件显式地请求它。 这可能意味着一个require()调用或import语句。 在调用之前,它们不会从后端获取。 这样做的好处是,初始页面加载速度会快得多,它只会将所需的模块导入到最初显示给用户的特性中。

另一方面,当用户开始使用某些特性时,在初始加载 5 分钟后,我们的应用将第一次需要或导入一些模块。 这意味着在初始加载之后会有一些延迟。 请注意,在后续会话中按需加载的模块数量应该较小。 因为在呈现给用户的初始页面中,肯定会预先加载一些共享模块。

我们必须考虑整个系统的依赖关系。 虽然我们可能会认为我们推迟了某些模块的加载,但可能会有一些间接的依赖,在不需要它们的时候,无意中加载了主屏幕上的模块。 开发人员工具中的网络面板是这方面的理想选择,因为很明显,我们正在加载实际上并不需要的东西。 如果我们的应用有很多特性,那么延迟加载尤其有用。 在初始加载时间上的节省是巨大的,并且可能有一些功能用户从来没有使用过,因此永远不需要加载。

下面是一个示例,它展示了在真正需要模块时才加载模块的概念:

// 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 总是感觉灵敏,即使它不是。

通信瓶颈

当我们的应用获得更多的活动部件时,它就需要更多的通信开销。 这是因为我们的组件需要相互通信,以实现功能的更大的行为。 如果我们愿意的话,我们可以将组件间的通信开销减少到几乎为零,但这样我们就会面临单片重复代码的问题。 如果我们想要模块化的组件,通信就必须发生,但这是有代价的。

本节着眼于我们在扩展我们的软件时将面临的一些问题,即通信瓶颈。 我们需要在不牺牲模块性的情况下,寻找改善通信性能的折衷方案。 最有效的方法之一就是使用 web 浏览器中的分析工具。 它们可以揭示用户在与我们的 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);
    });

松散耦合的组件是一件好事,因为它们分离了关注点,并在破坏其他组件的风险更低的情况下给予我们更多的实现自由。 我们耦合组件的方式建立了一个可重复的模式。 在初始实现之后的某个时刻,随着软件的成熟,我们将意识到曾经很好地服务于我们的模式现在变得过于沉重。 我们很好地理解了组件的关注点,我们不需要我们认为可能需要的实现自由。 解决这个问题的方法是改变模式。 模式就是所遵循的,所以它是我们的代码在未来组件中的最终指示符。 通过删除不必要的组件,它是修复通信瓶颈的最佳场所。

分析代码

我们可以凭直觉,看一下我们的代码; 现在发生的事情比需要发生的要多得多。 正如我们在上一节中看到的,我们在整个应用中使用的组件间通信模式很能说明问题。 我们可以在逻辑设计级别看到过多的组件,但是在运行时的物理级别呢?

在开始重构代码、更改模式、删除组件等等之前,我们需要对代码进行概要分析。 这将让我们了解代码的运行时性能特征,而不仅仅是它的外观。 配置文件为我们提供了做出优化决策所需的信息。 最重要的是,通过分析我们的代码,我们可以避免微优化,这些优化对最终用户的体验几乎没有影响。 至少,我们可以对需要处理的性能问题进行优先排序。 组件之间的通信开销可能是最重要的,因为它对用户有最切实的影响,并且是一个巨大的扩展障碍。

我们可以使用的第一个工具是浏览器的内置分析工具。 我们可以在与整个应用交互时手动使用开发人员工具 UI 来分析整个应用。 这对于诊断 UI 中的特定响应性问题非常有用。 我们还可以编写使用相同的浏览器内部分析机制的代码,以针对较小的代码片段(如单个函数),并获得相同的输出。 得到的概要文件本质上是一个调用堆栈,其中列出了在哪里花费了多少 CPU 时间。 这为我们指明了正确的方向,因此我们可以集中精力优化昂贵的代码。

注释

我们只是触及了分析 JavaScript 应用性能的表面。 这是一个巨大的话题,你可以谷歌“分析 JavaScript 代码”——有大量好的资源。 这里有一个很好的资源让你开始:https://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 和类似的工具被用来衡量我们代码的原始性能。 输出告诉我们代码每秒运行多少次操作。 这种方法真正有用的地方在于比较两个或多个函数实现。 该概要可以给出一个细分,哪个功能是最快的,通过有多少 margin。 说到底,这是我们需要的最重要的侧写信息。

组件优化

既然我们已经修复了组件通信性能瓶颈,现在是时候看看组件内部,看看实现细节和它们可能出现的性能问题了。 例如,维护状态是 JavaScript 组件的一个常见需求,但是,由于需要所有的簿记代码,这就不能很好地扩展性能。 我们还需要知道函数会改变其他组件使用的数据所带来的副作用。 最后,DOM 本身,以及我们的代码与它交互的方式,有很大的潜在无响应性。

维护状态的组件

我们代码中的大多数组件都需要维护状态,这在很大程度上是不可避免的。 例如,如果我们的组件是由模型和视图组成的,那么视图需要根据模型的状态知道何时呈现自己。 该视图还保存对 DOM 元素的引用(直接或通过选择器字符串),并且任何给定的元素在任何时候都有状态。

所以状态是我们生命的组成部分——有什么大不了的? 真的没有。 事实上,我们可以编写一些非常好的事件驱动代码来对这些状态变化做出反应,从而导致用户正在查看的内容发生变化。 当然,当我们扩大规模时,问题就来了; 我们的组件在单独的基础上需要维护更多的状态,后端提供的数据模型变得更加复杂,DOM 元素也在增长。 所有这些有状态的东西都是相互依赖的。 随着这样的系统的发展,会出现大量的复杂性,并且会严重影响性能。

幸运的是,我们使用的框架为我们处理了很多这种复杂性。 不仅如此,它们还针对这些类型的状态更改操作进行了大量优化,因为它们对于使用它们的应用来说是非常基础的。 不同的框架采用不同的方法来处理组件状态的变化。 例如,有些采用了更自动化的方法,在监视状态变化时需要更多的开销。 另一些则更显式,状态被显式更改,直接导致事件被触发。 后一种方法需要程序员遵守更多的纪律,但也需要更少的开销。

当我们扩大组件的数量和复杂性时,我们可以做两件事来避免可能发生的性能问题。 首先,我们可以确保我们只对重要的事情保持状态。 例如,如果我们为从未发生的状态更改设置处理程序,这是浪费。 同样地,如果我们有改变状态和触发事件的组件,但它们不会导致 UI 更新,这也是浪费。 尽管很难发现,如果这些隐藏的宝石可以避免,我们也将避免未来与响应相关的规模问题。

Components that maintain state

视图可以对任何模型属性的变化做出同样的反应; 或者,它们可以对特定的属性更改有专门的响应。 虚拟 dom 试图为我们自动化这个过程。

处理副作用

在前面的小节中,我们查看了组件维护的状态,以及如果不小心的话它们会如何影响性能。 那么这些州的变化是如何发生的呢? 它们不是自发发生的——某些东西必须显式地改变变量的值。 这被称为副作用,也就是有可能损害性能的其他东西,而且是不可避免的。 副作用是导致我们在前一节中提到的状态变化的原因,如果不小心处理,它们也会损害性能。

与具有副作用的函数相反的是纯函数。 它们接收输入并返回输出。 两者之间没有任何变化。 像这样的函数具有所谓的参考透明性——这意味着对于给定的输入,无论调用函数多少次,输出都是相同的。 这个属性对于优化和并发性之类的事情很重要。 例如,对于给定的输入,我们总是得到相同的结果,函数调用的时间位置实际上并不重要。

考虑一下我们的应用与特定于特性的组件共享的通用组件。 它们不太可能维护状态——状态更可能在更接近 DOM 的组件中。 这些顶级组件中的函数是很好的无副作用实现候选者。 甚至我们的特性组件也可能实现无副作用的函数。 根据经验,我们应该尽可能地将状态和副作用推给 DOM。

正如我们在第 4 章组件通信和职责中看到的,很难在脑海中追踪复杂的发布/订阅事件系统中发生了什么。 对于事件,我们不需要跟踪这些路径,但是对于函数,情况就不同了。 挑战在于,如果我们的功能改变了某些东西的状态,而这在系统的其他地方造成了问题,就很难追踪到这类问题。 此外,我们使用的无副作用函数越多,所需的健全检查代码就越少。 我们经常会遇到一些代码检查某些东西的状态,似乎没有任何原因。 原因是,这是它工作的原因。 随着开发工作的扩大,这种方法目前只能得到一个。

下面的例子显示了一个有副作用的函数和一个没有副作用的函数:

// 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 的响应性,从而减少呈现引擎的工作量。

例如,reflows 是渲染事件,导致需要进行的整个类的计算。 本质上,当我们的元素发生变化时,就会发生回流,这可能会导致附近其他元素的布局发生变化。 整个过程在整个 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,所以不会干扰它的响应。 然而,这项技术超出了本书的范围。

API 数据

随着我们继续扩展,会给我们带来性能问题的最后一个主要障碍是应用数据本身。 这是我们必须特别注意的一个领域,因为有太多的影响因素在发挥作用。 更多的功能不一定会转化为更多的数据,但它通常会。 这意味着更多类型的数据,更多的数据量。 后者主要受我们软件不断增长的用户基础的影响。 作为 JavaScript 架构师,我们的工作是弄清楚如何扩展我们的应用,以处理增加的加载时间和数据到达浏览器后增加的大小。

加载延迟

扩展应用性能的最大风险可能是数据本身。 我们的应用数据随时间变化和发展的方式在某种程度上是一种现象。 我们在前端添加的特性当然会影响数据的形状,但我们的 JavaScript 代码并不能控制用户数量或他们与我们的软件交互的方式。 后两点可能导致数据爆炸,如果我们的前端没有做好准备,它就会停止。

作为前端工程师,我们面临的挑战是,当我们在等待数据时,没有什么东西可以显示给用户。 我们所能做的就是采取必要的步骤,提供可接受的加载用户体验。 这就引出了一个问题——当我们在等待数据加载时,我们是用加载消息阻塞整个屏幕,还是为等待数据的元素显示加载消息? 使用第一种方法,用户做一些不被允许的事情的风险很小,因为我们阻止他们与 UI 交互。 对于第二种方法,我们必须担心当有未处理的网络请求时用户与 UI 的交互。

这两种方法都不是理想的,因为在加载数据时,应用的响应能力从根本上受到了限制。 我们不想完全阻止用户与 UI 交互。 因此,也许我们需要强制数据加载的严格超时。 从好的方面看,我们保证了响应性,即使响应是通知用户后端占用了太长时间。 就用户而言,如果需要完成某些事情,有时等待是必要的。 有时候,糟糕的用户体验是可取的,而不是无意中创造更糟糕的体验。

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

处理大数据集

在上一节中,我们讨论了在应用数据的前端开发中所面临的一些可伸缩性问题。 随着应用的增长,数据也在增长,这就给加载带来了挑战。 一旦我们成功地将数据输入浏览器,我们仍然有大量的数据要处理,这可能导致用户交互反应迟钝。 例如,如果我们有一个 1000 项的集合,并且一个事件将这个结构传递给几个组件进行处理,那么用户体验就会受到影响。 我们需要的是一种工具,它可以帮助我们将那些庞大且难以跨越多个组件的数据转换成一些过滤掉的基本数据。

这就是低级实用程序库在大型数据集上方便复杂的转换的地方。 较大的框架可能会公开类似的工具——它们可能在内部使用低级实用工具。 我们要对数据执行的转换是 map-reduce 类型的。 这就是抽象模式,函数式编程库,比如下划线/lodash,提供了许多这种模式的变体。 这如何帮助我们扩展大数据集? 我们可以编写干净的可重用映射和减少功能,同时将大部分优化推迟到这些库。

注释

理想情况下,应用只加载呈现当前页面所需的数据。 很多时候这是不可能的——API 不能解释我们的特性所需的每一个可能的查询场景。 所以我们使用 API 进行广泛的过滤,然后当数据到达时,我们的组件使用更具体的标准来过滤数据。

这里的规模问题是后台过滤的内容和浏览器中过滤的内容之间的混淆。 如果一个组件更依赖于 API,而其他组件则在本地进行大部分过滤,这将导致开发人员的困惑和非直观的代码。 如果 API 发生变化(即使是细微的变化),它甚至可能导致不可预测的错误,因为我们的组件使用它的方式不同。

映射或减少的时间越少,用户对 UI 的响应就越快。 这就是为什么我们必须在尽可能早的时候只获取用户看到的数据。 例如,我们不希望 API 数据一到达就在事件中传递。 我们需要以这样一种方式来构建组件通信,即对大型集合进行计算代价高昂的过滤,并尽可能快地进行。 这减轻了所有组件的负载,因为它们现在使用的是一个更小的集合。 因此,扩展到更多的组件并不是什么大问题,因为它们将有更少的数据需要处理。

在运行时优化组件

我们的代码应该针对常见情况进行优化。 这是一个很好的扩展策略,因为随着更多的特性和用户添加到混合中,增长的是普通情况,而不是边缘情况。 但是,总有两种同样常见的情况需要处理。 考虑将我们的软件部署到许多客户环境中。 随着时间的推移,随着功能的发展以满足客户的要求,对于任何给定的功能块,可能会有两到三种常见情况。

如果我们有两个处理常见情况的函数,那么我们必须找出在运行时使用哪个函数。 这些常见情况是非常粗粒度的。 例如,常见的情况可能是“集合很大”或“集合很小”。 检查这些条件并不昂贵。 因此,如果我们能适应常见情况的变化,那么我们的软件就会比我们不能适应变化的条件时反应更快。 例如,如果集合很大,函数可以采用不同的方法来过滤它。

Optimizing components at runtime

组件可以在运行时根据宽泛的分类(如小集合或大集合)改变其行为

小结

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

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

API 数据是用户所关心的,用户体验会降低,直到我们拥有它。 我们研究了扩展 API 和其中的数据所带来的一些可伸缩性问题。 一旦我们有了数据,我们的组件需要能够快速映射和缩减它,而数据集则会随着我们的规模不断增长。 现在我们已经对如何使体系结构性能良好有了更好的了解,现在是时候研究如何使它在各种上下文中具有可测试性和功能性了。