页面埋点

原文作者 杨运心 实习期间,公司内部KM的文章,侵删

埋点:收集产品数据、上报相关行为数据。通过分析数据来辅助产品优化迭代
BI: 商业智能,公司内部做数据分析相关的部门

痛点:

  • 在构造埋点字段的时候需要根据 BI 的规则,把若干个字段拼接成一个,这样费时费力还有错误的风险;
  • 一些曝光场景下的点不好打比如:分页列表、虚拟列表;他们的的曝光埋点实现较为繁琐;
  • 逻辑复用问题:特别是曝光相关的点需要在业务代码里面做额外的处理,所以逻辑复用很困难,对现有代码的侵入也很严重;

目前主流的埋点方案

手动代码埋点:用户触发某动作后,手动上报数据

  • 优:准确、可定制。
  • 缺: 埋点逻辑与业务逻辑耦合,不利于代码维护。

可视化埋点:通过可视化工具来配置采集数据。核心是查找dom然后绑定事件。主流如:Mixpanel https://mixpanel.com/

  • 优:可做到按需配置,同时不会像全埋点一样产生大量无用数据
  • 缺: 运行时参数比较难获取,页面结构变化时,可能需要进行重新配置

无埋点(全埋点):自动采集全部事件并上报数据,数据在后端过滤计算

  • 优:可以收集所有用户行为,全面
  • 缺:无效的数据很多、上报数据量大

需要一个准确、高效、埋点与业务解耦、项目迁移工作量小的埋点方案

声明式的组件化埋点 + 缓冲队列

  • 为了解决埋点代码与业务逻辑耦合的问题,我们认为可以在视图层处理,埋点可以归纳为两大类,点击与曝光埋点。我们可以抽象出两个组件分别处理这两种场景。
  • 在一些场景下快速滑动、频繁点击会在短时间打出大量的点,造成频繁的接口调用,这在移动端是要避免的,针对这种场景我们引入了缓冲队列,产生的点位信息先进入队列,通过定时任务分批次上报数据,针对不同类型的点也可以应用不同的上报频率。
  • 目前对于一些字段采用的是人工拼接,比如 BI 定义的 _mspm2 等相关通用字段,类似这种我们完全可以在库统一处理,既不容易出错,也方便后期拓展。
  • 对于页面级曝光,我们可以在埋点库初始化后自动注册关于页面曝光的相关事件,不需要使用者关心


    image.png

点击埋点

点击埋点我们开始的思考是提供一个组件,包裹需要进行点击埋点的 dom 元素,也有可能是组件,然后给子元素绑定点击事件,当用户触发事件时进行埋点相关处理。

按照上述思路我们就必须绑定点击事件到 dom 上,但是我们又不想引入额外的 dom 元素,因为这会增加 dom 结构层级,给使用者带来麻烦,这样留给我们的操作空间就剩下 props.children ,所以我们去递归 TrackerClick 组件的 children,找到最外层的dom元素,同时要求 TrackerClick 下面必须有一个 container 元素,按照这个思路我们进行了处理。

export default function TrackerClick({
    name,
    extra,
    immediate,
    children,
}) {
    handleClick = () => {
        // todo append queue
    };

    function AddClickEvent(ele) {
        return React.cloneElement(ele, {
            onClick: (e) => {
                const originClick = ele.props.onClick || noop;
                originClick.call(ele, e);
                handleClick();
            }
        });
    }

    function findHtmlElement(ele) {
        if (typeof ele.type === 'function') {
            if (ele.type.prototype instanceof React.Component) {
                ele = new ele.type(ele.props).render();
            } else {
                ele = ele.type(ele.props);
            }
        }
        if (typeof ele.type === 'string') {
            return AddClickEvent(ele);
        }
        return React.cloneElement(ele, {
            children: findHtmlElement(ele.props.children)
        });
    }

    return findHtmlElement(React.Children.only(children));
}

// case1
<TrackerClick name='namespace.click'>
    <button>点击</button>
</TrackerClick>

// case2
<TrackerClick name='namespace.click'>
    <CustomerComp>
        <button>点击</button>
    </CustomerComp>
</TrackerClick>

从使用上来说很简便,达到了我们的目的。但是经过我们的实践也发现了一些问题,比如使用者并不清楚里面的实现细节,有可能里面没有一个 container 包裹,也可能使用了 React.Fragment 造成一些不可预估的行为、同时也无形的增加了dom结构层级(虽然我们没有引入,但是我们在告诉用户,你最好有个 container )。

我们又在反思这种方案的合理性,虽然使用上带来了便捷,但是带来了不确定性。经过讨论我们决定把绑定的工作交给组件使用者,我们只需要明确告诉他可以使用哪些方法,这是确定性的工作。使用方只需要把触发的回调绑定到对应的事件上即可。

改造后如下:

<TrackerClick name='namespace.click'>
{
    ({ handleClick }) => <button onClick={handleClick}>点击坑位</button>
}
</TrackerClick>

曝光埋点

曝光对于我们来说一直是比较麻烦的,我们先来看看曝光埋点的一些要求:

  • 元素出现在视窗内一定的比例才算一次合法的曝光
  • 元素在视窗内停留的时长达到一定的标准才算曝光
  • 统计元素曝光时长

站在前端的角度看实现这三点就比较复杂了,再加上一些分页、虚列表的场景就更加繁琐,带着这些问题调研了 IntersectionObserver

IntersectionObservers calculate how much of a target element overlaps (or "intersects with") the visible portion of a page, also known as the browser's "viewport"

IntersectionObservers计算目标元素与页面可见部分的重叠程度(或 "相交"),也被称为浏览器的 "视口"。

[图片上传失败...(image-acf860-1605579661870)]

const intersectionObserver = new IntersectionObserver(function(entries) {
  // If intersectionRatio is 0, the target is out of view
  // and we do not need to do anything.
  if (entries[0].intersectionRatio <= 0) return;

  console.log('Loaded new items');
}, {
    // 曝光阈值
    threshold: 0
});
// start observing
intersectionObserver.observe(document.querySelector('.scrollerFooter'));

上面是 MDN 的一个例子,所以我们是可以知道元素什么时候进入以及什么时候离开 viewport,间接的上面三点需求我们都可以实现。

经过调研,在能力方面可以满足我们的需求、兼容性方面有对应的intersection-observer polyfill; 对于分页、虚列表,我们只需要关注我们需要观测的列表item,所以我们需要实现一个高性能的 ReactObserver 组件来提供 intersection-observer 的能力并对外提供相应的回调。如何实现一个高性能的Observer此处不做赘述。

下面是曝光组件绑定 dom 的两种方式

// case1: 直接绑定dom
render() {
    return (
        <div styleName='tracker-exposure'>
            {
                arr.map((item, i) => (
                    <TrackerExposure
                        name='pagination.impress'
                        extra={{ modulePosition: i + 1 }}
                    >
                        {({ addRef }) => <div ref={addRef}>{i + 1}</div>}
                    </TrackerExposure>
                ))
            }
        </div>
    );
}

// case2: 自定义组件
const Test = React.forwardRef((props, ref) => (<div ref={ref} style={{
        width: '150px',
        height: '150px',
        border: '1px solid gray'
    }}>TEST</div>)
)

render() {
    return (<div styleName="tracker-exposure">
        {
            arr.map((item, i) => <TrackerExposure
                name="pagination.impress"
                extra={{ modulePosition: i + 1 }}>
                {
                    ({ addRef }) => <Test ref={addRef} />
                }
            </TrackerExposure>
            )
        }

    </div>)
}

使用上我们仅提供一个 addRef 用以获取 dom 执行监听工作,其他工作都交给库来处理,曝光变得如此简单。针对上述3点要求,我们提供配置如下:

  • threshold: 曝光阈值,当 element 出现在视窗多少比例触发
  • viewingTime:元素曝光时长,用来判断是否是一次时长合规的曝光
  • once:是否重复打曝光埋点

运行时参数

一般固定的参数我们会放在config配置文件中管理,当然也有一些运行时的参数,比如 userId,modulePosition 等运行时字段,针对这种场景我们提供 extra props 通过组件的 props 传递,在组件内部拼装,使用时只需要传入对应业务字段即可。

5.5 appendQueue

一些场景下我们没法绑定事件到dom上,比如原生的元素:audio、video,以及封装层级很深的业务组件,类似这种只对外提供了回调,针对这种场景我们提供了 appendQueue 方法,把点加入到缓冲队列中。

appendQueue({
    name: 'module.click',
    action: 'click',
    extra: {
        userId: 'xxx',
    }
})

定时任务

我们的设计是所有产生的点都会进入缓冲队列中,通过定时任务上报。目前策略是点击类上报频率 1000ms,曝光类 3000ms,当然这个间隔也不是凭空想象的,经过跟算法、BI 讨论商定出来的,兼顾了前端的需求与算法那边实时性的要求,目前这两个值也是支持配置的。

关于定时任务的时间间隔,我们取点击和曝光上报频率的最大公约数,以减少执行次数。

页面曝光

我们在初始化的时候会根据配置文件中约定的字段判断是否需要处理页面曝光;

页面曝光的关键是采集页面曝光的时机,浏览器的页面生命周期标准和规范才开始制定没多久,各个厂商支持的都不是很好,参考 Chrome 的页面生命周期中的 visibilitychange 事件作为采集页面曝光的时机。

page life

visiblitychange 的浏览器兼容情况

[图片上传失败...(image-35f277-1605579661870)]

使用

import Tracker, {
    TrackerExposure,
    appendQueue
} from 'music/tracker';

const generateConfig = () => ({
    opus: {
        mspm: 'xxxx091781c235b0c828xxxx'
    },
    'playstart': {
        mspm: 'xxxx91981c235b0c8286xxxx',
        _resource_1_id: '',
        _resource_1_type: 'school'
    },
    viewstart: {
        mspm: 'xxxxd091781c235b0c828xxx',
        type: 'page'
    },
    viewend: {
        mspm: 'xxxx17b1b200b0c2e3xxxxxx',
        type: 'page',
        _time: ''
    }
});

export default Tracker;
export {
    generateConfig,
    TrackerExposure,
    appendQueue
};

import React, { useEffect, useState } from 'react';
import Tracker, { generateConfig, TrackerExposure, appendQueue } from './tracker.js';

const Demo = () => {
    const [opusList, setOpusList] = useState([]);

    useEffect(() => {
        Tracker.init({
            common: {
                osVer: 'xxx',
                activityId: 'xxx',
            },
            config: generateConfig()
        });

        // fetch opuslist
        setOpusList(opus);
    }, []);

    const handleStart = () => {
        appendQueue({
            name: 'playstart',
            action: 'playstart'
        });
    }

    return <>
        {
            opusList.map(opus => <TrackerExposure 
                    start="opus"
                    startExtra={{opusId: opus.id}}
                    threshold={0.5}>
                {
                    ({ addRef }) => <div ref={addRef} >{opus.name}</div>
                }
            </TrackerExposure>)
        }
        <Player onStart={handleStart}>
    <>;
}

总结

我们在音街移动站中进行了迁移、在多个运营活动中进行了使用,达到了我们预期的目标;在提效方面,埋点库把费时的部分处理了,我们需要做的就是从埋点平台把坑位信息放入配置文件,业务开发的时候使用对应的组件就可以了,几乎没有太大的成本,且对于代码复用和维护来说也达到了目的。

在使用过程中发现对于点击类埋点 appendQueue 使用频率远高于 TrackerClick 组件,因为大部分元素的点击事件都有他自己的回调函数,但是我们使用 TrackerClick 的初衷是埋点代码和业务代码解耦,这个也要根据实际场景去选择。

参考资料

本文发布自 网易云音乐大前端团队,文章未经授权禁止任何形式的转载。我们常年招收前端、iOS、Android,如果你准备换工作,又恰好喜欢云音乐,那就加入我们 grp.music-fe(at)corp.netease.com!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 219,427评论 6 508
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,551评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 165,747评论 0 356
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,939评论 1 295
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,955评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,737评论 1 305
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,448评论 3 420
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,352评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,834评论 1 317
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,992评论 3 338
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,133评论 1 351
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,815评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,477评论 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 32,022评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,147评论 1 272
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,398评论 3 373
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 45,077评论 2 355

推荐阅读更多精彩内容