手动封装前端埋点监控工具库

1.意义

实现埋点功能的意义主要体现在以下几个方面:

  • 数据采集:
    埋点是数据采集领域(尤其是用户行为数据采集领域)的术语,它针对特定用户行为或事件进行捕获、处理和发送的相关技术及其实施过程。通过埋点,可以收集到用户在应用中的所有行为数据,例如页面浏览、按钮点击、表单提交等。
  • 数据分析:
    采集的数据可以帮助业务人员分析网站或者App的使用情况、用户行为习惯等,是后续建立用户画像、用户行为路径等数据产品的基础。通过数据分析,企业可以更好地了解用户需求,优化产品和服务。
  • 改进决策:
    通过对埋点数据的分析,企业可以了解用户的真实需求和行为习惯,从而做出更符合市场和用户需求的决策,提高产品和服务的质量和竞争力。
  • 优化运营:
    通过埋点数据,企业可以了解用户的兴趣和行为,从而更好地定位目标用户群体,优化运营策略,提高运营效率和收益。
  • 预测趋势:
    通过对埋点数据的分析,企业可以预测市场和用户的未来趋势,从而提前做好准备,把握市场机遇,赢得竞争优势。

总之,实现埋点功能可以帮助企业更好地了解用户需求和行为习惯,优化产品和服务,改进决策,优化运营并预测趋势,具有重要的意义和作用。

2.怎么做?

3.需要监控什么?

  • 错误统计
    首先,我们的代码发布到线上总是会发生奇奇怪怪的错误,错误原因也五花八门,可能是浏览器兼容问题,可能是代码里面没做兜底,也可能是后端接口挂掉了等等错误,可能随便一个错误都会影响用户的使用,所以对线上进行错误监控显的尤为重要,能够让我们第一时间去响应报错并解决。
  • 行为日志埋点
    对于一些常见的电商app,比如淘宝,都有一套自己的用户行为分析的系统,分析用户浏览时间比较长的页面有哪些,常点击的按钮有哪些等等行为,通过分析用户的这些行为去制定不同的策略引导你购物,这些都可以通过前端埋点去实现对用户行为的监控。
  • PV/UV统计
    我们上线那么多的前端页面,肯定特别想知道我们的用户对哪个页面的访问次数比较多,也想知道每天有多少的用户访问我们的系统,这就需要用到PV,UV的统计

所以我们系统的设计就主要围绕上面着三点进行设计,主要流程如下:

  • 数据采集:
    数据采集做的就是采集我们系统的监控数据,包括PV,UV用户行为前端报错的数据。
  • 日志上报:
    上报做的就是将第一步采集到的数据发送到服务端。
  • 日志查询:
    这一步就是在后台查询我们采集并上报的数据,方便对系统进行分析。

我们的SDK做的主要是对前两部分的实现。

4.前置知识

在实现该功能之前我们需要了解一些前置知识。

4.1 JS模块化

我们需要在nodejs环境下使用rollup打包输出支持不同规范的模块,因此了解JS模块化相关知识是必要的。

主流模块化规范有:

  • CommonJS规范
  • AMD规范
  • CMD规范
  • ESM规范
  • UMD规范

这里建议看一下我写的这篇博客:https://blog.youkuaiyun.com/fageaaa/article/details/146003360

序号模块化规范备注
1CommonJSCommonJS规范主要用于服务端编程,加载模块是同步的,这并不适合在浏览器环境,因为同步意味着阻塞加载,浏览器资源是异步加载的,因此有了AMD和CMD解决方案
2AMDAMD规范在浏览器环境中异步加载模块,而且可以并行加载多个模块。不过,AMD规范开发成本高,代码的阅读和书写比较困难,模块定义方式的语义不顺畅。
3CMDCMD规范整合了CommonJS和AMD规范的特点, CMD规范与AMD规范很相似,都用于浏览器编程,依赖就近,延迟执行,可以很容易在Node.js中运行。不过,依赖SPM 打包,模块的加载逻辑偏重
4UMDUMD是AMD和CommonJS两者的结合,这个模式中加入了当前存在哪种规范的判断,所以能够“通用”,它兼容了AMD和CommonJS,同时还支持老式的“全局”变量规范
5ESMES6 在语言标准的层面上,实现了模块功能,而且实现得相当简单,完全可以取代 CommonJS 和 AMD 规范,成为浏览器和服务器通用的模块解决方案。

4.2 rollup

要发布npm包,打包库是必然的,而rollupwebpack更适合打包库,因此学习rollup也是必要的。

关于webpackrollup的区别,可以看我写的这篇博客:https://blog.youkuaiyun.com/weixin_43599321/article/details/135279904

这里再总结一下rollup和webpack的区别:

  • rollup很适用于库的构建,而webpack 比较适合应用开发
  • 由于rollup不能够直接读取node_modules中的依赖项,需要引入加载npm模块的插件:rollup-plugin-node-resolve
  • 由于rollup默认只支持esm模块打包,所以需要引入插件来支持cjs模块:rollup-plugin-commonjs
  • vite就是rollup开发而来的

4.3 history

实现Page View埋点往往需要使用HistoryAPI,因为它可以帮助我们更好地控制页面的状态和导航。

SPA中,页面的状态通常由内部状态管理,而不是通过URL来表现。因此,传统的PV埋点方法(例如通过document.referrer)可能无法正确计算PV。

使用History API可以让我们更精细地控制页面的导航和状态。我们可以使用history.pushState()方法将新的状态添加到历史记录中,并更新URL,但不会触发页面刷新。这样,我们可以在用户与页面交互时跟踪其导航路径,并计算PV

另外,当用户点击浏览器的后退按钮时,我们可以使用popstate事件来获取上一个历史记录状态,并根据需要进行处理。这可以帮助我们处理用户在SPA中的导航,并提供更准确的PV数据。

4.4 前端的各种文件

JavaScript 提供了一些 API 来处理文件或原始文件数据,例如:FileBlobFileReaderArrayBufferbase64 等。它们之间的关系如下:
在这里插入图片描述

关于这方面的知识详情可见我写的博客https://blog.youkuaiyun.com/fageaaa/article/details/146267074

4.5 sendBeacon发送请求

XMLHttpRequest是一种用于发送HTTP请求的 API,它需要设置请求头、处理响应等,比较麻烦,而且它会在主线程中创建一个新的HTTP请求,可能会阻塞主线程。当需要发送的数据量比较大时,使用XMLHttpRequest是可行的,但在埋点场景下,通常需要发送的数据量很小,而且需要以非阻塞的方式发送,这时navigator.sendBeacon()就更合适,因此我们有必要学习navigator.sendBeacon发送请求,它能够更有效地处理小数据量的后台传输。

navigator.sendBeacon()用于将数据以非阻塞(后台)方式发送到服务器。此方法主要用于在网页会话期间定期发送小数据包,而不会影响页面的加载或用户交互。即使页面卸载(关闭)也会发送请求,解决了使用XMLHttpRequest发送同步请求而迫使用户代理延迟卸载文档的问题。

语法:navigator.sendBeacon(url, data);

  • url
    url参数表明data将要被发送到的网络地址。
  • data
    data参数是将要发送的ArrayBufferArrayBufferViewBlobDOMStringFormDataURLSearchParams类型的数据。

5.项目搭建

5.1 流程图

在这里插入图片描述

5.2 项目大致结构

在这里插入图片描述
其中src文件夹中是埋点监控的核心代码。

5.3 初始化

初始化其实很简单,就是获取用户传过来的参数,然后调用我们的初始化函数就可以了,在初始化函数中,我们可以注入一些监听事件来实现数据统计的功能。

//src/index.js
import { loadConfig } from './utils/util';
import { tracker } from './tracker/actionTracker';
import { errorCaptcher } from './tracker/errorTracker';
import { lazyReport, report } from './report/report';
import { getCache } from './cache/cache';

/**
 * 初始化配置
 * @param {*} options 
 */
function init(options) {
  // ------- 加载配置 ----------
  // 1.拿到配置信息 
  // 2.注入监控代码
  loadConfig(options);

  // -------- uv统计 -----------
  lazyReport('user', '加载应用');

  // ------ 防止卸载时还有剩余的埋点数据没发送 ------
  window.addEventListener('unload', () => {
    const data = getCache();
    report(data);

    // if (data.length > 0) {
    //   report(data);
    // }
  });
}

export { init, tracker, errorCaptcher };

其中loadConfig中代码如下:

//src/utils/util.js
import { autoTrackerReport } from '../tracker/actionTracker';
import { hashPageTrackerReport, historyPageTrackerReport } from '../tracker/pageTracker';
import { errorTrackerReport } from '../tracker/errorTracker';

/**
 * 加载配置
 * @param {*} options 
 */
export function loadConfig(options) {
  const { 
    appId,  // 系统id
    userId, // 用户id
    reportUrl, // 后端url
    autoTracker, // 自动埋点
    delay, // 延迟和合并上报的功能
    hashPage, // 是否hash路由
    errorReport // 是否开启错误监控
  } = options;

  // --------- appId ----------------
  if (appId) {
    window['_monitor_app_id_'] = appId;
  }

  // --------- userId ----------------
  if (userId) {
    window['_monitor_user_id_'] = userId;
  }

  // --------- 服务端地址 ----------------
  if (reportUrl) {
    window['_monitor_report_url_'] = reportUrl;
  }

  // -------- 合并上报的间隔 ------------
  if (delay) {
    window['_monitor_delay_'] = delay;
  }

  // --------- 是否开启错误监控 ------------
  if (errorReport) {
    errorTrackerReport();
  }

  // --------- 是否开启无痕埋点 ----------
  if (autoTracker) {
    autoTrackerReport();
  }

  // ----------- 路由监听 --------------
  if (hashPage) {
    hashPageTrackerReport(); // hash路由上报
  } else {
    historyPageTrackerReport(); // history路由上报
  }
}

6.数据监控功能

6.1 错误监控

前端是直接和用户打交道的,前端页面报错是很影响用户体验一件事,即使在测试充分后上线也会因为用户的操作行为以及操作的环境出现各种各样的错误,所以,不光是后端需要加报警监控,前端的错误监控也很重要。

常见的错误类型有以下几种:

6.1.1 语法错误

语法错误一般在开发阶段就可以发现,比如常见的单词拼写错误,中英文符号错误等。注意:语法错误是无法被try catch捕获的,因为在开发阶段就能发现,所以一般不会发布到线上环境。

try {
  let name = 'heima; // 少一个单引号
  console.log(name);
} catch (error) {
  console.log('----捕获到了语法错误-----');
}

6.1.2 同步错误

同步错误指的是在js同步执行过程中的错误,比如变量未定义,是可以被try catch给捕获到的。

try {
  const name = 'heima';
  console.log(nam);
} catch (error) {
  console.log('------同步错误-------')
}

6.1.3 异步错误

异步错误指的是在setTimeout等函数中发生的错误,是无法被try catch捕获到的。

try {
  setTimeout(() => {
    undefined.map();
  }, 0);
} catch (error) {
  console.log('-----异步错误-----')
}

异步错误的话我们可以用window.onerror来进行处理,这个方法比try catch要强大很多:

/**
 * @param {String}  msg    错误描述
 * @param {String}  url    报错文件
 * @param {Number}  row    行号
 * @param {Number}  col    列号
 * @param {Object}  error  错误Error对象
 */
 window.onerror = function (msg, url, row, col, error) {
   console.log('出错了!!!');
   console.log(msg);
   console.log(url);
   console.log(row);
   console.log(col);
   console.log(error);
};

6.1.4 promise错误

promise 中使用 catch 可以捕获到异步的错误,但是如果没有写 catch 去捕获错误的话 window.onerror 也捕获不到的,所以写 promise 的时候最好要写上 catch ,或者可以在全局加上 unhandledrejection 的监听,用来监听没有被捕获的promise错误。

window.addEventListener("unhandledrejection", function(error){
  console.log('捕获到异常:', error);
}, true);

6.1.5 资源加载错误

资源加载错误指的是比如一些资源文件获取失败,可能是服务器挂掉了等原因造成的,出现这种情况就比较严重了,所以需要能够及时的处理,网路错误一般用 window.addEventListener 来捕获。

window.addEventListener('error', (error) => {
  console.log(error);
}, true);

6.1.6 总结

所以SDK错误监控的实现,就是围绕这几种错误实现的。 try-catch 用来在可预见情况下监控特定的错误,window.onerror 主要是来捕获预料之外的错误,比如异步错误。但是 window.onerror 也并不是万能的,它可以捕获语法,同步,异步的错误,但是对于promise错误以及网络错误还是无能为力,所以还需要 unhandledrejection 监听来捕获promise错误,最后,再加上 error 监听捕获资源加载的错误就能将各种类型的错误全覆盖了。

关于错误监控的完整代码如下:

//src/tracker/errorTracker.js
import { lazyReport } from "../report/report";

/**
 * 全局错误捕获
 */
export function errorTrackerReport() {
  // --------  js error ---------
  const originOnError = window.onerror;
  window.onerror = function (msg, url, row, col, error) {
    // 处理原有的onerror,不然项目原有的onerror会被覆盖
    if (originOnError) {
      originOnError.call(window, msg, url, row, col, error);
    }
    // 错误上报
    lazyReport("error", {
      message: msg,
      file: url,
      row,
      col,
      error,
      errorType: "jsError",
    });
  };

  // ------  promise error  --------
  window.addEventListener("unhandledrejection", (error) => {
    lazyReport("error", {
      message: error.reason,
      error,
      errorType: "promiseError",
    });
  });

  // ------- resource error --------
  window.addEventListener(
    "error",
    (error) => {
      let target = error.target;
      let isElementTarget =
        target instanceof HTMLScriptElement ||
        target instanceof HTMLLinkElement ||
        target instanceof HTMLImageElement;
      //避免重复上报。除了资源以外的元素的报错,走的是上面的上报,不应该走到下面的上报  
      if (!isElementTarget) {
        return; // js error不再处理
      }
      lazyReport("error", {
        message: "加载 " + target.tagName + " 资源错误",
        file: target.src,
        errorType: "resourceError",
      });
    },
    true
  );
}

/**
 * 手动捕获错误
 */
export function errorCaptcher(error, msg) {
  // 上报错误
  lazyReport("error", {
    message: msg,
    error: error,
    errorType: "catchError",
  });
}

6.2 用户行为监控

埋点是监控用户在我们应用上的一些动作表现,是不是经常感觉有些应用推荐的内容都是自己感兴趣的,这就是埋点这个“内鬼”在搞怪,比如你在淘宝上的某类型的鞋子的页面浏览了几分钟,那么就会有一个“张三在2022-7-16 15:30搜索了某款运动鞋并浏览了十分钟”的记录的上报,后台就可以根据这些上报的数据去分析用户的行为,并且制定之后推送或者产品的迭代优化等,对于产品后续的发展起着重要作用。埋点又分为手动埋点和无痕埋点。

6.2.1 手动埋点

手动埋点就是手动的在代码里面添加相关的埋点代码,比如用户点击某个按钮,就在这个按钮的点击事件中加入相关的埋点代码,或者提交了一个表单,就在这个提交事件中加入埋点代码。

// 方式1
<button
  onClick={() => {
    // 先会执行业务代码
    ...
    
    //然后对该业务行为进行埋点
  	tracker('click', '用户去支付');
    // tracker('visit', '访问新页面');
    // tracker('submit', '提交表单');
  }}
>操作</button>

// 方式2
<button 
	data-target="支付按钮"
	onClick={() => {
    // 业务代码
  }}
>手动上报</button>
  • 优点:可控性强,可以自定义上报具体的数据。
  • 缺点:对业务代码侵入性强,如果有很多地方需要埋点就得一个一个手动的去添加埋点代码。

6.2.2 无痕埋点

无痕埋点是为了解决手动埋点的缺点,实现一种不用侵入业务代码就能在应用中添加埋点监控的埋点方式。

<button onClick={() => {
  // 只用写业务代码
}}>自动埋点</button>
// 自动埋点实现
function autoTracker () {
  // 添加全局click监听
  document.body.addEventListener('click', function (e) {
    const clickedDom = e.target;
    // 获取data-target属性值
    let target = clickedDom?.getAttribute('data-target');
    if (target) {
      // 如果设置data-target属性就上报对应的值--手动埋点
      tracker('click', target);
    } else {
      // 如果没有设置data-target属性就上报被点击元素的html路径
      const path = getPathTo(clickedDom);
      tracker('click', path);
    }
  }, false);
};
  • 优点:不用侵入务代码就能实现全局的埋点。
  • 缺点:只能上报基本的行为交互信息,无法上报自定义的数据;上报次数多,服务器性能压力大。

6.2.3 总结

关于用户行为监控的完整代码如下:

//src/tracker/actionTracker.js
import { lazyReport } from '../report/report';
import { getPathTo } from '../utils/util';

/**
 * 手动上报
 */
export function tracker(actionType, data) {
  lazyReport('action', {
    actionType,
    data
  });
}

/**
 * 自动上报
 */
export function autoTrackerReport() {
  // 自动上报
  document.body.addEventListener('click', function (e) {
    const clickedDom = e.target;

    // 获取标签上的data-target属性的值
    let target = clickedDom?.getAttribute('data-target');

    // 获取标签上的data-no属性的值
    //记得给需要手动埋点的元素加上属性'data-no',以表示这个元素进行手动埋点,不需要无痕埋点
    //这样子可以避免重复上报
    let no = clickedDom?.getAttribute('data-no');
    // 避免重复上报
    if (no) {
      return;
    }

    if (target) {
      lazyReport('action', {
        actionType: 'click',
        data: target
      });
    } else {
      // 获取被点击元素的dom路径
      const path = getPathTo(clickedDom);
      lazyReport('action', {
        actionType: 'click',
        data: path
      });
    }
  }, false);
}
//src/utils/util.js
/**
 * 获取元素的dom路径
 * @param {*} element 
 * @returns 
 */
export function getPathTo(element) {
  //如果被点击的元素有id就返回id
  if (element.id !== '')
    return '//*[@id="' + element.id + '"]';
  //如果被点击的元素是body,就返回body标签名  
  if (element === document.body)
    return element.tagName;
  
  //递归遍历拿到的是下面这种形式的dom路径
  //*[@id="root"]/DIV[1]/DIV[2]/BUTTON[1]
  let ix= 0;
  let siblings = element.parentNode.childNodes;
  for (let i = 0; i < siblings.length; i++) {
    let sibling = siblings[i];
    if (sibling === element)
      return getPathTo(element.parentNode) + '/' + element.tagName + '[' + (ix + 1) + ']';
    if (sibling.nodeType === 1 && sibling.tagName === element.tagName)
      ix ++;
  }
}

6.3 PV/UV监控

6.3.1 PV

PV即页面浏览量,用来表示该页面的访问数量

在SPA应用之前只需要监听 onload 事件即可统计页面的PV,在SPA应用中,页面路由的切换完全由前端实现,主流的react和vue框架都有自己的路由管理库,而单页路由又区分为 hash 路由和 history 路由,两种路由的原理又不一样,所以统计起来会有点复杂。我们这里将分别针对两种路由来实现不同的采集数据的方式。

6.3.1.1 history路由

history路由依赖全局对象 history 实现的

  • history.back(); 返回上一页,和浏览器回退功能一样
  • history.forward(); 前进一页,和浏览器前进功能一样
  • history.go(); 跳转到历史记录中的某一页, 如history.go(-1); history.go(1)
  • history.pushState(); 添加新的历史记录
  • history.replaceState(); 修改当前的记录项

history路由的实现主要依赖的就是 pushStatereplaceState 来实现的,但是这两种方法不能被 popstate 监听到popstate 只能监听到history.back()history.forward()history.go()这三种,,所以需要对这两种方法进行重写来实现数据的采集。

/**
 * 重写pushState和replaceState方法
 * @param {*} name 
 * @returns 
 */
const createHistoryEvent = function (name) {
  // 拿到原来的处理方法
  const origin = window.history[name];
  return function(event) {
    if (name === 'replaceState') {
      const { current } = event;
      const pathName = location.pathname;
      if (current === pathName) {
        let res = origin.apply(this, arguments);
        return res;
      }
    }

    let res = origin.apply(this, arguments);
    let e = new Event(name);
    e.arguments = arguments;
    window.dispatchEvent(e);
    return res;
  };
};

window.history.pushState = createHistoryEvent('pushState');
window.history.replaceState = createHistoryEvent('replaceState');

function listener() {
  const stayTime = getStayTime(); // 停留时间
  const currentPage = window.location.href; // 页面路径
  lazyReport('visit', {
    stayTime,
    page: beforePage,
  })
  beforePage = currentPage;
}

// history.go()、history.back()、history.forward() 监听
window.addEventListener('popstate', function () {
  listener()
});

// history.pushState
window.addEventListener('pushState', function () {
  listener()
});

// history.replaceState
window.addEventListener('replaceState', function () {
  listener()
});
6.3.1.2 hash路由

urlhash的改变会触发 hashchange 的监听,所以我们只需要在全局加上一个监听函数,在监听函数中实现采集并上报就可以了。但是在reactvue中,对于hash路由的跳转并不是通过 hashchange 的监听实现的,而是通过 pushState 实现,所以,还需要加上对 pushState 的监听才可以。

export function hashPageTrackerReport() {
  let beforeTime = Date.now(); // 进入页面的时间
  let beforePage = ''; // 上一个页面
  
  // 上报
  function listener() {
    const stayTime = getStayTime();
    const currentPage = window.location.href;
    lazyReport('visit', {
      stayTime,
      page: beforePage,
    })
    beforePage = currentPage;
  }

  // hash路由监听
  window.addEventListener('hashchange', function () {
    listener()
  });
}

6.3.2 UV

统计的是一天内访问该网站的用户数

uv统计比较简单,就只需要在SDK初始化的时候上报一条消息就可以了

/**
 * 初始化配置
 * @param {*} options 
 */
function init(options) {
  // 1.拿到配置信息 
  // 2.注入监控代码
  loadConfig(options);
 
  report('user', '加载应用'); // uv统计
  
  ...
}

之所以一行代码就可以,是因为在loadConfig中,一开始就保存了用户的id。后台只需要统计所有用户的id,就知道当访问该网站的用户数。

6.3.3 总结

关于PV/UV监控的完整代码如下:

//src/tracker/pageTracker.js
import { lazyReport } from '../report/report';

/**
 * history路由监听
 */
export function historyPageTrackerReport() {
  let beforeTime = Date.now(); // 进入页面的时间
  let beforePage = ''; // 上一个页面

  // 获取在某个页面的停留时间
  function getStayTime() {
    let curTime = Date.now();
    let stayTime = curTime - beforeTime;
    beforeTime = curTime;
    return stayTime;
  }

  /**
   * 重写pushState和replaceState方法
   * @param {*} name 
   * @returns 
   */
  const createHistoryEvent = function (name) {
    // 拿到原来的处理方法
    const origin = window.history[name];
    return function(event) {
      //执行该方法原有的操作
      let res = origin.apply(this, arguments);
      let e = new Event(name);
      e.arguments = arguments;
      window.dispatchEvent(e);
      return res;
    };
  };

  // history.pushState
  window.addEventListener('pushState', function () {
    listener()
  });

  // history.replaceState
  window.addEventListener('replaceState', function () {
    listener()
  });

  //原本的history对象上本来就有pushState和replaceState方法
  //当它不能被监听,我们需要重写
  //重写之后既要保证之前原有的方法生效,同时要让window可以监听到这两个动作
  //所以需要用到自定义事件
  window.history.pushState = createHistoryEvent('pushState');
  window.history.replaceState = createHistoryEvent('replaceState');

  function listener() {
    const stayTime = getStayTime(); // 停留时间
    const currentPage = window.location.href; // 页面路径
    lazyReport('visit', {
      stayTime,
      page: beforePage,
    })
    beforePage = currentPage;
  }

  // 页面load监听
  window.addEventListener('load', function () {
    // beforePage = location.href;
    listener()
  });

  // unload监听
  window.addEventListener('unload', function () {
    listener()
  });

  // history.go()、history.back()、history.forward() 监听
  window.addEventListener('popstate', function () {
    listener()
  });
}

/**
 * hash路由监听
 */
export function hashPageTrackerReport() {
  let beforeTime = Date.now(); // 进入页面的时间
  let beforePage = ''; // 上一个页面

  function getStayTime() {
    let curTime = Date.now();
    let stayTime = curTime - beforeTime;
    beforeTime = curTime;
    return stayTime;
  }

  function listener() {
    const stayTime = getStayTime();
    const currentPage = window.location.href;
    lazyReport('visit', {
      stayTime,
      page: beforePage,
    })
    beforePage = currentPage;
  }

  // hash路由监听
  window.addEventListener('hashchange', function () {
    listener()
  });

  // 页面load监听
  window.addEventListener('load', function () {
    listener()
  });

  const createHistoryEvent = function (name) {
    const origin = window.history[name];
    return function(event) { 
      let res = origin.apply(this, arguments);
      let e = new Event(name);
      e.arguments = arguments;
      window.dispatchEvent(e);
      return res;
    };
  };

  //`url`上`hash`的改变会触发 `hashchange` 的监听,所以我们只需要在全局加上一个监听函数,
  //在监听函数中实现采集并上报就可以了。
  //但是在`react`和`vue`中,对于`hash`路由的跳转并不是通过 `hashchange` 的监听实现的,
  //而是通过 `pushState` 实现,所以,还需要加上对 `pushState` 的监听才可以。
  window.history.pushState = createHistoryEvent('pushState');

  // history.pushState
  window.addEventListener('pushState', function () {
    listener()
  });
}

7.数据上报功能

7.1 数据上报的方式

7.1.1 XMLHttpRequest或Fetch API

这种最简单的,就跟请求其他业务接口一样,只不过上传的是埋点的数据。但是在通常的情况下,一般在公司里面处理埋点的服务器和处理业务逻辑的处理器不是同一台,所以还需要手动解决跨域的问题,另一方面,如果在上报的过程中刷新或者重新打开新页面,可能会造成埋点数据的缺失,所以该方式并不能很好的适应埋点的需求。

优点:

  • 可以发送异步请求,支持GET和POST等多种HTTP方法。

缺点:

  • 需要处理跨域请求的问题(如设置CORS)。
  • 不支持异步操作,可能会造成埋点数据的缺失
const data = { event: 'click', element: 'button' };
 
// 使用XMLHttpRequest
const xhr = new XMLHttpRequest();
xhr.open('POST', ' https://example.com/track ');
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.send(JSON.stringify(data));
 
// 使用Fetch API
fetch(' https://example.com/track ', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json'
  },
  body: JSON.stringify(data)
});

7.1.2 img标签

img标签的方式是通过将埋点数据伪装成图片URL的请求方式,这样就避免了跨域的问题,但是因为浏览器对url的长度会有限制,所以通过这种方式上报不适合大数据量上报的场景,而且也会存在刷新或者打开页面的时候上报的数据丢失的情况。

优点:

  • 简单易用,兼容性好,可以跨域上报。
  • 不会阻塞页面加载和关闭。

缺点:

  • 只能发送GET请求,无法获取响应结果。
  • 不适合大数据量上报
  • 不支持异步操作,可能会造成埋点数据的缺失

通过创建一个Image对象,将要上报的数据作为URL参数拼接到一个1x1像素的透明图片URL中,发送一个GET请求来触发上报。

const data = { event: 'click', element: 'button' };
const url = ` https://example.com/track?data= ${encodeURIComponent(JSON.stringify(data))}`;
const img = new Image();
img.src = url;

7.1.3 sendBeacon

鉴于以上两种方式的缺点,sendBeacon应运而生了,sendBeacon可以说是为埋点量身定做的,这种方式不会有跨域的限制,也不会存在因为刷新页面等情况造成数据丢失的情况,唯一的缺点就是在某些浏览器上存在兼容性的问题,所以在日常的开发场景中,通常采用sendBeacon上报和img标签上报结合的方式。

 * 上报
 * @param {*} type 
 * @param {*} params 
 */
export function report(type, params) {
  const appId = window['_monitor_app_id_'];
  const userId = window['_monitor_user_id_'];
  const url = window['_monitor_report_url_'];

  const logParams = {
    appId, // 项目的appId
    userId,
    type, // error/action/visit/user
    data: params, // 上报的数据
    currentTime: new Date().getTime(), // 时间戳
    currentPage: window.location.href, // 当前页面
    ua: navigator.userAgent, // ua信息
  };

  let logParamsString = JSON.stringify(logParams);

  if (navigator.sendBeacon) { // 支持sendBeacon的浏览器
    navigator.sendBeacon(url, logParamsString);
  } else { // 不支持sendBeacon的浏览器
    let oImage = new Image();
    oImage.src = `${url}?logs=${logParamsString}`;
  }
}

7.2 合并上报

对于无痕埋点来说,一次点击就进行一次上报对服务器来说压力有点大,所以最好是能进行一个合并上报。

//src/cache/cache.js
const cache = [];

export function getCache() {
  return cache;
}

export function addCache(data) {
  cache.push(data);
}

export function clearCache() {
  cache.length = 0
}

// src/report/lazyReport.js
import { getCache, addCache, clearCache } from '../cache/cache';

let timer = null;

/**
 * 上报
 * @param {*} type 
 * @param {*} params 
 */
export function lazyReport(type, params) {
  const appId = window['_monitor_app_id_'];
  const userId = window['_monitor_user_id_'];
  const delay = window['_monitor_delay_'];

  const logParams = {
    appId, // 项目的appId
    userId, // 用户id
    type, // error/action/visit/user
    data: params, // 上报的数据
    currentTime: new Date().getTime(), // 时间戳
    currentPage: window.location.href, // 当前页面
    ua: navigator.userAgent, // ua信息
  };

  let logParamsString = JSON.stringify(logParams);
  addCache(logParamsString);

  const data = getCache();

  if (delay === 0) { // delay=0相当于不做延迟上报
    report(data);
    return;
  }

  if (data.length > 10) {
    report(data);
    clearTimeout(timer);
    return;
  }

  clearTimeout(timer);
  timer = setTimeout(() => {
    report(data)
  }, delay);
}

export function report(data) {
  const url = window['_monitor_report_url_'];

  // ------- fetch方式上报 -------
  // 跨域问题
  // fetch(url, {
  //   method: 'POST',
  //   body: JSON.stringify(data),
  //   headers: {
  //     'Content-Type': 'application/json',
  //   },
  // }).then(res => {
  //   console.log(res);
  // }).catch(err => {
  //   console.error(err);
  // })

  // ------- navigator/img方式上报 -------
  // 不会有跨域问题
  if (navigator.sendBeacon) { // 支持sendBeacon的浏览器
    navigator.sendBeacon(url, JSON.stringify(data));
  } else { // 不支持sendBeacon的浏览器
    let oImage = new Image();
    oImage.src = `${url}?logs=${data}`;
  }
  clearCache();
}

8.发布埋点监控sdk

8.1 配置markdown文件

里面的内容可以根据实际情况编写。这个文件相当于一个粗略的说明书,当后续发布到npm上后,里面的内容会在上面显示。

在这里插入图片描述

8.2 打包项目

在给项目打包时候需要考虑兼容性问题。我们需要安装的插件如下:

//package.json
  ...
  "devDependencies": {
    "@babel/preset-env": "^7.18.10",
    "@rollup/plugin-babel": "^5.3.1",
    "rollup": "^2.77.2",
    "rollup-plugin-babel": "^4.4.0"
  }

配置babel:

//.babelrc.js
{
  "presets": ["@babel/preset-env"]
}

配置打包文件:

//rollup.config.js
const path = require('path');
const babel = require('rollup-plugin-babel');

const resolve = function (...args) {
  return path.resolve(__dirname, ...args);
};

export default { 
  input: resolve('./src/index.js'), // 入口文件
  output: [
    {
      file: resolve('./lib/index.esm.js'),
      format: 'esm', // ES 模块文件
    },
    {
      file: resolve('./lib/index.umd.js'),
      format: 'umd', // umd 规范的可执行文件
      name: 'monitorSdk',
    }
  ],
  watch: {  // 配置监听处理
    exclude: 'node_modules/**'
  },
  plugins: [
    // 使用插件 @rollup/plugin-babel
    babel({
      exclude: 'node_modules/**',
      extensions: ['.js']
    })
  ]
};

然后再package.json中配置脚本:

  "scripts": {
    "build": "rollup -c",
    "serve": "rollup -c -w"
  },

执行npm run build打包项目,会生成一个lib打包文件夹:

在这里插入图片描述

我们可以复制打包文件夹中的内容到应用项目中

在这里插入图片描述

然后对其进行引入:

在这里插入图片描述

也可以将我们写的代码发布到npm上,然后通过npm i命令下载到node_modules,然后引入到项目中。

8.3 发布sdk

配置package.json

{
  "name": "my-custom-monitor-sdk",//定义库的名称
  "version": "1.0.0",//定义库的版本信息
  //"private": true,(将private去掉,因为这里是第三方的组件库,不需要private属性)
  "description": "前端监控sdk",//定义库的描述信息
  "main": "src/index.js",//定义库的入口文件
  "keywords": [
    "my-custom-monitor-sdk",
    "前端监控",
    "数据埋点"
  ],//定义库的关键词,方便用户找到库
  "author": "太阳与星辰",//库的作者
  //"files": [   
  //  "dist",
  //  "components"
  //],//制定希望发表的文件目录,因为不是所有文件都需要发表
  "scripts": {
    "build": "rollup -c",
    "serve": "rollup -c -w"
  },
  "license": "ISC",
  "devDependencies": {
    "@babel/preset-env": "^7.18.10",
    "@rollup/plugin-babel": "^5.3.1",
    "rollup": "^2.77.2",
    "rollup-plugin-babel": "^4.4.0"
  }
}

在终端输入:npm login
按照提示输入自己的账号、密码(不会显示自己写了啥)、邮箱,登录成功。

发布:npm publish

  • 发布库的名字不能与npm平台上的库的名字重合

    如果有报错(npm publish发包报错!npm ERR! 403 403 Forbidden - PUT http://registry.npmjs.org/vue-auto-router-cli - You do not have permission to publish “vue-auto-router-cli”. Are you logged in as the correct user?),说明这个库的名字已经被别人用啦,就需要修改package.json的name。

  • 发布当前库的版本不能与该库曾经发布的版本一样

  • 发布组件库版本时候,要将镜像切为源国外镜像

    • 国外镜像 npm config set registry https://registry.npmjs.org/
    • 国内旧淘宝镜像(已经废弃)npm config set registry https://registry.npm.taobao.org
    • 国内新淘宝镜像 npm config set registry https://registry.npmmirror.com/

如下,说明该库发布成功。

在这里插入图片描述

9.测试发布的sdk

由于埋点监控sdk必须与后端相互配合。后端必须有相应的接口并且与前端一致,在这里我就用在控制台上打印数据来当成对上传数据的模拟。实际开发中可自行开发后端API。

所以需要修改一下代码:

在这里插入图片描述

然后再修改库版本->发布库

在这里插入图片描述

随便找一个应用项目,安装埋点监控sdk:

npm i my-custom-monitor-sdk

在这里插入图片描述
在这里插入图片描述

无痕埋点:

//src/main.js
import { init } from "my-custom-monitor-sdk";
init({
  appId: "vue0001", // appId
  userId: "user0001", // userId
  reportUrl: "http://localhost:3009/report/actions", // 请求的后端地址
  delay: 0, // 延时上报的时间
  autoTracker: false, // 自动埋点
  hashPage: false, // 是否为hash路由,为fasle的话则默认为history路由
  errorReport: true, // 是否开启错误监控
});

手动埋点:

<template>
  <div>
    <h2>page1</h2>
    <!-- 手动埋点 -->
    <button
      style="marginright: 20px"
      @click="tracker('click', '按钮1被点击了')"
    >
      按钮1
    </button>

    <!-- 属性埋点 -->
    <button data-target="按钮2被点击了" style="marginright: 20px">按钮2</button>

    <!-- 自动埋点 -->
    <button style="marginright: 20px">按钮3</button>
  </div>
</template>
<script setup name="page1">
import { tracker } from "my-custom-monitor-sdk";
</script>

手动上报错误:

<template>
  <div>
    <h2>page2</h2>
    <!-- 异步错误 -->
    <button style="marginright: 20px" @click="handleAsyncError">
      异步错误
    </button>
  </div>
</template>

<script>
import { errorCaptcher } from "my-custom-monitor-sdk";

export default {
  methods: {
    handleSyncError: function () {
      try {
        const name = "heima";
        name.map();
      } catch (error) {
        console.log("---- 捕获到同步错误 ---");
        errorCaptcher(error, "同步执行错误");
      }
    },
  },
};
</script>

运行项目:

在这里插入图片描述

点击page1,并且点击按钮1

在这里插入图片描述

后续我们只需要对已有的埋点监控sdk进行完善即可。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

太阳与星辰

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值