如何在用户离开页面时可靠地发送 HTTP 请求
有几次,我需要发送一个请求,其中包含一些数据,以便在用户执行导航到其他页面或提交表单之类的操作时进行记录。请考虑以下人为示例:单击链接时向外部服务发送一些信息:HTTP
<a href="/some-other-page" id="link">Go to Page</a>
<script>
document.getElementById('link').addEventListener('click', (e) => {
fetch("/log", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
some: "data"
})
});
});
</script>
这里没有什么非常复杂的。允许链接像往常一样运行(我没有使用),但在该行为发生之前,会在 上触发请求。无需等待任何类型的响应。我只想把它发送到我正在点击的任何服务。e.preventDefault()``POST``click
乍一看,您可能希望该请求的调度是同步的,之后我们将继续导航离开页面,而其他服务器成功处理该请求。但事实证明,这并不总是发生的事情。
浏览器不保证保留打开的 HTTP 请求
当发生终止浏览器中页面的情况时,无法保证进程内请求会成功(请参阅有关页面生命周期的“已终止”和其他状态的详细信息)。这些请求的可靠性可能取决于几个因素 — 网络连接、应用程序性能,甚至外部服务本身的配置。HTTP
因此,在这些时刻发送数据可能根本不可靠,如果您依靠这些日志来做出对数据敏感的业务决策,这将带来潜在的重大问题。
为了帮助说明这种不可靠性,我使用上面包含的代码设置了一个带有页面的小型 Express 应用程序。单击链接时,浏览器将导航到 ,但在此之前,将触发请求。/other``POST
当一切都发生时,我打开了浏览器的“网络”选项卡,并且我正在使用“慢速3G”连接速度。一旦页面加载并且我清除了日志,事情看起来非常安静:
但是一旦点击链接,事情就会出错。发生导航时,请求将被取消。
这使我们对外部服务实际上能够处理请求几乎没有信心。只是为了验证此行为,当我们以编程方式使用以下方式导航时,也会发生这种情况:window.location
document.getElementById('link').addEventListener('click', (e) => {
+ e.preventDefault();
// Request is queued, but cancelled as soon as navigation occurs.
fetch("/log", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
some: 'data'
}),
});
+ window.location = e.target.href;
});
无论导航如何或何时发生并且活动页面被终止,这些未完成的请求都有被放弃的风险。
但是为什么它们被取消了呢?
问题的根源在于,默认情况下,XHR 请求(通过 或 ) 是异步且非阻塞的。一旦请求排队,请求的实际工作就会在后台传递给浏览器级 API。fetch``XMLHttpRequest
因为它与性能有关,所以这很好 - 你不希望请求占用主线程。但这也意味着当页面进入“终止”状态时,它们存在被遗弃的风险,无法保证任何幕后工作都能完成。以下是 Google 如何总结该特定生命周期状态:
一旦浏览器开始卸载页面并从内存中清除页面,该页面将处于终止状态。在此状态下,任何新任务都无法启动,如果正在进行的任务运行时间过长,则可能会终止这些任务。
简而言之,浏览器的设计假设是,当页面被关闭时,无需继续处理由它排队的任何后台进程。
那么,我们有哪些选择呢?
也许避免此问题的最明显方法是尽可能延迟用户操作,直到请求返回响应。过去,通过使用 中支持的同步标志,以错误的方式完成了此操作。但是使用它完全阻塞了主线程,导致许多性能问题 - 我过去写过一些关于这个问题的文章 - 所以这个想法甚至不应该被考虑。事实上,它正在退出平台(Chrome v80 +已经删除了它)。XMLHttpRequest
相反,如果您要采用这种类型的方法,最好等待 在返回响应时解析 。恢复后,您可以安全地执行该行为。使用我们之前的代码段,它可能看起来像这样:Promise
document.getElementById('link').addEventListener('click', async (e) => {
e.preventDefault();
// Wait for response to come back...
await fetch("/log", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
some: 'data'
}),
});
// ...and THEN navigate away.
window.location = e.target.href;
});
这样可以完成工作,但也有一些不平凡的缺点。
首先,它通过延迟所需行为的发生来损害用户体验。收集分析数据当然有利于业务(并希望是未来的用户),但让你的现有用户支付成本来实现这些好处并不理想。更不用说,作为外部依赖项,服务本身中的任何延迟或其他性能问题都将呈现给用户。如果分析服务的超时导致客户完成高价值操作,那么每个人都会失败。
其次,这种方法并不像最初听起来那么可靠,因为某些终止行为不能以编程方式延迟。例如,在延迟某人关闭浏览器选项卡方面是无用的。因此,充其量,它将涵盖为某些用户操作收集数据,但不足以全面信任它。e.preventDefault()
指示浏览器保留未完成的请求
值得庆幸的是,有一些选项可以保留绝大多数浏览器中内置的未完成请求,并且不需要牺牲用户体验。HTTP
使用 Fetch 的标志keepalive
如果在使用 时将 keepalive
标志设置为 ,则相应的请求将保持打开状态,即使启动该请求的页面已终止也是如此。使用我们最初的示例,这将创建一个如下所示的实现:true``fetch()
<a href="/some-other-page" id="link">Go to Page</a>
<script>
document.getElementById('link').addEventListener('click', (e) => {
fetch("/log", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
some: "data"
}),
keepalive: true
});
});
</script>
单击该链接并进行页面导航时,不会发生请求取消:
相反,我们只剩下一个状态,仅仅是因为活动页面从未等待过接收任何类型的响应。(unknown)
像这样的单行代码很容易解决,特别是当它是常用浏览器API的一部分时。但是,如果您正在寻找一种具有更简单界面的更集中的选项,那么还有另一种具有几乎相同浏览器支持的方法。
用Navigator.sendBeacon()
该函数专门用于发送单向请求(信标)。一个基本实现看起来像这样,发送一个带有字符串化的JSON和一个“文本/纯文本”:Navigator.sendBeacon()``POST``Content-Type
navigator.sendBeacon('/log', JSON.stringify({
some: "data"
}));
但此 API 不允许您发送自定义标头。因此,为了让我们将数据作为“application/json”发送,我们需要做一个小的调整并使用:Blob
<a href="/some-other-page" id="link">Go to Page</a>
<script>
document.getElementById('link').addEventListener('click', (e) => {
const blob = new Blob([JSON.stringify({ some: "data" })], { type: 'application/json; charset=UTF-8' });
navigator.sendBeacon('/log', blob));
});
</script>
最后,我们得到相同的结果 - 即使在页面导航后也允许完成的请求。但是还有更多的事情正在发生,可能会给它带来优势:信标以低优先级发送。fetch()
为了进行演示,下面是“网络”选项卡中同时使用和同时使用时显示的内容:fetch()``keepalive``sendBeacon()
默认情况下,获取“高”优先级,而信标(在上面注明为“ping”类型)具有“最低”优先级。对于对页面功能不重要的请求,这是一件好事。直接取自信标规范:fetch()
此规范定义了一个接口,该接口 [...] 最大限度地减少了与其他时间关键型操作的资源争用,同时确保此类请求仍被处理并传递到目标。
换句话说,确保其请求远离那些对您的应用程序和用户体验真正重要的请求。sendBeacon()
该属性的荣誉奖ping
值得一提的是,越来越多的浏览器支持ping
属性。当附加到链接时,它会发出一个小请求:POST
<a href="http://localhost:3000/other" ping="http://localhost:3000/log">
Go to Other Page
</a>
这些请求标头将包含单击链接的页面(),以及该链接的值():ping-from``href``ping-to
headers: {
'ping-from': 'http://localhost:3000/',
'ping-to': 'http://localhost:3000/other'
'content-type': 'text/ping'
// ...other headers
},
它在技术上类似于发送信标,但有一些明显的限制:
- 它被严格限制在链接上使用,如果您需要跟踪与其他交互(如按钮单击或表单提交)关联的数据,则它成为非入门级。
- 浏览器支持很好,但不是很好。在撰写本文时,Firefox 特别没有默认启用它。
- 您无法随请求一起发送任何自定义数据。如前所述,您将获得的最多是几个标题,以及任何其他标题。
ping-*
考虑到所有因素,如果你可以发送简单的请求并且不想编写任何自定义JavaScript,那么这是一个很好的工具。但是,如果您需要发送更多内容,则可能不是最好的选择。ping
那么,我应该去哪一个呢?
使用或发送最后一秒请求肯定存在权衡。为了帮助辨别哪种情况最适合不同的情况,需要考虑以下事项:fetch``keepalive``sendBeacon()
如果出现以下情况,您可以使用 + :fetch()``keepalive
- 您需要轻松地将自定义标头与请求一起传递。
- 您希望向服务(而不是 .
GET``POST
- 您正在支持较旧的浏览器(如IE),并且已经加载了polyfill。
fetch
但如果出现以下情况,则可能是更好的选择:sendBeacon()
- 您正在发出不需要太多自定义的简单服务请求。
- 您更喜欢更干净、更优雅的 API。
- 您希望保证您的请求不会与应用程序中发送的其他高优先级请求竞争。
避免重复我的错误
我选择深入研究浏览器在页面终止时如何处理进程内请求的本质是有原因的。不久前,我的团队看到特定类型的分析日志的频率突然发生变化,因为我们在提交表单时开始触发请求。这种变化是突然而重大的 - 比我们历史上看到的下降了约30%。
深入研究这个问题出现的原因,以及可用于避免再次出现这个问题的工具,挽救了这一天。所以,如果有的话,我希望了解这些挑战的细微差别可以帮助人们避免我们遇到的一些痛苦。祝您登录愉快!
from:https://css-tricks.com/send-an-http-request-on-page-exit/
文章来源:刘俊涛的博客 欢迎关注公众号、留言、评论,一起学习。
若有帮助到您,欢迎捐赠支持,您的支持是对我坚持最好的肯定(_)