有时候我们希望页面离开的时候收集一些信息发送到服务器,一般的做法是发送一个图片 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 发下就行了。