在 WebPageTest 中性能测试单页应用程序的配方

Avatar of Nicolas Goutay
Nicolas Goutay

DigitalOcean 为您旅程的每个阶段提供云产品。立即开始使用 200 美元的免费积分!

WebPageTest 是一款 在线工具 以及一个 开源项目,可帮助开发者审核其网站的性能。作为 Theodo 的 Web 性能布道师,我每天都在使用它。我始终对它免费提供给广大 Web 开发社区和 Web 性能人员的功能感到惊叹。

但是,在处理单页应用程序(通常使用 React、Vue、Svelte 或任何其他前端框架编写)时,事情很快就会变得困难。您如何才能通过登录页面?**当用户流程的大部分发生在客户端并且没有特定的 URL 可以指向时,您如何测试用户流程的性能?**

在本文中,我们将了解如何解决这些问题(以及更多问题),并且您将准备好使用 WebPageTest 测试单页应用程序的性能!

**注意:**本文需要对 WebPageTest 的一些高级功能有中级理解。

如果您对 Web 性能感到好奇,并且想要了解 WebPageTest 的入门知识,我强烈推荐以下资源

测试单页应用程序的问题

单页应用程序(SPA)彻底改变了网站的工作方式。SPA 并不像以前那样让后端(例如 DjangoRailsLaravel)完成大部分繁重工作并将“即用型”HTML 传递到浏览器,而是严重依赖 JavaScript 让浏览器计算 HTML。此类前端框架包括 ReactVueAngularSvelte

WebPageTest 的简洁性是其对开发者吸引力的一部分:访问 http://webpagetest.org/easy,输入您的 URL,稍等片刻,然后!您的性能审核已准备就绪。

如果您正在构建 SPA 并希望衡量其性能,您可以依赖端到端测试工具,如 SeleniumCypressPuppeteer。但是,我发现这些工具都没有 WebPageTest 提供的那么多的性能相关信息和易于使用的工具。

但是,使用 WebPageTest 测试 SPA 可能很复杂。

在许多 SPA 中,大部分站点都受登录表单的保护。我经常使用 Netlify 托管我的站点(包括我的 个人博客),并且我在应用程序中花费的大部分时间都在经过身份验证的页面上,例如列出我所有网站的仪表板。由于我的仪表板上的信息特定于,因此任何尝试访问 https://app.netlify.com/teams/phacks/sites 的其他用户都不会看到我的仪表板,而是会被重定向到登录或 404 页面。

WebPageTest 也是如此。如果我在 http://webpagetest.org/easy 中输入我的仪表板 URL,则审核将针对登录页面执行。

此外,使用简单的 WebPageTest 审核无法实现测试和监控 SPA 中动态交互的性能。

以下是一个示例。Nuage 是一个域名注册商,具有花哨的动画和美观的动态界面。当您搜索要购买的域名时,异步调用会获取请求的结果,并在检索到结果时显示。

您可能已经注意到上面的视频中,当我在键入搜索词时,页面的 URL 不会发生变化。因此,无法使用简单的 WebPageTest 审核来测试搜索体验的性能,因为我们没有合适的 URL 指向搜索内容的操作,而只能指向一个空的搜索页面。

在使用 WebPageTest 时,SPA 范式转变可能会引发其他一些问题

  • 单击周围以导航网页通常比简单地转到新的 URL 更难,但在 SPA 中有时是唯一的选择。
  • SPA 中的身份验证通常使用 JSON Web 令牌 而不是传统的 cookie 实现,这排除了直接在 WebPageTest 中设置身份验证 cookie 的选项(如 此处 所述)。
  • 对于您的 SPA,使用 React 和 Redux(或其他应用程序状态管理库)可能意味着表单更难以以编程方式填写,因为使用 .innerText().value() 设置字段的值可能不会将其转发到应用程序存储。
  • 由于 API 调用通常是异步的,并且可以使用各种加载程序来指示加载状态,因此这些加载程序可能会“欺骗”WebPageTest,使其认为页面实际上已完成加载,而实际上尚未完成。我见过这种情况发生在比平时更长的 API 调用(5 秒以上)中。

由于我在多个项目中都遇到了这些问题,因此我想出了一系列提示和技巧来解决这些问题。

选择元素的多种方法

选择 DOM 元素是执行各种自动化测试的关键部分,无论是使用 SeleniumCypress 进行端到端测试,还是使用 WebPageTest 进行性能测试。选择 DOM 元素允许我们单击链接和按钮、填写表单以及更普遍地与应用程序交互。

使用原生浏览器 API 选择特定 DOM 元素的方法有多种,从简单的 document.getElementsByClassName 到棘手但功能强大的 XPath 选择器。在本节中,我们将看到三种不同的可能性,按复杂程度递增排序。

按 id、className 或 tagName 获取元素

如果要选择的元素(例如,“清空购物车”按钮)具有特定的唯一 id(例如 #empty-cart)、类名或页面上唯一的 button,则可以使用 getElementsBy 方法单击它

const emptyCartButton = document.getElementById("empty-cart");
// or document.getElementsByClassName(".empty-cart-button")[0]
// or document.getElementsByTagName("button")[0]
emptyCartButton.click();

如果同一页面上有多个按钮,则可以在与元素交互之前过滤结果列表

const buttons = document.getElementsByTagName("button");
const emptyCartButton = buttons.filter(button =>
  button.innerText.includes("Empty Cart")
)[0];
emptyCartButton.click();

使用复杂的 CSS 选择器

有时,要交互的特定元素在其 ID、类或标签中都没有有趣的唯一性属性。

解决此问题的一种方法是手动添加此唯一性,仅用于测试目的。将 #perf-test-empty-cart-button 添加到特定按钮对您的网站标记没有害处,并且可以极大地简化您的测试设置。

但是,此解决方案有时可能无法实现:您可能无法访问应用程序的源代码,或者可能无法快速部署新版本。在这些情况下,了解 document.querySelector(及其变体 document.querySelectorAll)以及使用复杂的 CSS 选择器非常有用。

以下是一些使用 document.querySelector 可以实现的功能示例

// Select the first input with the `name="username"` property
document.querySelector("input[name='username']");
// Select all number inputs
document.querySelectorAll("input[type='number']");

// Select the first h1 inside the <section>
document.querySelector("section h1");

// Select the first direct descendent of a <nav> which is of type <img>
document.querySelector("nav > img");

这里有趣的是,您可以使用 CSS 选择器的全部功能。我建议您查看始终有用的 MDN 的 选择器参考表

核武器:XPath 选择器

XML 路径语言(XPath)虽然功能非常强大,但比上面的 CSS 解决方案更难掌握和维护。我很少需要使用它,但知道它的存在绝对有用。

一个这样的实例是当您想按其文本值选择节点时,并且无法使用 CSS 选择器。以下是在这些情况下可以使用的一个方便的代码段

// Returns the  that has the exact content 'Sep 16, 2015'
document.evaluate(
  "//span[text()='Sep 16, 2015']",
  document,
  null,
  XPathResult.FIRST_ORDERED_NODE_TYPE,
  null
).singleNodeValue;

我不会详细介绍如何使用它,因为这会让我偏离本文的目标。公平地说,我甚至不知道上面许多参数的含义。但是,如果您想深入了解该主题,我绝对可以推荐MDN 文档

常见用例的方案

在以下部分,我们将了解如何在单页应用程序的常见用例中测试性能。我称这些为我的测试方案

为了说明这些方案,我将使用React Admin 演示网站作为示例。React Admin是一个开源项目,旨在构建管理应用程序和后台办公系统。

这是一个典型的 SPA 示例,因为它使用 React(顾名思义),调用远程 API,具有登录界面,许多表单并依赖于客户端路由。我鼓励您访问快速查看该网站(演示帐户是demo/demo),以便了解我们将尝试实现的目标。

身份验证和表单

React Admin 的身份验证页面要求用户输入用户名和密码。

React Admin 的身份验证屏幕

直观地,可以采取以下方法填写表单并提交

const [usernameInput, passwordInput] = document.getElementsByTagName("input");
usernameInput.value = "demo"; // innerText could also be used here
passwordInput.value = "demo";
document.getElementsByTagName("button")[0].click();

如果您在登录页面上的 DevTools 控制台中依次运行这些命令,您会发现所有字段都已重置,并且在单击按钮提交时登录请求失败。问题在于我们使用.value()(或.innerText())设置的新值没有反馈到 Redux 存储中,因此应用程序没有“处理”它们。

那么我们需要做的是明确告诉 React 值已更改,以便它相应地更新内部簿记。这可以通过使用Event 接口来实现。

const updateInputValue = (input, newValue) => {
  let lastValue = input.value;
  input.value = newValue;
  let event = new Event("input", { bubbles: true });
  let tracker = input._valueTracker;
  if (tracker) {
    tracker.setValue(lastValue);
  }
  input.dispatchEvent(event);
};

注意:此解决方案非常hacky(即使根据其作者本人所说),但是它适用于我们的目的。

我们更新后的脚本变为

const updateInputValue = (input, newValue) => {
  let lastValue = input.value;
  input.value = newValue;
  let event = new Event("input", { bubbles: true });
  let tracker = input._valueTracker;
  if (tracker) {
    tracker.setValue(lastValue);
  }
  input.dispatchEvent(event);
};

const [usernameInput, passwordInput] = document.getElementsByTagName("input");

updateInputValue(usernameInput, "demo");
updateInputValue(passwordInput, "demo");

document.getElementsByTagName("button")[0].click();

万岁!您可以在浏览器的控制台中尝试一下——它非常有效。

将其转换为实际的 WebPageTest 脚本(使用脚本关键字、单行命令和制表符分隔的参数)将如下所示

setEventName    Go to Login

navigate    https://marmelab.com/react-admin-demo/

setEventName    Login
    
exec    const updateInputValue = (input, newValue) => {  let lastValue = input.value;  input.value = newValue;  let event = new Event("input", { bubbles: true });  let tracker = input._valueTracker;  if (tracker) {  tracker.setValue(lastValue);  }  input.dispatchEvent(event);};

exec    const [usernameInput, passwordInput] = document.getElementsByTagName("input")

exec    updateInputValue(usernameInput, "demo")
exec    updateInputValue(passwordInput, "demo")

execAndWait document.getElementsByTagName("button")[0].click()

请注意,单击提交按钮将我们带到一个新页面并触发 API 调用,这意味着我们需要使用execAndWait 命令。

您可以在此地址查看测试的完整结果。(注意:结果可能已被 WebPageTest 存档——但是,您也可以自己再次运行测试!)

这是一个简短的视频(由 WebPageTest 捕获),您可以在其中看到我们确实通过了身份验证步骤

在页面之间导航

对于传统的服务器端渲染页面,在 WebPageTest 脚本中从一个 URL 导航到下一个 URL 是通过navigate <url> 命令完成的。

但是,对于 SPA,这并不能反映用户的体验,因为客户端路由意味着服务器在导航中没有作用。因此,直接访问 URL 会显着降低测量的性能(因为 JavaScript 框架需要编译、解析和执行的时间),而用户在更改页面时不会遇到这种减慢。由于模拟用户流程至关重要,我们需要在客户端处理导航。

希望这比填写表单简单得多。我们只需要选择将带我们到新页面的链接(或按钮),然后.click() 它!让我们继续我们之前的示例,尽管现在我们想测试“评论”列表和单个“评论”页面的性能。

用户通常会单击左侧导航菜单中的“评论”项目,然后单击列表中的任何项目。检查 DevTools 中的元素可能会导致我们采用以下选择策略

document.querySelector("a[href='#reviews']"); // select the Reviews link in the menu
document.querySelector("table tr"); // select the first item in the Reviews list

由于这两个点击都会导致页面转换和 API 调用(以获取评论),因此我们需要在脚本中使用execAndWait 关键字

setEventName    Go to Login

navigate    https://marmelab.com/react-admin-demo/

setEventName    Login

exec    const updateInputValue = (input, newValue) => {  let lastValue = input.value;  input.value = newValue;  let event = new Event("input", { bubbles: true });  let tracker = input._valueTracker;  if (tracker) {    tracker.setValue(lastValue);  }  input.dispatchEvent(event);};

exec    const [usernameInput, passwordInput] = document.getElementsByTagName("input")

exec    updateInputValue(usernameInput, "demo")
exec    updateInputValue(passwordInput, "demo")

execAndWait document.getElementsByTagName("button")[0].click()

setEventName    Go to Reviews

execAndWait document.querySelector("a[href='#/reviews']").click()

setEventName    Open a single Review

execAndWait document.querySelector("table tbody tr").click()

这是 WebPageTest 上运行的完整脚本的视频

来自 WebPageTest 的审核结果显示了脚本每个步骤的性能指标和瀑布图,使我们能够监控每个 API 调用和交互的性能

Internet Explorer 11 兼容性如何?

WebPageTest 允许我们选择测试将使用哪个位置、浏览器和网络条件。Internet Explorer 11 (IE11) 属于可用的浏览器选项,如果您在 IE11 上尝试以前的脚本,它们将失败。

这是由于两个原因造成的

ES6 语法问题可以通过将我们的脚本转换为 ES5 语法(没有箭头函数,没有letconst,没有数组解构)来克服,这可能看起来像这样

setEventName    Go to Login

navigate    https://marmelab.com/react-admin-demo/

setEventName    Login

exec    var updateInputValue = function(input, newValue) {  var lastValue = input.value;  input.value = newValue;  var event = new Event("input", { bubbles: true });  var tracker = input._valueTracker;  if (tracker) {    tracker.setValue(lastValue);  }  input.dispatchEvent(event);};

exec    var usernameInput = document.getElementsByTagName("input")[0]
exec    var passwordInput = document.getElementsByTagName("input")[1]

exec    updateInputValue(usernameInput, "demo")
exec    updateInputValue(passwordInput, "demo")

execAndWait document.getElementsByTagName("button")[0].click()

setEventName    Go to Reviews

execAndWait document.querySelector("a[href='#/reviews']").click()

setEventName    Open a single Review

execAndWait document.querySelector("table tbody tr").click()

为了绕过缺少 CustomEvent 支持的问题,我们可以转向 polyfills 并手动在脚本顶部添加一个。 此 polyfill 在 MDN 上可用:

(function() {
  if (typeof window.CustomEvent === "function") return false;
  function CustomEvent(event, params) {
    params = params || { bubbles: false, cancelable: false, detail: undefined };
    var evt = document.createEvent("CustomEvent");
    evt.initCustomEvent(
      event,
      params.bubbles,
      params.cancelable,
      params.detail
    );
    return evt;
  }
  CustomEvent.prototype = window.Event.prototype;
  window.CustomEvent = CustomEvent;
})();

然后,我们可以用CustomEvent 替换所有Event 的提及,设置 polyfill 以适应一行,我们就可以开始了!

setEventName    Go to Login

navigate    https://marmelab.com/react-admin-demo/

exec    (function(){if(typeof window.CustomEvent==="function")return false;function CustomEvent(event,params){params=params||{bubbles:false,cancelable:false,detail:undefined};var evt=document.createEvent("CustomEvent");evt.initCustomEvent(event,params.bubbles,params.cancelable,params.detail);return evt}CustomEvent.prototype=window.Event.prototype;window.CustomEvent=CustomEvent})();

setEventName    Login

exec    var updateInputValue = function(input, newValue) {  var lastValue = input.value;  input.value = newValue;  var event = new CustomEvent("input", { bubbles: true });  var tracker = input._valueTracker;  if (tracker) {    tracker.setValue(lastValue);  }  input.dispatchEvent(event);};

exec    var usernameInput = document.getElementsByTagName("input")[0]
exec    var passwordInput = document.getElementsByTagName("input")[1]

exec    updateInputValue(usernameInput, "demo")
exec    updateInputValue(passwordInput, "demo")

execAndWait document.getElementsByTagName("button")[0].click()

setEventName    Go to Reviews

execAndWait document.querySelector("a[href='#/reviews']").click()

setEventName    Open a single Review

execAndWait document.querySelector("table tbody tr").click()

瞧!

WebPageTest 脚本的常规提示和技巧

最后我想做的一件事是提供一些使编写 WebPageTest 脚本更容易的提示和技巧。如果您有任何建议,请随时在 Twitter 上私信我

安全第一!

如果您的脚本包含敏感数据(如凭据),请记住选中两个隐私复选框!

WebPageTest 安全控制

浏览文档

这些WebPageTest 脚本文档包含许多我在本文中未介绍的功能,从 DNS 覆盖到 iPhone 模拟,甚至包括 if/else 条件语句。

当您计划编写新的脚本时,我建议首先查看可用的参数,并查看是否有任何参数可以帮助简化或增强脚本的健壮性。

长时间加载状态

有时,远程 API 调用(例如,获取评论)需要很长时间。加载指示器(例如旋转器)可用于告诉用户等待一段时间,因为正在发生某些事情

WebPageTest 试图通过确定屏幕上的内容是否正在更改来检测页面何时完成加载。如果您的加载指示器持续很长时间,WebPageTest 可能会将其误认为是页面设计的一部分,并在 API 调用返回之前结束审核——从而截断您的度量。

解决此问题的一种方法是告诉 WebPageTest 在停止测试之前至少等待一定持续时间。这是“高级”选项卡下可用的参数

WebPageTest 最小测试持续时间

保持脚本(和结果)易于阅读

  • 慷慨地使用空行和注释 (//),因为单行 JavaScript 命令有时难以理解。
  • 将多行版本保留在某个地方作为参考,并在即将测试时将所有内容单行化。这有助于提高可读性。非常有用。
  • 使用 setEventName 为不同的“步骤”命名。这使得测试更易于阅读,因为它明确了审计所经历的页面序列,并且也会出现在 WebPageTest 结果中。

迭代你的脚本

  • 首先,确保你的脚本在浏览器中工作。为此,去除 WebPageTest 关键字(脚本每一行的第一个单词),然后将每一行复制粘贴到浏览器控制台中,以验证在每一步中所有内容是否按预期工作。
  • 一旦你准备好将测试提交到 WebPageTest,首先使用非常轻量的设置:只运行一次,使用快速浏览器(咳咳——不是 IE11——咳咳),没有网络限流,没有重复查看,一个尺寸合适的实例(弗吉尼亚州杜勒斯通常具有良好的响应时间)。这将帮助你更快地检测和纠正错误。

自动化你的脚本

你的测试脚本运行顺利,并且你开始获取单页应用程序的性能报告。随着你发布新功能,定期监控其性能以尽早发现回归非常重要。

为了解决这个问题,我目前正在开发 Falco,一个即将开源的 WebPageTest 测试运行器。Falco 负责自动化你的审计,然后在易于阅读的界面中呈现结果,同时允许你在需要时阅读完整报告。你可以关注我的 Twitter,了解它何时开源,并了解更多关于网页性能和 WebPageTest 的信息!