前端JS埋点自定义采集数据方案

一、确定方案

最近在做一个数据统计的需求,查找了很多第三方数据追踪、统计工具,都没有完美适配需求的。最终在某次头脑风暴会上敲定了原生实现的方案。

首先要明确的就是收集数据的工作,也就是埋点,什么时候采集数据,采集什么数据,这些都是需要明确的,正确的采集到数据以后再向后端发送,后端进行存储和处理,处理完以后以图表、文字等各种分析结果的形式呈现,这也是使这些采集到的数据真正产生意义的地方。

需求中需要统计某些特定页面的访问数据,进入该页面的入口,该页面下产品的加购、下单、支付数据,以此计算出加购率、下单率、支付率,分析转化率,形成图表等进行展示。实际这就是统计一整条完整的客户消费链路,从哪个入口被吸引进哪个页面,对哪个产品产生兴趣进行加购以及后续下单支付。

在实现的过程中主要的难点就是如何正确记录这个操作链路以备后续提交数据,用户的操作场景是复杂的,站内各种跳转、地址栏输入url访问、从站外链接点进来、浏览器前进后退、页面刷新、开了多个浏览器窗口等等……

最终确定了一个方案,我称之为“入口生成id”算法,核心就是只要进入新入口就生成新的操作链路id。先说提交浏览数据,只要进入了特定页面就提交浏览数据,特定页面的每次访问都一定对应着某入口(这里把手输url、站外链接都看成是入口);再说提交加购数据,假如从该页面进入某产品页面完成加购是我们期待的行为或者说想统计的操作链路,但是用户可能没有按照预期的路径操作,可能是地址栏手动输入某url离开该页面,可能是点击了非预期的某模块进入了非预期的页面,这种情况下之前的操作链路就相当于结束或者断开了,需要生成新的操作链路id,因为它已经偏离了我们想统计的方向。这时我们就可以把非预期的模块都看成新入口,如统一成“TBD"(随便取的),只要用户点击了这些模块就相当于刷新了入口进入了新页面,生成了新的操作链路id,在完成加购的时候进行入口判断,如果是TBD入口说明是以非预期的方式完成加购的,则不提交数据计入统计;最后是下单和支付数据,用户加购以后何时下单支付是无法预计的,因此无法直接将下单和支付同当前记录的操作链路id和页面数据联系起来(也许现在从购物车下单的产品是三个月以前完成加购的,而此时本地存储记录的是当下的操作链路),所以需要后端在加购的时候将原来的加购产品与操作链路联系起来,从购物车下单支付的时候可以由该产品找到对应的操作链路。

操作序列的id要求具有唯一性,因此使用了uuid,直接npm引入项目就可以:

// 首先安装uuid
npm install uuid

// 在代码中使用它
const { v4: uuidv4 } = require('uuid');

const uuid = uuidv4();
console.log(uuid);

// 这将生成一个随机的UUID,类似于f47ac10b-58cc-4372-a567-0e02b2c3d479

记录操作链路最终使用了indexedDB + localStorage + sessionStorage的方案,这三种存储方式都有各自的优缺点,如果只取其一用是不够满足存储要求的,结合使用才是最合适的。

  • indexedDB用于存储历史,考虑到浏览器前进后退的功能,使用indexedDB存储历史,方便浏览器前进后退时回溯之前对应的入口、页面类型等数据,发挥了indexedDB大容量存储的特点;
  • localStorage用于存储lastTab(网站某些点击行为会弹出新的浏览器窗口,此时需要延续原来窗口下的操作id,页面数据)、lastTabIndex(新开浏览器窗口时取索引用,使每一个窗口对应不同的索引)、tabNum(记录浏览器窗口数量),发挥了localStorage在同一域名下的不同浏览器窗口共享数据的特点;
  • sessionStorage用于存储当前浏览器窗口对应的索引,当前窗口下对应的入口信息,发挥了sessionStorage的会话级存储和隔离性特点;

二、实施难点

方案好不容易制定下来了,真正开始编写代码时又遇到了重重困难:

1. 手动输入url访问

手动输入url访问被视为入口的一种,如何判断为手动输入url访问呢,查阅资料发现了window.performance.navigation.type,但是真正投入使用时发现它将来可能会被弃用,所以换成了performance.getEntriesByType('navigation')[0].type

当performance.getEntriesByType('navigation')[0].type的值为”navigate“时,代表是用户点击链接或者输入URL的方式,但是它并不能区分开具体是哪一种方式。

最后选择了使用“document.referrer”,当输入链接访问时,document.referrer的值为空字符串,但是这种方式下进行页面刷新,document.referrer也一直为空,所以区分不出是第一次链接访问还是之后的页面刷新。不过可以结合performance.getEntriesByType('navigation')[0].type辅助判断,页面刷新时其值为“reload”。

2. 浏览器前进后退

大坑之一。出于安全性的考虑,浏览器不允许 JavaScript 直接查看用户的浏览历史,所以基本没有办法获取到具体的 URL,最后决定在网站中自己记录一份存到indexedDB里面,包括url、页面类型、入口信息、操作id等,当网站不是以前进后退的方式跳转时,将相关信息记录到indexedDB中;当网站以前进后退的方式跳转时,说明该页面之前已经被记录过,不需要再记录,直接调之前的记录取出来使用。

如何判断前进后退呢,浏览器并没有提供方法可以判断该页面是通过前进进入的还是后退,performance.getEntriesByType('navigation')[0].type值为“back_forward”时,说明是前进或者后退,只找到了这一种笼统的判别方式。

核心思想就是进入某页面,如果是通过浏览器前进后退方式进入的,通过该页面url去indexedDB的记录中去找,但是有一个不可忽视的影响,就是该页面url在indexedDB中有多条记录的情况,这里我只是简单粗暴的取了倒序最近的一次记录,因为实在没有找到合适的方法可以解决这一问题。即便引进指针的思想,用两个指针指向上一次回溯的url和当前回溯的url,也会存在有A -> B -> A的这种路径,如果知道了上一次回溯的页面是B,当前需要回溯A,但是存在这种记录,就无法确定是使用哪一条A的记录,根源就在于没办法知道浏览器是在前进还是后退。

3. 浏览器窗口多开

大坑之一。要完成统计需求,需要知道不同窗口下的所有操作链路,如果是输入url访问的方式则该窗口下的链路与上一个窗口毫无关系,是独立开的;如果是网页内点击自动打开新tab的跳转行为,则分情况看待。如打开了产品页面,则产品页面应该与原来的tab下的链路信息保持一致,也就是视为同一链路,如打开了要统计的特定页面,则相当于从新入口进入了该页面,需要生成新的操作链路id。

出于安全性的考虑,浏览器又是没有直接地提供可以用于判断网站打开的窗口数量、当前处于哪个窗口等方法。苦思冥想终于才找到突破口——sessionStorage,没错就是sessionStorage,不同浏览器窗口之间的 sessionStorage是隔离的,它们不共享数据,每个浏览器窗口都有自己独立的 sessionStorage 存储空间,因此可以利用这一点设置变量进行判断:

if(!sessionStorage.getItem("tabIndex")) {
  var lastTabIndex = localStorage.getItem("lastTabIndex")   // 记录上一次开到的tab索引 
  var index = Number(lastTabIndex)+1   // 索引递增
  sessionStorage.setItem("tabIndex",index)   // 设置窗口标记
  localStorage.setItem("lastTabIndex",index)   // 把这一次开到的tab索引保存回去
  var tabNum = localStorage.getItem("tabNum")   // 获取之前的窗口数量
  tabNum = Number(tabNum) + 1   // 窗口数量加1
  localStorage.setItem("tabNum",tabNum)   // 保存
}

当这个窗口下没有设置过变量tabIndex时,就说明是新的窗口,通过localStorage记录的窗口索引递增进行赋值,同时窗口数量+1。

4. indexedDB的使用

之前没有接触过indexedDB,要使用它还真不是那么容易。indexedDB其实就是浏览器给提供的一个数据库,能存储大量数据,也适用于一些离线场景。基本语法如下:

// 打开数据库 名称为MyDatabase 版本号为1
const request = window.indexedDB.open('MyDatabase', 1); 

// 数据库打开失败的回调
request.onerror = function(event) {
  console.log("无法打开数据库");
};

// 数据库打开成功的回调
request.onsuccess = function(event) {
  const db = event.target.result;
  console.log("数据库已打开", db);
  const transaction = db.transaction('MyObjectStore', 'readwrite'); // 创建可读写的事务
  const objectStore = transaction.objectStore('MyObjectStore');

  const data = { id: 1, name: 'John Doe', age: 30 };
  const addRequest = objectStore.add(data); // 添加数据
  
  // 数据添加成功的回调
  addRequest.onsuccess = function(event) {
    console.log("数据已添加");
  };
};

// 首次创建数据库或数据库结构升级时会触发
request.onupgradeneeded = function(event) {
  const db = event.target.result;
  // 创建对象存储空间,主键为id
  const objectStore = db.createObjectStore('MyObjectStore', { keyPath: 'id' });
  console.log("数据库升级完成");
};

注意首次创建打开数据库时一定要加上onupgradeneeded的回调,否则会报错。存储的设计上是基于tabindex的,存储了每个窗口下的操作链路,因此将其修改成了如下:

const pageRequest = window.indexedDB.open("myDatabase", 1);

pageRequest.onerror = function(event) {
  console.log("打开数据库失败");
}

pageRequest.onsuccess = function(event) {
  console.log("打开数据库成功");
  var db = event.target.result;
  var transaction = db.transaction(["tabStore"], 'readwrite');
  var tabStore = transaction.objectStore("tabStore");
  var currentTabIndex = sessionStorage.getItem("tabIndex")
  ……
  // 添加操作链路数据
  const addRequest = tabStore.add(newData);
  addRequest.onsuccess = function(event) {
    console.log("数据添加成功");
  }
  addRequest.onerror = function(event) {
    console.log("无法添加数据");
  }
  ……
  // 修改操作链路数据
  const updateRequest  = tabStore.put(newData);
  updateRequest.onsuccess = function(event) {
    console.log("数据修改成功");
  };
  updateRequest.onerror = function(event) {
    console.log("无法修改数据");
  }
  ……

  // 查找记录取出对应信息(浏览器前进后退时)
  const getTabData = tabStore.get(currentTabIndex)
  getTabData.onerror = function(event) {
    console.log("读取数据失败")
  }
  getTabData.onsuccess = function(event) {
    const result = event.target.result;
    if(result == undefined) {
      console.log('未找到匹配记录');
      ……
    } else {
      console.log("找到匹配记录");
      ……
    }
  }
} 

pageRequest.onupgradeneeded = function(event) {
  var db = event.target.result;
  // 创建对象存储空间 主键为tabIndex
  var tabStore = db.createObjectStore("tabStore", { keyPath: "tabIndex" });
}

5. 站外链接点击访问

如果是站外某些平台投放的网站链接,当点击访问网站时也需要统计。我采用了最简单的方式——url携带参数,投放链接时将url携带上约定好的查询参数,当访问网站页面时,一旦读取到该参数就说明是来自站外某平台,此时就可以针对这个入口进行记录。需要注意的是为了避免用户复制链接直接访问的形式带来的影响(使统计到的入口信息不准,一个入口是站外,一个是链接访问的形式),也为了使页面上后续的操作不再携带这个参数,可以在记录完之后将该参数去除。我用的方法是路由前置守卫

app.router.beforeEach((to, from, next) => {
  // 假设约定的参数是entrance=XXX的形式
  if(to.query.entrance) {
    // 先记录进localStorage,不影响路由跳转,后续再处理
    localStorage.setItem("entrance", to.query.entrance)
  }
  next(to.path); // 跳转到不带该参数的页面
  } else {
    next()
  }
});

当进入页面时只需要判断localStorage时是否有source,再进行记录即可,要注意记录完之后将该项移除(removeItem),记录一次以后就失效了,否则后续再进入页面还在一直记录。

6. 页面从缓存恢复

当点击浏览器前进后退按钮时有时候不会重新加载页面,而是直接从缓存中恢复页面,这种情况performance.getEntriesByType('navigation')[0].type的判断会失效,因为页面根本就没有加载行为。这里需要用到“pageshow”的监听,代码如下:

window.addEventListener('pageshow', (e) => {
  if(e.persisted) {
    console.log("页面从缓存中恢复")
    // 从indexedDB的记录中查找 同浏览器前进后退的逻辑
    ……
  }
});

三、相关优化

1. 数据定时清理

必要的工作,尤其是indexedDB,存储了大量数据又不去及时清理,后果可想而知,我主要设置了两个清理点,一个是网站的所有窗口都关闭的时候,一个是用户无操作一小时后(防止用户一直不关窗口的情况)。

判断所有窗口都关闭又是个坑,前面已经说过了浏览器不提供判断的方法,这里利用了“beforeunload”的监听,核心思想就是当触发beforeunload事件时tab数量减1,当进入页面时tab数量加1,当页面跳转或者刷新时就不会影响tab数量,-1+1正好抵消;而当某tab关闭时不会再+1,就实现了tab数量减1的效果,代码如下:

window.removeEventListener("beforeunload",() => {
  if (localStorage.getItem("tabNum")) {
    var numTabs = Number(localStorage.getItem("tabNum"));
    numTabs = numTabs > 0 ? numTabs - 1 : 0;
    localStorage.setItem("tabNum", numTabs);
  }
});

在进入页面加1之前如果tabNum为0,说明是第一次打开第一个窗口,可以执行数据清理工作。要注意的一点是,当只有一个窗口时,此时tabNum为1,但当执行页面刷新、跳转时会先触发beforeunload,此时tabNum值为0,这里不能触发数据清理工作,可以借助sessionStorage辅助判断,如果sessionStorage中存过了相关数据说明不是第一次打开窗口,则不需要清理数据,数据清理的语法基本如下:

const clearRequest = window.indexedDB.open("myDatabase", 1);
clearRequest.onerror = function(event) {
  console.log("打开数据库失败");
}
clearRequest.onsuccess = function(event) {
  var db = event.target.result;
  var transaction = db.transaction(["tabStore"], 'readwrite');
  var tabStore = transaction.objectStore("tabStore");
  tabStore.clear();
  console.log("存储空间对象清空成功")
}
clearRequest.onupgradeneeded = function(event) {
  var db = event.target.result;
  var tabStore = db.createObjectStore("tabStore", { keyPath: "tabIndex" });
}

第二种用户无操作一小时的判断设置定时器就可以,代码如下:

function clearStorage() {
  // 清理数据的相关操作
  ……
  // 清除浏览器的前进历史
  history.pushState(null, null, window.location.href);
  // 重新加载页面
  window.location.href = window.location.href
}


// 设置等待时间(以毫秒为单位)
const waitTime = 60 * 60 * 1000; // 60分钟

// 声明一个计时器变量
let timer;

// 定义计时器函数
function startTimer() {
  timer = setTimeout(clearStorage, waitTime);
 }

// 监听用户的操作事件(例如鼠标移动、键盘输入)
window.addEventListener('load', resetTimer);
window.addEventListener('mousemove', resetTimer);
window.addEventListener('keypress', resetTimer);

// 启动计时器
startTimer();

// 重置计时器
function resetTimer() {
  clearTimeout(timer);
  startTimer();
}

2. 代码优化

代码优化的工作其实就是提取公共逻辑、做好注释、提升代码可阅读性,等等,相信这也不用多说了……

3. 性能优化

首先是加上的监听要在合适的时机及时移除,否则会影响性能,而且可能会影响页面原来的监听逻辑;其次关于记录操作链路、给后端提交数据都应该在不影响页面原有功能的前提下进行,尽量都放在页面加载完成以后执行,同时要做好错误捕获,不要让异常影响页面原来的功能。

四、总结

其实整个功能写下来第一感受还是第三方工具香……

但是也收获了很多,想出来各种奇奇怪怪的办法,这个解决方案肯定还是有很多不足的,大佬们如果有更好的方案欢迎指导!

  • 3
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值