• 手写一个埋点SDK吧~


    请添加图片描述

    您好,如果喜欢我的文章,可以关注我的公众号「量子前端」,将不定期关注推送前端好文~

    写在前面

    博主最近半年的时间都在投入 concis react组件库的开发,最近阶段也是想要做一些市面组件库所没有的东西,concis 主要为业务平台开发提供了一系列组件,而埋点在业务中的实用性是很高的,搭配业务端埋点和后台监控,可以收集到很多信息,如性能参数、错误捕捉、请求响应过慢等一系列问题,因此本文记录了开发一个埋点SDK组件的全过程。

    效果

    先看使用方式吧,这是一个普通的React 项目中的 App.jsx 组件:

    import React from 'react'
    import { Route, Routes } from "react-router-dom";
    import A from './pages/A';
    import B from './pages/B'
    import { Track } from 'concis'
    
    function App() {
      const trackRef = React.useRef();
    
      // 用在项目根目录,定时上报,如每隔一小时上报一次
      setInterval(() => {
        getTrackData();
      }, 60 * 60 * 1000)
    
      function getTrackData() {
        const res = trackRef.current.callbackTrackData();
        //接口上报...
      }
    
      return (
        <div>
          <Routes>
            <Route path="/" element={<A />} />
            <Route path="/a" element={<A />} />
            <Route path="/b" element={<B />} />
          </Routes>
          <Track ref={trackRef} />
        </div>
      )
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30

    Track 组件运行在项目根目录,做到每个页面信息收集的作用,并且向外暴露了 callbackTrackData api,可以结合业务中的场景,在指定时刻收集信息,并上报到后端。

    思路

    Track 组件本身并不复杂,其实就是将一系列数据采集、信息捕捉、请求拦截汇集在了组件内部,并记录在状态中,在需要的时候向外暴露。

    因此在组件中定义这些状态:

    const Track = (props, ref) => {
      const { children } = props;
    
      const [performanceData, setPerformanceData] = useState({});
      const xhrRequestResList = useRef([]);
      const fetchRequestResList = useRef([]);
      const resourceList = useRef({});
      const userInfo = useRef({});
      const errorList = useRef([]);
      const clickEventList = useRef([]);
      
      //...
      
      return (
          //...
      )
      
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • performanceData用于收集页面性能相关参数,如FP、FCP、FMP、LCP、DOM Load、white time等一系列参数。
    • xhrRequestResList用于捕获页面中所有xhr 请求,收集请求方式、响应完成时间。
    • fetchRequestResList用于捕获页面中所有fetch 请求,收集请求方式、响应完成时间。
    • resourceList用于收集页面中所有文件、静态资源的请求数据,如jscssimg
    • userInfo用于收集用户相关信息,如浏览器参数、用户IP、城市、语言等。
    • errorList用于收集发生在生产环境下错误的捕获,包括errorrejectError
    • clickEventList用于收集用户在页面上的点击行为。

    performanceData

    页面加载相关的性能参数代码如下:

    const collectPerformance = async () => {
        const fp = await collectFP();
        const fcp = await collectFCP();
        const lcp = await collectLCP();
        const loadTime = await collectLoadTime();
        const dnsQueryTime = collectDNSQueryTime();
        const tcpConnectTime = collectTCPConnectTime();
        const requestTime = collectRequestTime();
        const parseDOMTreeTime = collectParseDOMTree();
        const whiteTime = collectWhiteTime();
        setPerformanceData({
          fp,
          fcp,
          lcp,
          dnsQueryTime,
          tcpConnectTime,
          requestTime,
          parseDOMTreeTime,
          whiteTime,
          loadTime,
        });
      };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22

    这里以fpfcp举例,主要用到了PerformanceObserver api,收集这些参数,代码如下:

    const collectFP = () => {
      return new Promise((resolve) => {
        const entryHandler = (list) => {
          for (const entry of list.getEntries()) {
            if (entry.name === 'first-paint') {
              resolve(entry);
              observer.disconnect();
            }
          }
        };
        const observer = new PerformanceObserver(entryHandler);
        observer.observe({ type: 'paint', buffered: true });
      });
    };
    
    const collectFCP = () => {
      return new Promise((resolve) => {
        const entryHandler = (list) => {
          for (const entry of list.getEntries()) {
            if (entry.name === 'first-contentful-paint') {
              resolve(entry);
              observer.disconnect();
            }
          }
        };
        const observer = new PerformanceObserver(entryHandler);
        observer.observe({ type: 'paint', buffered: true });
      });
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29

    在这里插入图片描述

    而其他参数则是直接使用了 window.performance.timing 计算得来。

    xhrRequestResList

    捕获xhr请求其实很简单,在原有的XMLHttpRequest.prototype上的opensend方法上记录我们所需要的参数,如urlmethod,同时在send方法中介入loadend方法,当请求完成,整理参数。

    // 统计每个xhr网络请求的信息
    const monitorXHRRequest = (callback) => {
      const originOpen = XMLHttpRequest.prototype.open;
      const originSend = XMLHttpRequest.prototype.send;
      XMLHttpRequest.prototype.open = function newOpen(...args) {
        this.url = args[1];
        this.method = args[0];
        originOpen.apply(this, args);
      };
    
      XMLHttpRequest.prototype.send = function newSend(...args) {
        this.startTime = Date.now();
    
        const onLoadend = () => {
          this.endTime = Date.now();
          this.duration = this.endTime - this.startTime;
    
          const { status, duration, startTime, endTime, url, method } = this;
          const reportData: xhrRequestType = {
            status,
            duration,
            startTime,
            endTime,
            url,
            method: (method || 'GET').toUpperCase(),
            success: status >= 200 && status < 300,
            subType: 'xhr',
            type: 'performance',
          };
          callback(reportData);
          this.removeEventListener('loadend', onLoadend, true);
        };
    
        this.addEventListener('loadend', onLoadend, true);
        originSend.apply(this, args);
      };
    };
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38

    在这里插入图片描述

    当状态码在200~300之间,则判定为success,最后通过异步回调函数的机制,回传到组件中,加入状态。

    fetchRequestResList

    捕获 fetch 思路和 xhr 类似,只不过fetch 本身基于 promise 实现,在重写 fetch api的时候通过promise的形式去写就可以。

    // 统计每个fetch请求的信息
    const monitorFetchRequest = (callback) => {
      const originalFetch = window.fetch;
    
      function overwriteFetch() {
        window.fetch = function newFetch(url, config) {
          const startTime = Date.now();
          const reportData: fetchRequestType = {
            startTime,
            endTime: 0,
            duration: 0,
            success: false,
            status: 0,
            url,
            method: (config?.method || 'GET').toUpperCase(),
            subType: 'fetch',
            type: 'performance',
          };
          return originalFetch(url, config)
            .then((res) => {
              reportData.endTime = Date.now();
              reportData.duration = reportData.endTime - reportData.startTime;
              const data = res.clone();
              reportData.status = data.status;
              reportData.success = data.ok;
              callback(reportData);
              return res;
            })
            .catch((err) => {
              reportData.endTime = Date.now();
              reportData.duration = reportData.endTime - reportData.startTime;
              reportData.status = 0;
              reportData.success = false;
              callback(reportData);
              throw err;
            });
        };
      }
      overwriteFetch();
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40

    xhr一样,最后通过异步回调函数的形式回传到组件中。

    resourceList

    获取页面中网络请求以外的其他资源,通过 window.performance.getEntriesByType api,整理出指定资源的信息,最后组装成一个resource 列表。

    const getResources = () => {
      if (!window.performance) return;
      const data = window.performance.getEntriesByType('resource');
      const resource = {
        xmlhttprequest: [],
        css: [],
        other: [],
        script: [],
        img: [],
        link: [],
        fetch: [],
        // 获取资源信息时当前时间
        time: new Date().getTime(),
      };
      data.forEach((item: resourceItemType<number> & PerformanceEntry) => {
        const arry = resource[item.initiatorType];
        arry &&
          arry.push({
            name: item.name, // 资源名称
            type: 'resource',
            sourceType: item.initiatorType, // 资源类型
            duration: +item.duration.toFixed(2), // 资源加载耗时
            dns: item.domainLookupEnd - item.domainLookupStart, // DNS 耗时
            tcp: item.connectEnd - item.connectStart, // 建立 tcp 连接耗时
            redirect: item.redirectEnd - item.redirectStart, // 重定向耗时
            ttfb: +item.responseStart.toFixed(2), // 首字节时间
            protocol: item.nextHopProtocol, // 请求协议
            responseBodySize: item.encodedBodySize, // 响应内容大小
            resourceSize: item.decodedBodySize, // 资源解压后的大小
            isCache: isCache(item), // 是否命中缓存
            startTime: performance.now(),
          });
      });
      function isCache(entry) {
        // 直接从缓存读取或 304
        return entry.transferSize === 0 || (entry.transferSize !== 0 && entry.encodedBodySize === 0);
      }
    
      return resource;
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40

    在这里插入图片描述

    userInfo

    用户信息分为两类:

    • 浏览器(navigator)的信息;
    • 用户本身的信息,如IP、所在位置;

    这里获取第二类的方式通过第三方接口接入:

    const getUserIp = () => {
      return new Promise((resolve, reject) => {
        const scriptElement = document.createElement('script');
        scriptElement.src = `https://pv.sohu.com/cityjson?ie=utf-8`;
        document.body.appendChild(scriptElement);
        scriptElement.onload = () => {
          try {
            document.body.removeChild(scriptElement);
            // @ts-ignore
            resolve(window.returnCitySN);
          } catch (e) {
            reject(e);
          }
        };
      });
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    获取浏览器相关的参数代码如下:

    const getNativeBrowserInfo = () => {
      const res: nativeBrowserInfoType = {};
      if (document) {
        res.domain = document.domain || ''; // 获取域名
        // res.url = String(document.URL) || ''; //当前Url地址
        res.title = document.title || '';
        // res.referrer = String(document.referrer) || ''; //上一跳路径
      }
      // Window对象数据
      if (window && window.screen) {
        res.screenHeight = window.screen.height || 0; // 获取显示屏信息
        res.screenWidth = window.screen.width || 0;
        res.color = window.screen.colorDepth || 0;
      }
      // navigator对象数据
      if (navigator) {
        res.lang = navigator.language || ''; // 获取所用语言种类
        res.ua = navigator.userAgent.toLowerCase(); // 运行环境
      }
      return res;
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21

    总体列表如图:

    在这里插入图片描述

    errorList

    捕捉错误分为了同步错误(console.aaa(123))和异步错误(promise所遗漏未捕捉到的reject)

    因此在全局挂载两个通用事件,当捕获到错误时推入错误列表中即可。

    const getJavaScriptError = (callback) => {
      window.addEventListener('error', ({ message, filename, type }) => {
        callback({
          msg: message,
          url: filename,
          type,
          time: new Date().getTime(),
        });
      });
    };
    
    const getJavaScriptAsyncError = (callback) => {
      window.addEventListener('unhandledrejection', (e) => {
        callback({
          type: 'promise',
          msg: (e.reason && e.reason.msg) || e.reason || '',
          time: new Date().getTime(),
        });
      });
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20

    在这里插入图片描述

    clickEventList

    收集点击行为信息原理是全局挂载mouseDowntouchstart事件,在触发事件时收集DOM事务相关信息,代码如下:

    const onClick = (callback) => {
      ['mousedown', 'touchstart'].forEach((eventType) => {
        let timer;
        window.addEventListener(eventType, (event) => {
          clearTimeout(timer);
          timer = setTimeout(() => {
            const target = event.target as eventDom & EventTarget;
            const { top, left } = (target as any).getBoundingClientRect();
            callback({
              top,
              left,
              eventType,
              pageHeight: document.documentElement.scrollHeight || document.body.scrollHeight,
              scrollTop: document.documentElement.scrollTop || document.body.scrollTop,
              type: 'behavior',
              subType: 'click',
              target: target.tagName,
              paths: (event as any).path?.map((item) => item.tagName).filter(Boolean),
              startTime: event.timeStamp,
              outerHTML: target.outerHTML,
              innerHTML: target.innerHTML,
              width: target.offsetWidth,
              height: target.offsetHeight,
              viewport: {
                width: window.innerWidth,
                height: window.innerHeight,
              },
            });
          }, 500);
        });
      });
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32

    可以捕获到对应DOM节点的触发时机和位置信息,在后台分析数据时过滤出指定的DOM可以对于热门用户行为进行分析报告。

    在这里插入图片描述

    写在最后

    至此,Track 组件写完了,业务方可以结合Track 组件在需要上报的时机进行数据收集,这其实是 concis 给业务端做出的收集层的便捷,由于上报的业务场景太多,本来是想在组件内部一起做完的,最后还是决定组件层只做数据收集,分析和上报留给业务方。

    如果你对 concis 组件库感兴趣,欢迎点个star支持一下我们~

    Github

    官方文档

    在这里插入图片描述

  • 相关阅读:
    WP-AutoPostPro 汉化版: WordPress自动采集发布插件
    day01
    2022“杭电杯” 中国大学生算法设计超级联赛(4)1 2 题解
    七年前端,如何做好一个team leader
    数据可视化项目(二)
    Node18.x基础使用总结(二)
    MySql数据库允许外网访问连接
    Acwing 287. 积蓄程度
    Android.mk和Android.bp
    python打包系列1 - pyinstaller打包遇坑笔记
  • 原文地址:https://blog.csdn.net/m0_46995864/article/details/127827451