在很多情况下,我需要发送一个带有数据的 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()
),但在该行为发生之前,会在 click
事件上触发一个 POST
请求。不需要等待任何响应。**我只希望它被发送**到我正在访问的任何服务。
乍一看,您可能会认为该请求的调度是同步的,之后我们会继续从页面导航到其他页面,同时其他服务器成功处理该请求。但事实证明,情况并非总是如此。
浏览器不保证保留打开的 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 请求(通过 fetch
或 XMLHttpRequest
)是异步的,且不会阻塞。只要请求排队,请求的实际工作就会被转交给后台的浏览器级 API。
就性能而言,这是好事——您不希望请求占用主线程。但这同时也意味着,当页面进入“已终止”状态时,它们有被放弃的风险,无法保证后台的任何工作能够完成。以下是 Google 对该特定生命周期状态的总结
页面在开始卸载并从浏览器内存中清除后,就会处于已终止状态。在此状态下,无法启动任何新任务,如果正在进行的任务运行时间过长,可能会被终止。
简而言之,浏览器的设计假设是,当页面被关闭时,无需继续处理页面排队的任何后台进程。
那么,我们有哪些选择?
也许最明显的避免此问题的办法是,尽可能地延迟用户操作,直到请求返回响应。过去,这已经通过使用 XMLHttpRequest
中支持的同步标志来错误地实现。但使用它会完全阻塞主线程,导致大量性能问题——我之前已经写过关于其中一些内容——因此,不应该考虑这个想法。事实上,它即将从平台中移除(Chrome v80+ 已经将其移除)。
相反,如果您要采用这种方法,最好等到 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
请求的选项,而且无需影响用户体验。
keepalive
标志
使用 Fetch 的 如果使用 fetch()
时,将keepalive
标志设置为 true
,即使发起该请求的页面被终止,相应的请求也会保持打开状态。使用我们最初的示例,这将使实现看起来像这样
<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()
使用 Navigator.sendBeacon()
函数专门用于发送单向请求(信标)。一个基本的实现看起来像这样,使用字符串化的 JSON 和“text/plain” Content-Type
发送一个 POST
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()
同时使用时,在“网络”选项卡中显示的内容

默认情况下,fetch()
具有“高”优先级,而信标(在上面标注为“ping”类型)具有“最低”优先级。对于对页面功能并不重要的请求来说,这是一个好事。直接摘自信标规范
此规范定义了一个接口,该接口[…]最大限度地减少了与其他时间关键操作的资源竞争,同时确保这些请求仍能得到处理并传递到目标。
换句话说,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),并且已经加载了
fetch
的 polyfill。
sendBeacon()
可能是更好的选择
但是,如果你需要以下功能,- 你正在执行简单的服务请求,不需要太多自定义。
- 你更喜欢更简洁、更优雅的 API。
- 你想要保证你的请求不会与应用程序中发送的其他高优先级请求竞争。
避免重蹈我的覆辙
我之所以深入研究浏览器在页面终止时如何处理进程内请求,是有原因的。前段时间,我的团队发现,在我们开始在表单提交时就触发请求后,特定类型的分析日志的频率突然发生了变化。这种变化是突然的,而且很显著——与我们以往观察到的数据相比下降了约 30%。
深入研究了这个问题产生的原因以及可用的避免再次出现这种情况的工具,终于解决了问题。所以,如果说有什么值得借鉴的话,我希望了解这些挑战的细微差别能帮助其他人避免我们遇到的麻烦。祝大家日志记录愉快!
使用 Service Worker 的 fetch 和
background sync API 在后台独立于页面状态运行请求。
事实上,这甚至可以保证请求被发送,尽管需要 HTTPS。
Service Worker 很适合这种情况。特别是因为它可以访问所有 HTTP 请求,使其能够独立于 UI 代码记录它们。
请注意,sendBeacon 在大约 96% 的浏览器上可用,而 fetch 带 keepalive 截至今天仅在约 80% 的浏览器上可用。
很棒的文章!
你能用 Beacon API 传递身份验证头吗?
不过,上次我尝试的时候,Beacon 在移动设备上不起作用。
InstantPage Js 可以帮助解决速度问题。
https://instant.page/
我在客户端使用 Axios 作为我的 HTTP 请求库,我需要发送自定义身份验证头。看来这将是一个比较难的实现
如果 fetch 用于低优先级请求,则可以相应地设置重要性
wait fetch("/log", {importance: "low", keepalive: true, ...});
{importance: "low"}
在 fetch 初始化中需要 Chrome 101。https://webdev.ac.cn/priority-hints/
感谢您撰写这篇文章。我刚和一些同事讨论过这个问题。这解决了我在用户离开页面后请求被取消的担心。
我想要评论一下,关于使用 fetch 带 keepalive 的这部分
* 你正在支持较旧的浏览器(如 IE),并且已经加载了 fetch 的 polyfill。
是这样的吗?拥有 fetch polyfill 的旧浏览器将无法保持请求活动,因为 polyfill 并非神奇的浏览器代码,它只是现有功能的 JavaScript 包装器,因此它违背了目的。
这些方法中有哪一种涵盖了关闭浏览器的用例?据我所知,sendBeacon 不能在页面卸载处理程序中使用……
不,sendBeacon() 正是用来捕获页面最后一刻的分析数据的。它很可能可以在 unload() 中使用。如果不行,请使用 beforeunload();这样可以。我在 timeonsite.js 库中测试了 Beacon API,发现它可以工作;它完全依赖 sendBeacon() 进行实时数据捕获。它似乎是一个游戏规则的改变者。
我很好奇被取消的请求是否到达。
它是否只取决于互联网状态?
例如,如果没有瓶颈,它就可以到达。
如果存在瓶颈,它就无法到达,因为 TCP 断开了连接
目前正在开发一个保证即使渲染器即将消失也能传递的信标提案
https://github.com/darrenw/docs/blob/main/explainers/beacon_api.md
您尝试过打开 WebSocket 吗?WebSocket 是持久连接。双方都可以检测到对方何时断开连接。您可以使用它来了解页面是否已更改或选项卡是否已关闭。
为什么没有提到 unload/beforeunload?
我认为值得注意的是,fetch polyfill 可能使用同步 XHR 请求,而不是支持 keepalive。
很好奇有哪些 polyfill 是推荐的,以及是否有任何 polyfill 在回退到同步 XHR 之前,会先进行 keepalive 和 send beacon 的功能检测。
Alex,fetch 的 keepAlive 标志与 Connection: “keep-alive” 头 (https://www.imperva.com/learn/performance/http-keep-alive/) 之间有什么关系吗?
我认为这就是为什么现代分析跟踪器如 timeonsite.js 完全依赖于 **sendBeacon()** 而不是使用“sync”标志的 XMLHttpRequest 或使用“keepAlive”标志的 Fetch() API。它似乎在浏览器中的所有类型的 **unload()** 事件中都表现良好且高度稳定。 https://saleemkce.github.io/timeonsite/docs/index.html#real-time-example
这里有两个问题:
如果链接指向您自己的网站,那么该服务绝对可以收到请求。为什么您要发送单独的请求,而不是直接通过该请求进行记录/分析?
如果链接是一个外部网站,现在许多网站会先跳转到自己网站上的一个中间页面,然后再跳转,这样他们自己的网站也可以被记录。为什么您要发送单独的请求?
很棒。感谢您的分享!
fetch keepalive 和 navigator.sendBeacon 现在都是我工具箱中很棒的补充。
为什么没有提到 Firefox 不支持
keepalive
?这似乎是一个在某些情况下使用sendBeacon
的很好的理由 :-)查看 https://bugzilla.mozilla.org/show_bug.cgi?id=1342484
谢谢,信标工作得很好