背景
昨天在完成一项重构升级工作,将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文件内容)
改造难度
- 如何在
http://www.a.com
页面下获取http://www.b.com/c.js
文件的内容并进行钩子注入?这里需要解决的是跨域问题 - 钩子安装在
page scripts
中,覆盖率检测的「server」部分则是在content scripts
中,如何解决page scripts
与content scripts
之间的通信,则是最大的难题 - 其他问题,暂不罗列了。。。
解决问题
1、解决跨域获取js
文件内容的问题
这类问题一般来讲,比较通用的方案是jsonp
,XMLHttpRequest
有同源策略的限制,但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使用的,即通过XMLHttpRequest
在background-scripts
中获取JS文件内容,通过「message」机制在content-scripts
和background-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-scripts
与content-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的过程中,也正遇到这样的难题,或许本文的解决方案值得参考。