提高 SPA 网页性能





5.00/5 (20投票s)
掌握内存泄漏问题,提升使用 JQuery/KnockoutJS 的 SPA 或基于 SPA 的混合移动应用程序的用户体验
注意:这是一个快速阅读。您可以在等待构建处理时快速浏览一下。:)
引言
单页应用程序(SPA)的概念在过去几年中已经出现——有时您甚至在不知情的情况下就使用了它。您经常访问的许多网站都使用了这个概念…… Office365、Gmail、Facebook 以及 Azure 的管理控制台都让我印象深刻。与传统的将所有内容发布回服务器进行页面刷新和获取新内容的主要区别在于,单页应用程序让用户“停留在同一页面上”,并且目标是仅替换页面生命周期中发生变化的“应用程序”的交互部分。在旧时代,我们不必过多担心浏览器中的内存——我的意思是,为什么需要呢?……用户在每个页面上花费的时间很短,就会被完全刷新并重新发布到服务器,服务器处理发送来的数据,然后为用户提供一个全新的页面。#内存泄漏?
单页应用程序范例意味着用户主要停留在单个页面上,只有他们与之交互的各个“DOM 部分”才会真正发生变化。例如,为了显示“撰写邮件”表单,真的需要进行一次完整的服务器回发吗?……我们不能直接让它在收件箱列表上显示为模态窗口,以便用户可以快速移动吗?……因此,页面的生命周期,以及我们与页面生命周期以及用户体验的整个关系都发生了改变,需要比以前更仔细地考虑事情。
本文档提供了一些与优化单页应用程序相关的内存和用户体验的通用建议,以及在混合移动应用程序中使用 SPA 概念时的建议。本文档涵盖了一些容易实现的目标,并非详尽无遗,我相信一些有经验的读者会提供其他建议来扩展本文档。
我附上了一个基本的 MVC ASP.NET 应用程序,它演示了一些概念。它是在 VS 2015 社区版中构建的(如果您还没有,请赶紧获取——它是免费的!)。
背景
我最近在优化一个混合移动“单页”应用程序,这个应用程序最初性能很好,但遗憾的是,现在开始变得缓慢。正如我所料,这是内存泄漏的经典案例,原因是在不再需要它们时对象未能被仔细清理,以及其他一些导致应用程序获得不应有坏名声的问题。性能下降发生在 Cordova 中使用 Visual Studio 工具开发的混合移动应用程序中,该应用程序被设计为一个单页移动应用程序。然而,它也可以发生在标准浏览器中从服务器运行的 ASP.NET 应用程序中。除了内存泄漏之外,还有一些其他问题侵入了应用程序,导致用户体验不佳。本文档涵盖了针对该项目提出的通用建议,但同样适用于标准的单页应用程序。所讨论的项目使用了 JavaScript/JQuery 以及 KnockoutJS,因此这些内容也在此讨论。
文章范围
本文档帮助优化的应用程序是一个混合移动应用程序,使用了基于 KnockoutJS 构建的 SPA 框架,SammyJS 作为路由器,以及 JQuery/JavaScript 作为核心库。这里讨论的主题涵盖了这些主要内容。本文档并不打算非常深入,它只是对在该特定项目中有所帮助的一些内容的快速概述,希望也能帮助您在类似的项目开始变得缓慢时获得一些快速的成效。
点击/事件绑定
所讨论的应用程序使用了 Knockout 绑定的事件以及标准的 JS/JQuery 事件绑定/挂钩的组合。
例如
$('#MyButton').click(
function(
{
alert('yeo');
}
)
)
非常简单且非常常见。然而,如果这些事件钩子随着时间的推移而累积并且没有被正确释放,就可能出现问题——它们最终可能被孤立,超出垃圾回收器的范围,从而保留在内存中并导致最终的性能下降。为了应对这个问题,我们需要确保我们同时拥有所有对象及其相关事件的设置和拆除。
JQuery
示例 - 设置
$('#MyButton').click(function() { return false; });
在 JQuery < 1.7 中,实现这一点的方法是使用 unbind
方法,如下所示——(拆卸)
$('#MyButton').unbind('click');
这实际上做了什么 就是移除可能绑定到给定按钮的 CLICK 事件上的所有事件。最近,JQuery > 1.7 通过使用‘off
’API 调用,为我们提供了更精确的控制——(拆卸)
$('#MyButton').off('click');
如果您想让多个不同的方法从 OnClick
事件中调用,您可以使用命名空间。
$('#MyButton').on('click.SomeNamespace', function() { /* Do X */ });
$('#MyButton').on('click.SomeOtherNamespace', function() { /* Do Y */ });
$('#MyButton').off('click.SomeOtherNamespace'); // Y killed, X remains
Knockout
根据 Knockout 模型的使用方式,它需要知道您是否已完成在 Click
事件中创建的绑定。
<button id='myButton' data-bind="click: doStuff">Click it</button>
上面的代码告诉 KO 将元素的 OnClick
绑定到一个名为 doStuff()
的方法。如果这个方法被保留一段时间,它可以被重复使用。然而,如果我们显式地拆除该元素(比如一个周围的弹出模态窗口),那么我们需要告诉 KO 关于这一点,以便它可以为我们清理。
‘cleanNode
’方法将告诉 KO 移除附加到节点上的任何处理程序。
ko.cleanNode(element)
‘removeNode
’方法将告诉 KO 移除附加到节点上的任何处理程序,然后也移除该节点本身。
ko.removeNode(element)
使用 clean/remove 节点的地方可以是例如我们创建一个新的模态弹出窗口,或一个表单,供用户输入,然后我们完成它。在这种情况下,我们完成了,所以我们应该使用 clean/remove。
工作示例
var element = $('#myButton');
ko.cleanNode(element);
示例
为了演示可能发生的情况,我附上了一个小的演示到文章中。
(1) 正确拆卸钩接的事件...
在演示的第一部分,我们有一个简单的循环来创建一个 DIV 堆栈,然后我们将其钩接到事件上(在本例中,使用类名)。
// create divs
$('#myButtonCreate').on('click', function (event) {
for (x = 1; x < 1000; x++) {
i++;
var html = "<div class='childObj'
id='obj" + i + "'>Object ID " + i + "</div>";
$("#divStack").prepend(html);
}
});
// hook events to the divs based on class
$('#myButtonEvents').click(
function () {
// attach events to objects
$('.childObj').click(function () {
alert('here at: ' + $(this).attr('id'));
});
}
);
创建之后,我们可以使用一个简单的调用来计算 DOM 中的节点数量。
$("*").length
这让我们对事情的进展有了一个大致的了解。
当我们只移除 DIV
s,使用‘.empty()
’时,我们仍然剩下存在的事件……
// remove divs, not events
$('#myButtonRemoveOnlyDivs').click(
function () {
$('.childObj').empty(); // removes from dom, does not remove children or events
}
);
当我们使用‘.remove()
’移除 div
s 时,这会移除 div
s 以及它们相关的子元素和事件……
// 'remove' removes an element from the dom, plus any children and associated events
$('#myButtonRemoveDivAndEvents').click(
function () {
$('.childObj').remove();
}
);
现在计数下降到了预期值(考虑到除了测试 div
s 之外还存在哪些事件!)。
(2) 使用命名空间在单个监听器上创建多个事件
设置按钮来点击 (1) 为单个按钮添加 2 个带有命名空间的事件 (2) 添加另一个按钮来从第一个按钮的命名空间中移除一个事件。
<h3>Use Namespaces to isolate discrete code in similar events</h3>
<input type="button" id="myNameSpaceEvents"
value="5 - Add namespace events" />
<input type="button" id="myNameSpaceEventsRemoveOne"
value="6 - Remove single namespace event" />
设置代码,首先向按钮添加 2 个事件(alert X 和 Y),其次,代码使用 namespace
选项选择性地从按钮点击事件中移除其中一个事件。
// namespace JS for 'X'
$('#myNameSpaceEvents').on('click.NameSpaceX',
function () {
alert('NS X');
});
// namespace JS for 'Y'
$('#myNameSpaceEvents').on('click.NameSpaceY',
function () {
alert('NS Y');
});
// using the namespace, remove one event
$('#myNameSpaceEventsRemoveOne').click(
function () {
$('#myNameSpaceEvents').off('click.NameSpaceX'); // Y killed, X remains
});
上述代码也包含在附加的小型演示 MVC 应用中。
Chrome 内存统计
有一个非常有用的 JavaScript 代码,它只在 Chrome 中有效,绝对值得您花时间查看。实际上,它使您能够实时跟踪网页上的 JavaScript 内存,并直观地看到不同事物对 JavaScript 内存的影响,而无需进行堆快照或因为打开开发者工具而占用屏幕空间等。它非常适合演示上述代码如何影响内存使用(即:添加 div 然后移除它们,是否正确移除了事件)。
Memory-stats.js 在 github 上可用。如果您下载了附加的示例项目(见文章顶部!),我在那里包含了它,这样您就可以看到如何在 ASP.NET Visual Studio 项目中使用它。
设置
要使用该代码,您必须使用命令行标志启动 Chrome。我不想每次都为命令行执行此操作,因此我将其添加到我的 Visual Studio‘浏览方式’菜单中,如下所示。
- 从‘浏览器运行’菜单中,点击‘浏览方式’...
- 在弹出菜单中,找到 chrome.exe(通常在 program files .. Google .. Chrome .. application 中),然后输入显示的启动参数。
- 现在您的菜单中会出现一个新的浏览器运行类型,随时准备显示其优点……
使用 mem-Stats
将 memStats 集成到您的网站中有几个步骤。
导入 memory-Stats.js
- 在您的 cshtml 文件中引用它
<script src="~/Scripts/memory-stats.js"></script>
- 包含连接库并显示图表的代码。在这种情况下,它显示在页面底部。
- 声明为
script
变量var stats = new MemoryStats();
- 创建一个您可以调用的函数
function callStats(){ stats.domElement.style.position = 'fixed'; stats.domElement.style.right = '0px'; stats.domElement.style.bottom = '0px'; document.body.appendChild(stats.domElement); requestAnimationFrame(function rAFloop() { stats.update(); requestAnimationFrame(rAFloop); } )}
上面的代码设置了图表的位置,并在一个循环中调用
stats.update()
函数。 - 声明为
使用 mem-Stats 进行测试 - 设置
在我们的示例中,我创建了一对新按钮——一个用于显示统计图(您可能希望根据需要隐藏/显示它),另一个用于生成大量示例内存垃圾用法。
<h3>Use Chrome Memory memStats Monitor</h3>
<input type="button" id="chromeStats" value="Show monitor" />
<input type="button" id="garbageGen" value="Generate garbage" />
这些被钩接到 click
事件,调用相关的方法。
$('#chromeStats').click(
function(){
callStats();
});
$('#garbageGen').click(
function(){
generateGarbage();
});
结果
现在让我们看看它是如何实际工作的。
开始之前 | ![]() |
添加 100k div 对象后 | ![]() |
向 100k div 对象添加 OnClick 事件后 | ![]() |
移除 DIV s,但未移除相关事件后 | ![]() |
移除 DIV s 和相关事件后 | ![]() |
很容易看出,如果不清理使用过的对象和事件,它们很容易不断累积并最终对性能产生负面影响。所以,如果设置了它,别忘了拆卸它!
小心使用 Knockout 可观察对象...
可观察对象是 Knockout 的一个关键方面,它提供了表单字段、数据和模型数据之间的双向绑定能力等等。然而,必须非常小心,仅在必要时使用它们。很容易陷入拥有长链式可观察对象导致应用严重减速的陷阱。
在大型模型中,可观察对象可能产生与 SQL 中的索引维护相同的影响……对于更新/导入大量数据,最好关闭索引,导入数据,然后重新打开索引。
对于这个技巧,请检查您的代码和模型,并确保您只在绝对需要的地方使用可观察对象。例如,如果一个 KO 模型对象的成员不用于数据绑定,或者不是其他成员所必需的,而仅仅是一个存储对象,那么它就没有必要成为可观察对象,并且此属性应该被更改。这一级别的足够多的更改可能整体上非常显著。
考虑一下在应用程序的某些地方实际上是否需要可观察对象,或者您是否可以维护一个单独的 JS 对象来表示您的数据。这意味着您会失去一些 KO 的优势,但对可观察对象的级联效果和相关开销有了更多的控制。
您还可以关闭模型中的级联通知——有关详细信息,请参阅此处。
拆分您的 Knockout 模型
在 SPA 中使用的原始 Knockout 模型可能会随着时间的推移变得非常大。因此,可观察对象、计算值等的自动化的内部开销也随之增长,并最终导致整体应用程序的效率和速度不如它本可以达到的。初始设计可能基于轻量级应用程序和有限屏幕的前提,然而,屏幕的复杂性以及对象之间的交互自然会从第一个迭代开始急剧增长。主要问题是当我们对模型的一个离散部分进行操作时,我们实际上更新了整个模型。这会导致应用程序变慢,需要一些重新设计。
将您笨重的、集成的模型拆分成更小的块,从这个……
到这个……
除了复杂模型的内部导致比开始时运行更慢之外,当您拥有一个非常复杂/大型的模型以及相应数量的数据绑定时,它还会产生很大的连锁反应。说实话,用户真的能在同一时间处理所有数据绑定的 HTML 对象吗?……答案很可能是否定的。因此,只绑定用户一次可以处理的内容,并将自动化保留给后台运行的 Knockout/JS 对象。
为了更清楚地解释这一点——这是一个随着时间推移而发展的项目中的 UI 模式。
模型代表组织中的一个用户。起初,它只是一个简单的“用户详细信息”(简单信息),以及一个 1 对多(1->M)的可观察数组,显示用户参与的安全角色。
在用户安全角色表中,它显示了角色的列表(管理员、普通、IT……),并且快速、高效地完成了它的工作。随着时间的推移,项目的复杂性不断增加,每次页面或模型加载时加载的信息量、链接/可观察/数据绑定的数组也急剧增加……
当然,从业务流程的角度来看,所有在臃肿的选项卡控件中的内容都是有效的,但关于可用性和速度的抱怨开始涌入——有些东西必须改变。
管理此类问题的高层解决方案是将任何大型的单个模型分解成多个小型、相关但未连接的模型。为此,从上到下将每个组件分解成离散的模型。当需要级联更新时,创建一个 JS 方法,该方法在可观察对象或更改的计算事件中,调用相关/链接模型中的相关方法,但仅针对脏增量。始终牢记“用户现在是否需要这些数据,或者是否可以稍后加载/显示它……”。
所以……重新回顾一下……这是问题
并且在不改变 UI 的情况下,这是解决方案……
保存数据
在 SPA 中,我们可以因为多种原因保存数据……其中两个原因是在用户处理大量数据时以防万一作为备份,以及将 CRUD 更新推送到服务器。将一个大型对象模型保存在内存中,并在用户每次进行更改时将其序列化,这并不少见。这效率不高——您实际上应该只保存发生的更改增量。
此领域的优化目标是,尽可能地只更改已更改的内容,而不是整个模型。例如,如果我们向待办事项列表添加一个新任务,那么就不需要将其添加到对象模型中,然后保存整个模型。相反,您可以(如果需要)将其推送到模型,但之后只保存增量,即实际添加/更改的部分,作为标准 JS 对象表示为string
。
这种模式是有意义的,因为很多时候,当数据返回到服务器时,它会被分解成行级结构。增量更改可以封装在一系列负载包中发送到服务器,然后用于根据需要提供 CRUD。
示例数据包
- 日期时间:2016 年 2 月 22 日Z13:45:23
- 包 ID:ABC123
- 设备信息:{json 数据}
- 用户信息:{json 数据}
- 记录 ID: 3736354456
- 操作:更新
- 类型:Task.TodoList.Note
- 负载:{json 数据}
随着用户在应用程序中前进,可以构建任意数量的上述数据包并将其放入总线队列中。在同步时,我们不发送整个模型,而是发送一系列微数据包(可以批处理)到服务器,服务器根据需要更新相关表数据。
此模式还为我们提供了一种从数据角度跟踪用户操作的可靠方法,允许存储数据包作为版本控制系统(如果需要)。
加载数据的控件
当不使用像 Ionic 这样的框架用于混合移动,或 Bootstrap 用于桌面时,通常会从一种用户界面风格开始,最终得到一种……好吧,让我们委婉地说,“不太理想”的东西。
在某些情况下,最初选择的控件是错误的,或者控件的实现方式不利于高效、及时和响应式的用户体验。例如,使用预测/滚动插件来显示客户名称。它可能以 30 个条目开始,当时看起来很灵巧很棒,但当它被推送到包含 2500 多个客户的实时列表时,它就成为了应用程序和用户的沉重负担,应该重新设计体验。总的来说,您应该只提供用户一次可以处理的最少内容。在数据输入情况下,用户通常只会输入他们正在搜索的内容的大致近似值,因此不会发生频繁加载/重新加载。与其将“所有数据”呈现在用户面前,不如向他们呈现“足够”的数据来完成工作。
数据呈现
用户认知一次只能处理少数几个对象——因此,我们应该只呈现当前可以看到/使用的内容。不要使用无限预测滚动条,而应考虑使用其他方法,如在回调时加载的受限分页数据,或“智能”预测加载器。
智能数据加载
考虑数据加载的方式,并考虑如何加载最少的数据。这里有两个例子。
-
当加载到分页列表中时,我们显然只加载填充该列表所需的数据量。如果列表中显示项的数量设置为 10,并且有 15 个结果,那么我们应该获取总计数(15),但只加载正在查看的页面中的 10 个结果。
-
缓存数据是一种智能的方式,可以在提供更流畅的用户体验的同时,只加载您所需的最低限度。例如,考虑一个无限滚动条——当用户滑动时,他们可以看到滚动过的内容,可能是 10 个项目。与分页示例不同,我们不仅加载当前的 10 个项目,还额外加载 10 个准备用于下一次滑动。一旦下一个十个项目进入视野,我们就立即去获取下一个十个,依此类推。别忘了我们刚刚滑过的那些数据——就像缓存即将查看的数据一样,我们应该丢弃我们已经看到的数据(取决于内容等),这样我们就不会留下快速填满的内存堆栈。
少即是多
在尝试加快应用程序并使其更快捷时,我们不仅应该解决速度问题,还应该解决用户对速度的感知和可用性问题。虽然有将尽可能多的功能塞进一个屏幕的诱惑,但通常来说,少即是多。与其不断地往屏幕上添加按钮,不如强烈考虑一个特定的功能/数据/按钮在那个地方真正会被使用/需要的频率,并考虑将这些东西移到一个“高级”按钮或侧滑“额外信息/功能”滑动面板下。要在此领域取得进展,请与您的业务团队一起检查每个屏幕,以确定在哪里可以减少混乱。
历史
- 2016 年 2 月 22 日 - 版本 1
- 2016 年 2 月 23 日 - 版本 2 - 添加了代码示例和相关注释
- 2016 年 3 月 4 日 - 版本 3 - 添加了 chrome memStats 部分(非常有用!),添加了更新的
SampleCode
可供下载