chrome extension之content-scripts与page-scripts之间的通信 进入全屏
line

背景

昨天在完成一项重构升级工作,将FeHelper中的Js覆盖率检测工具进行全新升级,从原来的「Inject scripts file from ucren's website」方式重构成「chrome extension content-scrpits」。这个事儿难度还是略大的,基本需要将@dron提供的Tracker源码进行重新设计和改造,改造完成后旨在达到这几个目的:

  • http和https的页面均可运行(以前只支持http协议的页面)
  • 支持内网域名下正常使用,如:localhost、127.0.0.1
  • 检测速度能大幅度提升(不再需要通过一个server端做代理,来获取js文件内容)

改造难度

  1. 如何在http://www.a.com页面下获取http://www.b.com/c.js文件的内容并进行钩子注入?这里需要解决的是跨域问题
  2. 钩子安装在page scripts中,覆盖率检测的「server」部分则是在content scripts中,如何解决page scriptscontent scripts之间的通信,则是最大的难题
  3. 其他问题,暂不罗列了。。。

解决问题

1、解决跨域获取js文件内容的问题

这类问题一般来讲,比较通用的方案是jsonpXMLHttpRequest有同源策略的限制,但jsonp则能跨过这个障碍,比如在「http://www.a.com 」的页面下要去分析「http://www.b.com/c.js 」的内容,则可以借助另外一个「proxy server 」来完成,形如:

<script type="text/javascript" src="http://your-proxy-server/get-remote-file?url=http%3A%2F%2Fwww.b.com%2Fc.js&callback=afterFileLoadedCallback"></script>

最终的效果会是:

afterFileLoadedCallback('--来自http://www.b.com/c.js的文件内容--');

这方案虽能解决,但依然是依赖「proxy server」的,假设这个server突然故障罢工了,那所有的用户都无法正常使用这个功能了。所以必须要尽可能的保证FeHelper对任何第三方服务的独立性!

好在「chrome extension background-scripts」是可当一个独立server使用的,即通过XMLHttpRequestbackground-scripts中获取JS文件内容,通过「message」机制在content-scriptsbackground-scripts之间进行数据通信,不细说,形如:

content-scripts

// 通过background scripts加载远程脚本内容
function loadJsFileContentFromBgScripts(url) {

    var pm = new Tracker.Promise();
    var timeStart = Tracker.Util.time();

    var timer = setTimeout( function(){
        pm.reject();
    }, timeout );

    //向background发送一个消息,要求其加载并处理js文件内容
    chrome.extension.sendMessage({
        type : MSG_TYPE.GET_JS,
        link : url
    },function(respData){
        clearTimeout( timer );
        pm.resolve( {
            response: respData.content,
            consum: Tracker.Util.time() - timeStart
        } );
    });

    return pm;
}

background-scrips

// background中响应来自content-scripts的消息
chrome.runtime.onMessage.addListener(function (request, sender, callback) {

    if (request.type == MSG_TYPE.GET_JS) {
        //直接AJAX获取JS文件内容
        _readFileContent(request.link, callback);
    }
});

// 远程数据加载
var _readFileContent = function(link,callback){

    //创建XMLHttpRequest对象,用原生的AJAX方式读取内容
    var xhr = new XMLHttpRequest();
    //处理细节
    xhr.onreadystatechange = function() {
        //后端已经处理完成,并已将请求response回来了
        if (xhr.readyState === 4) {
            var respData;

            //判断status是否为OK
            if (xhr.status === 200 && xhr.responseText) {
                //OK时回送给客户端的内容
                respData = {
                    success : true, //成功
                    content : xhr.responseText  //文件内容
                };
            } else {    //失败
                respData = {
                    success : false,    //失败
                    content : "load remote file content failed." //失败信息
                };
            }

            //触发回调,并将结果回送
            callback(respData);
        }
    };

    //打开读通道
    xhr.open('GET', link, true);

    //设置HTTP-HEADER
    xhr.setRequestHeader("Content-Type","text/plain;charset=UTF-8");
    xhr.setRequestHeader("Access-Control-Allow-Origin","*");

    //开始进行数据读取
    xhr.send();
};

至此,「问题1」算是完美解决!

2、解决page-scriptscontent-scripts之间通信的问题

做过「chrome extension」开发的应该都知道,「page scripts」和「content scripts」是属于不同的sand box,两者之间不可直接通信(简单来说,就是两边定义的变量都是被完全隔离开的)

如果两者之间可正常通信,那么理想情况是这样的:

content-scripts

window.__tracker__ = function (groupId) {
    Tracker.StatusPool.arrivedSnippetGroupPut(groupId);
};

page-scripts

window.__tracker__ ('1,2,3,4,5');

不过仔细想,对于一个页面来讲,脚本可以分为「page scripts」和「content scripts」,甚至以后还有更多,但是DOM只有唯一的一份儿!各种scripts都是为DOM提供服务的。

「page scripts」可以操作「DOM」,「content scripts」亦可,从这个角度出发,问题基本有解决思路了:巧用DOM Event

  • 通过「content scripts」在页面上创建一个隐藏的<button>节点
  • 为节点绑定click事件,被点击以后则通过function call的形式执行「content scripts」中的动作
  • 在「page scripts」中,获取该隐藏节点
  • 将参数设置到该节点上,并触发该几点的click事件

也就是说,最后的改造思路会是这样的:

content-scripts

window.__tracker__ = function (groupId) {
    // do something
};

window.__trackerScriptStart__ = function (codeId, scriptTagIndex) {
    // do something
};

window.__trackerScriptEnd__ = function (codeId) {
    // do something
};  

// 按钮绑定click事件
document.getElementById('btnTrackerProxy').addEventListener('click', function (e) {
    var type = this.getAttribute('data-type');
    switch (type) {
        case '__tracker__':
            var groupId = this.getAttribute('data-groupId');
            window[type](groupId);
            break;

        case '__trackerScriptStart__':
            var codeId = this.getAttribute('data-codeId');
            var scriptTagIndex = this.getAttribute('data-scriptTagIndex');
            window[type](codeId, scriptTagIndex);
            break;

        case '__trackerScriptEnd__':
            var codeId = this.getAttribute('data-codeId');
            window[type](codeId);
            break;
    }
}, false);

page-scripts

// 获取button节点
var getProxyEl = function () {
    return top.document.getElementById('btnTrackerProxy');
};

window.__tracker__ = function (groupId) {
    var proxy = getProxyEl();
    proxy.setAttribute('data-type', '__tracker__');
    proxy.setAttribute('data-groupId', allGroupIds);
    proxy.click();
};

window.__trackerScriptStart__ = function (codeId, scriptTagIndex) {
    var proxy = getProxyEl();
    proxy.setAttribute('data-type', '__trackerScriptStart__');
    proxy.setAttribute('data-codeId', codeId);
    proxy.setAttribute('data-scriptTagIndex', scriptTagIndex);
    proxy.click();
};

window.__trackerScriptEnd__ = function (codeId) {
    var proxy = getProxyEl();
    proxy.setAttribute('data-type', '__trackerScriptEnd__');
    proxy.setAttribute('data-codeId', codeId);
    proxy.click();
};

至此,整套方案是能完全跑通了,两种不同「sand box」的脚本已经能完成正常通信。

性能优化

虽然用上面的方法已经满足了需求,实现了scripts之间的通信,但是这是在每次通信的时候都操作一下「DOM」节点,为其设置相关属性再处罚对应事件,一次两次的并发还好,如果并发量特别大,性能必定是一个问题。做了一次极限测试,当「page scripts」中触发按钮事件的频次上升时,页面逐渐卡顿,该Tab占用的内存和CPU情况都是急剧上升,甚至直接crash!所以性能问题必须要优化。

最简单的优化方案,当然是「把连续的多次操作合并成一次操作」,用一个简单队列来实现:

  • 设定队列最大长度,达到阈值时统一处理
  • 为了防止操作频次稀疏的情况下,队列无法达到阈值,再增加一个times-up机制,定期统一处理

于是,将触发机制改造为:

content-scripts

// 从响应单个groupId修改为响应批量
window.__tracker__ = function (groupId) {
    [].concat((groupId || '').split(',')).forEach(function (item) {
        Tracker.StatusPool.arrivedSnippetGroupPut(item);
    });
};

page-scripts:

/**
 * 队列管理器
 * 钩子“tracker”会执行的非常频繁,如果每次执行都去trigger proxy click,性能会极其低下,所以需要用一个队列来批量执行
 */
var QueueMgr = (function () {

    var _queue = [];
    var _lastPopTime = 0;

    // 检测队列是否已满:最大长度500个
    var full = function () {
        return _queue.length >= 500;
    };

    // 入队列
    var push = function (item) {
        _queue.push(item);
    };

    // 全部出队列
    var popAll = function () {
        var result = _queue.join(',');
        _queue = [];
        _lastPopTime = new Date().getTime();
        return result;
    };

    // 判断距离上一次出队列是否已经大于100ms
    var timesUp = function () {
        return (new Date().getTime() - _lastPopTime) >= 100;
    };

    return {
        full: full,
        timesUp: timesUp,
        push: push,
        pop: popAll
    };
})();

window.__tracker__ = function (groupId) {

    // 先入队列,不丢下任何一条消息
    QueueMgr.push(groupId);

    // 队列已满 or 等待时间到了
    if (QueueMgr.full() || QueueMgr.timesUp()) {
        var allGroupIds = QueueMgr.pop();
        var proxy = getProxyEl();
        proxy.setAttribute('data-type', '__tracker__');
        proxy.setAttribute('data-groupId', allGroupIds);
        proxy.click();
    }
};

如果你在开发chrome extension的过程中,也正遇到这样的难题,或许本文的解决方案值得参考。

阿里巴巴-钉钉-开放平台,能力开放&开发者运营岗位招聘中, 期待你的加入!
钉钉开放,让应用开发更简单
充分开放,是钉钉的重要方向!除致力于为开发者打造丰富的开放API, 更易接入的场景化能力包, 完备的应用开发工具之外, 还需要持续构建开放能力的布道、开发者生态运营体系,包括培训、沙龙、大会、社区合作等等。业务在快速发展,我们也还需要更多优秀的小伙伴加入!

评论区域

line
  • Aiello 2017-05-30 18:11:33 回复
    你好,我按照你的方法,发现不行。具体就是,chrome.extension.sendMessage 的回调函数特别快(可能允许的超时很小,当然回调不应该有超时啊),我在做ajax 的时候,callback都是秒回调,并且值为 undefined。不清楚是怎么回事,也搜不到相关的文章
  • Alien 2016-01-26 07:38:13 回复
    回复 dron : 志龙赞赞的
    dron said:
    做为挖坑的前人,过来手工点个赞,敬佩 alien 持之以恒的精神。
  • dron 2016-01-25 22:55:18 回复
    做为挖坑的前人,过来手工点个赞,敬佩 alien 持之以恒的精神。