前言
最近负责的一个项目,项目的客户端是微信小程序。客户提了需求,需要进行数据埋点,收集用户行为数据,为后续提升用户体验及产品优化提供基础数据收集。于是在一个风和日丽的日子,撸起袖子动手干。
思路
先看看客户端常见的数据埋点方案如下图。
侵入式埋点这个如果在页面少,数据收集需求变化不大的情况下,还是挺经济实惠的。鉴于当前项目页面较多,数据收集的需求变化也在不断的调整,所以侵入式埋点的方案不适用本项目,只有松耦合这个方案了。
小程序的特点
根据松耦合埋点方案,首先要解决的问题是拦截小程序所有控件的操作数据。在Web中有BOM对象及DOM对象可以给我们进行全方位的操作,特别是BOM对象,可以进行全局事件拦截。而小程序类似BOM对象的主要有下面三个对象:App、Page、Component。
- App:App对象有且只有一个,属于小程序的总控对象,App下面包含着n个Page对象。
- Page:Page则通过字面理解就是页面的总控对象,一个小程序可以有多个Page,每一个Page对象对应一个页面,Page下面可以包含n个Component及其他普通的小程序组件。
- Component:Component则是自定义组件对象,类似于Page,只明生命周期及一些特性上有所区别,自定义组件对象在实际项目中比较常用,主要用来解决小程序页面堆栈太小的问题,这个后续再写另外一篇来讲。
小程序的事件机制也是采用与JavaScript一样的事件机制,捕获->执行->冒泡的机制,JavaScript在BOM及DOM对象中都提供了addEventListener这种订阅/发布机制,使我们可以轻松的拦截所有的事件,又不影响现有的流程及代码,从而实现松耦合埋点方案。但是小程序没有提供addEventListener的订阅/发布机制,没有办法通过这样子的方案来实现。
1.拦截器方式
翻了官网,发现没有办法统一的处理,那么只能退而求其次,减少对现有的代码的侵入,于是想到了拦截器的机制来实现。
看看下面小程序原生的代码
App({
onLaunch(options) {
// Do something initial when launch.
},
onShow(options) {
// Do something when show.
},
onHide() {
// Do something when hide.
},
onError(msg) {
console.log(msg)
},
globalData: 'I am global data'
})
提供拦截器,这里只拿App的onLaunch事件进行示例,其他事件都差不多这样处理即可。
var appFilter = function(config){
if(config.onLaunch){
let _onLaunch = config.onLaunch;
config.onLaunch = function(ops){
//在这里干埋点的事
//例如存储数据、上送数据
_onLaunch.call(this);//调用原来的执行逻辑
}
}
return config;
}
//App使用拦截器示例
App(appFilter({
onLaunch(options) {
// Do something initial when launch.
},
onShow(options) {
// Do something when show.
},
onHide() {
// Do something when hide.
},
onError(msg) {
console.log(msg)
},
globalData: 'I am global data'
})
)
这个也是JS好玩的地方,一切皆为对象。通过新增一个Function对象,增强方法之后,把旧的Function对象进行替换。上面的App的onLaunch方法,经过appFilter过滤器的处理后,就替换成过滤器里面加了埋点事件的新方法了,对于开发人员来讲,不需要去改动原来App.onLaunch事件里面的代码,目的达到了,但是不够完美,在写过滤器的实现逻辑时,给了我一个灵感,我是否可以用这种方式来实现不侵入代码的埋点?答案是可以的。
2.增强扩展方式
利用新增一个Function对象,增强方法之后,把旧的Function对象进行替换这样的原理,依次将App、Page、Component这三个对象进行增强扩展,示例代码如下。
//先把原生的三个对象保存起来
const originalApp = App,
originalPage = Page,
originalComponent = Component;
//在原生的事件函数里面,添加数据埋点,并替换成新的事件函数
const _extendsApp = function (conf, method) {
const _o_method = conf[method];
conf[method] = function (ops) {
//在此处进行数据埋点
if (typeof _o_method === 'function') {
_o_method.call(this, ops);
}
}
}
//重新定义App这个对象,将原来的App对象覆盖
App = function(conf){
//定义需要增强的方法
const methods = ['onLaunch', 'onShow', 'onHide', 'onError']
methods.map(function (method) {
_extendsApp(conf, method);
})
//另外增强扩展埋点上送的方法
conf.william = {
addActionData: function (ops) {
console.log('addActionData');
},
addVisitLog: function (ops) {
console.log('addVisitLog');
}
}
return originalApp(conf);
}
//Page及Component对象类似App的处理即可
至此,整个小程序的生命周期都在掌握之中了,可以按需采集对应的数据,并且对于开发人员来讲,还不需要去修改及调整代码,松耦合埋点方案搞定。
3.增强扩展+订阅/发布
上面的实现方案虽然实现了松耦合,但是个人觉得还不够完美,埋点的动作必须要写在增加的方法里面,这样子可维护性较差,也不够灵活。解耦这事现在对我来说信手拈来,祭出了我的EventHub(基于订阅/发布模式实现的消息总线),完美。
//引用EventHub
import EventHub from '../../utils/eventhub.min';
//先把原生的三个对象保存起来
const originalApp = App,
originalPage = Page,
originalComponent = Component;
//在原生的事件函数里面,添加数据埋点,并替换成新的事件函数
const _extendsApp = function (conf, method) {
const _o_method = conf[method];
conf[method] = function (ops) {
//在此处进行数据埋点
//此处改成消息发布
if (typeof EventHub != "undefined") {
EventHub.emit('app' + method, ops);
}
if (typeof _o_method === 'function') {
_o_method.call(this, ops);
}
}
}
//重新定义App这个对象,将原来的App对象覆盖
App = function(conf){
//定义需要增强的方法
const methods = ['onLaunch', 'onShow', 'onHide', 'onError']
methods.map(function (method) {
_extendsApp(conf, method);
})
//另外增强扩展埋点上送的方法
conf.william = {
addActionData: function (ops) {
console.log('addActionData');
},
addVisitLog: function (ops) {
console.log('addVisitLog');
}
}
return originalApp(conf);
}
//Page及Component对象类似App的处理即可
这样子就可以把埋点处理的逻辑抽离到另外一个JS文件中去实现。
EventHub.on('apponLaunch',function(ops){
//在这里可以处理数据埋点的事
})
延展性思考
基于上述的实现方案,我们除了增强生命周期,也可以像上面那样去增加公用的方法,例如App.william.addActionData,更可以通过增强setData方法来进行数据溯源或者进行差量比较来提升性能等方式。
分享一个简单自写的订阅发布模型,有需要的同学请自便
(function webpackUniversalModuleDefinition(root, factory) {
if(typeof exports === 'object' && typeof module === 'object')
module.exports = factory();
else if(typeof define === 'function' && define.amd)
define([], factory);
else if(typeof exports === 'object')
exports["EventHub"] = factory();
else
root["EventHub"] = factory();
})(this, function() {
return /******/ (function(modules) { // webpackBootstrap
/******/ // The module cache
/******/ var installedModules = {};
/******/
/******/ // The require function
/******/ function __webpack_require__(moduleId) {
/******/
/******/ // Check if module is in cache
/******/ if(installedModules[moduleId]) {
/******/ return installedModules[moduleId].exports;
/******/ }
/******/ // Create a new module (and put it into the cache)
/******/ var module = installedModules[moduleId] = {
/******/ i: moduleId,
/******/ l: false,
/******/ exports: {}
/******/ };
/******/
/******/ // Execute the module function
/******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
/******/
/******/ // Flag the module as loaded
/******/ module.l = true;
/******/
/******/ // Return the exports of the module
/******/ return module.exports;
/******/ }
/******/
/******/
/******/ // expose the modules object (__webpack_modules__)
/******/ __webpack_require__.m = modules;
/******/
/******/ // expose the module cache
/******/ __webpack_require__.c = installedModules;
/******/
/******/ // identity function for calling harmony imports with the correct context
/******/ __webpack_require__.i = function(value) { return value; };
/******/
/******/ // define getter function for harmony exports
/******/ __webpack_require__.d = function(exports, name, getter) {
/******/ if(!__webpack_require__.o(exports, name)) {
/******/ Object.defineProperty(exports, name, {
/******/ configurable: false,
/******/ enumerable: true,
/******/ get: getter
/******/ });
/******/ }
/******/ };
/******/
/******/ // getDefaultExport function for compatibility with non-harmony modules
/******/ __webpack_require__.n = function(module) {
/******/ var getter = module && module.__esModule ?
/******/ function getDefault() { return module['default']; } :
/******/ function getModuleExports() { return module; };
/******/ __webpack_require__.d(getter, 'a', getter);
/******/ return getter;
/******/ };
/******/
/******/ // Object.prototype.hasOwnProperty.call
/******/ __webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); };
/******/
/******/ // __webpack_public_path__
/******/ __webpack_require__.p = "";
/******/
/******/ // Load entry module and return exports
/******/ return __webpack_require__(__webpack_require__.s = 0);
/******/ })
/************************************************************************/
/******/ ([
/* 0 */
/***/ (function(module, exports) {
/**
* 2016-11-21 William
* Publish/Subscribe
* 用于解决组件间的通信耦合
*/
var EventHub = (function () {
// 页面加载完成事件
var EVENT_RENDER_COMPLETE = 'EVENT_RENDER_COMPLETE';
var _handlers = {};
/**
* 添加订阅事件,返回事件对象
* @param {string} e 事件
* @param {function} fn 回调函数
* @return {Object} event_object 事件类型
*/
var _on = function (e, fn) {
if (!(e in _handlers)) {
_handlers[e] = [];
}
var _event_object = {
fn: fn,
e: e
};
_handlers[e].push(_event_object);
return _event_object;
};
/**
* 发布消息
* @param {string} e 事件类型
*/
var _emit = function (e) {
if (!_handlers[e]) {return;}
var args = Array.prototype.slice.call(arguments, 1);
for (var i = 0; i < _handlers[e].length; i++) {
(function () {
var _handlerFn = _handlers[e][i].fn;
// var _handler = function() {
_handlerFn.apply(this, args);
// }.bind(this);
// setTimeout(_handler, 0);
})();
}
};
/**
* 取消订阅
* @param {Object} event_object
*/
var _off = function (event_object) {
if (_handlers[event_object]) {
for (var i = _handlers[event_object].length - 1; i > -1; i--) {
if (event_object === _handlers[event_object][i].e) {
_handlers[event_object].splice(i, 1);
}
}
}
};
var _ready = function (fn) {
_on(EVENT_RENDER_COMPLETE, fn);
};
return {
PLATFORM_AJAX_STATUS:'PLATFORM_AJAX_STATUS',//Ajax请求状态事件
PLATFORM_LOADING:'PLATFORM_LOADING',//Ajax请求加载事件
/**
* 添加订阅事件,返回事件对象
* @param {string} e 事件
* @param {function} fn 回调函数
* @return {Object} event_object 事件类型
*/
on: _on,
/**
* 发布消息
* @param {string} e 事件类型
*/
emit: _emit,
/**
* 取消订阅
* @param {Object} event_object
*/
off: _off,
/**
* 页面完成加载
*/
ready: _ready
};
})();
module.exports = EventHub;
/***/ })
/******/ ]);
});