WebPageTest 是一款 在线工具 以及一个 开源项目,可帮助开发者审核其网站的性能。作为 Theodo 的 Web 性能布道师,我每天都在使用它。我始终对它免费提供给广大 Web 开发社区和 Web 性能人员的功能感到惊叹。
但是,在处理单页应用程序(通常使用 React、Vue、Svelte 或任何其他前端框架编写)时,事情很快就会变得困难。您如何才能通过登录页面?**当用户流程的大部分发生在客户端并且没有特定的 URL 可以指向时,您如何测试用户流程的性能?**
在本文中,我们将了解如何解决这些问题(以及更多问题),并且您将准备好使用 WebPageTest 测试单页应用程序的性能!
**注意:**本文需要对 WebPageTest 的一些高级功能有中级理解。
如果您对 Web 性能感到好奇,并且想要了解 WebPageTest 的入门知识,我强烈推荐以下资源
- “如何使用 WebPageTest 及其 API” 在 CSS-Tricks 上由 Edouardo Bouças 对 WebPageTest 功能进行了很好的概述;
- “谁拥有 F1 中速度最快的网站?” 由 Jake Archibald 解释了如何分析 WebPageTest 结果可以帮助您在以一级方程式为主题的案例研究中改进性能。
测试单页应用程序的问题
单页应用程序(SPA)彻底改变了网站的工作方式。SPA 并不像以前那样让后端(例如 Django、Rails 和 Laravel)完成大部分繁重工作并将“即用型”HTML 传递到浏览器,而是严重依赖 JavaScript 让浏览器计算 HTML。此类前端框架包括 React、Vue、Angular 或 Svelte。
WebPageTest 的简洁性是其对开发者吸引力的一部分:访问 http://webpagetest.org/easy,输入您的 URL,稍等片刻,然后瞧!您的性能审核已准备就绪。
如果您正在构建 SPA 并希望衡量其性能,您可以依赖端到端测试工具,如 Selenium、Cypress 或 Puppeteer。但是,我发现这些工具都没有 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 元素是执行各种自动化测试的关键部分,无论是使用 Selenium 或 Cypress 进行端到端测试,还是使用 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 的身份验证页面要求用户输入用户名和密码。

直观地,可以采取以下方法填写表单并提交
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 语法(例如箭头函数),它不被 IE11 100% 支持。
- 我们用于处理表单的 CustomEvent API不受 IE11 支持。
ES6 语法问题可以通过将我们的脚本转换为 ES5 语法(没有箭头函数,没有let
和const
,没有数组解构)来克服,这可能看起来像这样
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 脚本文档包含许多我在本文中未介绍的功能,从 DNS 覆盖到 iPhone 模拟,甚至包括 if/else 条件语句。
当您计划编写新的脚本时,我建议首先查看可用的参数,并查看是否有任何参数可以帮助简化或增强脚本的健壮性。
长时间加载状态
有时,远程 API 调用(例如,获取评论)需要很长时间。加载指示器(例如旋转器)可用于告诉用户等待一段时间,因为正在发生某些事情。
WebPageTest 试图通过确定屏幕上的内容是否正在更改来检测页面何时完成加载。如果您的加载指示器持续很长时间,WebPageTest 可能会将其误认为是页面设计的一部分,并在 API 调用返回之前结束审核——从而截断您的度量。
解决此问题的一种方法是告诉 WebPageTest 在停止测试之前至少等待一定持续时间。这是“高级”选项卡下可用的参数

保持脚本(和结果)易于阅读
- 慷慨地使用空行和注释 (
//
),因为单行 JavaScript 命令有时难以理解。 - 将多行版本保留在某个地方作为参考,并在即将测试时将所有内容单行化。这有助于提高可读性。非常有用。
- 使用
setEventName
为不同的“步骤”命名。这使得测试更易于阅读,因为它明确了审计所经历的页面序列,并且也会出现在 WebPageTest 结果中。
迭代你的脚本
- 首先,确保你的脚本在浏览器中工作。为此,去除 WebPageTest 关键字(脚本每一行的第一个单词),然后将每一行复制粘贴到浏览器控制台中,以验证在每一步中所有内容是否按预期工作。
- 一旦你准备好将测试提交到 WebPageTest,首先使用非常轻量的设置:只运行一次,使用快速浏览器(咳咳——不是 IE11——咳咳),没有网络限流,没有重复查看,一个尺寸合适的实例(弗吉尼亚州杜勒斯通常具有良好的响应时间)。这将帮助你更快地检测和纠正错误。
自动化你的脚本
你的测试脚本运行顺利,并且你开始获取单页应用程序的性能报告。随着你发布新功能,定期监控其性能以尽早发现回归非常重要。
为了解决这个问题,我目前正在开发 Falco,一个即将开源的 WebPageTest 测试运行器。Falco 负责自动化你的审计,然后在易于阅读的界面中呈现结果,同时允许你在需要时阅读完整报告。你可以关注我的 Twitter,了解它何时开源,并了解更多关于网页性能和 WebPageTest 的信息!