如果是你该如何实现前端数据上报?

1,033 阅读10分钟

前言

在前公司进行活动开发的时候经常会有这样的需求,需要统计页面的相关数据,并进行上报,但是如果要你设计一个实现数据上报的方法有哪些呢? 本文就介绍一下我整理的一些数据上报的一些方法。

一、实现一个埋点监控SDK

前公司经常会有这样的需求,一个活动上线,用户的活动不可预测,用户实际使用的时候发生了什么:几时进入活动?点击某个弹窗的确认按钮,几成会点击取消?有没有出现页面崩溃? 所以我们需要一个埋点监控SDK去做数据的收集,后续再统计分析。有了分析数据,才能有针对性对网站进行优化:PV特别少的页面就不要浪费大量人力; 比较有名的埋点监控有Google Analytics,除了web端,还有iOS、安卓的SDK。

埋点监控的职能范围

因为业务需要的不同,大部分公司都会自己开发一套埋点监控系统,但基本上都会涵盖这三类功能:

用户行为监控

负责统计PV(页面访问次数)、UV(页面访问人数)以及用户的点击操作等行为。

这类统计是用的最多的,有了这些数据才能量化我们的工作成果。

页面性能监控

开发和测试人员固然在上线之前会对这些数据做评估,但用户的环境和我们不一样,也许是3G网,也许是很老的机型,我们需要知道在实际使用场景中的性能数据,比如页面加载时间、白屏时间等。

错误报警监控

获取错误数据,及时处理才能避免大量用户受到影响。除了全局捕获到的错误信息,还有在代码内部被catch住的错误告警,这些都需要被收集到。

下面会从api的设计出发,对上述三种类型进一步展开。

SDK的设计

在开始设计之前,先看一下SDK怎么使用

import StatisticSDK from 'StatisticSDK';
// 全局初始化一次
window.insSDK = new StatisticSDK('uuid-12345');


<button onClick={()=>{
  window.insSDK.event('click','confirm');
  ...// 其他业务代码
}}>确认</button>
复制代码

首先把SDK实例挂载到全局,之后在业务代码中调用,这里的新建实例时需要传入一个id,因为这个埋点监控系统往往是给多个业务去使用的,通过id去区分不同的数据来源。

首先实现实例化部分:

class StatisticSDK {
  constructor(productID){
    this.productID = productID;
  }
}
复制代码

数据发送

数据发送是一个最基础的api,后面的功能都要基于此进行。通常这种前后端分离的场景会使用AJAX的方式发送数据,但是这里使用图片的src属性。原因有两点:

  1. 没有跨域的限制,像srcipt标签、img标签都可以直接发送跨域的GET请求,不用做特殊处理;
  2. 兼容性好,一些静态页面可能禁用了脚本,这时script标签就不能使用了;

但要注意,这个图片不是用来展示的,我们的目的是去「传递数据」,只是借助img标签的的src属性,在其url后面拼接上参数,服务端收到再去解析。

class StatisticSDK {
  constructor(productID){
    this.productID = productID;
  }
  send(baseURL,query={}){
    query.productID = this.productID;
    let queryStr = Object.entries(query).map(([key, value]) => `${key}=${value}`).join('&')
    let img = new Image();
    img.src = `${baseURL}?${queryStr}`
  }
}
复制代码

img标签的优点是不需要将其append到文档,只需设置src属性便能成功发起请求。

通常请求的这个url会是一张1X1px的GIF图片,网上的文章对于这里为什么返回图片的是一张GIF都是含糊带过,这里查阅了一些资料并测试了:

  1. 同样大小,不同格式的的图片中GIF大小是最小的,所以选择返回一张GIF,这样对性能的损耗更小;
  2. 如果返回204,会走到img的onerror事件,并抛出一个全局错误;如果返回200和一个空对象会有一个CORB的告警;

当然如果不在意这个报错可以采取返回空对象,事实上也有一些工具是这样做的

  1. 有一些埋点需要真实的加到页面上,比如垃圾邮件的发送者会添加这样一个隐藏标志来验证邮件是否被打开,如果返回204或者是200空对象会导致一个明显图片占位符
<img src="http://www.example.com/logger?event_id=1234">
复制代码

更优雅的web beacon

这种打点标记的方式被称web beacon(网络信标)。除了gif图片,从2014年开始,浏览器逐渐实现专门的API,来更优雅的完成这件事:Navigator.sendBeacon

使用很简单

Navigator.sendBeacon(url,data)
复制代码

相较于图片的src,这种方式的更有优势:

  1. 不会和主要业务代码抢占资源,而是在浏览器空闲时去做发送;
  2. 并且在页面卸载时也能保证请求成功发送,不阻塞页面刷新和跳转;

现在的埋点监控工具通常会优先使用sendBeacon,但由于浏览器兼容性,还是需要用图片的src兜底。

用户行为监控

上面实现了数据发送的api,现在可以基于它去实现用户行为监控的api。

class StatisticSDK {
  constructor(productID){
    this.productID = productID;
  }
  // 数据发送
  send(baseURL,query={}){
    query.productID = this.productID;
      let queryStr = Object.entries(query).map(([key, value]) => `${key}=${value}`).join('&')
      let img = new Image();
      img.src = `${baseURL}?${queryStr}`
  }
  // 自定义事件
  event(key, val={}) {
    let eventURL = 'http://demo/'
    this.send(eventURL,{event:key,...val})
  }
  // pv曝光
  pv() {
    this.event('pv')
  }
}
复制代码

用户行为包括自定义事件和pv曝光,也可以把pv曝光看作是一种特殊的自定义行为事件。

页面性能监控

页面的性能数据可以通过performance.timing这个API获取到,获取的数据是单位为毫秒的时间戳。

上面的不需要全部了解,但比较关键的数据有下面几个,根据它们可以计算出FP/DCL/Load等关键事件的时间点:

  1. 页面首次渲染时间:FP(firstPaint)=domLoading-navigationStart
  2. DOM加载完成:DCL(DOMContentEventLoad)=domContentLoadedEventEnd-navigationStart
  3. 图片、样式等外链资源加载完成:L(Load)=loadEventEnd-navigationStart

上面的数值可以跟performance面板里的结果对应。

回到SDK,我们只用实现一个上传所有性能数据的api就可以了:

class StatisticSDK {
  constructor(productID){
    this.productID = productID;
    // 初始化自动调用性能上报
    this.initPerformance()
  }
  // 数据发送
  send(baseURL,query={}){
    query.productID = this.productID;
      let queryStr = Object.entries(query).map(([key, value]) => `${key}=${value}`).join('&')
      let img = new Image();
      img.src = `${baseURL}?${queryStr}`
  }
  // 性能上报
  initPerformance(){
    let performanceURL = 'http://performance/'
    this.send(performanceURL,performance.timing)
  }
}
复制代码

并且,在构造函数里自动调用,因为性能数据是必须要上传的,就不需要用户每次都手动调用了。

错误告警监控

错误报警监控分为JS原生错误和React/Vue的组件错误的处理。

JS原生错误

除了try catch中捕获住的错误,我们还需要上报没有被捕获住的错误——通过error事件和unhandledrejection事件去监听。

error

error事件是用来监听DOM操作错误DOMException和JS错误告警的,具体来说,JS错误分为下面8类:

  1. InternalError: 内部错误,比如如递归爆栈;
  2. RangeError: 范围错误,比如new Array(-1);
  3. EvalError: 使用eval()时错误;
  4. ReferenceError: 引用错误,比如使用未定义变量;
  5. SyntaxError: 语法错误,比如var a = ;
  6. TypeError: 类型错误,比如[1,2].split('.');
  7. URIError: 给 encodeURI或 decodeURl()传递的参数无效,比如decodeURI('%2')
  8. Error: 上面7种错误的基类,通常是开发者抛出

也就是说,代码运行时发生的上述8类错误,都可以被检测到。

unhandledrejection

Promise内部抛出的错误是无法被error捕获到的,这时需要用unhandledrejection事件。

回到SDK的实现,处理错误报警的代码如下:

class StatisticSDK {
  constructor(productID){
    this.productID = productID;
    // 初始化错误监控
    this.initError()
  }
  // 数据发送
  send(baseURL,query={}){
    query.productID = this.productID;
      let queryStr = Object.entries(query).map(([key, value]) => `${key}=${value}`).join('&')
      let img = new Image();
      img.src = `${baseURL}?${queryStr}`
  }
  // 自定义错误上报
  error(err, etraInfo={}) {
    const errorURL = 'http://error/'
    const { message, stack } = err;
    this.send(errorURL, { message, stack, ...etraInfo})
  }
  // 初始化错误监控
  initError(){
    window.addEventListener('error', event=>{
      this.error(error);
    })
    window.addEventListener('unhandledrejection', event=>{
      this.error(new Error(event.reason), { type: 'unhandledrejection'})
    })
  }
}
复制代码

和初始化性能监控一样,初始化错误监控也是一定要做的,所以需要在构造函数中调用。后续开发人员只用在业务代码的try catch中调用error方法即可。

React/Vue组件错误

成熟的框架库都会有错误处理机制,React和Vue也不例外。

React的错误边界

错误边界是希望当应用内部发生渲染错误时,不会整个页面崩溃。我们提前给它设置一个兜底组件,并且可以细化粒度,只有发生错误的部分被替换成这个「兜底组件」,不至于整个页面都不能正常工作。

它的使用很简单,就是一个带有特殊生命周期的类组件,用它把业务组件包裹起来。

这两个生命周期是getDerivedStateFromErrorcomponentDidCatch

代码如下:

// 定义错误边界
class ErrorBoundary extends React.Component {
  state = { error: null }
  static getDerivedStateFromError(error) {
    return { error }
  }
  componentDidCatch(error, errorInfo) {
    // 调用我们实现的SDK实例
    insSDK.error(error, errorInfo)
  }
  render() {
    if (this.state.error) {
      return <h2>Something went wrong.</h2>
    }
    return this.props.children
  }
}
...
<ErrorBoundary>
  <BuggyCounter />
</ErrorBoundary>
复制代码

建了一个在线sandbox可以体验,公众号后台回复「错误边界demo」获取地址

回到SDK的整合上,在生产环境下,被错误边界包裹的组件,如果内部抛出错误,全局的error事件是无法监听到的,因为这个错误边界本身就相当于一个try catch。所以需要在错误边界这个组件内部去做上报处理。也就是上面代码中的componentDidCatch生命周期。

Vue的错误边界

vue也有一个类似的生命周期来做这件事,不再赘述:errorCaptured

Vue.component('ErrorBoundary', {
  data: () => ({ error: null }),
  errorCaptured (err, vm, info) {
    this.error = `${err.stack}\n\nfound in ${info} of component`
    // 调用我们的SDK,上报错误信息
    insSDK.error(err,info)
    return false
  },
  render (h) {
    if (this.error) {
      return h('pre', { style: { color: 'red' }}, this.error)
    }
    return this.$slots.default[0]
  }
})
...
<error-boundary>
  <buggy-counter />
</error-boundary>

复制代码

现在我们已经实现了一个完整的SDK的骨架,并且处理了在实际开发时,react/vue项目应该怎么接入。

实际生产使用的SDK会更健壮,但思路也不外乎,感兴趣的可以去读一读源码。

原文链接:juejin.cn/post/708567…

二、使用sendBeacon进行前端数据上报

刚刚说到了现在实现前端埋点的方法大多数用的sendBeacon,现在就再细致的说一下如何实现使用sendBeacon进行前端数据上报。

上报数据的时机

  • 页面加载时

此时进行数据上报,只需要在页面 load 时上报即可。

**

window.addEventListener('load', reportData, false);
  • 页面卸载或页面刷新时

此时进行数据上报,只需要在页面 beforeunload 时上报即可。

**

window.addEventListener('beforeunload', reportData, false);
  • SPA 路由切换时

    • 如果是 vue-routerreact-router@3 及以下版本,则可以在 hooks 里进行上报操作。
    • 如果是 react-router@4 则需要在 Routes 根组件的生命周期内进行上报。
  • 页面多个 tab 切换时

如果是这种情况,可以在 visibilitychange 时通过读取 document.visibilityStatedocument.hidden 区分页面 tab 的激活状态,判断是否需要进行上报。

**

document.addEventListener("visibilitychange", function() {
  if(document.visibilityState === 'visible') {
    reportData();
  }
  if(document.visibilityState === 'hidden') {
    reportData2();
  }
  // your code ...
});

上报数据的方法

1. 直接发请求上报

我们可以直接将数据通过 ajax 发送到后端,以 axios 为例。

**

axios.post(url, data);

但这种方法有一个问题,就是在页面卸载或刷新时进行上报的话,请求可能会在浏览器关闭或重新加载前还未发送至服务端就被浏览器 cancel 掉,导致数据上报失败。

我们可以将 ajax 请求改为同步方法,这样就能保证请求一定能发送到服务端。由于 fetchaxios 都不支持同步请求,所以需要通过 XMLHttpRequest 发送同步请求。

**

const syncReport = (url, { data = {}, headers = {} } = {}) => {
  const xhr = new XMLHttpRequest();
  xhr.open('POST', url, false);
  xhr.withCredentials = true;
  Object.keys(headers).forEach((key) => {
    xhr.setRequestHeader(key, headers[key]);
  });
  xhr.send(JSON.stringify(data));
};

这里要注意的是,将请求改为同步以后,会阻塞页面关闭或重新加载的过程,这样就会影响用户体验。

2. 动态图片

我们可以通过在 beforeunload 事件处理器中创建一个图片元素并设置它的 src 属性的方法来延迟卸载以保证数据的发送,因为绝大多数浏览器会延迟卸载以保证图片的载入,所以数据可以在卸载事件中发送。

**

const reportData = (url, data) => {
  let img = document.createElement('img');
  const params = [];
  Object.keys(data).forEach((key) => {
    params.push(`${key}=${encodeURIComponent(data[key])}`);
  });
  img.onload = () => img = null;
  img.src = `${url}?${params.join('&')}`;
};

此时服务端可以返回一个 1px * 1px 的图片,保证触发 imgonload 事件,但如果某些浏览器在实现上无法保证图片的载入,就会导致上报数据的丢失。

3. sendBeacon

为了解决上述问题,便有了 navigator.sendBeacon 方法,使用该方法发送请求,可以保证数据有效送达,且不会阻塞页面的卸载或加载,并且编码比起上述方法更加简单。

用法如下:

**

navigator.sendBeacon(url, data);

url 就是上报地址,data 可以是 ArrayBufferViewBlobDOMStringFormdata,根据官方规范,需要 request header 为 CORS-safelisted-request-header,在这里则需要保证 Content-Type 为以下三种之一:

  • application/x-www-form-urlencoded
  • multipart/form-data
  • text/plain

我们一般会用到 DOMString , BlobFormdata 这三种对象作为数据发送到后端,下面以这三种方式为例进行说明。

  • DOMString

如果数据类型是 string,则可以直接上报,此时该请求会自动设置请求头的 Content-Typetext/plain

**

const reportData = (url, data) => {
  navigator.sendBeacon(url, data);
};
  • Blob

如果用 Blob 发送数据,这时需要我们手动设置 Blob 的 MIME type,一般设置为 application/x-www-form-urlencoded

**

const reportData = (url, data) => {
  const blob = new Blob([JSON.stringify(data), {
    type: 'application/x-www-form-urlencoded',
  }]);
  navigator.sendBeacon(url, blob);
};
  • Formdata

可以直接创建一个新的 Formdata,此时该请求会自动设置请求头的 Content-Typemultipart/form-data

**

const reportData = (url, data) => {
  const formData = new FormData();
  Object.keys(data).forEach((key) => {
    let value = data[key];
    if (typeof value !== 'string') {
      // formData只能append string 或 Blob
      value = JSON.stringify(value);
    }
    formData.append(key, value);
  });
  navigator.sendBeacon(url, formData);
};

注意这里的 JSON.stringify 操作,服务端需要将数据进行 parse 才能得到正确的数据。

总结

我们可以使用 sendBeacon 发送数据,这一方法既能保证数据可靠性,也不影响用户体验,如果浏览器不支持该方法,则可以降级使用同步的 ajax 发送数据。

原文链接:www.jianshu.com/p/04e88271a…