在用户离开页面时可靠地发送 HTTP 请求

Avatar of Alex MacArthur
Alex MacArthur

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

在很多情况下,我需要发送一个带有数据的 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”连接速度。页面加载后,我清空了日志,一切看起来都很平静

Viewing HTTP request in the network tab

但只要单击链接,事情就会变得糟糕。当导航发生时,请求会被取消。

Viewing HTTP request fail in the network tab

这让我们几乎无法相信外部服务实际上已经成功处理了请求。为了验证这种行为,当我们使用 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 请求(通过 fetchXMLHttpRequest)是异步的,且不会阻塞。只要请求排队,请求的实际工作就会被转交给后台的浏览器级 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 请求的选项,而且无需影响用户体验。

使用 Fetch 的 keepalive 标志

如果使用 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>

当单击该链接并进行页面导航时,不会发生请求取消

Viewing HTTP request succeed in the network tab

相反,我们得到的是 (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() 使用 keepalivesendBeacon() 同时使用时,在“网络”选项卡中显示的内容

Viewing HTTP request in the network tab

默认情况下,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
},

从技术上讲,它类似于发送信标,但有一些显著的限制

  1. 它严格限制在链接上使用,如果你需要跟踪与其他交互(如按钮点击或表单提交)相关的數據,它就无用武之地。
  2. 浏览器支持良好,但不理想 在撰写本文时,Firefox 特别地默认没有启用它。
  3. 你无法与请求一起发送任何自定义数据。 如前所述,你最多只能获得几个 ping-* 头,以及其他一起发送的任何头。

综上所述,如果你只想发送简单的请求并且不想编写任何自定义 JavaScript 代码,那么 ping 是一个不错的工具。但如果你需要发送更多实质性的内容,它可能不是最好的选择。

那么,我应该选择哪一个呢?

使用 fetchkeepalivesendBeacon() 发送最后一刻的请求肯定会有权衡取舍。为了帮助你判断在不同情况下最合适的方案,这里有一些需要考虑的因素

如果你需要以下功能,可以考虑使用 fetch() + keepalive

  • 你需要轻松地将自定义头与请求一起传递。
  • 你想要向服务发送 GET 请求,而不是 POST 请求。
  • 你正在支持较旧的浏览器(如 IE),并且已经加载了 fetch 的 polyfill。

但是,如果你需要以下功能,sendBeacon() 可能是更好的选择

  • 你正在执行简单的服务请求,不需要太多自定义。
  • 你更喜欢更简洁、更优雅的 API。
  • 你想要保证你的请求不会与应用程序中发送的其他高优先级请求竞争。

避免重蹈我的覆辙

我之所以深入研究浏览器在页面终止时如何处理进程内请求,是有原因的。前段时间,我的团队发现,在我们开始在表单提交时就触发请求后,特定类型的分析日志的频率突然发生了变化。这种变化是突然的,而且很显著——与我们以往观察到的数据相比下降了约 30%。

深入研究了这个问题产生的原因以及可用的避免再次出现这种情况的工具,终于解决了问题。所以,如果说有什么值得借鉴的话,我希望了解这些挑战的细微差别能帮助其他人避免我们遇到的麻烦。祝大家日志记录愉快!