1

概述

通常一个全新功能上线都会采集用户行为数据,分析新功能使用情况,便于后续完善优化。而采集数据的行为一般有三个维度:元素点击、元素悬停、元素曝光,下文将研讨如何优雅地采集这三个维度的数据。

方案

原生采集

原生采集顾名思义就是不做任何封装,直接调用第三方数据采集平台提供的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事件,只有在鼠标移出包裹元素范围才会触发;
    image.png
  • 曝光采集,初始进入页面,此时用户并没有触发滚动事件,当目标元素在可视区域内,或者用户交互过程使得目标元素呈现在可视区域内,该过程也需要采集曝光。
    image.png
    image.png
    这两种场景的关键在于知道目标元素何时呈现,然后再去判断是否在可视区域。当初次渲染或者更新,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上报,因此我们可以利用事件捕获冒泡机制拦截事件流,统一封装采集逻辑,配置项集成在目标元素上。
image.png

<!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 APIcore-js3并没有实现,可单独引入polyfill来实现;
  • 悬停采集方案,一种是使用mouseentermouseout、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实现更为简单,便于理解和维护。第三种插拔式插件封装好处在于可在不同技术栈中使用,但配置项较为复杂。


记得要微笑
1.9k 声望4.5k 粉丝

知不足而奋进,望远山而前行,卯足劲,不减热爱。


引用和评论

0 条评论