概述
通常一个全新功能上线都会采集用户行为数据,分析新功能使用情况,便于后续完善优化。而采集数据的行为一般有三个维度:元素点击、元素悬停、元素曝光,下文将研讨如何优雅地采集这三个维度的数据。
方案
原生采集
原生采集顾名思义就是不做任何封装,直接调用第三方数据采集平台提供的SDK
(比如,神策提供的Web JS SDK
)。
const memoRef = useRef({});
const wait = 2000;
const handleClickTrack = () => {
if (memoRef.current.prevClickTime) {
// 已经采集过,无需再次采集
return;
}
if (wait) {
// 频繁点击设置采集时间间隔
if (Date.now() - memoRef.current.prevClickTime >= wait) {
cassSensors.track(eventName, eventData);
memoRef.current.prevClickTime = Date.now();
}
} else {
// 每次都触发采集
cassSensors.track(eventName, eventData);
memoRef.current.prevClickTime = Date.now();
}
}
<Button onClick={handleClickTrack} />
这种采集方式简单粗暴可靠,理解简单,但缺点也突出,目标采集元素的组件内部都需要实现采集逻辑,对业务代码的侵入性较强,不便于长期迭代维护。比如,有一天公司决定用GrowingIO
替换成神策Web JS SDK
,是不是涉及到的每个工程文件都要更改代码呢?)。
Hooks
Hooks
利用事件冒泡机制精准拦截目标元素采集行为,属于一种切面思想,抽离采集逻辑,降低对业务组件的侵入性。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<script src="https://cdn.bootcdn.net/ajax/libs/react/18.2.0/umd/react.development.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/react-dom/18.2.0/umd/react-dom.development.js"></script>
<style>
html,
body {
width: 100%;
height: 100%;
overflow: hidden;
}
#root {
width: 100%;
height: 100%;
overflow-y: scroll;
}
.container {
display: flex;
flex-direction: column;
align-items: center;
}
.card {
width: 400px;
height: 200px;
border: 1px solid #8c8c8c;
margin-bottom: 10px;
}
.wrap {
position: relative;
display: inline-block;
overflow: hidden;
}
.target {
position: absolute;
left: 50%;
top: 50%;
transform: translateX(-50%) translateY(-50%);
width: 100px;
height: 50px;
border: 1px solid #8c8c8c;
background-color: #ccc;
}
</style>
</head>
<body>
<div id="root"></div>
<script>
/** 上报函数 */
window.cassSensors = {
track: (eventName, eventData) => {
console.log(eventName, eventData);
},
};
/** hooks */
const useTrackable = (comp, options) => {
const { eventName, eventType, eventData, once, wait } = options;
/** 缓存埋点<string, object> */
const momeRef = React.useRef(new Map());
/** 当前元素的唯一id */
const uuidRef = React.useRef(Math.random().toString(36).slice(2));
/** 渲染Ref */
const renderRef = React.createRef(null);
/** 初始化缓存 */
React.useMemo(() => {
momeRef.current.set(uuidRef.current, {});
}, []);
/** 点击埋点 */
const handleClickTrack = React.useCallback(() => {
const uuid = uuidRef.current;
if (once && momeRef.current.get(uuid).prevClickTime) {
/** 已经采集过,不用再次采集 */
return;
}
if (wait) {
if (
Date.now() - (momeRef.current.get(uuid).prevClickTime || 0) >=
wait
) {
cassSensors.track(eventName, eventData);
momeRef.current.get(uuid).prevClickTime = Date.now();
}
} else {
cassSensors.track(eventName, eventData);
momeRef.current.get(uuid).prevClickTime = Date.now();
}
}, []);
/** 悬停埋点 */
const handleMouseoverTrack = React.useCallback((e) => {
const uuid = uuidRef.current;
if (once && momeRef.current.get(uuid).prevHoverTime) {
/** 已经采集过,不用再次采集 */
return;
}
if (momeRef.current.get(uuid).hoverTimer) {
return;
}
if (wait) {
momeRef.current.get(uuid).hoverTimer = setTimeout(() => {
cassSensors.track(eventName, eventData);
momeRef.current.get(uuid).prevHoverTime = Date.now();
clearTimeout(momeRef.current.get(uuid).hoverTimer);
momeRef.current.get(uuid).hoverTimer = null; // clearTimeout可以清除定时器,但不会将hoverTimer值置🈳️
}, wait);
} else {
cassSensors.track(eventName, eventData);
momeRef.current.get(uuid).prevHoverTime = Date.now();
clearTimeout(momeRef.current.get(uuid).hoverTimer);
momeRef.current.get(uuid).hoverTimer = null;
}
});
const handleMouseleaveTrack = React.useCallback((e) => {
/** 清除定时器 */
const uuid = uuidRef.current;
clearTimeout(momeRef.current.get(uuid).hoverTimer);
momeRef.current.get(uuid).hoverTimer = null;
});
// 是否在可视区域内(屏幕中央 上下80%范围)
const isInViewPort = React.useCallback((ele) => {
var viewWidth = document.documentElement.clientWidth;
var viewHeight = document.documentElement.clientHeight;
var elePos = ele.getBoundingClientRect();
return (
elePos.top >= viewHeight * 0.1 &&
elePos.left >= 0 &&
elePos.right <= viewWidth &&
elePos.bottom <= viewHeight * 0.9
);
});
/** 曝光埋点 */
const handleScrollTrack = React.useCallback(() => {
const uuid = uuidRef.current;
const targetEle = document.getElementById(uuid);
if (once && momeRef.current.get(uuid).prevScrollTime) {
return;
}
if (isInViewPort(targetEle)) {
if (wait) {
if (momeRef.current.get(uuid).scrollTimer) {
return;
}
momeRef.current.get(uuid).scrollTimer = setTimeout(() => {
cassSensors.track(eventName, eventData);
momeRef.current.get(uuid).prevScrollTime = Date.now();
clearTimeout(momeRef.current.get(uuid).scrollTimer);
momeRef.current.get(uuid).scrollTimer = null;
}, wait);
} else {
cassSensors.track(eventName, eventData);
momeRef.current.get(uuid).prevScrollTime = Date.now();
clearTimeout(momeRef.current.get(uuid).scrollTimer);
momeRef.current.get(uuid).scrollTimer = null;
}
} else {
clearTimeout(momeRef.current.get(uuid).scrollTimer);
momeRef.current.get(uuid).scrollTimer = null;
}
});
/** 防抖 */
const debounce = React.useCallback((fn, wait) => {
let timer = null;
return function (args) {
const that = this;
if (timer) {
clearTimeout(timer);
timer = null;
}
timer = setTimeout(() => {
fn.call(that, args);
clearTimeout(timer);
timer = null;
}, wait);
};
});
React.useEffect(() => {
const $wrap = document.getElementById(uuidRef.current);
if (eventType === "click" && $wrap) {
/** 冒泡捕获 */
$wrap.addEventListener(eventType, handleClickTrack, false);
}
/** mouseenter和mouseleave事件不支持捕获冒泡 */
if (eventType === "mouseover" && $wrap) {
$wrap.addEventListener("mouseenter", handleMouseoverTrack, false);
// 这儿不能使用mouseout,当包裹元素里含有子元素,如果鼠标从包裹元素移到子元素也会触发mouseout,但鼠标并没有离开包裹元素范围
// 只有鼠标离开包裹元素范围才会触发mouseleave
$wrap.addEventListener("mouseleave", handleMouseleaveTrack, false);
}
/** scroll也不支持事件捕获冒泡 */
const debounceScrollTrack = debounce(handleScrollTrack, 200);
if (eventType === "scroll") {
const $container = document.getElementById("root");
if ($container) {
$container.addEventListener("scroll", debounceScrollTrack, false);
}
}
return () => {
/** 注销事件 */
if (eventType === "click" && $wrap) {
$wrap.removeEventListener(eventType, handleClickTrack, false);
}
if (eventType === "mouseover" && $wrap) {
$wrap.removeEventListener('mouseenter', handleMouseoverTrack, false);
$wrap.removeEventListener('mouseleave', handleMouseleaveTrack, false);
}
if (eventType === "scroll") {
const $container = document.getElementById("root");
if ($container) {
$container.removeEventListener("scroll", debounceScrollTrack, false);
}
}
};
}, []);
React.useEffect(() => {
if (eventType === "scroll" && renderRef && renderRef.current) {
const uuid = uuidRef.current;
// 说明目标元素已经渲染到页面上,可以采集到页面没有滚动时的第一帧画面曝光的元素
if (
isInViewPort(renderRef.current) &&
!momeRef.current.get(uuid).scrollTimer &&
!momeRef.current.get(uuid).prevScrollTime
) {
if (wait) {
momeRef.current.get(uuid).scrollTimer = setTimeout(() => {
cassSensors.track(eventName, eventData);
momeRef.current.get(uuid).prevScrollTime = Date.now();
clearTimeout(momeRef.current.get(uuid).scrollTimer);
momeRef.current.get(uuid).scrollTimer = null;
}, wait);
} else {
cassSensors.track(eventName, eventData);
momeRef.current.get(uuid).prevScrollTime = Date.now();
clearTimeout(momeRef.current.get(uuid).scrollTimer);
momeRef.current.get(uuid).scrollTimer = null;
}
}
}
}, [renderRef]);
return React.createElement(
"div",
{ id: uuidRef.current, ref: renderRef, className: "wrap" },
React.createElement(comp, {}, null)
);
};
/** 目标元素 */
const Button = () => {
return React.createElement(
"div",
{ className: "card" },
React.createElement("span", {}, React.createElement('div', { className: 'target' }, '目标元素'))
);
};
/** */
const TrackButtonWrap = () => {
/** ScrollTrackButton得到的是一个object,不能作为createElement的第一个参数,必须为string | functionComponent | classComponent */
const ScrollTrackButton = useTrackable(Button, {
once: true,
wait: 2000,
eventName: "SCROLL_EVENT",
eventType: "scroll",
eventData: JSON.stringify({ id: "scroll" }),
});
const ClickTrackButton = useTrackable(() => ScrollTrackButton, {
once: false,
wait: 2000,
eventName: "CLICK_EVENT",
eventType: "click",
eventData: JSON.stringify({ id: "click" }),
});
const HoverTrackButton = useTrackable(() => ClickTrackButton, {
once: true,
wait: 2000,
eventName: "HOVER_EVENT",
eventType: "mouseover",
eventData: JSON.stringify({ id: "mouseover" }),
});
return React.createElement("div", {}, HoverTrackButton);
};
ReactDOM.createRoot(document.getElementById("root")).render(
React.createElement("div", { className: "container" }, [
React.createElement("div", { className: "card" }, null),
React.createElement(TrackButtonWrap, {}, null),
React.createElement("div", { className: "card" }, null),
React.createElement("div", { className: "card" }, null),
React.createElement("div", { className: "card" }, null),
React.createElement("div", { className: "card" }, null),
])
);
</script>
</body>
</html>
上述代码中有以下几处需要注意:
- 悬停采集,鼠标从包裹元素移动到子元素时会触发
mouseout
事件,导致采集行为不准确,改用监听mouseleave
事件,只有在鼠标移出包裹元素范围才会触发; - 曝光采集,初始进入页面,此时用户并没有触发滚动事件,当目标元素在可视区域内,或者用户交互过程使得目标元素呈现在可视区域内,该过程也需要采集曝光。
这两种场景的关键在于知道目标元素何时呈现,然后再去判断是否在可视区域。当初次渲染或者更新,React
生命周期precommit
阶段会重新计算绑定在元素上的ref
,当ref.current
有值,就代表元素已经挂载,可采集曝光。 - 如果目标元素既要采集点击、悬停,又要采集曝光,可使用封装好的钩子函数
useTrackable
嵌套包裹
HOC
高阶组件与Hooks
实现思想类似,但是高阶组件需要将目标元素单独抽离成组件包装,相对而言,没有Hooks
简便
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<script src="https://cdn.bootcdn.net/ajax/libs/react/18.2.0/umd/react.development.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/react-dom/18.2.0/umd/react-dom.development.js"></script>
<style>
html,
body {
width: 100%;
height: 100%;
overflow: hidden;
}
#root {
width: 100%;
height: 100%;
overflow-y: scroll;
}
.container {
display: flex;
flex-direction: column;
align-items: center;
}
.card {
width: 400px;
height: 200px;
border: 1px solid #8c8c8c;
margin-bottom: 10px;
}
.wrap {
position: relative;
display: inline-block;
overflow: hidden;
}
.target {
position: absolute;
left: 50%;
top: 50%;
transform: translateX(-50%) translateY(-50%);
width: 100px;
height: 50px;
border: 1px solid #8c8c8c;
background-color: #ccc;
}
</style>
</head>
<body>
<div id="root"></div>
<script>
/** 上报函数 */
window.cassSensors = {
track: (eventName, eventData) => {
console.log(eventName, eventData);
},
};
const trackHOC = (comp, options) => {
const { eventName, eventType, eventData, once, wait } = options;
const uuid = Math.random().toString(36).slice(2);
const memo = new Map().set(uuid, {}); // 利用闭包记录采集行为
return class TrackWrap extends React.Component {
constructor(props) {
super(props);
}
/** 点击采集 */
handleClickTrack = () => {
if (once && memo.get(uuid).prevClickTime) {
/** 已经采集过,不用再次采集 */
return;
}
if (wait) {
if (Date.now() - (memo.get(uuid).prevClickTime || 0) >= wait) {
cassSensors.track(eventName, eventData);
memo.get(uuid).prevClickTime = Date.now();
}
} else {
cassSensors.track(eventName, eventData);
memo.get(uuid).prevClickTime = Date.now();
}
};
/** 悬停采集 */
handleMouseenterTrack = () => {
if (once && memo.get(uuid).prevHoverTime) {
/** 已经采集过,不用再次采集 */
return;
}
if (memo.get(uuid).hoverTimer) {
return;
}
if (wait) {
memo.get(uuid).hoverTimer = setTimeout(() => {
cassSensors.track(eventName, eventData);
memo.get(uuid).prevHoverTime = Date.now();
clearTimeout(memo.get(uuid).hoverTimer);
memo.get(uuid).hoverTimer = null; // clearTimeout可以清除定时器,但不会将hoverTimer值置🈳️
}, wait);
} else {
cassSensors.track(eventName, eventData);
memo.get(uuid).prevHoverTime = Date.now();
clearTimeout(memo.get(uuid).hoverTimer);
memo.get(uuid).hoverTimer = null;
}
};
handleMouseleaveTrack = (e) => {
/** 清除定时器 */
clearTimeout(memo.get(uuid).hoverTimer);
memo.get(uuid).hoverTimer = null;
};
// 是否在可视区域内(屏幕中央 上下80%范围)
isInViewPort = (ele) => {
var viewWidth = document.documentElement.clientWidth;
var viewHeight = document.documentElement.clientHeight;
var elePos = ele.getBoundingClientRect();
return (
elePos.top >= viewHeight * 0.1 &&
elePos.left >= 0 &&
elePos.right <= viewWidth &&
elePos.bottom <= viewHeight * 0.9
);
};
/** 防抖 */
debounce = (fn, wait) => {
let timer = null;
return function (args) {
const that = this;
if (timer) {
clearTimeout(timer);
timer = null;
}
timer = setTimeout(() => {
fn.call(that, args);
clearTimeout(timer);
timer = null;
}, wait);
};
};
handleScrollTrack = () => {
const targetEle = document.getElementById(uuid);
if (once && memo.get(uuid).prevScrollTime) {
return;
}
if (this.isInViewPort(targetEle)) {
if (wait) {
if (memo.get(uuid).scrollTimer) {
return;
}
memo.get(uuid).scrollTimer = setTimeout(() => {
cassSensors.track(eventName, eventData);
memo.get(uuid).prevScrollTime = Date.now();
clearTimeout(memo.get(uuid).scrollTimer);
memo.get(uuid).scrollTimer = null;
}, wait);
} else {
cassSensors.track(eventName, eventData);
memo.get(uuid).prevScrollTime = Date.now();
clearTimeout(memo.get(uuid).scrollTimer);
memo.get(uuid).scrollTimer = null;
}
} else {
clearTimeout(memo.get(uuid).scrollTimer);
memo.get(uuid).scrollTimer = null;
}
};
componentDidMount() {
const $wrap = document.getElementById(uuid);
if (eventType === "click" && $wrap) {
/** 冒泡捕获 */
$wrap.addEventListener(eventType, this.handleClickTrack, false);
}
/** mouseenter和mouseleave事件不支持捕获冒泡 */
if (eventType === "mouseover" && $wrap) {
$wrap.addEventListener(
"mouseenter",
this.handleMouseenterTrack,
false
);
$wrap.addEventListener(
"mouseleave",
this.handleMouseleaveTrack,
false
);
}
/** scroll也不支持事件捕获冒泡 */
if (eventType === "scroll") {
const $container = document.getElementById("root");
const targetEle = document.getElementById(uuid);
if ($container) {
$container.addEventListener(
"scroll",
this.debounce(this.handleScrollTrack, 100),
false
);
}
// 说明目标元素已经渲染到页面上,可以采集到页面没有滚动时可视区域曝光的元素
if (
targetEle &&
this.isInViewPort(targetEle) &&
!memo.get(uuid).scrollTimer &&
!memo.get(uuid).prevScrollTime
) {
if (wait) {
memo.get(uuid).scrollTimer = setTimeout(() => {
cassSensors.track(eventName, eventData);
memo.get(uuid).prevScrollTime = Date.now();
clearTimeout(memo.get(uuid).scrollTimer);
memo.get(uuid).scrollTimer = null;
}, wait);
} else {
cassSensors.track(eventName, eventData);
memo.get(uuid).prevScrollTime = Date.now();
clearTimeout(memo.get(uuid).scrollTimer);
memo.get(uuid).scrollTimer = null;
}
}
}
}
render() {
return React.createElement(
"div",
{ id: uuid, className: "wrap" },
React.createElement(comp, {}, null)
);
}
};
};
/** 目标元素 */
const Button = () => {
return React.createElement(
"div",
{ className: "card" },
React.createElement("div", { className: "target" }, "目标元素")
);
};
/** */
const TrackButtonWrap = () => {
/** ScrollTrackButton得到的是一个object,不能作为createElement的第一个参数,必须为string | functionComponent | classComponent */
const ScrollTrackButton = trackHOC(Button, {
once: false,
wait: 2000,
eventName: "SCROLL_EVENT",
eventType: "scroll",
eventData: JSON.stringify({ id: "scroll" }),
});
const ClickTrackButton = trackHOC(ScrollTrackButton, {
once: true,
eventName: "CLICK_EVENT",
eventType: "click",
eventData: JSON.stringify({ id: "click" }),
});
const HoverTrackButton = trackHOC(ClickTrackButton, {
once: false,
wait: 2000,
eventName: "HOVER_EVENT",
eventType: "mouseover",
eventData: JSON.stringify({ id: "mouseover" }),
});
return React.createElement(HoverTrackButton, {}, null);
};
ReactDOM.createRoot(document.getElementById("root")).render(
React.createElement("div", { className: "container" }, [
React.createElement("div", { className: "card" }, null),
React.createElement(TrackButtonWrap, {}, null),
React.createElement("div", { className: "card" }, null),
React.createElement("div", { className: "card" }, null),
React.createElement("div", { className: "card" }, null),
React.createElement("div", { className: "card" }, null),
])
);
</script>
</body>
</html>
插拔式
前端工程的技术栈有很多,Spring MVC、Vue、React
等,如果每种技术栈都去实现一遍,同步维护,成本就比较高了。数据采集都是通过事件机制触发,调用第三方SDK
上报,因此我们可以利用事件捕获冒泡机制拦截事件流,统一封装采集逻辑,配置项集成在目标元素上。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- IE 11以下不兼容MutationObserver API,引入polyfill -->
<script src="./MutationObserver.js"></script>
<title>Document</title>
<style>
html,
body {
width: 100%;
height: 100%;
overflow: hidden;
}
#root {
width: 100%;
height: 100%;
overflow-y: scroll;
}
.container {
display: flex;
flex-direction: column;
align-items: center;
}
.card {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
width: 400px;
height: 200px;
border: 1px solid #8c8c8c;
margin-bottom: 10px;
}
.target {
position: absolute;
left: 50%;
top: 50%;
transform: translateX(-50%) translateY(-50%);
width: 100px;
height: 50px;
border: 1px solid #8c8c8c;
background-color: #ccc;
}
</style>
</head>
<body>
<div id="root">
<div class="container">
<div class="card"></div>
<div class="card">
<div
data-exposure
data-eventname="SCROLL_EVENT"
data-eventtype="scroll"
data-eventdata="{ id: 'scroll' }"
data-once="false"
data-wait="2000"
data-uuid="ucwbsrt6l48"
>
<div
onclick="handleClickTrack()"
>
<div
data-eventname="MOUSEOVER_EVENT"
data-eventtype="mouseover"
data-eventdata="{ id: 'mouseover' }"
data-once="false"
data-wait="2000"
data-uuid="v3s7a7030yp"
>
目标元素
</div>
</div>
</div>
</div>
<div class="card"></div>
<div class="card"></div>
</div>
</div>
<script>
// 手动采集点击事件
var handleClickTrack = function() {
cassSensors.track('CLICK_EVENT', { id: 'click' });
}
</script>
<script>
/** 上报函数 */
window.cassSensors = {
track: function (eventName, eventData) {
console.log(eventName, eventData);
},
};
/**
* 采集元素属性
* data-eventname: 事件名称
* data-eventdata: 采集数据,JSON字符串类型
* data-eventtype: 采集事件类型,'click' | 'mouseover' | 'scroll','click'表示点击事件,'mouseover'表示悬浮事件,'scroll'表示曝光事件
* data-once: 事件触发次数,'true' | 'false',true表示只会采集一次,false表示每次触发都会采集
* data-wait: 采集间隔事件,毫秒,事件每隔多少毫秒才能触发
* data-desc: 采集描述
* data-uuid: 采集元素的唯一标识,用于控制多个同元素采集行为,记录每个条目是否采集了,不能存在uuid相同
* data-exposure: 元素是否曝光采集
* data-cancollected: 是否采集,默认可以采集
*
* 注意事项:
* 1、如果一个元素即要曝光又要点击采集 或者 既要曝光又要悬停,曝光配置需要在悬停或者点击配置上层,不然event.target取值会有问题;
* 2、如果一个元素既要点击又要悬停,如果同时配置会导致一个其中一个事件采集不到,因为event.target只会取层级高的元素;
* 3、如果采集元素由组件封装在内,配置无法穿透组件设置到目标元素上,上报不会触发。一是使用window.cassSensorsTrack手动上报;二是在外层元素添加onMousedownCapture事件给里面采集元素添加配置属性;
*/
;(function () {
// 需要绑定的事件类型
var eventTypes = ["click", "mouseover", "scroll"];
// 缓存进入页面后,所有埋点事件的触发数据,比如是否已经触发过。将变量抛出,以便外面控制
window.cassSensorsMemo = {};
// 是否在可视区域内(屏幕中央 上下90%范围)
var isInViewPort = function (ele) {
var viewWidth = document.documentElement.clientWidth;
var viewHeight = document.documentElement.clientHeight;
var elePos = ele.getBoundingClientRect();
return (
elePos.top >= viewHeight * 0.05 &&
elePos.left >= 0 &&
elePos.right <= viewWidth &&
elePos.bottom <= viewHeight * 0.95
);
};
/**
* @descption 上报函数,挂载到全局对象上,兼容手动非配置上报场景
* @param {object} options eventName, subEvent, desc, eventType, eventData, canFragment
*/
window.cassSensorsTrack = (options) => {
cassSensors.track(options.eventName, options.eventData);
};
/** 曝光拦截 */
var handleExposureIntercept = function () {
// 获取所有采集曝光的元素
var $exposureEles = document.querySelectorAll("[data-exposure]");
try {
for (var index = 0; index < $exposureEles.length; index++) {
var $exposureEle = $exposureEles[index];
var exposureEventName = $exposureEle.getAttribute("data-eventname");
var exposureEventData = $exposureEle.getAttribute("data-eventdata");
var exposureOnce = $exposureEle.getAttribute("data-once") || "false";
var exposureWait = Number($exposureEle.getAttribute("data-wait") || "0");
var exposureDesc = $exposureEle.getAttribute("data-desc") || "";
var exposureUUID = $exposureEle.getAttribute("data-uuid") || exposureEventName || "";
var exposureCollected = $exposureEle.getAttribute("data-cancollected") || "true"; // 是否可以采集,默认可以采集
// 如果采集元素没有绑定事件名,直接退出
if (!exposureEventName) throw new Error("曝光元素没有绑定事件名");
if (exposureCollected === "false") return;
// 初始化缓存
if (
!Object.prototype.hasOwnProperty.call(
cassSensorsMemo,
exposureUUID
)
) {
cassSensorsMemo[exposureUUID] = {};
}
// 判断是否在可视区域内
if (isInViewPort($exposureEle)) {
if (
exposureOnce === "true" &&
cassSensorsMemo[exposureUUID].prevExposureTime
) {
// 已经采集过了,不用再次采集
continue;
}
/** 如果已存在定时器了,说明正在曝光采集 */
if (cassSensorsMemo[exposureUUID].exposureTimer) {
continue;
}
if (exposureWait) {
(function (time, uuid, eventName, desc, data) {
cassSensorsMemo[uuid].exposureTimer = setTimeout(
function () {
cassSensorsMemo[uuid].prevExposureTime = Date.now();
cassSensorsTrack({
eventName,
desc,
eventType: "scroll",
eventData: data,
});
clearTimeout(cassSensorsMemo[uuid].exposureTimer);
cassSensorsMemo[uuid].exposureTimer = null; // clearTimeout可以清楚的掉定时器,但不会更改cassSensorsMemo[uuid].exposureTimer的值,需要手动置空
},
time
);
})(
exposureWait,
exposureUUID,
exposureEventName,
exposureDesc,
exposureEventData
);
} else {
// 无需等待
cassSensorsMemo[exposureUUID].prevExposureTime = Date.now();
cassSensorsTrack({
eventName: exposureEventName,
desc: exposureDesc,
eventType: "scroll",
eventData: exposureEventData,
});
clearTimeout(cassSensorsMemo[exposureUUID].exposureTimer);
cassSensorsMemo[exposureUUID].exposureTimer = null;
}
} else {
/** 不在视图区域的清除定时器 */
clearTimeout(cassSensorsMemo[exposureUUID].exposureTimer);
cassSensorsMemo[exposureUUID].exposureTimer = null;
}
}
} catch (error) {
return;
}
};
// 拦截捕获事件
var handleEventIntercept = function (event) {
/** 点击、悬浮 */
if (["click", "mouseover"].includes(event.type)) {
try {
// document元素没有getAttribute方法
var eventName = event.target.getAttribute("data-eventname") || ""; // 事件名称
var eventData = event.target.getAttribute("data-eventdata") || ""; // 采集数据
var eventType = event.target.getAttribute("data-eventtype") || ""; // 事件类型
var once = event.target.getAttribute("data-once") || "false"; // 进入页面后是否只触发一次
var wait = Number(event.target.getAttribute("data-wait") || "0"); // 事件间隔,比如鼠标悬停时长,毫秒
var desc = event.target.getAttribute("data-desc") || ""; // 采集描述
var uuid = event.target.getAttribute("data-uuid") || eventName || ""; // 采集描述
var canCollected = event.target.getAttribute("data-cancollected") || "true"; // 是否可以采集,默认可以采集
if (canCollected === "false") return;
} catch (error) {
return;
}
// 鼠标点击
if (event.type === "click") {
/** 点击的当前元素非采集元素 */
if (eventType !== event.type || !eventName) {
return;
}
// 初始化缓存
if (
!Object.prototype.hasOwnProperty.call(cassSensorsMemo, uuid)
) {
cassSensorsMemo[uuid] = {};
}
if (once === "true" && cassSensorsMemo[uuid].prevClickTime) {
return; // 已经采集过了
}
if (wait) {
if (cassSensorsMemo[uuid].prevClickTime) {
// 上次访问时间点与当前时间点比较
if (
Date.now() - cassSensorsMemo[uuid].prevClickTime >=
wait
) {
cassSensorsMemo[uuid].prevClickTime = Date.now();
cassSensorsTrack({
eventName,
desc,
eventType,
eventData,
});
}
} else {
// 没有触发过
cassSensorsMemo[uuid].prevClickTime = Date.now();
cassSensorsTrack({
eventName,
desc,
eventType,
eventData,
});
}
} else {
// 无需等待
cassSensorsMemo[uuid].prevClickTime = Date.now();
cassSensorsTrack({
eventName,
desc,
eventType,
eventData,
});
}
}
// 悬浮
if (event.type === "mouseover") {
/** 悬停的当前元素非采集元素,将其他采集元素的定时器清除 */
if (eventType !== event.type || !eventName) {
Object.keys(cassSensorsMemo).forEach(function (uid) {
var memoItem = cassSensorsMemo[uid];
if (
Object.prototype.hasOwnProperty.call(memoItem, "hoverTimer")
) {
clearTimeout(memoItem.hoverTimer);
memoItem.hoverTimer = null;
}
});
return;
}
// 初始化缓存
if (
!Object.prototype.hasOwnProperty.call(cassSensorsMemo, uuid)
) {
cassSensorsMemo[uuid] = {};
}
if (once === "true" && cassSensorsMemo[uuid].prevHoverTime) {
return; // 已经采集过了
}
// 如果当前元素已存在定时器,直接return
if (cassSensorsMemo[uuid].hoverTimer) {
return;
}
if (wait) {
// 悬停采集
cassSensorsMemo[uuid].hoverTimer = setTimeout(() => {
cassSensorsMemo[uuid].prevHoverTime = Date.now();
cassSensorsTrack({
eventName,
desc,
eventType,
eventData,
});
// 采集后清除定时器
clearTimeout(cassSensorsMemo[uuid].hoverTimer);
cassSensorsMemo[uuid].hoverTimer = null;
}, wait);
} else {
// 立即触发
cassSensorsMemo[uuid].prevHoverTime = Date.now();
cassSensorsTrack({
eventName,
desc,
eventType,
eventData,
});
}
}
}
/** 曝光 */
if (event.type === "scroll") {
handleExposureIntercept();
}
};
/** 防抖函数 */
var debounce = function (fn, wait) {
var timer = null;
return function (args) {
var that = this;
if (timer) {
clearTimeout(timer);
timer = null;
}
timer = setTimeout(function () {
fn.call(that, args);
clearTimeout(timer);
timer = null;
}, wait);
};
};
/** 初始进入页面,没有触发滚动,可视区域内的目标元素需要采集曝光;或者弹出浮窗内的目标元素也要采集曝光 */
var exposureListener = function () {
// 选择需要观察变动的节点
var targetNode = document.getElementById("root");
// 观察器的配置(需要观察什么变动)
var config = { attributes: false, childList: true, subtree: true };
var debounceExposureIntercept = debounce(
handleExposureIntercept,
200
);
// 当观察到变动时执行的回调函数
var callback = function (mutationsList, observer) {
if (
mutationsList.some(function (mutation) {
// A child node has been added or removed.
return (
mutation.type === "childList" && !!mutation.addedNodes.length
);
})
) {
/** 只有元素节点增加的情况才触发曝光 */
debounceExposureIntercept();
}
};
// 创建一个观察器实例并传入回调函数
var observer = null;
var isIE =
navigator.userAgent.indexOf("compatible") > -1 &&
navigator.userAgent.indexOf("MSIE") > -1;
if (isIE) {
// the return value of "test" must be used
var valid = new RegExp("MSIE (\\d+\\.\\d+);").test(
navigator.userAgent
);
if (parseFloat(RegExp.$1) < 11) {
// IE 11 以下不支持MutationObserver,使用polyfill,虽然JsMutationObserver的实现使用了WeakMap,但该文件之前引入了corejs,有原生实现的WeakMap
// https://github.com/talee/mutationobserver-breaks-characterdata
observer = new JsMutationObserver(callback);
} else {
observer = new MutationObserver(callback);
}
} else {
observer = new MutationObserver(callback);
}
// 以上述配置开始观察目标节点
observer.observe(targetNode, config);
// 之后,可停止观察
window.onunload = function () {
observer.disconnect();
};
};
/** 绑定事件 */
var bindEvents = function () {
var debounceIntercept = debounce(handleEventIntercept, 100);
eventTypes.forEach(function (eventType) {
var isDebounce = ["scroll"].includes(eventType);
document.addEventListener(
eventType,
isDebounce ? debounceIntercept : handleEventIntercept,
true
);
});
window.onunload = function () {
eventTypes.forEach(function (eventType) {
document.removeEventListener(
eventType,
isDebounce ? debounceIntercept : handleEventIntercept,
true
);
});
};
};
window.addEventListener("load", function () {
/** 绑定拦截事件 */
bindEvents();
/** 初始曝光采集 */
exposureListener();
});
})();
</script>
</body>
</html>
有几个特殊场景需要注意:
- 曝光采集,初始页面可视区域的目标元素,和用户交互后呈现出的目标元素应当如何采集?由于此时没有触发滚动事件,
scroll
事件回调捕获不到,可以利用MutationObserver API
监听页面是否有新增DOM
,如果有则执行曝光采集逻辑。IE 11
以下不支持MutationObserver API
,core-js3
并没有实现,可单独引入polyfill
来实现; - 悬停采集方案,一种是使用
mouseenter
与mouseout、mouseleave
组合事件监听悬停;另一种是使用mouseover
监听悬浮,如果悬浮的是目标元素则执行采集逻辑。如果悬浮的不是目标元素则清除所有悬停定时器; 如果一个元素既要点击采集又要悬停采集,嵌套属性配置会导致其中一个事件采集不到,因为
event.target
对应层级高的元素,只拿得到一个属性配置项。解决方法一是改造上述配置规则,可配置多事件属性;二是其中一种事件改成手动采集;<!-- 多事件配置 --> <div data-eventname="['CLICK_EVENT, MOUSEOVER_EVENT']", data-eventtype="['click', 'mouseover']", data-once="['false', 'true']", data-wait="[1000, 2000]", data-eventdata="[{ id: 'click' }, { id: ''mouseover' }]" > 目标元素 </div> <!-- 手动采集 --> <div onclick="handleClickTrack()"> <div data-eventname="MOUSEOVER_EVENT", data-eventtype="mouseover" data-once="true" data-wait="2000" data-eventdata="{ id: 'mouseover' }" > 目标元素 </div> </div> <script> var handleClickTrack = function() { cassSensors.track(eventName, eventData); } </script>
如果目标元素封装在组件内,属性配置项无法穿透组件直接设置到目标元素上,上报不会触发。解决方法一是使用
window.cassSensorsTrack
手动上报;二是在外层元素添加onMousedownCapture
事件捕获拦截,给组件内目标元素添加属性配置;const Button = () => <span id="target"></span> // 手动采集 const handleClickTrack = () => { cassSensors.track(eventName, eventData); } <div onClick={handleClickTrack}><Button /></div> // onMousedownCapture比onClickCapture优先级高,利用该事件给目标元素配置属性 const handleMousedownIntercept = () => { const $target = document.getElementById('target'); if ($target) { $target.setAttribute('data-eventname', 'CLICK_EVENT'); $target.setAttribute('data-eventtype', 'click'); $target.setAttribute('data-eventdata', '{ id: "click" }'); $target.setAttribute('data-once', 'false'); $target.setAttribute('data-wait', '1000'); } } <div onMousedownCapture={handleMousedownIntercept}><Button /></div>
注:封装埋点插件不采用冒泡机制,而采用捕获机制的原因是有些业务场景下,事情需要阻止冒泡,可能影响到数据采集
总结
以上三种方案均能抽离封装采集逻辑,优雅实现数据上报,相对而言,第一种Hooks
实现更为简单,便于理解和维护。第三种插拔式插件封装好处在于可在不同技术栈中使用,但配置项较为复杂。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。