前言
最近偶然从阮大博客看到这篇文章感觉很不错,于是想着翻译下来给大家做个分享,本文采用意译而非直译(包括标题),希望给大家带来收获。
正文
在某些场景中,我有一个需求:在用户执行某些如导航到另外一个页面或者提交表单等操作时,我需要发送一个带有数据的 HTTP 请求来进行记录。接下来我们看一个点击链接向外部服务发送请求的 demo:
<a href="/other" id="link">Go to Other 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 请求。我只是希望它能正常发送这个请求而无需任何形式的响应。
乍一看,也许你希望这个 POST 请求是同步的:在请求发送后导航离开该页面,服务端成功处理请求。但事与愿违,情况并不总这样。
浏览器并不能保证 Http 请求正常发送
当在浏览器中触发了某些行为导致当前页面关闭时,正在处理中的 HTTP 请求并不能被保证成功发送或处理( 关于浏览器页面生命周期的介绍,可以看这个谷歌文档)。这些请求的可靠性与网络连接、应用性能甚至和外部服务的配置有关系。
因此,在离开页面的时候发送请求并不靠谱,而你依赖这些请求的日志数据来做某些业务决策时将会给你带来很大的潜在问题。
为了论证这种不可靠性,我使用 Express 创建一个小型服务,并使用上面的代码来发起请求。单击链接时,浏览器在导航到 /other
之前,会触发 POST 请求。下面我打开浏览器的控制面板中的 Network
,并选择 Slow 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(网络线程)。由于性能原因,关于网络处理是不会占用 JS 主线程的,但是这意味着当页面进入 “终止” 状态时,它们有被销毁的风险,并不能保证放入队列里的任务能够完成。以下是谷歌对页面生命周期的总结 :
一旦页面开始卸载并且内存被浏览器释放时,页面就处于 “终止” 状态,在这种状态下是不会创建新任务的,而且正在进行的任务取决于任务的运行时间,如果太长就会被 kill 掉。
简而言之,浏览器这样设计的目的在于当一个页面销毁时就没必要继续处理后台的任何任务进程了。
所以,该如何解决这个问题呢?
或许避免这种问题最明显的方式就是在请求响应成功前尽可能延迟用户操作。在以前,这是通过使用 XMLHttpRequest 中支持的同步标志以错误的方式完成的。但使用同步标记会完全阻塞主线程执行,导致很多性能问题(我之前有写过相关的文章),所以这种想法不能被接受,事实上,它正在退出历史舞台(Chrome v80+ 已经将其删除)。相反,如果你要采用这种方法,最好就是等待 Promise 状态变化来解决该问题。在 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
})
这么做虽然可以完成需求,但仍然有缺点。
首先,它以牺牲用户体验为前提来延迟所需行为的发生。 收集分析型数据确实有助于业务(还有潜在的用户),但是让现有的用户付出这个代价并不现实。更何况,后端服务作为外部依赖,它本身的任何延迟或其他性能问题都会暴露给用户。如果后端分析服务API超时导致一个用户完成高花销的操作,那所有用户都会流失。
其次,这种方法并不像刚开始听的那样可靠,因为某些“终止”行为不能以编程方式延迟。例如, e.preventDefault()
无法延迟用户关闭浏览器选项卡。因此,它充其量只能涵盖为某些用户操作收集数据,但不足以全面信任它。
如何让浏览器保留未完成的请求
值得庆幸的是,有一些选项可以保留绝大多数浏览器中内置的未完成的 HTTP 请求,并且不需要损害用户体验。
使用 fetch API 中的 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>
当链接被点击的时候,页面导航行为触发,而且没有任何请求被取消:
相反,我们处于(未知)状态,仅仅是因为活动页面(注:当前跳转后的页面)从未等待接收任何类型的响应。
一行代码就能解决的问题,而且还是我们常见的浏览器 API 某个配置。但是,如果你要找一个更简单更几种的接口,那么还有一个几乎被所有主流浏览器支持的方法。
使用 Navigator.sendBeacon()
Navigator.sendBeacon()
是专门用来发送单个请求的,比如下面是一个 POST 请求,它发送 Content-Type
为 text/plain
的JSON 字符串:
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()
更有优势的地方在于 Beacon
请求优先级比较低。
为了演示,Network 选项卡中显示的内容是同时使用带有 keepalive
的 fetch()
和 sendBeacon()
的请求:
默认情况下,fetch
有一个较高的优先级,而 beacon
则有一个最低的优先级(注意上面 Type 为 ping 的请求),对于对页面功能不重要的请求来说,这是一件好事。下面是 Beacon 的 规范:
该规范定义了一个接口,最大限度地减少与其他时间关键操作的资源争用,同时确保此类请求仍被处理并交付到目的地。
换一种方式说,sendBeacon()
会确保它的请求不会给用户体验带来影响。
使用 ping
作为 HTML 属性
值得一提,现在已经有大量浏览器支持了 ping 属性。
当把这个属性加到 a
标签上时会额外发送一个 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
},
在技术上和信标很蕾西,但也有一些值得注意的限制:
- 在链接(a 标签)上使用的时候有严格限制,如果你需要跟踪和其他交互有关的数据,比如按钮单击或表单提交,这种方式不能作为首选考虑。
- 浏览器支持一般。在写作这篇文章的时候,火狐默认还不支持。
- 不能发送任何自定义数据,最多就是如前所述的几个
ping-*
请求头,或者其他请求头。
综合考虑,如果你想发送简单的请求并且不想编写任何 JavaScript 脚本,那么 ping
是一个很好的工具。但是,如果您需要发送更多实质内容,则可能不是最好的选择。
所以,哪种方式值得选择呢?
不管使用 fetch
中的 keepalive
还是 sendBeacon()
发送最后一秒请求,我们需要根据不同的场景做一个权衡。为了辨别什么情况下使用前者或后者,以下列了一些需要考虑的点:
keepalive 配置使用场景
- 你需要简单发送一些自定义头
- 你想要发送 GET 请求而不是 POST
- 你需要支持老浏览器(IE)并且已经添加了 fetch 的 polyfill
sendBeacon()
使用场景
- 发送一些不需要自定义的请求头
- 更喜欢简单整洁且优雅的 API
- 想要发送的请求优先级比其他请求优先级低
避免我的错误
我选择深入研究浏览器在页面终止时如何处理进行中的请求是有原因的。不久前,我的团队在提交表单时并发送请求后,发现了特定类型分析日志的频率突然发生变化。这种变化很突然而且很明显比以往相比下降了约 30%。深入研究出现此问题的原因,并且得出再次避免该问题的方法,才挽救了局面。所以,希望我的这些经历能够帮助到一些人。
总结
其实这篇文章主要的业务点在于前端监控方向,比如数据上报,用户行为分析等等,而我们在一些 C 端产品中经常需要埋点来分析和统计用户数据,来辅助决策业务,帮助运营来分析数据。另外就是错误类别的监控了,比如页面崩溃时如何收集数据。文章提到的两个 API 其实就是用于解决在当前页面销毁时无法可靠地发送请求的问题,通过一些实践得出两个 API 的优缺点以及适用场景。从原文评论来看其实还有很多值得思考的地方:
- 可以考虑 Web Workder/Service Worker
- fetch 中 keepalive 配置 与 Connection 的 kee-alive 有什么关联?
- 为什么没有提到 unload/beforeunload 事件?
- websocket 的心跳探测机制是可以知道页面的状态的
- 其实 fetch 也是可以配置请求优先级的,比如
fetch("/log", {importance: "low", keepalive: true, ...})
(Chrome 101 才支持)
大家可以一起思考一下...
关于写作的思考
读完这篇文章后,我感觉写得十分好,惊叹于这种层层递进的感觉。原文作者先从一个小场景来引出问题,然后再给出解决方案,去对比各种解决方案的优缺点,给出每种解决方案的使用场景是什么,最后给出自己在工作中遇到的问题,从而让读者加深印象。通篇看下来我觉得这种写作方式是很值得学习的,在这过程中使用代码和图片辅助理解能够让读者感到不枯燥,层层递进也容易阅读,而且都是基于实际业务中写的一篇好文章。
从以往看过的文章来看,要么就是面试题这类材文章居多要么就是一些晦涩难懂的文章,当然这也给了我很大的启发,做技术也需要用心分享,而一个好的分享能够给自己带来很大的收益,别人也能有赏心悦目的感觉。