之前有同学(@jet )对海豚录制这块的功能感兴趣,所以就专门写一个帖子来说一下海豚录制功能的实现逻辑以及解决了哪些坑。

下面详细说一下海豚中录制功能的实现细节以及代码 DEMO。

海豚对录制功能做了比较多的考量,前期也在这方面栽了很多跟头,比如:

  1. 获取到的 DOM 元素的 Selector,选择出来之后,可能页面会有多个 DOM 元素的情况,因为有些 ID 相同的容器,只是隐藏了而已
  2. 点击事件的时候,DOM 元素绑定了有 mousedown/mouseup 事件去实现触摸反馈的样式,这样 className 是变化了的,导致获取到的 Selector 是错误的,回放的时候找不到 DOM 元素
  3. 一些元素的 ID 是时间戳或者随机数字的形式,每次页面加载都不一样,比如:id-123123 这样的形式,数字是每次都会变化的,导致录制获取到的 Selector 是某个时刻的,回放又是一个问题。
  4. 录制的 Selector 太长了,可读性太差,导致无法很好直观的看到点击的是什么内容
  5. ...

接下来看看海豚中的录制工具针对上面的 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 出错的问题。

好了,海豚整个录制功能就是这个样子的了。


↙↙↙阅读原文可查看相关链接,并与作者交流