之前有同学(@jet )对海豚录制这块的功能感兴趣,所以就专门写一个帖子来说一下海豚录制功能的实现逻辑以及解决了哪些坑。
下面详细说一下海豚中录制功能的实现细节以及代码 DEMO。
海豚对录制功能做了比较多的考量,前期也在这方面栽了很多跟头,比如:
接下来看看海豚中的录制工具针对上面的 N 多录制相关的问题的解决方式(以点击事件为例子):
海豚的录制功能是全 JS 实现的,有一个前提就是,页面的点击事件的捕获和冒泡机制没有被破坏。
document.addEventListener("click",function(e){
if(isNotRecording){ return; }
var target = e.target,
tagName =target.tagName && target.tagName.toLowerCase() || '';
//如果是HTML控件的话,则不记录click事件
if(isIgnoreElement(target)){ return; }
if(tagName === "html" || tagName === "body"){ return; }
actions.push("click::" + window.getSelector(target) + "::" + (+new Date()));
actionPaths.push({
url : window.location.href,
title : target.title,
id : target.getAttribute('_id_'),
className : target.getAttribute('_class_'),
selector : window.getSelector(target),
tagName : target.tagName.toLowerCase(),
innerText : target.innerText || target.value,
event : 'click',
target : target
});
}, true);
上面有一个isIgnoreElement
函数的调用,主要是为了屏蔽掉一些元素的点击事件,比如一些 HTML 控件:
var isIgnoreElement = function(target){
if(!target){ return false; }
return ' textarea select option '.indexOf(' ' + target.tagName.toLowerCase() + ' ') !== -1;
}
window.getSelector
方法就是获取点击目标 DOM 元素的 Selector,这个扩展开来讲一下获取 Selector 的大概实现逻辑。
首先为了解决一些模拟触摸反馈导致 className 变化的场景,那么就需要把页面渲染之后全部 DOM 元素的 id 和 class 都储存起来,然后获取的时候都获取这些储存的 id 和 class:
var tagIdAndClass = function(element){
if(element && element.id){
element.setAttribute('_id_', element.id);
}
if(element && element.className){
element.setAttribute('_class_', element.className);
}
var childNodes = element.childNodes;
if(childNodes.length){
for(var i = 0,len = childNodes.length; i < len; i++){
tagIdAndClass(childNodes[i]);
}
}
}
tagIdAndClass(document.body);
//DOM结构有变化的时候,重新将新增的元素的id和class储存起来
document.addEventListener('DOMNodeInserted', function(e){
var elem = e.target;
tagIdAndClass(elem);
});
然后,点击该元素之后,就是获取 Selector 的逻辑:
var _getSelector_ = function(element){
if(!element){ return; }
if(typeof element === 'string'){ return element; }
var tagName = element.tagName && element.tagName.toLowerCase() || '';
if(!tagName){ return ''; }
function trim(string){
return string && string.toString().replace(/^\s+|\s+$/,"") || string;
}
//去掉一些使用时间戳作为ID的元素
var id = element.getAttribute('_id_');
if(id && !(/\d{3,13}/).test(id) && !(/^\d+$/).test(id)){
//如果这个ID在页面上是唯一的,那么就返回该ID,否则再继续往上层父元素添加selector
if(document.querySelectorAll('#' + id).length === 1){
return '#' + id;
}
}
if(element == document || element == document.documentElement){
return 'html';
}
if (element == document.body){ return 'html > ' + element.tagName.toLowerCase(); }
if(!element.parentNode){return element.tagName.toLowerCase();}
var ix = 0,
siblings = element.parentNode.childNodes,
elementTagLength = 0,
classname = trim(element.getAttribute('_class_'));
//判断该className是否在整个文档中是唯一的
if(classname && document.querySelectorAll("." + classname.replace(/\s+/g,".")).length === 1){
return "." + classname.replace(/\s+/g,".");
}
for (var i = 0,l = siblings.length; i < l; i++) {
if(classname){
if(siblings[i].nodeType === 1 && (trim(siblings[i].getAttribute('_class_')) === classname)){
++elementTagLength;
}
}else{
if((siblings[i].nodeType == 1) && (siblings[i].tagName === element.tagName)){
++elementTagLength;
}
}
}
for (var i = 0,l = siblings.length; i < l; i++) {
var sibling = siblings[i];
if (sibling === element){
return arguments.callee(element.parentNode) + ' > ' + (classname ? "." + classname.replace(/\s+/g,".") : element.tagName.toLowerCase()) + ((!ix && elementTagLength === 1) ? '' : ':nth-child(' + (ix + 1) + ')');
}else if(sibling.nodeType == 1){
ix++;
}
}
};
通过上面的方式,解决了 id 带有随机数字的场景。但是获取到的 Selector 可能比较长,那么需要缩短一下:
window.getSelector = function(element){
var selector = _getSelector_(element);
var element = document.querySelector(selector),
selectors = selector.split('>'),
length = selectors.length,
preSelector = selector;
for(var i = 1; i < length; i++){
var css = selectors.slice(i, length).join('>');
if(document.querySelector(css) !== element){
break;
}
preSelector = css;
}
return preSelector.trim();
}
上面获取到的 Selector 就是最终所需要的 Selector 了,可以用在回放的逻辑里面。但是还有一个问题,该 Selector 最终不能唯一定位一个元素,那么就需要去重,这个去重的逻辑就是在回放的逻辑里面,只获取到显示出来那一个 DOM 元素:
var visible = function(elem){
$elem = Zepto(elem);
return !!($elem.width() || $elem.height()) && $elem.css("display") !== "none"
}
var getTarget = function(element){
var targets = document.querySelectorAll(element),
target;
//如果有多个,则获取到visible的那一个
if(targets.length > 1){
for(var i = 0, len = targets.length; i < len; i++){
if(visible(targets[i])){
target = targets[i];
break;
}
}
} else {
target = targets[0];
}
return target;
}
至此,监控页面点击事件,以及获取 DOM 元素的 Selector 逻辑完毕。
这里海豚定义了 action 相关的 api(以点击事件为例):
var action = monitor.createAction();
action
.wait(2000)
.click("#content > div > div:nth-child(3) > .indexInner", {
waitTime : 2000,
}, function(elem){
//elem参数为当前Selector的DOM引用
monitor.log(window.location.href);
})
.end(function(){ monitor.complete(); });
上面就是一个录制后自动生成的一段可直接执行的测试代码,对用户来说可以不用改变什么,就可以执行这个交互行为,并配合 page-diff 逻辑,就可以监控交互行为后页面的 UI 变化。
var action = monitor.createAction();
action
.wait(2000)
.click("#content > div > div:nth-child(3) > .indexInner", {
usePageDiff : true,
waitTime : 2000,
root : '#content',
excludeSelectors : ['.tips', '.ads'],
ignoreTextSelectors : true
}, function(elem){
//elem参数为当前Selector的DOM引用
monitor.log(window.location.href);
})
.end(function(){ monitor.complete(); });
这里对waitTime
说明一下,它的作用是执行了该点击行为之后,多久去进行 pagediff 操作,避免一些异步操作导致加载缓慢使得 diff 出错的问题。
好了,海豚整个录制功能就是这个样子的了。