用户行为大数据分析平台-jsSdk采集库实现详细解说

573 阅读21分钟

背景

进入互联网后,数据成爆发式增长,互联网数据分析平台发展如雨后春笋般。这些平台除了提供超级给力的数据分析能力外,还提供了各种数据采集工具,本文就此分享了web前端端数据js采集库开发的心得体验。这是我们现在做的平台文档:hubble.netease.com/help/_book/…
有兴趣的朋友可以参考下小伙伴分享的该平台初步介绍:blog.csdn.net/qq_28773159…
下面进入正题。


准备阶段

客户端数据采集库是数据分析平台的一个环节,开发sdk时候前,首先明确需求,然后跟数据端和服务端一起确定方案,这里限于篇幅,不再细说。
sdk方案参考如下:

graph TD

A[平台生成APPYKEY] --> B
B[页面接入sdk,填入APPKEY] --> C
C[其它功能性配置后,配置初始化] --> D1
D1[自然来源模块初始化] --> D2
D2[渠道推广初始化] --> D3
D3[jsbridge模块初始化] --> D4
D4[热力图模块初始化] --> D5
D5[热力图模块初始化后,首次进入应用触发da_activate事件] --> D6
D6[是广告推广,触发da_ad_click事件] --> D7
D7[启动单页面功能,修改referrer属性] --> D8
D8[开启一个会话,触发da_session_start事件] --> D9
D9[触发da_screen事件,即PV事件] --> D10

D10[abtest模块初始化] --> E

E[其它自定义事件和设置用户属性流程]

API命名约定:对外API采用下划线分割命名、sdk内部API采用下划线开头驼峰式命名、内部使用的变量下划线开头。
关于该sdk的API,请查看:hubble.netease.com/help/_book/…

接入sdk方式实现

当前只考虑两种:异步接入和同步接入。

A:异步接入

在大部分场景下,我们应尽量保证sdk不影响接入页的渲染,引入js文件时,属性async设置为true ,这样脚本相对于页面的其余部分异步地执行。

在引导js代码中添加:

(function(document, datracker, root) {
  // 加载sdk
  function loadJs(version) {
    var script, firstScript;
    script = document.createElement('script');
    script.type = 'text/javascript';
    script.async = true;
    script.src = 'http://xxx.sdk.'+ sdkVersion +'.js';
    firstScript = document.getElementsByTagName('script')[0];
    // 将sdk插入到页面所有js脚本的头部
    firstScript.parentNode.insertBefore(script, firstScript);
  }
  // sdkVersion 指定加载的sdk版本
  loadJs(sdkVersion);
})(document, window['DATracker'] || [], window);

由于sdk是异步加载,我们必须保证sdk未加载完这段时间内用户能正确调用API。

首先在引导js代码中预注册命名空间和API方法

代码流程

graph TD

A[页面引入导入代码] --> B
B[若window中未注册DATracker,注册] --> C
C[定义init方法] --> C2
C2[注册people类] --> C3
C3[预声明API] --> C4
C4[循环解析声明API,注册API] --> C5
C5[保存配置项]

思考:设计上面这个流程的目的,是为了解决当sdk未加载时,能成功调用API。
那么实现的关键点:

  1. 初始化sdk的命名空间;
  2. 预定义sdk的API;
  3. 将用户调用的API和设置的参数保存到队列中,格式要固定,比如 [ [fn, [arguments]], ... ],这样sdk内部才能按照规则解析;
  4. 由于还有people类和abtest类,那么设计API的时候,应该类似:DATracker.people.set,DATracker.abtest.get_variation, 这时候解析预定义的API,需要特别处理;
  5. 若用户引用的引导代码和实际加载的sdk版本有兼容问题,那么应该在引导代码增加版本编号, 同时在sdk加载完毕后,做对应的判断,若不支持,应抛出提示,阻止继续进行;
  6. sdk的凭证和配置信息保存到变量,当sdk加载完毕后,实例化需要用到;

思路其实已经很清晰了,首先window下注册sdk命名空间,类型为数组,然后再注册API方法,用户每次调用API,就把fn和参数保存到队列中。 等到sdk加载完毕后,再实例化sdk,同时将window下预注册的sdk命名空间指向实例化的sdk。

实现如下:

(function(document, datracker, root) {
  // ...此处省略加载sdk代码
  
  // 注册sdk对象
  // 首先判断sdk是否已初始化过,可通过判断是否有某个属性,如版本
  if (!datracker['sdkVersion']) {
    // 注册sdk命名空间
    window['DATracker'] = datracker;
    datracker['libraryConfig'] = {
      token: '',
      config: {}
    };
    // 引导代码版本,其实就是sdk对应的大版本啦
    datracker['sdkVersion'] = 'xx.xx';
    
    // 设置方法
    function _setAndDefer(target, fn) {
      var split = fn.split('.');
      // 若数组长度等于2,说明是people类
      if (split.length === 2) {
        target = target[split[0]];
        fn = split[1];
      }
      // 这里将用户调用的方法以及设置的参数保存到队列汇总
      // 当sdk加载完毕后,将执行队列中方法
      // 保存格式:[ [fn, [arguments]]  ]
      target[fn] = function() {
        target.push([fn]
         .concat(Array.proptotype.slice.call(arguments, 0)));
      };
    }
    
    // datracker对象注册sdk init方法
    // token: 接入数据平台的凭证
    // config: sdk配置信息
    /**
       // 调用 register_attributes:
       DATracker.init('appkey-xxx', {});
       DATracker.register_attributes({'event': 'test-page'});
     */
    datracker['init'] = function(token, config) {
      datracker['people'] = datracker['people'] || [];
      datracker['abtest'] = datracker['abtest'] || [];
      
      // 预声明API,空格分割,示例
      var functions = 'register_attributes register_attributes_once clear_attributes people.set abtest.get_variation'.join(' ');
      
      for (var i = 0; i < functions.length; i += 1) {
        _setAndDefer(datracker, functions[i]);
      }
      // 保存配置
      datracker['libraryConfig'].token = token;
      datracker['libraryConfig'].config = config;
    };
    
  }
})(document, window['DATracker'] || [], window);

功能说明:我们是用户行为分析平台,所以sdk提供了设置事件和设置用户两大类API。
当用户使用异步方式时,首先将上面的代码引入,然后调用 DATracker.init('xxx') 初始化sdk。

等待sdk加载完毕后,初始化sdk,从队列中依次真正执行API方法。

思路:

  1. 首先我们应该声明sdk库类和people类,同时分别扩展 _init方法;
  2. 实现sdk实例化方法,实例化后,运行队列中的方法,返回实例化对象;
  3. sdk加载完毕后,调用入口函数init_async,保证只实例化一次,实例化成功后,将window下预定义的sdk命名空间指向实例化;

实现如下:

// 引入纯函数帮助类文件,
// 不涉及sdk业务
// 不再展开细说
import _ from './utils';
//引入abtest
import abtest from './abtest';

// 引入sdk方式类型:同步方式标记
var INIT_SYNC = 1;
// 引入sdk方式类型:异步方式标记
var INIT_ASYNC = 2;
// 当前引入sdk方式类型标记
var _initType;
// 保存实例的变量
var _datrackerMaster;

// sdk库类
var DATrackerLib = function() {};
// sdk People 类
var DATrackerPeople = function() {};

// sdk库内部初始化方法
DATrackerLib.prototype._init = function(token, config) {
  var _self = this;
  _self['_hasLoaded'] = true;
  //.... 此处暂时省略
};
// sdk People 类初始化方法
DATrackerPeople.prototype._init = function(_DATrackerInstance) {
  this._DATracker = _DATrackerInstance;
};

// 执行队列中的方法
// 队列中item格式:[ [fn, [arguments]]  ]
DATrackerLib.prototype._executeArray = function(array) {
  _.each(array, function(item) {
    if (item) {
      // 要保证item保存的是实例中的方法
      if (_.isArray[item] && _.isFunction(this[item[0]])) {
        // 执行方法
        this[item[0]].apply(this, item.slice(1));
      }
    }
  }, this);
};

// 实例化sdk
var _createDtlib = function(token, config) {
  var instance;
  var target = _datrackerMaster;
  // 如果引入sdk方式是异步
  if (target && _initType === INIT_ASYNC) {
    // 检测 ,是否已初始化
    // 即检测_datrackerMaster
    if (!_.isArray(target)) {
      // console.log('已经实例化了');
      return;
    }
  }
  
  // 调用库对象
  instance = new DATrackerLib();
  
  // sdk库调用 _init方法,
  // 各个子模块初始化
  instance._init(token, config);
  // 设置People类
  instance['people'] = new DATrackerPeople();
  instance['people']._init();
  
  instance['abtest'] = abtest;
  instance['abtest']._init(instance);
  
  // 接下来执行队列中的方法,
  // 只要判断 target 类型是否为数组
  // 因为若是异步引入的话,
  // window['DATracker']一开始定义的类型为数组
  if (!_.isUndefined(target) && _.isArray(target)) {
    // 执行people类的队列
    instance._executeArray.call(instance['people'], target['people']);
    // 执行abtest类的队列
    instance._execute_array.call(instance['abtest'], target['abtest']);
    // 执行其它方法
    instance._executeArray.call(target);
  }
  return instance;
};

// 为啥要写到这个函数中,因为我们还要支持其他加载方式呀     
// 打包的时候,只要引入不同的入口函数
export function init_async() {    
    // 获取window下注册的sdk对象
    _datrackerMaster = window['DATracker'];
    
    // 判断是否注册
    if (_.isUndefined(_datrackerMaster) ) {
      return;
    }
    
    // 同样的,我们要判断是否初始化过
    if (_datrackerMaster['_hasLoaded']) {
      return;
    }
    // 设置当前引入sdk方式类型标记为异步
    _initType = INIT_ASYNC;
    
    // 当然,如果使用的引导代码版本过低(引入的sdk版本太低了),
    // 我们得阻止执行啦
    var version = _datrackerMaster['sdkVersion'] || 0;
    // 当前引导sdk版本不支持已下载的sdk
    if (version < 2.0) {
      console.log('引导代码版本太低啦,更新下吧');
      return;
    }
    // 实例化sdk类的各个子模块
    _datrackerMaster = _createDtlib.
      apply(this, _datrackerMaster['libraryConfig']);
    // 修改DATracker对象,重新指向的才是sdk实例
    window['DATracker'] = _datrackerMaster;
}


B:同步接入

目标:sdk加载完毕后,能立即调用API执行。
首先:页面上需直接引入sdk文件:


<script src="xxx.xx.js"></scirpt> 

其次:加载完毕后,调用入口函数init_sync ,实例化sdk,同时在window下注册sdk命名空间,指向该实例;

实现如下:


export function init_sync() {
    // 设置当前引入sdk方式类型标记为同步
    _initType = INIT_SYNC;
    // 实例化sdk类以及各个子模块
    _datrackerMaster = _createDtlib.
      apply(this, _datrackerMaster['libraryConfig']);
    // window下注册sdk对象,指向sdk实例
    window['DATracker'] = _datrackerMaster; 
}

正式启程

sdk内部初始化设计

我们从sdk初始化调用的_init方法开始讲起。
思考下,init方法中我们需要加什么?
我们回到准备阶段目录,里面讲解了sdk中有N个子模块,这些子模块什么时候初始化在这里探讨下。

从网站自然来源讲起:
当从百度跳转到站点后,sdk此时初始化完毕,上报了各种事件数据,对此我们要记住用户是从百度过来的并保存到本地,同时每次上报事件都需要加上该数据。自然每次上报数据前,我们可以获取下该数据。但是该模块并非简单简单的获取referrer属性,首先我们要判断是否外链,若是得更新本地数据,进入站点其它页面后,我们需要再次从本地中获取数据。我们在设计该模块也需要有个 init 方法,自然该模块初始化最好是在 sdk的 _init方法内,而且必须最开始执行。

import _ from './utils';
import source  from './source';
// ....省略
// sdk库内部初始化方法
DATrackerLib.prototype._init = function(token, config) {
  var _self = this;
  _self['_hasLoaded'] = true;
  source.init(token);
};

站点来源还有一个模块就是渠道推广了,原理跟自然来源模块其实是一样,不同的是判断依据和属性值。同样,也是必须最开始执行初始化。

import _ from './utils';
import source  from './source';
import campaign from './campaign';
// ....省略
// sdk库内部初始化方法
DATrackerLib.prototype._init = function(token, config) {
  var _self = this;
  _self['_hasLoaded'] = true;
  source.init(token);
  campaign.init(token);
};

jsbridge模块的一个作用是打通h5和APP之间的数据互相传输,自然我们期望能马上初始化。

import _ from './utils';
import source  from './source';
import campaign from './campaign';
import appJsBridge from './appJsBridge';
// ....省略
// sdk库内部初始化方法
DATrackerLib.prototype._init = function(token, config) {
  var _self = this;
  _self['_hasLoaded'] = true;
  source.init(token);
  campaign.init(token);
  //打通app与h5
  var bridegObj = app_js_bridge();
  //获取native传递给客户端数据
  _self.get_appStatus = function(func) {
    var callback = function(app_info) {
      try{
        if(typeof func === 'function') {
          func(app_info);
        }
       } catch(e){}
    };
    bridegObj.getAppStatus(callback);
  };
  //发送数据到native
  _self._get_SendCall = function(data, event_name, callback, jsTrack) {
    if(_self.get_config('is_heatmap_render_mode')) {
      return false;
    }
    bridegObj.getSendCall(data, event_name, callback, jsTrack);
  };
};

后面我们会讲解该模块的实现,接下来就是热力图模块了。
热力图模块有两大功能:

  1. 自动采集目标元素的数据;
  2. 拉取数据,绘制热力图;

当sdk进入展示热力图模式时,为了不影响数据的正确性,页面触发的任何事件我们都不能上报,所以我们就有了 is_heatmap_render_mode 这个字段做标记。也因为这个功能需求,剩余子模块必须在热力图模块初始化完毕后,才能确定是否初始化。这些子模块一旦初始化,都会触发指定事件。

// ...省略
import heatmap from './heatmap';
// ...省略
DATrackerLib.prototype._init = function(token, config) {
  var _self = this;
  // ...省略
  heatmap.init(_self, function() {
    _self._loaded();
    _self._send_da_activate();
    // 发送广告点击事件
    if(campaign.data.isAdClick) {        
        _self._ad_click();
    }
    // 启动单页面,修改 referrer
    if(_self.get_config('is_single_page')) {
        _self['cookie'].register({
            currentReferrer: location.href
        });
        // 单页面模块初始化
        _self._single_page();
    }
    // 启动自动触发 PV 事件
    if (_self.get_config('track_pageview')) {
        _self.track_pageview();
    } else {
        // session模块检测
        _self._session();
    }
  });
}

自此,在_init 方法里添加了各子模块的初始化过程。

在 _init方法里,我们应用了_getConfig、_register、_registerOnce、_setConfigtrack_类的这些方法,我们来分析下。

设计sdk的时候,我们考虑了各子模块的设计,同时通过一些规则,保证子模块和主模块之间、子模块和子模块之间和谐相处。这些规则是系统配置、临界点、依赖条件、上报数据、数据传递等,我们需要设计一些必须的API来操作规则。

方法 说明
_setConfig() 设置实例配置信息
_getConfig() 获取实例配置信息
_register() 保存设置的属性值
_registerOnce() 只设置保存一次
track() 处理设置事件信息并上报事件信息
people.set() 处理设置用户信息并上报用户属性
_sendRequest() 发送数据方法

当然还有其它一些API,这类API中,有些必须在设计sdk时考虑进去,有些是开发时候考虑。

发送数据API设计

下面我们就分析 track 这个API,该API即给内部使用,也给用户调用,是上报数据的核心API。
它应该包含哪些功能?
设计该API,它核心功能确定为处理事件数据并上报数据。作为数据采集sdk,这是必须有的API。
基于这个设计思想,一开始应该这样:

// 事件跟踪
DATrackerLib.prototype.track = function(eventName, properties) {
   if (_.isUndefined(eventName)) {
      return;
   }
   this._sendRequest(eventName, properties);
}

在讲解热力图的时候,已经知道当 is_heatmap_render_mode标志值是true时,不能发送数据:

// 事件跟踪
DATrackerLib.prototype.track = function(eventName, properties) {
   if (_.isUndefined(eventName)) {
      return;
   }
   // 热力图渲染模式不上报数据
   if (this._getConfig('is_heatmap_render_mode')) {
      return;
   }
   this._sendRequest(eventName, properties);
}

基于模型设计,上报的任意事件都应带有公共属性:

// 事件跟踪
DATrackerLib.prototype.track = function(eventName, properties) {
   if (_.isUndefined(eventName)) {
      return;
   }
   // 热力图渲染模式不上报数据
   if (this._getConfig('is_heatmap_render_mode')) {
      return;
   }
   var data = {
       // ...此处省略
       attributes: {}
   };
   // 渠道推广信息
   data = _.extend(data, campaign.getParams());
   // 自然流量来源信息
   data = _.extend(data, source.getParams());
   // 自定义属性
   data.attributes = properties || {};
   this._sendRequest(eventName, data);
}

由于数据模型中有costTime字段 ,即用户去触发某个事件的耗时,比如:监听用户进入页面后,去点击播放按钮花费的时间。
那么需要判断timeEvent队列中是否有当前触发的事件,有的话设置costTime值,并清除掉。

// 事件跟踪
DATrackerLib.prototype.track = function(eventName, properties) {
   // ...此处省略
   // timeEvent 队列中,eventName注册的时间
   var startTimesTamp;
   // 耗时
   var costTime;
   if (!_.isUndefined( this['storage'].props.costTime[eventName] )) {
      delete this['storage'].props.costTime.eventName;
      this._register(this['storage'].props);
   }
   if (!_.isUndefined(startTimesTamp)) {
      costTime = new Date().getTime() - startTimesTamp;
   }
   
   var data = {
       // ...此处省略
       costTime: costTime
       attributes: {}
   };
   // ...此处省略
   this._sendRequest(eventName, data);
}

会话模块

接下来就是session了,这块得单独讲解下。
这里所谓的session,指的是这段时间内,用户的行为轨迹。我们设计的时候,考虑两点:

  1. 设计会话开始和会话结束;
  2. 保证所有用户行为事件都在某个会话内;

先从第二点讲起:
我们设想下,用户触发一个行为后,sdk得给它指定到当前的会话内,最简单的方式就是在track里实现这个指定。 首先用户触发了一个行为,sdk执行track方法,此时我们认为该行为属于当前会话,同时我们开启会话检测。

// 事件跟踪
DATrackerLib.prototype.track = function(eventName, properties) {
   // ...此处省略
   // 当触发的事件非指定的这些事件(da_session_start,da_session_close,da_activate),
   // 触发检测 _session() 方法,如果过期,就新启一个会话
   if (!_.include(['da_session_start', 'da_session_close', 'da_activate'], eventName)) {
     this._session();
   }
   // ...此处省略
}

我们已经假设当前已经有个会话,我们回到讲解 _init方法,可以看到已经做session检测了。

再来说第一点:
首先我们确定一个时间间隔,然后再判断是否关闭当前会话,开启新会话,具体实现放置在 _session方法内。

会话开始有三种情况:

  1. 当前无会话;
  2. 最近一次触发的用户行为事件超出当前会话时间;
  3. 从第三方网站跳转过来;

我们开始设计第一种情况:当前无会话。
无会话,也可以理解为会话开始时间为0,故在 _init 方法里,我们首先注册会话时间,并设置为0:

// ...省略
DATrackerLib.prototype._init = function(token, config) {
  _self = this;
  // ...省略
  _self['storage']._registerOnce({
    sessionStartTime: 0
  });
  // ...省略
};

此时进入session判断:

DATrackerLib.prototype._session = function() {
   var sessionStartTime = this['storage'].props.sessionStartTime / 1000;
   if (sessionStartTime === 0) {
       this['storage']._register({
           sessionUuid: _.UUID(),
           sessionStartTime: new Date().getTime()
       });
       this.track('da_session_start');
   }
};

从第三方网站跳转过来,原先的会话结束,开启新会话。通过referrer来判断是否第三方跳转过来。

DATrackerLib.prototype._session = function() {
   var sessionStartTime = this['storage'].props.sessionStartTime / 1000;
   //其它渠道
   var otherWBool = !this._check_referer();
   var set = function() {
       this['storage']._register({
           sessionUuid: _.UUID(),
           sessionStartTime: new Date().getTime()
       });
   };
   if (sessionStartTime === 0) {
       set();
       this.track('da_session_start');
   }
   if (otherWBool) {
       // 关于 session close,需要单独处理,这里只是展示用
       this.track('da_session_close');
       set();
       this.track('da_session_start');
   }
};

会话时间是否超期,首先需要设计一个超时时间,业内一般设计为30分钟;第二需要确定最近行为事件触发时间,然后跟当前时间作比较,这个时间我们可以每次触发非session事件后,保存下来。

首先获取最近触发的时间,再跟当前时间做比较,超过设定范围,开启新会话。

DATrackerLib.prototype._session = function() {
   // ...省略
   var updatedTime = this['storage'].props.updatedTime / 1000;
   // 获取当前时间
   var nowDateTime = new Date().getTime();
   if ( nowDateTime/1000 > updatedTime + 60 * this._getConfig('session_interval_mins') ) {
       // 关于 session close,需要单独处理,这里只是展示用
       this.track('da_session_close');
       set();
       this.track('da_session_start');
   }
};

至于最近行为触发时间,因为每次触发 track 时,若非session事件,我们都会调用 _session方法,所以设计的时候,就直接在 _session中保存。

DATrackerLib.prototype._session = function() {
   // ...省略
   var updatedTime = this['storage'].props.updatedTime / 1000;
   // 获取当前时间
   var nowDateTime = new Date().getTime();
   if ( nowDateTime/1000 > updatedTime + 60 * this._getConfig('session_interval_mins') ) {
       // 关于 session close,需要单独处理,这里只是展示用
       this.track('da_session_close');
       set();
       this.track('da_session_start');
   }
    this['storage']._register({
        updatedTime: nowDateTime
    });
};

这里还要再说明一下,所谓 session close,指的是一段时间内,若没有触发任何行为事件,那么我们就认为该会话已结束。 故可以看到上面代码,每次track时,我们就进入 _session方法内,同时更新 updatedTime。

接下来再来分析下 session close:
每个事件模型都有个time属性,这个属性表示事件的触发时间,事件触发时间一般来说都是当前时间,那么close事件的time属性应该也是当前时间了。但是我们之前讲过,每个session时间段是固定的,也就是说触发close时,它的time不应该超过设定的超时时间,否则查看用户行为轨迹就错乱了。所以close的time属性应该是最后一次触发的非session事件的时间才对。

首先我们在 track API中保存非session事件的触发时间,同时session事件的time要特殊设置

DATrackerLib.prototype.track = function(eventName, properties) {
    // ...省略
    // 当前时间
    var time = new Date().getTime();
    // 事件为 session时,重新设置 time
    if (eventName == 'da_session_close') {
        time = this['storage'].props.sessionCloseTime;
    }
    if (eventName == 'da_session_start') {
        time = this['storage'].props.sessionStartTime;
    }
    
    // 保存非session事件的触发时间
    if( !_.include(['da_session_close','da_session_start'], eventName) ) {
        this['storage']._register({
            LASTEVENT: {
                eventId: eventName,
                time: time
            }
        });
    }
};

然后我们新设计了一个方法,用来处理 session close事件,该事件还有一个 sessionTotalLength 属性,表示当触发close时,该会话持续时长。
我们已经说过,session是表示一段时间内,用户的行为轨迹集合,那么close事件的time 设计的时候,应该考虑不能跟该会话的最后一次行为事件time一样,否则的话行为轨迹就混乱了。

DATrackerLib.prototype._trackDaSessionClose = function() {
    var sessionStartTime = this['storage'].props.sessionStartTime;
    // 当前时间减去1,是为了不跟下一个会话的事件time一致
    var time = new Date().getTime() - 1;
    // 如果本地保存了该会话最后一次的行为事件time,重新设置 time
    var LASTEVENT = this['storage'].props.LASTEVENT;
    if (LASTEVENT && LASTEVENT.time) {
      // 不能跟最后一次行为事件time一样,得加1
       time = LASTEVENT.time + 1;
    }
    var sessionTotalLength = time - sessionStartTime;
    this.track('da_session_close', {
        sessionCloseTime: time,
        sessionTotalLength: sessionTotalLength
    });
};

pv设计

pv是网页统计分析基本的指标,设计采集该指标时需考虑sdk自动上报。
一般情况下,用户进入网页后,就只触发一次PV,故只要在sdk初始化阶段上报即可。

// ...省略
DATrackerLib.prototype._init = function(token, config) {
    var _self = this;
    // ...省略
    heatmap.init(_self, function() {
        self.track('da_screen');
    });
};
// ...省略

PV事件的自定义属性设计

我们事件模型中,任意行为事件是可以自定义属性的,上面实现不能满足用户也能设置PV事件的自定义属性。由于PV事件是sdk内部触发的,我们需要提供给用户一个钩子函数。当然设计该钩子函数,不应该仅仅是满足这个功能,否则就得不偿失了。

首先,我们配置项里添加钩子配置,当sdk初始化时生效该配置。

var DEFAULT_CONFIG = {
    // ...省略
    'loaded': function() {}
}; 

然后在 _init 方法内执行该配置

// ...省略
DATrackerLib.prototype._init = function(token, config) {
    // ...省略
    heatmap.init(_self, function() {
        self._getConfig('loaded')(self);
        self.track('da_screen');
    }); 
    // ...省略
};

设置一个变量,保存用户设置PV的自定义属性,触发 PV 时,使用该属性。

首先页面上配置钩子函数,然后设置 pageview_attributes

//sdk初始化,执行该方法
var beforeFn = function(datracker) {
    //datracker 为sdk实例对象
    //pageview_attributes, pageview事件的自定义事件属性
    datracker.pageview_attributes = {
        test: '12344'
    };
};
DATracker.init('xxxxx',{ loaded: beforeFn});

track方法内,判断若是pv事件,添加自定义属性 pageview_attributes 。

// 常量,内置事件列表
var DEFAULTEVENTID = {
    // ...省略
    'da_screen': {
        'dataType': 'pv'
    },
    // ...省略
};
// 事件跟踪上报
DATrackerLib.prototype.track = function(eventName, properties) {
   // ...省略
   // 事件类型,默认值 'e'
   var dataType = 'e';
   if (DEFAULTEVENTID[eventName]) {
       dataType = DEFAULTEVENTID[event_name].dataType;
   }
   //触发pageview时,外部设置了该pv的自定义事件属性,这里发送该自定义事件属性
   if(dataType === 'pv') {
       if(typeof this.pageview_attributes === 'object') {
            userSetProperties = 
            _.extend({}, this.pageview_attributes || {}, userSetProperties);
        }
   }
   // ...省略
}

单页面模式设计

上面已经讲过,用户每次访问页面时,sdk初始化,然后就会自动上报PV事件。但是如果站点应用是单页面类型时,由于sdk只初始化一次,此时PV事件只发送一次,显然,这是不合理的。
现有的单页面技术,一般有三种: hash、history、memoryhistory。 至于memoryhistory模式,一个应用一套方案,我们并没有合适的手段添加PV变动监听,故我们希望用户方自己手动调用下面 API触发PV事件上报。

DATrackerLib.prototype.track_pageview = function(attributes, page, call) {
    if (_.isUndefined(page)) {
        page = document.location.href;
    }
    var self = this;
    var callback = function() {
       var data = self.track('da_screen', _.extend({}, attributes));
       if(typeof call === 'function') {
           call(data);
       }
    };
    self._session(callback);
};

自动监听PV事件设计
默认sdk是不应该启动PV事件监听的,因为我们无法判断应用是否采用单页面技术方案。所以我们需要设计配置项,让用户来启动单页面应用PV监听,同时让用户确定是 hash还是 history 方案实现。

页面上调用初始化方法,开启单页面应用,采用 hash 模式:

 DATracker.init('xxxxx', {
    is_single_page: true,
    single_page_config: {
        mode: 'hash' // mode: 'history'
    }
 });

is_single_page 字段就是启动单页面应用开关了, mode 字段是单页面应用技术方案。
设计好用户使用方式后,接下来就是实现PV监听了。
当mode是 'hash'时,实现的原理是监听浏览器的'hashchange'事件;
当mode是 'history'时,实现的原理是监听浏览器的 'popstate' 事件,以及监听调用浏览器的 'pushState' 和 'replaceState' API;

首先我们设计singlePage类

var singlePage = {
    config: {
        mode: 'hash',
        callback_fn: function() {}
    },
    init: function(config) {
        this.config = _.extend(this.config, config || {});
        this._onEvent();
    },
    _onEvent: function() {
        if (this.config.mode === 'hash') {
            _.register_event(window, 'hashchange', _.bind(this._handleHashState, this));
        }
    },
    _handleHashState: function() {
        this._handleUrlChange();
    },
    _handleUrlChange: function() {
        var _self = this;
        // 等页面url变动后,再执行 callback_fn
        setTimeout(function() {
            if(_self.config.mode === 'hash') {
                if(typeof _self.config.callback_fn === 'function') {
                    _self.config.callback_fn.call();
                }
            }
        }, 0);
    }
};

上面我们设计了singlePage类,实现了 'hash' 的监听, 配置项中 callback_fn 是监听变动后的回调函数。

接下来我们实现 history 的监听。
我们知道,history 变动的时候,将触发 popstate 事件。 用 history 技术实现单页面,切换页面的时候,一般调用 pushState 或者 replaceState,但是调用这两个API,不会触发popstate事件,所以得重写这两个API,以便监听是否调用。 还有一点需要注意,调用 replaceState方法后触发的变动,我们默认是不算PV的,但我们也需要设计开关,让用户配置。

var singlePage = {
    config: {
        mode: 'hash',
        track_replace_state:false,
        callback_fn: function() {}
    },
    init: function(config) {
        this.config = _.extend(this.config, config || {});
        this.path = _getPath();
        this._onEvent();
    },
    _onEvent: function() {
        // ...省略
        if (this.config.mode === 'history') {
            if (!history.pushState || !window.addEventListener) return;
            this._on(history, 'pushState', _.bind(this._pushStateOverride, this) );
            this._on(history, 'replaceState', _.bind(this._replaceStateOverride, this) );
            window.addEventListener('popstate', _.bind(this._handlePopState, this));
        }
    },
    _pushStateOverride: function() {
        this._handleUrlChange(true);
    },
    _replaceStateOverride: function() {
        this._handleUrlChange(false);
    },
    _handlePopState: function() {
        this._handleUrlChange(true);
    },
    // ...省略
    _handleUrlChange: function(historyDidUpdate) {
        var _self = this;
        // 等页面url变动后,再执行 callback_fn
        setTimeout(function() {
            // ...省略
            if (self.config.mode === 'history') {
                if(historyDidUpdate || self.config.track_replace_state) {
                    if(typeof self.config.callback_fn === 'function') {
                        self.config.callback_fn.call();
                    }
                }
            }
        }, 0);
    },
    _getPath: function() {
        return location.pathname + location.search;
    },
    _on: function(obj, event, callFn) {
        if(obj[event]) {
            var fn = obj[event];
            obj[event] = function() {
                var args = Array.prototype.slice.call(arguments);
                callFn.apply(this, args);
                fn.apply(this, args);
            };
        } else {
            obj[event] = function() {
                var args = Array.prototype.slice.call(arguments);
                callFn.apply(this, args);
            };
        }
    }
};

上面我们基本实现了history的监听,但有几个问题需要解决:

  1. 我们通过重写API方式来实现监听,如果用户设置了两次相同的url,此时PV就重复发了;
  2. 如果设置的url为空,此时PV还是会触发,这是不合理的;

优化后的实现:

var singlePage = {
    // ...省略
    _handleUrlChange: function(historyDidUpdate) {
        var _self = this;
        // 等页面url变动后,再执行 callback_fn
        setTimeout(function() {
            // ...省略
            if (self.config.mode === 'history') {
                var oldPath = self.path;
                var newPath = _getPath();
                // url不相同且都不为空
                if(oldPath != newPath && self._shouldTrackUrlChange(newPath, oldPath)) {
                    self.path = newPath;
                    if(historyDidUpdate || self.config.track_replace_state) {
                        if(typeof self.config.callback_fn === 'function') {
                            self.config.callback_fn.call();
                        }
                    }
                }
            }
        }, 0);
    },
    // url存在
    _shouldTrackUrlChange: function(newPath, oldPath) {
        return !!(newPath && oldPath);
    }
    // ...省略
};

singlePage类我们已经设计完毕。
下面是调用singlePage类的方法:

// 单页面应用初始化调用方法
DATrackerLib.prototype._singlePage = function() {
    var _self = this;
    var fn = function() {};
    if(_self.get_config('single_page_config').mode === 'hash') {
        fn = function() {
            _self._singlePageview();
        };
    } else if(_self.get_config('single_page_config').mode === 'history') {
        fn = function() {
            _self._singlePageview();
        };
    }
    
    single_page.init({
        mode: _self._getConfig('single_page_config').mode,
        track_replace_state: _self._getConfig('single_page_config').track_replace_state,
        callback_fn: fn
    });
};

在数据模型中,每一个行为事件都带有 referrer 属性,这个属性我们是通过 document.referrer 拿的,但是单页面技术开发的应用,切换页面时,我们拿到的属性值是错误的,比如都是空字符串。这时候我们需要设计一个方案,保存上一个页面的url作为 referrer 属性值。

// ...省略
DATrackerLib.prototype._init = function(token, config) {
    // ...省略
    _self['storage']._register({
        sessionReferrer: document.referrer
    });
    // ...省略
};


//发送单页面的PV事件
DATrackerLib.prototype._singlePageview = function() {
    // ...此处省略
};

// 事件跟踪
DATrackerLib.prototype.track = function(eventName, properties) {
   // ...此处省略
   if (dataType === 'e') {
       
   }
    //启动单页面
    //解决单页面切换时无 referrer 问题
    if(this.get_config('is_single_page')) {
        if(properties['sessionReferrer'] != properties['referrer']) {
            properties['referrer'] = properties['sessionReferrer'];
        }
    }
}


@作者:白云飘飘(534591395@qq.com)

@github: github.com/534591395 欢迎关注我的微信公众号:

微信公众号
或者微信公众号搜索 新梦想兔,关注我哦。