前言
最近負(fù)責(zé)的一個(gè)項(xiàng)目送爸,項(xiàng)目的客戶端是微信小程序铛嘱。客戶提了需求袭厂,需要進(jìn)行數(shù)據(jù)埋點(diǎn)墨吓,收集用戶行為數(shù)據(jù),為后續(xù)提升用戶體驗(yàn)及產(chǎn)品優(yōu)化提供基礎(chǔ)數(shù)據(jù)收集纹磺。于是在一個(gè)風(fēng)和日麗的日子帖烘,擼起袖子動(dòng)手干。
思路
先看看客戶端常見(jiàn)的數(shù)據(jù)埋點(diǎn)方案如下圖橄杨。
侵入式埋點(diǎn)這個(gè)如果在頁(yè)面少秘症,數(shù)據(jù)收集需求變化不大的情況下,還是挺經(jīng)濟(jì)實(shí)惠的式矫。鑒于當(dāng)前項(xiàng)目頁(yè)面較多历极,數(shù)據(jù)收集的需求變化也在不斷的調(diào)整,所以侵入式埋點(diǎn)的方案不適用本項(xiàng)目衷佃,只有松耦合這個(gè)方案了。
小程序的特點(diǎn)
根據(jù)松耦合埋點(diǎn)方案蹄葱,首先要解決的問(wèn)題是攔截小程序所有控件的操作數(shù)據(jù)氏义。在Web中有BOM對(duì)象及DOM對(duì)象可以給我們進(jìn)行全方位的操作,特別是BOM對(duì)象图云,可以進(jìn)行全局事件攔截惯悠。而小程序類似BOM對(duì)象的主要有下面三個(gè)對(duì)象:App、Page竣况、Component克婶。
- App:App對(duì)象有且只有一個(gè),屬于小程序的總控對(duì)象丹泉,App下面包含著n個(gè)Page對(duì)象情萤。
- Page:Page則通過(guò)字面理解就是頁(yè)面的總控對(duì)象,一個(gè)小程序可以有多個(gè)Page摹恨,每一個(gè)Page對(duì)象對(duì)應(yīng)一個(gè)頁(yè)面筋岛,Page下面可以包含n個(gè)Component及其他普通的小程序組件。
- Component:Component則是自定義組件對(duì)象晒哄,類似于Page睁宰,只明生命周期及一些特性上有所區(qū)別肪获,自定義組件對(duì)象在實(shí)際項(xiàng)目中比較常用,主要用來(lái)解決小程序頁(yè)面堆棧太小的問(wèn)題柒傻,這個(gè)后續(xù)再寫(xiě)另外一篇來(lái)講孝赫。
小程序的事件機(jī)制也是采用與JavaScript一樣的事件機(jī)制,捕獲->執(zhí)行->冒泡的機(jī)制红符,JavaScript在BOM及DOM對(duì)象中都提供了addEventListener這種訂閱/發(fā)布機(jī)制青柄,使我們可以輕松的攔截所有的事件,又不影響現(xiàn)有的流程及代碼违孝,從而實(shí)現(xiàn)松耦合埋點(diǎn)方案刹前。但是小程序沒(méi)有提供addEventListener的訂閱/發(fā)布機(jī)制,沒(méi)有辦法通過(guò)這樣子的方案來(lái)實(shí)現(xiàn)雌桑。
1.攔截器方式
翻了官網(wǎng)喇喉,發(fā)現(xiàn)沒(méi)有辦法統(tǒng)一的處理,那么只能退而求其次校坑,減少對(duì)現(xiàn)有的代碼的侵入拣技,于是想到了攔截器的機(jī)制來(lái)實(shí)現(xiàn)。
看看下面小程序原生的代碼
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事件進(jìn)行示例膏斤,其他事件都差不多這樣處理即可。
var appFilter = function(config){
if(config.onLaunch){
let _onLaunch = config.onLaunch;
config.onLaunch = function(ops){
//在這里干埋點(diǎn)的事
//例如存儲(chǔ)數(shù)據(jù)邪驮、上送數(shù)據(jù)
_onLaunch.call(this);//調(diào)用原來(lái)的執(zhí)行邏輯
}
}
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'
})
)
這個(gè)也是JS好玩的地方莫辨,一切皆為對(duì)象。通過(guò)新增一個(gè)Function對(duì)象毅访,增強(qiáng)方法之后沮榜,把舊的Function對(duì)象進(jìn)行替換。上面的App的onLaunch方法喻粹,經(jīng)過(guò)appFilter過(guò)濾器的處理后蟆融,就替換成過(guò)濾器里面加了埋點(diǎn)事件的新方法了,對(duì)于開(kāi)發(fā)人員來(lái)講守呜,不需要去改動(dòng)原來(lái)App.onLaunch事件里面的代碼型酥,目的達(dá)到了,但是不夠完美查乒,在寫(xiě)過(guò)濾器的實(shí)現(xiàn)邏輯時(shí)柔逼,給了我一個(gè)靈感蓖谢,我是否可以用這種方式來(lái)實(shí)現(xiàn)不侵入代碼的埋點(diǎn)?答案是可以的。
2.增強(qiáng)擴(kuò)展方式
利用新增一個(gè)Function對(duì)象岸更,增強(qiáng)方法之后溺蕉,把舊的Function對(duì)象進(jìn)行替換這樣的原理,依次將App、Page蔑舞、Component這三個(gè)對(duì)象進(jìn)行增強(qiáng)擴(kuò)展,示例代碼如下嘹屯。
//先把原生的三個(gè)對(duì)象保存起來(lái)
const originalApp = App,
originalPage = Page,
originalComponent = Component;
//在原生的事件函數(shù)里面攻询,添加數(shù)據(jù)埋點(diǎn),并替換成新的事件函數(shù)
const _extendsApp = function (conf, method) {
const _o_method = conf[method];
conf[method] = function (ops) {
//在此處進(jìn)行數(shù)據(jù)埋點(diǎn)
if (typeof _o_method === 'function') {
_o_method.call(this, ops);
}
}
}
//重新定義App這個(gè)對(duì)象州弟,將原來(lái)的App對(duì)象覆蓋
App = function(conf){
//定義需要增強(qiáng)的方法
const methods = ['onLaunch', 'onShow', 'onHide', 'onError']
methods.map(function (method) {
_extendsApp(conf, method);
})
//另外增強(qiáng)擴(kuò)展埋點(diǎn)上送的方法
conf.william = {
addActionData: function (ops) {
console.log('addActionData');
},
addVisitLog: function (ops) {
console.log('addVisitLog');
}
}
return originalApp(conf);
}
//Page及Component對(duì)象類似App的處理即可
至此钧栖,整個(gè)小程序的生命周期都在掌握之中了,可以按需采集對(duì)應(yīng)的數(shù)據(jù)婆翔,并且對(duì)于開(kāi)發(fā)人員來(lái)講拯杠,還不需要去修改及調(diào)整代碼,松耦合埋點(diǎn)方案搞定啃奴。
3.增強(qiáng)擴(kuò)展+訂閱/發(fā)布
上面的實(shí)現(xiàn)方案雖然實(shí)現(xiàn)了松耦合潭陪,但是個(gè)人覺(jué)得還不夠完美,埋點(diǎn)的動(dòng)作必須要寫(xiě)在增加的方法里面最蕾,這樣子可維護(hù)性較差依溯,也不夠靈活。解耦這事現(xiàn)在對(duì)我來(lái)說(shuō)信手拈來(lái)瘟则,祭出了我的EventHub(基于訂閱/發(fā)布模式實(shí)現(xiàn)的消息總線)黎炉,完美。
//引用EventHub
import EventHub from '../../utils/eventhub.min';
//先把原生的三個(gè)對(duì)象保存起來(lái)
const originalApp = App,
originalPage = Page,
originalComponent = Component;
//在原生的事件函數(shù)里面醋拧,添加數(shù)據(jù)埋點(diǎn)慷嗜,并替換成新的事件函數(shù)
const _extendsApp = function (conf, method) {
const _o_method = conf[method];
conf[method] = function (ops) {
//在此處進(jìn)行數(shù)據(jù)埋點(diǎn)
//此處改成消息發(fā)布
if (typeof EventHub != "undefined") {
EventHub.emit('app' + method, ops);
}
if (typeof _o_method === 'function') {
_o_method.call(this, ops);
}
}
}
//重新定義App這個(gè)對(duì)象,將原來(lái)的App對(duì)象覆蓋
App = function(conf){
//定義需要增強(qiáng)的方法
const methods = ['onLaunch', 'onShow', 'onHide', 'onError']
methods.map(function (method) {
_extendsApp(conf, method);
})
//另外增強(qiáng)擴(kuò)展埋點(diǎn)上送的方法
conf.william = {
addActionData: function (ops) {
console.log('addActionData');
},
addVisitLog: function (ops) {
console.log('addVisitLog');
}
}
return originalApp(conf);
}
//Page及Component對(duì)象類似App的處理即可
這樣子就可以把埋點(diǎn)處理的邏輯抽離到另外一個(gè)JS文件中去實(shí)現(xiàn)丹壕。
EventHub.on('apponLaunch',function(ops){
//在這里可以處理數(shù)據(jù)埋點(diǎn)的事
})
延展性思考
基于上述的實(shí)現(xiàn)方案庆械,我們除了增強(qiáng)生命周期,也可以像上面那樣去增加公用的方法雀费,例如App.william.addActionData,更可以通過(guò)增強(qiáng)setData方法來(lái)進(jìn)行數(shù)據(jù)溯源或者進(jìn)行差量比較來(lái)提升性能等方式痊焊。
分享一個(gè)簡(jiǎn)單自寫(xiě)的訂閱發(fā)布模型盏袄,有需要的同學(xué)請(qǐng)自便
(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 () {
// 頁(yè)面加載完成事件
var EVENT_RENDER_COMPLETE = 'EVENT_RENDER_COMPLETE';
var _handlers = {};
/**
* 添加訂閱事件,返回事件對(duì)象
* @param {string} e 事件
* @param {function} fn 回調(diào)函數(shù)
* @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;
};
/**
* 發(fā)布消息
* @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請(qǐng)求狀態(tài)事件
PLATFORM_LOADING:'PLATFORM_LOADING',//Ajax請(qǐng)求加載事件
/**
* 添加訂閱事件,返回事件對(duì)象
* @param {string} e 事件
* @param {function} fn 回調(diào)函數(shù)
* @return {Object} event_object 事件類型
*/
on: _on,
/**
* 發(fā)布消息
* @param {string} e 事件類型
*/
emit: _emit,
/**
* 取消訂閱
* @param {Object} event_object
*/
off: _off,
/**
* 頁(yè)面完成加載
*/
ready: _ready
};
})();
module.exports = EventHub;
/***/ })
/******/ ]);
});