如何处理页面关闭时发送HTTP请求?

1,662 阅读7分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第26天,点击查看活动详情

在实际项目开发中,可能会遇到这样的业务问题:如何在用户离开或关闭页面时发送HTTP请求给服务端?可能有人会觉得页面都关闭了,还需要发送什么请求,完全没必要噻。但如果真有这样的业务需求落到自己的头上,那么我们应该如何来实现呢?

注:本文章基于Chrome 76,高版本的Chrome浏览器测试效果可能会有差异

关闭或离开页面

使用过Vue的会比较熟悉beforeDestoryonBeforeUnMounted这两个API,它们用来处理组件销毁前的事件。其实,在js中也有类似的方法:beforeunload

beforeunload会在浏览器关闭页面或刷新页面时触发,可以点击确定按钮关闭或刷新,也可以取消关闭或刷新,使用方法:

// 通过事件监听页面关闭或刷新
window.addEventListener('beforeunload', (event) => {
  // 阻止浏览器默认事件,也就是阻止关闭和刷新页面
  event.preventDefault();
  // chrome浏览器需要设置返回值
  event.returnValue = '';
});

实现效果:

image.png

如果是离开页面,比如说路由跳转,应该怎么处理呢?假设我们在页面上有一个链接,点击后会跳转到另一个页面:

<a href="https://baidu.com" id="link">点击跳转</a>
// js
document.getElementById("link").addEventListener('click', (e) => {
    e.preventDefault(); // 阻止浏览器默认事件,点击链接就不会发生跳转
    location.href = e.target.href; // 通过location的方式跳转页面
})

不管是关闭页面,还是跳转页面,都会面临一个同样的问题:页面变化前发送的HTTP请求,可能会被取消。

HTTP请求canceled?

js是单线程的,因此网络请求,包括fetch和XMLHttpRequest请求,被设计成是异步且非阻塞的。异步操作有一个好处,就是它不会占用主进程,但是这也会带来问题,如果主进程销毁了,例如页面关闭或者离开当前页面,那么原来异步进行的网络请求可能会被抛弃。直观的体现就是我们可以在network中看到请求已经canceled。举个栗子:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <a href="/other.html" id="link">离开页面</a>

    <script>
      document.getElementById("link").addEventListener("click", (e) => {
        fetch("http://localhost:8088/log", {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
          },
          body: JSON.stringify({
            data: "data",
          })
        });
      });
    </script>
  </body>
</html>

关于这个例子(下同)有几点需要说明:

  • 我们使用的是ES6新增的fetch()发送HTTP请求,而不是额外引入axios。fetch和axios在处理网络请求上有些差异,关于fetch的使用可以参考阮一峰老师的教程:Fetch API 教程
  • fetch()中传入的是请求后端接口地址,我们这里使用的是express框架搭建的简易后端,本次案例中未实现,详细的使用后面会有单独的文章介绍
  • 案例模拟的是离开页面的场景,关闭页面需要借助于beforeunloadunload,两者相差不大,掌握原理才是最重要的

执行过程(下同):

  • 打开控制台,并选择network,将网络状态选为slow 3g,这么做的目的是让我们能更清晰的看到执行过程
  • 点击“离开页面”,我们可以看到会有一个fetch请求处于pending状态
  • 然后页面跳转到other.html,可以发现刚才的log请求的状态变为了canceled,也就是被取消了

运行结果:

服务端监听:

image.png

network:

image-20220625212805843.png

可以看到,我们刚刚的log请求处于被取消的状态,如果是在实际业务场景中,那么就有可能导致我们的业务请求没有能发送到服务端。那么我们应该怎么解决呢?

如何解决这个问题

解决HTTP请求被canceled的问题,一般来说就是阻止关闭,保证HTTP请求发送成功后才关闭;或者在后台保持HTTP请求:

  • async/await
  • fetch + keepalive
  • navigator.sendBeacon()
  • ping

接下来就逐一讨论:

async/await

fetch()接口返回的是一个Promise()对象,因此我们可以等待fetch()接口完成后才执行页面跳转或则页面关闭,如果使用的是axios,这里也是一样的。使用async/await需要先阻止浏览器的默认事件: event.preventDefault(),等待请求完成后才执行对应的操作:

document.getElementById("link").addEventListener("click", (e) => {
    e.preventDefault(); // 阻止默认跳转行为
    fetch("http://localhost:8088/log", {
        method: "POST",
        headers: {
        "Content-Type": "application/json",
        },
        body: JSON.stringify({
            someData: "222",
        })
    }).then(() => {
        location.href = e.target.href; // 页面跳转
    });
});

或者使用async/await

document.getElementById("link").addEventListener("click", async (e) => {
    e.preventDefault(); // 阻止默认跳转行为
    await fetch("http://localhost:8088/log", {
        method: "POST",
        headers: {
        "Content-Type": "application/json",
        },
        body: JSON.stringify({
            someData: "222",
        })
    });// 同步
    location.href = e.target.href; // 页面跳转
});

运行结果:

我们可以明显的看到,虽然点击了链接,但是需要等到请求结束后才会执行跳转,也就是会有一个等待的过程。如果网络请求事件太长,这将会是一个很糟糕的体验。

image-20220625214948827.png

fetch + keepalive

keepalivefetch的一个属性,目的是告诉浏览器,即使页面卸载了,也要在后台保持连接,继续发送数据。用法也比较简单,直接传入true即可,默认是false:

document.getElementById("link").addEventListener("click", (e) => {
    fetch("http://localhost:8088/log", {
        method: "POST", // fetch支持GET、POST、PUT、DELETE的请求方法
        headers: {
        "Content-Type": "application/json",
        }, // 请求头
        body: JSON.stringify({
            some: "222",
        }), // 请求数据
        keepalive: true, // 保持在后台连接
    });
});

使用keepalive是简单且有效的,那如果我们想要追求更简单的方式呢

navigator.sendBeacon()

navigator.sendBeacon()方法可用于通过http post的方式将少量数据异步传输到服务器,它的实现原理和传统的XMLHttpRequest有所区别。使用方式一般有两种:

navigator.sendBeacon(url);
navigator.sendBeacon(url, data);

其中,data表示需要发送的Blob、FormData、ArrayBuffer等类型的数据。使用navigator.sendBeacon()无法自定义请求头部,但是我们可以借助于Blob对象来简单封装请求头和请求数据:

document.getElementById("link").addEventListener("click", (e) => {
    // 自定义请求头
    // const blob = new Blob([JSON.stringify({ some: "data" })], { type: 'application/json; charset=UTF-8' });
    // navigator.sendBeacon('http://localhost:8088/log', blob);
    // 直接发送
    navigator.sendBeacon('http://localhost:8088/log');
});

运行结果:

image.png

ping

这或许是最简单的解决方式了吧,ping包含一个以空格分割的url列表,传入的值为字符串,在超链接(a标签)中使用时,浏览器会在后台发送带有正文ping的POST请求。使用方式如下:

<a href="/other.html" id="link" ping="http://localhost:8088/log">离开页面</a>

直接使用在a标签上面即可,注意一定是有意义的a标签,这样写就不行了:

<a id="link" ping="http://localhost:8088/log">离开页面</a>

运行结果:

image.png

兼容性:

image-20220625223130672.png

对比四种方式的优缺点

目前能想到的就这四种,说了这么多,简单总结一下它们的优缺点,其实并没有谁对谁错,只是使用的场景不同。

async/await

  • 需要先阻止浏览器的默认事件,等到请求结束后再执行
  • 由于需要等待网络请求执行完成,因此会导致用户长时间得不到反馈
  • 如果业务场景不在乎等待时间,可以考虑

fetch + keepalive

  • fetch接口自带的属性,无需额外引入
  • 如果请求需要支持GET、POST、PUT、DELETE等,可以选择使用fetch
  • 兼容性较好,除了IE不支持

navigator.sendBeacon()

  • navigator.sendBeacon()只能是POST请求
  • 发送的数据量少,并且需要更加简洁的API
  • 该请求的优先级较低,不会与其他HTTP请求竞争资源
  • 兼容性较好,除了IE不支持

ping

  • 足够简单,仅依靠HTML就能完成,无需借助JavaScript
  • 不会阻塞页面后续行为,与navigator.sendBeacon()类似;并且支持跨域
  • 目前支持者a标签,其他元素设置ping属性是没有效果的
  • 只能是POST请求,不能发送GET请求
  • 无法自定义请求数据
  • 兼容性很好,除了IE不支持,FireFox默认未启用,需要再FireFox设置中开启

总结

本文总结了前端处理页面关闭时如何保证HTTP请求能顺利发送出去,总的来说有四种方式。每一种方式都有优缺点,需要读者自己权衡如何在实际业务场景种使用。比较推荐的是fetch + keepalivenavigator.sendBeacon()。复现文章中的代码,需要先检查一下浏览器版本以及是否清空了缓存。

转载请注明出处