我们倾向于认为扩大规模是一个单向的问题——我们只能从目前的规模扩大。 不幸的是,这并不奏效。 我们只能朝着一个方向前进,否则我们脚下的地基就会崩塌。 关键在于确定伸缩性限制,并围绕它们进行设计。
在本章中,我们将探讨 Javascript 架构师在几乎所有浏览器环境中所面临的基本伸缩约束。 我们还将把用户视为影响用户规模的因素,以及新功能与现有功能之间的冲突。 从臃肿的设计中缩小规模也是一项必要的活动。
我们的应用作为一个整体的组合决定了通过关闭特性来缩小规模的容易程度或困难程度。 这一切都与耦合有关,如果我们仔细观察,常常会发现我们需要重构组件,以便稍后可以轻松地删除它们。
规模限制我们的应用受其运行环境的限制。 这意味着运行客户机的硬件,以及浏览器本身。 关于网络应用的有趣之处在于,还有代码本身的传输需要考虑。 例如,如果我们正在编写后端代码,我们可以在遇到任何问题时添加更多代码,这并不是个问题,因为代码不会移动——它运行在一个地方。
对于 Javascript,大小很重要。 这是无法回避的事实。 因此,网络带宽很重要——无论是对 Javascript 构件的交付,还是来自 API 的应用数据。
在本节中,我们将讨论浏览器计算环境中强加于我们的硬伸缩约束。 随着应用的发展,我们越来越感受到这些约束的压力。 在为我们的应用规划新特性时,需要考虑每一个特性。
我们的 Javascript 工件的累积大小只能增长这么多。 最终,我们的应用的加载时间将受到影响,以至于没有人想要使用我们的应用。 巨大的 Javascript 工件通常预示着其他领域的膨胀。 例如,如果我们向浏览器发送巨大的文件,我们可能有太多的东西。 也许我们不需要那些没人使用的特性,或者在我们的组件中存在重复的代码。
不管是什么原因,结果都不好。 越小越好。 我们如何知道 Javascript 工件的文件大小是否足够小? 这要看情况了——没有通用的理想尺寸。 我们的应用部署在公共互联网上吗? 企业用户的 VPN 背后是什么? 这些类型的系统的用户可能有不同的接受标准。 一般来说,公共互联网用户将不会容忍糟糕的加载时间性能和功能膨胀。 另一方面,企业用户通常喜欢更多的功能,更能容忍平淡的加载时间。
不断增长的 Javascript 工件大小的最大贡献者是我们不断添加到产品中的新特性。 这就产生了增加重量的新组件。 任何给定的特性都有一个最小的文件集,每个文件都对应于遵循我们现有特性模式的组件。 如果我们的模式有一半像样,那么我们应该能够保持组件的大小合理。 然而,当涉及到截止日期时,重复代码总是会进入应用。 即使我们的代码尽可能地精简,我们仍然必须在需要的时候实现特性。
编译后的工件可以帮助我们解决大小问题。 我们可以连接和丑化文件,节省网络请求的数量和总带宽。 但是,任何给定的特性都会使这些编译过的工件不断增长。 在遇到任何问题之前,我们可以继续成长一段时间。 如前所述,这些问题是相对的,取决于环境和我们软件的用户。 在所有情况下,我们的 Javascript 工件的大小不可能无限增长。
Javascript 构件的大小是组成组件的所有模块的聚合结果
Javascript 构件的大小决定了应用的总体网络带宽消耗。 特别是当有更多的用户接受时,用户是我们所有架构问题的乘数。 再加上 Javascript 代码,就是我们的应用数据。 这些 API 调用还会影响总体网络带宽消耗和用户感知的延迟。
当我们的应用扩展地理边界时,我们会注意到各种各样的连接问题。 在世界上许多地方,高速网络根本不是一个选择。 如果进入这些市场对我们来说很重要,而且应该如此,那么我们的架构就需要应对缓慢的互联网连接。 使用 cdn 交付应用使用的库在这里会有帮助,因为它们考虑了请求的地理位置。
挑战在于,任何新特性都会增加新的网络带宽消耗。 还有代码的大小,以及新组件引入的新 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++
}
// 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 个实体。 如果需要遍历集合,则需要提供 offset 参数。 但是,我们可能希望进一步限制 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++
}
// 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。 如果它可以在有代码要运行的时候运行我们的代码,比如响应一个点击,那么 UI 就会感觉响应。 如果 CPU 忙于处理其他事情,我们的代码将不得不坐在那里等待。 用户也是如此。 显然,在给定的操作环境中,有许多软件要求 CPU 的注意,其中许多是完全不在我们的控制范围之内的。 我们不能减少浏览器之外的其他应用的使用,但我们可以从我们的 Javascript 应用内部减少 CPU 的使用。 但首先,我们必须了解这些 Javascript CPU 周期从何而来。
在架构层面,我们不会考虑让单个组件的小部分更高效的微观优化。 我们关心的是缩小规模,这在应用运行时对 CPU 消耗有显著影响。 在第 7 章、加载时间和响应时间中,我们看到了如何分析我们的代码。 这告诉我们 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++
}
// 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 还是其他。 谁知道呢,也许我们可以在我们的产品中使用这个功能。 最好把它排除在外,因为它限制我们扩大规模的可能性更小。 我们可能认为这是一段无害的代码,但完全排除它不是更好吗? 此外,这是一种良好的态度,可以逐渐灌输给我们周围的每个人,减少我们不需要的东西,然后考虑从那里走到哪里。 如果我们与我们所有的利益相关者建立先例,我们准备好并愿意削减脂肪,我们更有可能说服他们发布一个更精简的产品。
我们的应用只有这么大的扩展空间; 删除不相关的功能可以释放规模空间
根据我们正在开发的产品类型和它服务的用户类型,客户需求将转化为严格的计划和执行,或者是下意识的反应。 我们都想让我们的客户高兴,这就是我们开发这个软件的原因。 但是,正是这些快速决定去实现人们所渴望的东西,削弱了我们的架构。 就好像我们在实现这些功能时把它们当成了漏洞。 对于漏洞,我们会尽可能快地执行快速修复,因为我们需要将它们排除在外。
新特性不是 bug。 不管用户和管理层怎么说,如果没有他们需要的功能,他们还会活得更久。 我们需要找到一种方法来为自己争取必要的时间,以便将客户想要的新特性融入到我们的体系结构中。 这并不是说我们可以继续推迟——我们必须在一个及时的庄园里这样做。 也许删除用户不太关心的现有特性是前进的最快方法。
确定下一个版本中有哪些功能; 它们要么是我们已经拥有的功能,要么是客户想要的新功能
设计失败通过修改我们现在的代码来缩小规模是一回事。 例如,通过删除特性,或修改现有的组件来适应新计划的特性。 但这只能让我们在未来走这么远。 两年前看起来还不错的设计理念是针对我们两年前考虑的功能,其中一些功能今天可能已经不复存在了。
为了对我们的架构产生持久的影响,我们必须修复破碎的模式。 它们仍然在我们的产品中发挥作用,因为我们让它们发挥作用,即使它们可能不是最好的工具。 确定正确的设计不是一次性事件,它会随着软件的变化和规模的影响而发生。
在这一节中,我们将讨论几种解决设计缺陷的方法。 也许有很多我们不需要的活动部件。 可能由于组件通信模型的复杂性,我们处理 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 英尺的视图,在这里我们可以看到单个功能是如何适合的。 在第 3 章、成分组成中,我们讨论了成分组成,同样的原理在这里也适用。 这个想法是我们在一个稍微高一点的水平上操作。
在第 6 章、用户首选项和默认设置中,我们讨论了可配置性,这也与应用组合的想法有关。 例如,关闭特性,或打开默认禁用的特性。 应用作为一个整体的组合对我们缩小某些方面的能力有巨大的影响。
缩小的权宜之计是关闭特性。 困难的部分是让利益相关者同意这是一个好主意。 然后我们就可以删除这个特征,一切都搞定了,对吧? 不一定。 我们可能需要花一些时间来去掉这个功能。 例如,如果它接触了几个进入系统的入口点,而没有配置可以关闭这些入口点,该怎么办? 这没什么大不了的,这只是意味着我们需要花更多的时间来编写删除这些内容的代码。
唯一的问题是测试将该特性从系统中移除的效果。 对于没有配置来完成这项工作的场景,我们不得不花时间编写代码来完成这项工作,甚至在测试之前。 例如,我们可以花 5 分钟关闭配置值,然后立即得到结果。 也许我们很早就知道,在我们可以安全地从系统中删除这个特性之前,还有很多工作要做。
除了在删除某个特性后测试应用的运行时行为外,我们可能还需要一些构建时选项。 如果我们的产品代码被编译成少量的 Javascript 工件,那么我们需要一种方法从构建中完全删除这些特性。 通过配置禁用组件是一回事。 这意味着当我们的代码运行时,某些内容将无法加载,等等。 如果我们将该功能从源代码库中移除,那么显然就不那么需要担心了——我们的工具无法构建不存在的功能。 然而,如果我们有数百个潜在的组件可以包含在我们的构建工件中,我们需要一种方法来排除它们。
对我们的应用的下一个主要影响是新特性的添加。 是的,这个讨论是关于规模的,但是我们不能忽视在我们的应用中添加新特性。 毕竟,这就是我们一开始缩减规模的原因。 而不是构建一个功能更小的应用。 这是为我们的客户想要的功能腾出空间,并随着时间的推移提高我们产品的整体质量。
添加特征和删除特征的过程通常是并行的。 例如,在开发冲刺阶段,当一个团队实现一个新特性时,另一个团队负责删除引起问题的特性。 由于这两种活动都会对应用产生重大影响,所以我们必须考虑周全,尽量减少这些影响。
从本质上说,这意味着要确保删除旧功能不会对新添加的功能造成太大的干扰。 例如,如果新特性依赖于旧特性中的某些东西,该怎么办? 如果我们的设计是合理的,那么就不会有任何直接的依赖关系。 然而,人类并没有很好地理解复杂性,特别是通过间接的因果关系。 因此,扩展这个操作可能意味着我们不会同时执行这两个活动。
根据我们的组件间通信模型,向系统中添加新组件的效果应该相当温和
影响应用组成的最后一部分是我们正在使用的框架和库。 不用说,我们只想用我们需要的——用它或失去它,这么说吧。 当我们将较小的库作为依赖项时,这个主要是一个问题。 相反,框架在大部分情况下都是包容的。 这意味着您需要的所有东西可能已经在框架中了。 虽然这并不一定是真的,但它仍然帮助我们减少了对第三方库的依赖。
如今,甚至框架都是模块化的,这意味着我们可以挑选我们想要的优点,而不管其他的。 尽管如此,我们还是很容易从框架或其他方式引入我们并不真正使用的组件。 这在网站开发中经常发生。 我们需要这个功能,我们不想自己写因为那边的库已经做了。 然后它就消失在页面的混合中。 我们应该吸取网站没有的教训——我们的应用需要一组集中的依赖项,这是完成工作的必要条件。
小结本章介绍了应用中并非所有内容都是无限可伸缩的概念。 事实上,我们的应用没有什么是无限可伸缩的,因为每个方面都受到不同因素的限制。 这些因素都以独特的方式融合在一起,我们需要做出必要的权衡。 如果我们想继续扩大规模,就必须在其他领域缩小规模。
新功能来自于客户的需求,它们经常与我们已经实现的其他功能重叠。 这可能是因为我们没有很好地定义新特性,或者是因为进入系统的现有入口点没有很好地定义。 不管怎样,这都是一项具有挑战性的练习; 删除现有的功能,以取代新的功能。 我们经常需要删除重叠区域,因为它们会在代码级别和可用性级别造成混淆。
按比例缩小不仅仅是一件一件的活动,还需要考虑设计模式。 在我们删除了一个特性后,我们需要查看我们正在使用的模式,并问,我们想要在未来继续这样做吗? 修复模式是更好、更可扩展的前进道路。 即使我们缩小了规模,仍然存在出错的可能性。 在下一章中,我们将详细讨论失败组件,以及如何处理它们。*