近期一直在团队内开发可视化埋点平台,目前已经落地并成功在多个复杂业务中落地使用,在其中也踩了不少坑。故写此篇文章,给想要在团队内落地可视化埋点平台做一份参考,也是对笔者自己实现过程的一个记录。

前端埋点的常见合作模式

笔者见过的团队,埋点大致有几种合作模式。

  1. 最原始的方案:wiki 维护埋点信息,产品丢给开发一份 wiki 文件,记录了需要埋点的元素,比如按钮A,按钮B,图片C,同时如果是一个列表,比如一个音乐播放列表的下载按钮,那么产品可能还想要获取下载的音乐名称。开发拿到记录好埋点的 wiki,在点击时上报埋点,携带元素名称,比如按钮A,按钮B,而对于音乐列表的下载按钮,再携带音乐名称。

  2. 录入平台方案:基本同上一方案一致,但是产品和开发的交流,通过录入平台来维系。这一模式解决了 wiki 维护带来的后续查找不便和随意性。但是录入平台导致了产品要录入更多的信息,比如该该元素截图、附加信息、所属应用等等。而开发侧,由于平台的存在,每一个埋点与平台间,必然存在一个独一无二的 key 维护,开发上报埋点时将其携带,从而在平台上可以进行关联。

  3. 可视化录入方案:开发侧接入可视化埋点脚本,产品侧直接在可视化埋点平台进行圈选(类似 chrome inspector),圈选后埋点自动上报。

  4. 全埋点方案:开发侧接入全埋点脚本,用户的所有点击均会上报,但是由于上报的数据太多,对于存储侧的压力较大。同时由于上报的埋点比较零碎,对数据分析的要求较高。

上述的方案1和方案2,可以统称为手动埋点,其优劣如下:

  1. 手动埋点:产品侧和开发侧都有不少的繁琐的工作量。但是该方案非常灵活且埋点的准确性非常高。同时对于不同的页面状态的支持非常简单。

  2. 可视化录入:开发侧和产品侧都没有什么工作,方便的圈选模式能让产品轻易的进行埋点。但是该方案依赖可视化平台的能力,同时埋点的准确性可能会存在一定的问题。同时,如果页面有不同的状态,此时必须模拟用户进入该状态,才可以进行埋点的圈选工作

  3. 全埋点 or 无埋点:开发侧和产品侧无额外工作。但是全埋点数据收集上来之后,怎么对数据分析是一个问题,此处笔者并不了解,不做赘述。

可视化埋点平台的功能介绍

可视化埋点平台功能上的核心,便是提供用户一个可以方便对元素进行圈选的方案。用户可以对目标页面进行元素的选择,从而实现埋点的录入。大致的效果如下:

圈选示意

用户通过选择元素,录入名称,即可完成埋点的录入。同时,为了满足一些高级场景,可以顺带提供一颗同浏览器几乎一样的元素选择器,如下图:

dom圈选示意

与此同时,可视化埋点平台还需要具备反向圈选的能力,以便给使用者一个正向反馈,可以作为一个埋点是否正确的验证能力。如下图:

反向圈选示意

可视化埋点平台的架构设计

整体的架构图如下:

埋点平台架构

图中,共分为4个区域:

  1. 管理后台 + 圈选 sdk:位于图中的左上角部分。承担了可视化埋点项目维度的创建,圈选逻辑的实现,以及数据可视化等等功能,是用户能够看到的页面
  2. node 后台服务及数据服务:位于图中的左下角。其承担了项目的常规后台增删查改的部分,以及同已有的埋点数据对接的能力(由于笔者落地的可视化埋点,其埋点数据的存储及格式沿用了已有的平台)。
  3. c端页面 采集 sdk:位于图中的右上角部分,主要负责根据后台存储的该页面的埋点信息,反查元素,实现埋点数据的上报
  4. c端后台:位于图中的右下角部分,主要负责根据 c 端页面返回其埋点配置,由于需要应对大流量的问题,node 端单独拉了出来。

其中,2、4部分是 node 端提供的一些接口,相对而言比较常规,本文不做赘述。同时,1中项目管理、数据可视化,也是常规的前端页面,并非本文的重点。

本文将着重讨论1、3部分中,圈选功能和数据采集的实现。

核心圈选能力的设计

从表面上来看,用户的操作路径如下:

  1. 点击圈选按钮
  2. 鼠标划入目标页面,在想要埋点的元素上进行点击
  3. 在唤起的弹窗中录入埋点名称,即可完成埋点的录入。
  4. 完成录入后,自动对这些元素进行埋点。

而实际的内部核心流程如下(一些边界的场景进行了简化):

  1. 用户在圈选页面打开圈选开关(需要通知目标页面)
  2. 目标页面(以 iframe 的形式嵌在平台内)收到打开消息,开启圈选模式,此时鼠标的移动会在元素上增加浮层
  3. 用户点击,此时需要生成元素的唯一标识,传回给埋点平台
  4. 埋点平台收到消息,让用户填写埋点名称,即可完成这个埋点的录入流程。
  5. 真实用户打开页面后,该页面先获取录入的埋点元素标识
  6. 根据元素标识来反向选择到真实元素
  7. 监听真实元素的曝光和点击,上报埋点

可以看出: 可视化埋点平台的设计中,存在几个关键点:

  1. 如何获取元素的唯一标记,并可通过该标记反向选择元素
  2. 如何随着鼠标的移动,给元素添加浮层
  3. 频繁的跨域 iframe 通信,该如何简化
  4. 元素的曝光和点击如何同元素标记匹配上报

元素唯一标记

要实现不人工埋点,那么就需要给每一个 dom 元素进行标记,而这个标记,本文选择的便是 xpath。本质是记录其在整个 dom 树中的位置,这是一项比较久远的知识。说实话,在做可视化埋点平台前,笔者作为一名前端开发,也基本没有听说过 xpath。

举个 xpath 的实例,如下图,一个元素在 dom 树中的位置如下:

dom示例

那么可以通过右击 -> 复制 -> 复制xpath/复制完整xpath:

// xpath
const xpath = '//*[@id="__next"]/div[1]/div/div/section[1]/article/h1[1]'
// 完整 xpath
const fullXpath = '/html/body/div[1]/div[1]/div/div/section[1]/article/h1[1]'

其本质是从当前元素开始,首先确定当前元素处于兄弟节点中同 tag 元素的第几个,比如上图中,其处于 h1 标签的第一个,便是 h1[1],(如果同级只有一个 h1 元素,那么 chrome 会直接丢弃 [1])。依次往上执行,一直查找到根元素,也就是 html,即可得到元素的完整 xpath。

但是 chrome 本身没有把这个方法暴露出来,故需要开发者自行实现,一个非常简易的获取元素 xpath 的代码如下:

function getXpath(ele) {
    let cur = ele;
    const path = [];
    while (cur.nodeType === Node.ELEMENT_NODE) {
        const currentTag = cur.nodeName.toLowerCase();
        const nth = findIndex(cur, currentTag);
        path.push(`${(cur.tagName).toLowerCase()}${(nth === 1 ? '' : `[${nth}]`)}`);
        cur = cur.parentNode;
    }
    return `/${path.reverse().join('/')}`;
}

// 其中 findIndex 代码如下:
function findIndex(ele, currentTag) {
    let nth = 0;
    while (ele) {
        if (ele.nodeName.toLowerCase() === currentTag) nth += 1;
        ele = ele.previousElementSibling;
    }
    return nth;
}

通过 getXpath 方法,可以拿到元素的唯一标记,此时需要做的,便是根据元素的标记,反选回该元素,浏览器提供了原生的方法,如下:

function getEleByXpath(xpath) {
    const doc = document;
    const result = doc.evaluate(xpath, doc);
    const item = result.iterateNext();
    return item;
}

上述方法,可以返回匹配到的第一个元素,即可获得目标元素。

但是在实际的落地过程中,笔者发现了一些有趣的问题:

  1. svg 元素,无法通过上述的 getEleByXpath 进行反向选择。且监听元素曝光的 api: intersectionobserver,也不支持监听 svg 元素。
  2. /div/ul/div[1]/ul,对于 /div 的场景,如果 dom 结构中,存在两个 div,如果第一个 div 下,不存在 ul 元素,而第二个 div 下,存在 ul 元素,那么此时 getEleByXpath('/div/ul') 的第一个元素,便会命中第二个 div,会出现误判
  3. 对于实际业务中非常常见的动态弹窗场景,由于这些动态弹窗基本都是动态挂载在 body 下,且关闭后直接就销毁,那么对于下一个动态弹窗,由于位置一致,此时非常容易判定为同一个元素,带来误判
  4. 对于实际业务中,常见的三元表达式的场景比如: show ? <div /> : null,由于 show 变量的存在,导致其后面的兄弟元素的位置,会发生变化,从而导致 xpath 不稳定。
  5. 对于实际业务中,策略组件的场景,也非常容易带来 xpath 的重叠,比如 const Comp = comp[type]; return <Comp />
  6. 圈选功能的元素浮层,是否会对 dom 结构带来干扰,如何避免?
  7. 列表元素如何标记?

这些问题相对而言比较细节,留待后续文章进行讨论,此处还是以串联整个可视化埋点核心链路为准。

元素圈选功能

有了元素唯一标识,接下来便是对元素的圈选。(备注:此处参考了开源的 dom-inspector,在其基础上对多选、xpath,元素浮层等能力进行了解耦和加强,以满足实际场景的需要。)

圈选的本质,便是在用户鼠标移动的时候,在元素上层出现一个同样大小的浮层,以便用户识别。

获取用户鼠标移动和鼠标移动处的元素,在 body 上监听 mousemove 事件并取其 target 即可获取目标元素,接下来只需要获取元素的 content 大小、paddingmargin 大小及元素的位置,然后根据其位置挂载浮层即可。

获取元素大小及位置的方法很多,本文列举的仅仅是比较常规的方法(有更好的方案可以评论区讨论交流),如下:

// 获取元素的位置
function findPos(ele) {
    const computedStyle = getComputedStyle(ele);
    const pos = ele.getBoundingClientRect();
    // left,right, top 均 不包含 margin
    // 减去 margin,可以获取元素加上 margin 后,距离左侧和上侧的真实距离
    const x = pos.left - parseFloat(computedStyle['margin-left']);
    const y = pos.top - parseFloat(computedStyle['margin-top']);
    const r = pos.right - parseFloat(computedStyle['margin-right']);

    return {
        top: y,
        left: x,
        right: r,
    };
}

// 获取元素大小
export function getElementInfo(ele) {
    const result = {};
    const requiredValue = [
        'border-top-width',
        'border-right-width',
        'border-bottom-width',
        'border-left-width',
        'margin-top',
        'margin-right',
        'margin-bottom',
        'margin-left',
        'padding-top',
        'padding-right',
        'padding-bottom',
        'padding-left',
        'z-index',
    ];

    const computedStyle = getComputedStyle(ele);
    requiredValue.forEach((item) => {
        result[item] = parseFloat(computedStyle[item]) || 0;
    });
    
    // 用 offsetWidth 减去元素的 border 和 padding,来获取内容的宽高
    // 不用clientWidth 是因为内敛元素,该属性为0
    const width = ele.offsetWidth -
              result['border-left-width'] -
              result['border-right-width'] -
              result['padding-left'] -
              result['padding-right'];

    const height = ele.offsetHeight -
              result['border-top-width'] -
              result['border-bottom-width'] -
              result['padding-top'] -
              result['padding-bottom'];

    result.width = width;
    result.height = height;
    return result;
}

其中,使用 offsetWidth 而不是 clientWidth 是因为 clientWidth 在内联元素中为 0。具体可见 clientWidth vs offsetWidth,而不使用 getBoundingClientRect().width 是因为元素用了 transform 后,该值会随 transform 发生改变,具体可见 offsetWidth vs getBoundingClientRect().width

当然,上述代码隐藏了 svg 元素的处理,可以使用 getBoundingClientRect().width 或 svg 元素 特有的 getBBox 处理,本文不再赘述。

计算出元素的位置及宽高后,即可在其位置分别添加:元素内容区、元素 padding 区、元素 border 区、元素 margin 区,以及元素的 tag + 宽高。

综上,想要实现一个元素圈选器,那么只需要做如下操作:

  1. body 上监听 mousemove 事件(捕获阶段)
  2. 在事件回调中,计算 target 的大小和位置
  3. 依次为元素添加内容、padding 等浮层,即可完成一个元素圈选器的功能。

在实际应用中,还需要增加圈选开关来控制圈选器的打开与关闭,以免影响用户的正常点击行为,并可以增加上一次 target 的缓存来减少浮层的销毁与创建的频率等等。

iframe 通信

在圈选的过程中,平台侧需要通知 iframe 内目标页面开启圈选模式,而 iframe 中,当用户选中元素后,需要计算元素的 xpath 反向通信给平台侧。此处为了方便,后续将平台侧成为 main,而 iframe 内目标页面称为圈选 sdk。main侧记录埋点后,为了便于验证,也需要在用户 hover 到埋点上,通知圈选 sdk 进行元素反选,从而验可以对录入的埋点进行反向验证。

下图简单描述了两者之间的一个通信:

通信示意

可以看出,这中间需要频繁的一个 iframe 通信,同时也是一个双端通信,故笔者结合发布订阅模式抽象了一个通用的 iframe 跨域通信类,main 和 圈选sdk 均基于此类进行上层建设。核心代码如下:

class MsgCenter {
    constructor({
        sender, // 在 main 中 是iframe.contentWindow || 在圈选 sdk 中则是 window.parent
        receiver, // window in main and iframe
        source, // current window origin
        target, // target window origin
    }) {
        this.sender = sender;
        this.receiver = receiver;
        this.target = target;
        this.source = source;
        this.receiver.addEventListener('message', this._handleMessage.bind(this));

        this.listeners = {};
    }

    /*
    data: {
        type: '' // must define
        ext: {} // 用户自定义数据
        source, // 源页面 origin
        target, // 目标页面 origin
    }
    */
    sendMessage(type, ext) {
        if (!this.target) return;
        this.sender.postMessage(
            {
                source: this.source,
                target: this.target,
                type,
                ext,
            },
            this.target
        );
    }

    // 监听type类型消息
    on(type, callback) {
        if (this.listeners[type]) {
            this.listeners[type].push(callback);
        } else {
            this.listeners[type] = [callback];
        }
    }

    // 取消type类型消息的监听
    off(type, callback) {
        if (this.listeners[type]) {
            this.listeners[type].forEach((cb, index) => {
                if (cb === callback) {
                    this.listeners[type].splice(index, 1);
                }
            });
        }
    }

    // 内部根据type分发消息
    _handleMessage(event) {
        const { data, origin } = event;
        // 判断消息来源是否是来自于目标的页面
        if (origin === this.target) {
            if (this.listeners[data.type] && this.listeners[data.type].length) {
                this.listeners[data.type].forEach((callback) => callback(data.ext));
            }
        }
    }
}

在上述代码中,借助发布订阅模式,便可简化 main 和圈选 sdk 中的通信流程,具体使用方法如下:

// 在 main 中
const msgCenter = new MsgCenter({
    target: iframe.origin, // iframe 页面的 origin
    source: window.location.origin, // 当前页面的 origin
    receiver: window,
    sender: iframe.contentWindow, // 指向 iframe 的 window
});

// 向 iframe 发送消息,比如打开圈选开关等
msgCenter.sendMessage('enableSelector', {
    /*
    * 可传递自定义数据
    */
});

// 监听来自 iframe 消息
function receiveXpath(data) {
    console.log(data.xpath);
}

msgCenter.on('targetSelect', receiveXpath);

// 移除监听
msgCenter.off('targetSelect', receiveXpath);

而圈选 sdk,无非就是 sender 不同,使用伪代码如下:

// 在 iframe 中
const msgCenter = new MsgCenter({
    target: main.origin, // 圈选平台 页面的 origin
    source: window.location.origin, // 当前页面的 origin
    receiver: window,
    sender: window.parent, // 指向圈选平台 main 的 window
});

有了上述方法,可以大大简化代码中关于 iframe 双工通信的监听与取消。

至此,整个圈选流程的核心模块即可跑通:

开启圈选时,平台侧 main 向 目标页面 iframe 发送开启圈选消息,iframe 侧收到消息,开启元素圈选功能,选中后,再传递给平台侧,完成元素埋点的录入。同时平台侧 main,在埋点列表被 hover 时,也可向 iframe 发送选中元素消息,iframe 中收到消息,选择对应元素,以便验证埋点对应的元素是否可识别。

采集侧埋点匹配

埋点一共两种类型,元素的曝光和点击,下图是其基本流程:

采集侧逻辑

下面分别对其进行介绍:

首先是元素的曝光,目前基本都采用 intersectionObserver,该 api 兼容性也已经很高,同时也有 polyfill,基本上使用无虞。

对于元素的曝光埋点,理论上仅遍历一次埋点列表的 xpath,通过前文元素唯一标识介绍的方法 getEleByXpath,找到对应的元素并使用 intersectionObserver 进行监听即可。

但是不出意外的话,意外便发生了。

在实际的应用场景中,存在 dom 树变更的场景,此时需要借助 mutationObserver 来进行 dom 树的监听

  1. dom 树变化,原本不存在的元素可能就出现了,原本存在的元素消失了,如果不做 unobserver 的话,会存在内存泄漏问题
  2. 埋点数量较多的话,遍历可能会带来性能的损耗
  3. dom 树频繁变化,也会带来性能损耗

对于 dom 树变化带来的新旧元素的交替,可以存储一份 xpath -> dom node 的映射,在每次遍历时,对比新旧元素,新元素进行 observer, 旧元素进行 unobserver,来避免内存泄漏,其相关细节具体可参考前文,可视化埋点平台元素曝光采集的思路—intersectionObserver的实战经验

而对于埋点数量多,遍历可以通过 requireIdleCallback 包裹执行,不过实际并不会存在太大的性能问题。

对于 dom 树频繁变化,一方面可以在 mutationObserver 的初始化时,由于属性的变化不会带来 dom 结构的变化,故可忽略属性的变化,减少 mutationObserver 触发的频率,而另一方面,也可通过消抖节流来减少频率。

元素点击的匹配相对而言会简单一些,只需要在 body 上捕获阶段监听 click 事件,通过计算 event.target 的 xpath,同埋点列表中的 xpath 进行匹配即可判定。但是当 xpath 不完全匹配时,还需要判断点击元素,是否是埋点元素的子元素,如果是子元素,那么也算是匹配,这是因为:

如果用户录入了一个按钮的元素,而该按钮内部有文字,图片等等其他元素,那么此时点击图片、文字,均可触发该按钮的曝光,故需要对父子元素的场景进行判定,认为符合点击条件,进行上报。

小结

本章从元素唯一标识 xpath 讲起,做为圈选侧和采集侧沟通的标记。

接下来介绍了元素圈选的核心实现,通过计算元素大小、位置,在 mousemove 时为元素添加浮层,实现元素的圈选。

同时根据发布订阅模式,抽象了圈选平台页面和 iframe 页面的通信环节,为平台和 圈选 sdk 频繁通信打下基础。

最后介绍了采集侧匹配的方案,从而走通整个可视化埋点的核心链路。

可视化埋点平台系统链路

上文介绍了整体的架构和圈选部分核心的细节,本章主要对整个系统的链路进行串联,以便更好的描述整个平台的逻辑。整体结构如下图:

整体链路

平台侧的埋点录入页面,配合圈选 sdk,完成埋点的录入及验证(反向圈选),采集侧 sdk 通过对埋点进行匹配,对埋点数据进行收集,最终通过 node 服务对数据聚合后,在埋点平台进行可视化的数据呈现。

还有什么能做的

如上文所述,笔者可视化埋点平台的核心,便在于元素的唯一标记 xpath、iframe 通信及圈选器能力。

该方案能够实现预期的效果,对于大部分的场景,都能够满足,但是也存在一些边界问题:

  1. 元素唯一标记 xpath 的不稳定性及可能的重叠问题,需要进行改进
  2. h5 页面依赖 iframe,而 iframe 中,运行环境毕竟是浏览器,故实际情况可能与客户端内运行不一致,且存在登陆问题(iframe 跨域不携带 cookie问题)。
  3. pc 端页面的支持

其中,iframe 的依赖,可以通过将通信层改造成 websocket,圈选页面可以通过扫码在移动端打开,从而模拟和实际用户一摸一样的环境,也顺带解决了登陆问题。

而对于 xpath 的不稳定性,此处边界场景太多,后续会单独出一篇文章来介绍 xpath 在实际场景中遇到的重叠、不稳定问题,而解决后的 xpath 足以支撑非常复杂的移动端 h5 页面。

最后

整体来讲,可视化埋点平台的搭建,还是比较有趣的一个项目,确实同我们平时的业务开发不同,在此过程中,笔者也熟悉了像 mutationObserverintersectionObserver,这样的存在已久平时却也不接触的原生 api,也熟悉了出现很久的 xpath,而 dom 树的可视化、列表元素的判定,也是比较有趣的一些开发过程。

同时该平台也确实可以减轻产品/运营、开发埋点的不便,整体的方案及细节相信本文也讲的比较详细了,觉得还不错的话可以点赞鼓励下哦。