[译] 当用户离开页面时, 可靠得发送 HTTP 请求

原文 Reliably Send an HTTP Request as a User Leaves a Page

有些时候, 我需要在用户在导航到不同的页面或者提交表单的时候发送一些 HTTP 请求进行记录.
例如下面代码所示, 在点击a标签的同时发送一些请求

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<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请求. 这个请求的响应也无需被处理. 只是希望这个请求能被正常的发送.

乍一看, 你可能会认为请求的发送和页面的导航将会同时发生. 然后请求将会被服务端正确处理. 但事实证明, 并不总是如此.

浏览器并不保证保留打开的 HTTP 请求

当浏览器中的某个页面发生’终止’时, 浏览器无法保证正在进行中的请求将会成功发送( 关于’终止’和页面生命周期的相关内容)
这些请求的可靠性取决于这几件事: 网络连接情况, 应用性能, 接受请求的服务端配置

因此,在这些时刻发送数据并不可靠, 如果你依赖这些日志来做出数据铭感的业务决策, 这将带来潜在的重大问题.

为了帮助说明以上的不可靠, 我编写了一个满足上述的小型项目. 当点击链接时, 浏览器将会导航至其他页面. 但在进行导航之前将会发送一个POST请求

当所有都准备好时, 我打开了调试界面, 并且使用了 “Slow 3G” 对网络的速度进行限制:

当点击链接时, 可以看到请求发起了. 但由于导航开始, 请求被取消了

即使我们使用 window.location 以编程的方式导航时, 也会发生这种情况.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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 中支持的同步标志 synchronous flag, 但是这将阻塞主线程, 导致一些性能问题所以这种想法被渐渐抛弃(Chrome v80+ 已经将其删除)

如果你执意想要使用这种方法进行阻塞, 可以使用 Promise 进行处理, 就如下所示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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;
});

这可以很好的完成工作, 但是也有着不小(non-trivial)的缺点:

首先, 它会延迟用户的所需操作, 损害用户体验. 收集分析数据肯定有利于企业(并且以此获取更多的潜在用户), 但这样的操作将会使现有用户失去体验. 分析服务阻塞了用户高价值的操作, 那么将会丢失所有用户.

其次, 这种方式并不像最初听起来那样可靠, 因为某些终止行为不能以编程方式延迟. 例如, e.preventDefault() 无法阻止用户关闭选项卡. 因此只能收集部分用户的操作数据.

指挥浏览器保留未完成的请求

幸运的是, 有一些选项可以保留绝大多数浏览器内置的未完成的 HTTP 请求, 并不需要损害用户体验.

使用 keepalive 标识

如果在使用fetch时将 keepalive flag 标识设置为 true , 相应的请求将持续存在. 即使发起该请求的页面被终止. 使用之前的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<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>

当点击链接, 与之前一样页面进行导航, 但是没有请求被取消

因此, 请求将处于未知(Unknow)的状态, 因为活动页面并不会等待之前的请求结果.

仅需一行很容易就解决了, 而且是常用 API 所支持的. 但如果你再寻找其他方式进行解决的话, 那么还有一种常用的方式被多种浏览器支持.

使用 Navigator.sendBeacon()

Navigator.sendBeacon() 方法是专门用于发送单向请求(信号?(beacons)), 基础的使用方式如下:

1
2
3
navigator.sendBeacon('/log', JSON.stringify({
some: "data"
}));

但是这个 API 不允许发送自定义 header . 所以为了能以 “application/json” 的方式发送数据, 需要做一点小调整, 并使用 Blob:

1
2
3
4
5
6
7
8
<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 的优先级展示不同, 并且在同时发起.

默认情况下, fetch()High 优先级, 而发起的 beacon 请求的优先级为低级, 选取 beacon 的一段描述:

该规范定义了一个接口, […]最大限度减少与其他关键资源的资源争用, 同时确保此类请求仍被处理并交付到目的地

换句话说, sendBeacon() 确保不会影响用户体验, 以及真正重要的请求.

有关 ping 属性

值得一提的是,越来越多的浏览器支持 ping 属性。当附加到a标签链接时,它会触发一个小的 POST 请求:

1
2
3
<a href="http://localhost:3000/other" ping="http://localhost:3000/log">
Go to Other Page
</a>

这些请求的请求头将会包含点击链接的页面(ping-from), 以及指向的链接(ping-to):
1
2
3
4
5
6
headers: {
'ping-from': 'http://localhost:3000/',
'ping-to': 'http://localhost:3000/other'
'content-type': 'text/ping'
// ...other headers
},

在技术上类似于发送 beacon 信息, 但是有着很明显的限制:

  1. 仅能使用在链接上, 如果是使用表单或者按钮进行交互, 将会丢失这部分交互数据.
  2. 并不是所有的浏览器都支持, firefox 目前还未进行支持.
  3. 无法随请求一起发送任何自定义数据. 就如前面所描述的, 最多获取几个ping-*的请求头, 以及一些自带的头.

如果只是想要简单得追踪且并不想写Javascript, 那么ping是一个很好的工具. 但如果想要更近一步, 可能并不是一个好的选择.

所以我该选择哪一个?

使用 fetch() + keepalive 的组合

  • 你需要简单的传输自定义头内容.
  • 你需要发起除了POST之外的请求
  • 你需要支持较老的浏览器(IE)

也许 sendBeacon() 会是更好的选择

  • 你需要发出不需要太多自定义的简单服务请求
  • 你喜欢更干净, 更加优雅的API
  • 你希望保证正常业务请求的优先级

文章原文地址

原文地址

Author: Sean
Link: https://blog.whileaway.io/posts/8c0dca36/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.