MZhou's blog

GA源代码里的小技巧之Beacon请求

作者前段时间在做类似Google Analytics(以下简称GA)的第三方监控脚本。所以对GA的前端代码做过调研,对GA的压缩后代码做了一定程度上的人肉美化。这里美化的是analytics.js的j41版本,本文提到的小技巧也是基于这个版本的js。

智能Beacon

GA监控脚本一般都放在开发者的网页上。域名往往和Google不一样,这样发送请求到Google服务器的时候会涉及到跨域。普通的Ajax请求是做不到的,通常称这种请求为beacon或是ping。业内常用的一个方案是发送一个图片请求(GET方式),将请求参数放在图片请求的地址后面。例如:

https://www.google-analytics.com/r/collect?v=1&_v=j46&a=134081920&t=pageview&_s=1&dl=http%3A%2F%2Fzencode.in%2F&ul=zh-cn&de=UTF-8&dt=MZhou%27s%20blog%20-%20Taste%20of%20life.&sd=24-bit&sr=1280x800&vp=481x676&je=0&fl=22.0%20r0&_utma=9582782.1438874051.1425021219.1431473728.1471631422.8&_utmz=9582782.1471631422.8.1.utmcsr%3D(direct)%7Cutmccn%3D(direct)%7Cutmcmd%3D(none)&_utmht=1473782171819&_u=QACCAAABI~&jid=633265686&cid=1438874051.1425021219&tid=UA-36422454-1&_r=1&gtm=GTM-MP42BH&z=300649187

因为通常第三方监控请求没有很强的安全要求(不会发送密码、密钥之类的信息),所以使用图片请求将请求参数放在地址里面也是OK的。示例代码如下:

function imgPing(url, callback) {
    var key = '__SOME_RANDOM_KEY__' + (+new Date());
    var img = new Image();
    window[key] = img;
    img.onload = img.onerror = img.onabort = function () {
        img.onload = img.onerror = img.onabort = null;
        window[key] = null;
        img = null;
        if (callback) {
            callback();
        }
    };
    img.src = concatUrl;
    return true;
}

图片请求是GET请求,参数放在URL地址中,而URL地址的长度是有一定限制的。规范对URL长度并没有要求,但是浏览器、服务器、代理服务器都URL对长度有要求。例如:IE6、7、8(部分)的URL长度不能超过2083的字符长度,URL中的path部分不能超过2048。这就导致有些请求会发送不完全。

为了解决这个问题可以使用XMLHttpRequest(简称XHR)来发送跨域POST请求。当然这需要浏览器的跨域支持。发送POST请求时,参数都放在请求的payload中,不会受到URL长度所限制。但因为是POST请求,所以需要协议头部比GET方法多一点点,消耗也稍高。示例代码如下:

function xhrPing(url, params, callback) {
    if (hasCors()) {
        return;
    }

    var xhr = new window.XMLHttpRequest();
    xhr.open('POST', url, true);
    xhr.withCredentials = true;
    xhr.setRequestHeader('Content-Type', 'text/plain');
    if (callback) {
        xhr.onreadystatechange = function () {
            if (xhr.readyState !== 4) {
                return;
            }

            var status = xhr.status;
            var isSuccess = status >= 200 && status < 400;
            var error = null;
            if (!isSuccess) {
                error = new Error();
            }
            callback(error);
        };
    }
    xhr.send(params);
};

除了这两种方法之外,浏览器还提供了一个标准的用于发送beacon的方法:[navigator.sendBeacon](http://)。这个方法本质上和跨域的XHR请求没有多大区别,但是sendBeacon方法能够确保在页面关闭的时候还能发送成功。这也是它的最大优势。示例代码如下:

function beaconPing(url, params) {
    if (hasSendBeacon()) {
        return window.navigator.sendBeacon(url, params);
    }
    else {
        return false;
    }
};

sendBeacon出现之前,为了能够在页面关闭时发送beacon,常用的方法是两种:

  1. 先发送一个图片的beacon,然后死循环200ms左右来提高beacon请求发送成功的概率
  2. 发送同步的XHR请求,确保页面要等到XHR请求结束后才能关闭。不过同步XHR已经被W3C标准定义为不推荐使用了

    Synchronous XMLHttpRequest outside of workers is in the process of being removed from the web platform as it has detrimental effects to the end user's experience. (This is a long process that takes many years.) Developers must not pass false for the async argument when entry settings object's global object is a Window object. User agents are strongly encouraged to warn about such usage in developer tools and may experiment with throwing an InvalidAccessError exception when it occurs.

综合上面的讨论给出如下的对比表格:

方法 优点 缺点
图片请求 1. GET请求头部少,快
2. 支持广
1. URL长度限制
2. 需要延迟关闭才能用于unload发送
sendBeacon 1. unload时更能确保成功
2. 支持发送更多数据
1. POST请求消耗多
2. 旧浏览器支持少
XHR CORS 支持发送更多数据 1. POST请求消耗多
2. 旧浏览器支持少
3. unload时不能使用

如果没有指定发送方法,那么GA会在URL字符长度不超长时使用图片beacon的方式发送。如果超过了则尝试使用sendBeacon方法发送beacon请求,如果不支持sendBeacon则会采用跨域XHR发送。如果跨域XHR不支持则最后fallback到图片发送。实际代码如下:

var smartPing = function(api, param, callback) {
    callback = callback || noop;
    if (2036 >= param.length) {
        imgPing(api, param, callback);
    } else if (8192 >= param.length) {
        beaconPing(api, param, callback) || xhrPing(api, param, callback) || imgPing(api, param, callback);
    } else {
        errorPing('len', param.length);
        throw new OverLengthError(param.length);
    }
};

当然GA的做法并非最优,因为非IE6、7的浏览器图片请求发送的数据可以超过2083。如果真的有很多数据需要发送,那么我们可以合并短请求、拆分长请求。

资料:

MZhou's blog - Taste of life.

zmmbreeze / @zhoumm