在多种情况下,我需要发送一个包含某些数据的 HTTP 请求来记录用户执行的操作,例如导航到其他页面或提交表单。考虑一下这个例子,它在点击链接时将一些信息发送到外部服务:
<a href="https://www.php.cn/link/3cbfb2330b21840b385a45c958602663">Go to Page</a> document.getElementById('link').addEventListener('click', (e) => { fetch("/log", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ some: "data" }) }); });
这里没有什么特别复杂的地方。链接可以像往常一样工作(我没有使用 e.preventDefault()
),但在该行为发生之前,会触发一个 POST 请求。无需等待任何响应。我只想将其发送到我正在访问的任何服务。
乍一看,您可能会认为该请求的调度是同步的,之后我们将继续从页面导航离开,而其他服务器成功处理该请求。但事实证明,情况并非总是如此。
当发生某些事件终止浏览器中的页面时,不能保证正在处理的 HTTP 请求会成功(有关页面的生命周期“终止”和其他状态的更多信息)。这些请求的可靠性可能取决于多种因素——网络连接、应用程序性能,甚至外部服务的配置本身。
因此,在这些时刻发送数据可能远非可靠,如果您依赖这些日志来做出数据敏感的业务决策,则可能会出现一个潜在的重大问题。
为了说明这种不可靠性,我设置了一个小型 Express 应用程序,其中包含一个使用上面代码的页面。当点击链接时,浏览器导航到 /other
,但在发生这种情况之前,会发出一个 POST 请求。
虽然一切都在发生,但我打开了浏览器的“网络”选项卡,并且正在使用“慢速 3G”连接速度。页面加载后,我清除了日志,一切看起来都很安静:
但是,一旦点击链接,事情就会出错。当导航发生时,请求会被取消。
这让我们几乎无法确信外部服务实际上能够处理该请求。为了验证此行为,当我们使用 window.location
以编程方式导航时,也会发生这种情况:
document.getElementById('link').addEventListener('click', (e) => { e.preventDefault(); // 请求已排队,但在导航发生后立即被取消。 fetch("/log", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ some: 'data' }), }); window.location = e.target.href; });
无论导航如何或何时发生以及活动页面何时终止,这些未完成的请求都可能被放弃。
问题的根本原因是,默认情况下,XHR 请求(通过 fetch
或 XMLHttpRequest
)是异步且非阻塞的。一旦请求排队,请求的实际工作就会传递给后台的浏览器级 API。
就性能而言,这是很好的——您不希望请求占用主线程。但这也意味着,当页面进入“终止”状态时,它们有被放弃的风险,无法保证任何后台工作都能完成。以下是 Google 对该特定生命周期状态的总结:
一旦页面开始卸载并被浏览器从内存中清除,它就处于终止状态。在此状态下,无法启动任何新任务,如果正在进行的任务运行时间过长,则可能会被终止。
简而言之,浏览器的设计假设是,当页面被关闭时,无需继续处理其排队的任何后台进程。
避免此问题的最明显方法可能是,尽可能地延迟用户操作,直到请求返回响应。过去,这是通过使用 XMLHttpRequest
中支持的同步标志来错误地完成的。但是使用它会完全阻塞主线程,导致许多性能问题——我过去已经写过一些关于这方面的内容——所以这个想法甚至不应该被考虑。事实上,它即将退出平台(Chrome v80 已经将其删除了)。
相反,如果您要采取这种方法,最好等待 Promise 解析为返回的响应。返回后,您可以安全地执行该行为。使用我们之前的代码片段,这可能看起来像这样:
document.getElementById('link').addEventListener('click', async (e) => { e.preventDefault(); // 等待响应返回…… await fetch("/log", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ some: 'data' }), }); // ……然后导航离开。 window.location = e.target.href; });
这可以完成工作,但有一些非微不足道的缺点。
首先,它会通过延迟所需行为的发生来影响用户体验。收集分析数据肯定有益于业务(并有益于未来的用户),但这远非理想,因为它让当前的用户为实现这些好处付出代价。更不用说,作为一个外部依赖项,服务本身的任何延迟或其他性能问题都会反映给用户。如果来自您的分析服务的超时导致客户无法完成高价值操作,那么每个人都会输。
其次,这种方法并不像最初听起来那样可靠,因为某些终止行为无法以编程方式延迟。例如,e.preventDefault()
在延迟用户关闭浏览器选项卡方面毫无用处。因此,充其量,它只会涵盖收集某些用户操作的数据,但不足以完全信任它。
值得庆幸的是,大多数浏览器都内置了保留未完成 HTTP 请求的选项,而无需影响用户体验。
如果在使用 fetch()
时将 keepalive
标志设置为 true
,则即使启动该请求的页面已终止,相应的请求也会保持打开状态。使用我们的初始示例,这将使实现看起来像这样:
<a href="https://www.php.cn/link/3cbfb2330b21840b385a45c958602663">Go to Page</a> document.getElementById('link').addEventListener('click', (e) => { fetch("/log", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ some: "data" }), keepalive: true }); });
当点击该链接并发生页面导航时,不会发生请求取消:
相反,我们得到了一个(未知)状态,仅仅是因为活动页面从未等待接收任何响应。
这样的单行代码很容易修复,尤其是在它是常用浏览器 API 的一部分时。但是,如果您正在寻找具有更简单界面的更集中的选项,则还有另一种方法,其浏览器支持几乎相同。
Navigator.sendBeacon()
函数专门用于发送单向请求(信标)。一个基本的实现如下所示,它发送一个带有字符串化 JSON 和“text/plain”Content-Type
的 POST:
navigator.sendBeacon('/log', JSON.stringify({ some: "data" }));
但是此 API 不允许您发送自定义标头。因此,为了将我们的数据发送为“application/json”,我们需要进行一个小小的调整并使用 Blob:
<a href="https://www.php.cn/link/3cbfb2330b21840b385a45c958602663">Go to Page</a> document.getElementById('link').addEventListener('click', (e) => { const blob = new Blob([JSON.stringify({ some: "data" })], { type: 'application/json; charset=UTF-8' }); navigator.sendBeacon('/log', blob); });
最终,我们得到了相同的结果——即使在页面导航后也能完成请求。但是还有一些事情正在发生,这可能会使其优于 fetch()
:信标以低优先级发送。
为了演示,以下是在同时使用带有 keepalive
的 fetch()
和 sendBeacon()
时在“网络”选项卡中显示的内容:
默认情况下,fetch()
获取“高”优先级,而信标(上面标注为“ping”类型)具有“最低”优先级。对于对页面功能不重要的请求,这是一件好事。直接来自信标规范:
此规范定义了一个接口,该接口[……]最大限度地减少了与其他时间关键型操作的资源竞争,同时确保此类请求仍被处理并传递到目的地。
换句话说,sendBeacon()
确保其请求不会妨碍对您的应用程序和用户体验真正重要的请求。
值得一提的是,越来越多的浏览器支持 ping
属性。当附加到链接时,它会发出一个小的 POST 请求:
<a href="https://www.php.cn/link/fef56cae0dfbabedeadb64bf881ab64f" ping="http://localhost:3000/log"> Go to Other Page </a>
这些请求标头将包含单击链接的页面(ping-from),以及该链接的 href 值(ping-to):
<code>headers: { 'ping-from': 'http://localhost:3000/', 'ping-to': 'https://www.php.cn/link/fef56cae0dfbabedeadb64bf881ab64f' 'content-type': 'text/ping' // ...其他标头 },</code>
它在技术上类似于发送信标,但有一些值得注意的限制:
总而言之,如果您只发送简单的请求并且不想编写任何自定义 JavaScript,那么 ping
是一个不错的工具。但是,如果您需要发送更多内容,它可能不是最好的选择。
使用带有 keepalive
的 fetch()
或 sendBeacon()
发送最后一秒请求肯定存在权衡。为了帮助辨别哪种方法最适合不同的情况,以下是一些需要考虑的事项:
我选择深入研究浏览器处理页面终止时进程内请求的方式是有原因的。不久前,在我们开始在提交表单时立即触发请求后,我们的团队发现特定类型的分析日志的频率突然发生了变化。这种变化是突然且显著的——与我们历史上看到的相比下降了约 30%。
深入研究此问题的原因以及避免再次出现此问题的可用工具挽救了一天。因此,如果有什么的话,我希望了解这些挑战的细微之处能够帮助某人避免我们遇到的某些痛苦。快乐记录!
以上是当用户离开页面时,可靠地发送HTTP请求的详细内容。更多信息请关注PHP中文网其他相关文章!