有时候我们希望页面离开的时候收集一些信息发送到服务器,一般的做法是发送一个图片 beacon 请求,在 src 里带上希望传走的参数,类似这样:
var b = new Image();
b.src = 'http://s1.server.com/_.gif?q1=x1&q2=x2';
比起 Ajax 请求,这样做的好处不言而喻,我们不期望浏览器去处理 response,只要发出这个 http 请求就可以。
一些 chrome 浏览器的问题
不过在某些版本的 chrome 浏览器中,页面 unload 的时候会取消发送这些(至少是一部分) beacon 请求,可能是浏览器认为这些请求没有必要发出而做出的优化。从实际访问数据上来看,Chrome 中 80% 的 beacon 都会取消发送。这里的取消并不是说发出请求后 cancel 掉对 response 的接受或者直接中断 tcp 的连接,而是根本就没有发,因为前者至少在 accesslog 上会留下记录。
例如,用户点击会刷新当页的一个链接,我们希望在这个时候发送一些 beacon 请求,比如 ga 的数据和我们自己收集的数据,最后 server 端收到的数据量比实际值(或者跟比较准的统计值)少很多。
我拿 Mac 下自带的 apache2 和 Chrome 最新版本测试了一下,用了下面的简单的代码描述了一个极端的情况:
<a href="b.html">click me</a>
<script type="text/javascript">
var link = document.links[0];
link.addEventListener('click', function () {
var beaconSrc = 'a.gif?i=', i, bl = [];
// send 10 beacons in a row
for (i = 0; i < 10; i++) {
bl[i] = new Image();
bl[i].src = beaconSrc + i + '&rnd=' + Math.random();
}
});
</script>
这里打算发出 10 个 beacon 请求,看下 accesslog 或者走个代理,Chrome 一般会发走 2-4 个,有时甚至不发,打开 DevTools 可以看到所有的请求都是红色的,标记的状态是 cancel,但其中一部分是终止接收,另外一部分就是没发出。而看下 Firefox 或者 IE,全都发走了。
解决方案
延后页面 unload 的时间
看到栈爆网上有人提供这样的方法:
setTimeout(function () { window.localtion.href = link.href; }, 0)
其实我们之前也是用类似的方法解决部分有问题的链接,不过设置的是 200ms 的时延,推迟了页面 unload 的发生,所有 beacon 请求都发出。
但实际上这是一个下策,200ms 的时间已经是在用户的可感知范围了,为了一个小小的统计需求影响用户的体验真的是很不划算。
使用 localStorage 延后发送
另外一个方案就是将即将发送的 beacon 请求存入到 localStorage 中,在新载入的页面发送。为什么不放在 Cookie 中呢?Cookie 一般只有不到 5KB 的大小,咱就别再占用这点小地方了。用 localStorage 有这么几个比较合适的地方:
- 现在解决的问题是 Chrome 下发不走的问题,而 Chrome 老早就支持了,别的浏览器我都不用怎么管
- 5MB 大小的 size 够-用-了
- 可比 Cookie 好用多了
所以只要发的时候判断一下是否需要存到 localStorage 里就行了,不过在某些情况下 webkit 的隐私模式下访问 localStorage 可能会报错,(据说某些版本的 Chrome 的隐私模式下会安静地 fail 掉),所以简单检验能否使用的方法如下:
function checkLS() {
try {
var test = 'test';
localStorage.setItem(test, test);
return localStorage.getItem(test) === test;
}
catch (e) {
return false;
}
}
接下来在上面的函数中加上对 Chrome 的判断(或者直接对非 IE 的判断)就是使用 localStorage 延后发送 beacon 的条件了。可以用个字符串将各个 beacon 请求的 source 连接起来简单的存入到 localStorage,例如:
function beacon(src) {
if (!checkLS()) {
var b = new Image();
b.src = src;
return;
}
var beaconKey = 'b',
splitStr = 'CHROME_OR_NOT_IE',
beaconList = localStorage.getItem(beaconKey) || '';
localStorage.setItem(beaconKey, beaconList + splitStr + src);
}
// send when document loaded
function sendThemAll() {
if (!checkLS()) {
return;
}
var bl = [], i;
beaconKey = 'b',
splitStr = 'CHROME_OR_NOT_IE',
beaconStr = localStorage.getItem(beaconKey),
beaconList = beaconStr.split(splitStr);
beaconList.shift();
localStorage.setItem(beaconKey, '');
for(i = 0, j = beaconList.length; i < j; i++) {
bl[i] = new Image();
bl[i].src = beaconList[i];
}
}
当然,上面的代码比较简陋,但是也差不多够用了。
localStorage 的跨(子)域不能访问的问题
localStorage 在跨(子)域的情况下是不能互相访问的,也就是说如果用户会在一个网站的各个子域跳来跳去,每个子域下都可能会存和发 beacon 请求,并不是说这个页面存下了 src 下个页面就发走,而可能是下下个、下下下个或者很长时间才会发走,这样明显是不合理的,数据的实时性也不太好。
有其它的浏览器本地存储方案可以突破么,Session Storage, IndexedDB, Web SQL? 显然不行,因为它们其实干的都差不多是同一件事情,只不过是几个不同的标准和实现,安全上的限制肯定都会有。
既然存到多个子域的 localStorage 下不太好,那得想办法存到一个域下吧,比如 www 下。
使用 iframe 的 postMessage 跨域通信
走到这里我已经有点 “X-Y Problem” 的感觉了(怎么到了跨域通信了!),不过还好没偏得太远。。
postMessage 可以用来解决跨域通信的问题,用法简单干脆,iframe 的 contentWindow 去发一条信息,目标页面监听 message 事件,(当然判断一下消息的来源后)将收到的消息处理一下放入该页面所在域(比如 www)的 localStorage 中,发送所有 beacon 的话在页面 onload 的时候用上面的 sendThemAll
发下就行了。