当 sendBeacon 遇上 Blob

Heero.Luo发表于1年前,已被查看493次

2014 年,W3C 发布了信标(Beacon)的标准草案最终征求意见稿(目前已经是候选推荐草案)。该规范定义了一个异步非阻塞的数据上报接口,可以最大限度地减少对其他关键操作的资源占用,同时保证请求能正常发出。同年,该接口就被引入了 Firefox 和 Chrome,即 navigator.sendBeacon(下文简称为 sendBeacon)。

在实际开发工作中,该接口最常见的使用场景就是数据埋点。与其他埋点技术方案相比,sendBeacon 的优势在于:

  • 不会跟业务代码抢占资源,而是在浏览器空闲的时候再去发送;
  • 在页面卸载(关闭、刷新、跳转)时也能保证请求发送,同时不阻塞页面卸载。

第一个主角——sendBeacon

sendBeacon 的方法原型非常简单:

navigator.sendBeacon(url, data);

其中:

  • data 是将要发送的数据,可以是 ArrayBuffer、ArrayBufferView、Blob、FormData、URLSearchParams 或字符串。
  • URL 是 data 将要被发送到的网络地址。

当数据成功加入到传输队列时,返回值为 true,否则为 false。

一个简单的调用示例如下:

const data = new FormData();
data.append('id', '1');
data.append('type', 'test');
const result = navigator.sendBeacon(url, data);
console.log(result);

考虑到 sendBeacon 可能会存在加入队列失败的情况,以及浏览器兼容性问题,一般来说还需要加上降级支持。

let result;
if (typeof navigator.sendBeacon === 'function') {
  result = navigator.sendBeacon(url, data);
}
if (!result) {
  const xhr = new XMLHttpRequest();
  xhr.open('post', url);
  xhr.send(data);
}

除此以外,sendBeacon 还有以下注意要点:

  • 该方法的主要使用场景是将少量分析数据发送给服务器,以确保请求能够快速及时地完成,因此它会限制最大的负载体积。
  • 该方法总是以 HTTP POST 去发送请求,且无法设置自定义请求头或其他与请求、响应相关的属性。
  • 该方法没有提供获取响应结果的方式。
  • 调用该方法时必须以 navigator 作为上下文对象,否则会抛出 Illegal invocation 的异常。

第二个主角——Blob

如果需要通过 sendBeacon 发送 JSON 数据,可以这样调用:

navigator.sendBeacon(
  url,
  new Blob([JSON.stringify(data)], {
    type: 'application/json'
  })
);

发送这个请求时,浏览器会把 content-type 请求头设为 application/json。

Chrome 早期版本的安全问题

上述调用方式存在一个历史问题:Chrome 早期支持的 sendBeacon(Chrome 39~58)存在安全风险,跨域请求不会进行预检,相当于可以跨域提交任何数据。于是,从 Chrome 59 开始,对于跨域请求,浏览器不允许设置 content-type 请求头为 application/x-www-form-urlencoded、multipart/form-data 或者 text/plain 以外的值。一旦出现了这种情况,sendBeacon 就会抛出异常。

Chrome 59-80 的 sendBeacon 发送 JSON 数据时的异常

这个问题直到 Chrome 81 才被解决,此后由 sendBeacon 发送的跨域请求均遵循跨域安全策略,只要 content-type 的值不是上述的三个值之一,就要先进行预检,预检通过后才能发送数据。

考虑到很多浏览器或者 app 都是以 Chrome 为内核,并且版本多种多样,所以如果有发送 JSON 的需求,可以将内容以 text/plain 的类型发送,再由后端把文本解析为 JSON 数据。

navigator.sendBeacon(
  url,
  new Blob([JSON.stringify(data)], {
    type: 'text/plain' // 不指定 type 或者指定为空字符串也是不行的
  })
);

如果必须以 application/json 上报数据,那就需要做降级支持,当 sendBeacon 抛出异常时降级到 XMLHttpRequest。

let result;
const data = new Blob([JSON.stringify(data)], {
  type: 'application/json'
});
try {
  result = navigator.sendBeacon(url, data);
} catch (e) { }
if (!result) {
  const xhr = new XMLHttpRequest();
  xhr.open('post', url);
  xhr.send(data);
}

iOS 12 微信下阻塞同域名请求

如果通过 sendBeacon 发送 application/json 的 Blob,在 iOS 12.7(未测试 iOS 11 和 iOS 12 的其他版本)的微信下还有一个奇怪的问题。

iOS 12 微信下 sendBeacon 阻塞同域名请求

如上方截图所示,vConsole 显示 sendBeacon 的请求(api.php)已经发出,但后续几个同域名请求(get.php)都是 pending 状态,只有非同域请求(219878)能正常发出。如果用 Charles 或者 Fiddler 抓包,可以发现除 219878 外的所有请求其实都没有发出。微信社区也有类似问题的反馈

这种情况在同版本系统的 iOS Safari 下是不会出现的,所以也不知道是什么原因导致,把 Blob 的 type 改成 text/plain 之后也可以解决。

总结

综上所述,如果希望通过 sendBeacon 来上报埋点,还是得尽量使用 application/x-www-form-urlencoded、multipart/form-data 或 text/plain 作为 content-type,避免使用 application/json。此外,还必须考虑 sendBeacon 调用失败或入队失败时的保底方案。

评论 (0条)

发表评论

(必填)

(选填,不公开)

(选填,不公开)

(必填)