原创声明:本文为作者原创,未经允许不得转载,经授权转载需注明作者和出处
在一个比较成熟微信小程序中,为了统计每个页面的行为,如统计页面PV、UV、对页面元素点击等事件进行监听,并且上报到我们自己的数据统计服务器上,目前网上能找到的大部分方案是通过手动埋点的方式实现,这种方式效率较低,来一个页面就要加一个统计逻辑,对代码的侵入较多。且网上的方案都是基于原生微信小程序的解决方案,对于使用Taro进行开发的项目来说,有点力不从心,因此,通过一段时间的研究与实验,整理出这篇文章,用于梳理在使用Taro如何实现无侵入或低侵入(只需要在app.tsx中调用一个方法即可实现对所有的页面生命周期进行监听)。
taro-track
欢迎star,如有任何疑问,欢迎提ISSUES进行讨论
我们要想要实现无侵入或低侵入的监控页面声明周期函数,对于原生的微信小程序,我们可以参考网上的一个现有解决方案:小程序从手动埋点到自动埋点。
其实现原理主要是:通过代理微信小程序的Page
方法,在用户传递进来的生命周期钩子函数外层包装一层wrapper
函数,并在wrapper
函数中实现统一数据上报的逻辑,然后再调用用户定义的声明周期钩子函数,这样,使用者便可以在无感知的情况下进行编码,所有的数据收集与上报操作都可以在这个wrapper
函数中执行。
然而,上述方案仅适用于原生微信小程序,在基于Taro开发的微信小程序项目中,由于在Taro中所有单元都是组件Component
,而非Page
,经过本人的反复试验,Taro在运行的过程中,并没有调用过Page
方法,因此,通过代理微信原生Page
方法这条路是行不通了。
那么,既然在Taro
中一切皆组件,我们能不能通过代理Component
实现类似的逻辑呢?经过试验,这个想法是可行的,不过由于Component
的生命周期钩子跟Page
的生命周期钩子不一样,所以我们需要对其做一定的转化。
//// core/wx-tools.ts
/**
* 获取微信原生Page
* @returns {WechatMiniprogram.Page.Constructor}
*/
export function getWxPage():WechatMiniprogram.Page.Constructor {
return Page;
}
/**
* 重写微信原生Page
* @param newPage
*/
export function overrideWxPage(newPage: any):void {
Page = newPage;
}
/**
* 获取微信原生App
* @returns {WechatMiniprogram.App.Constructor}
*/
export function getWxApp():WechatMiniprogram.App.Constructor {
return App;
}
/**
* 重写微信原生App
* @param newApp
*/
export function overrideWxApp(newApp: any): void {
App = newApp;
}
/**
* 获取微信原生Component
* @returns {WechatMiniprogram.Component.Constructor}
*/
export function getWxComponent():WechatMiniprogram.Component.Constructor {
return Component;
}
/**
* 重写微信原生Component
* @param newComponent
*/
export function overrideWxComponent(newComponent: any): void {
Component = newComponent;
}
//// overrideWxPage.ts
import { getWxComponent, getWxPage, overrideWxComponent, overrideWxPage } from '@kiner/core/es';
// 需要代理的生命周期钩子,包含Page和Component的钩子
const proxyMethods = [
"onShow",
"onHide",
"onReady",
"onLoad",
"onUnload",
"created",
"attached",
"ready",
"moved",
"detached",
];
// 触发钩子的回调函数中的初始化参数
export interface OverrideWechatPageInitOptions {
__route__?: string
__isPage__?: boolean
[key:string]: any
}
// 触发钩子是调用的回调函数类型
export type OverrideWechatPageHooksCb = (method: string, options: OverrideWechatPageInitOptions)=>void;
// 用于存储所有的回调函数
const pageHooksCbs: OverrideWechatPageHooksCb[] = [];
export class OverrideWechatPage {
// 微信原生Page方法
private readonly wechatOriginalPage: WechatMiniprogram.Page.Constructor;
// 微信原生Component方法
private readonly wechatOriginalComponent: WechatMiniprogram.Component.Constructor;
// 是否使用taro框架
private readonly isTaro = true;
public constructor(isTaro:boolean=true) {
this.isTaro = true;
// 基于以后可能需要兼容头条、百度小程序需要,将所有操作原生微信小程序的操都独立抽离到单独的模块中进行维护,
// 若以后需要兼容其他小程序,只需要在盖某块内部进行api动态指定切换即可
// 保存微信原始Page对象,以便我们在销毁时恢复原状
this.wechatOriginalPage = getWxPage();
// 保存微信原始Component对象,以便我们在销毁时恢复原状
this.wechatOriginalComponent = getWxComponent();
}
public initialize(pageHooksCb: OverrideWechatPageHooksCb): void {
const _Page = getWxPage();
const _Component = getWxComponent();
// 将回调函数放入队列中,在触发原生生命周期钩子时依次调用
pageHooksCbs.push(pageHooksCb);
console.info(`原始Page对象`, pageHooksCbs, this.wechatOriginalPage);
const self = this;
// 根据是否使用Taro框架筛选需要代理的钩子函数
// 若使用Taro则需代理组件的生命周期钩子,若使用原生小程序则代理Page的生命周期钩子
const needProxyMethods = proxyMethods.filter(item=>this.isTaro?!item.startsWith('on'):item.startsWith('on'));
/**
* 实现代理Page|Component的逻辑
* @param {OverrideWechatPageInitOptions} options
* @returns {string}
*/
const wrapper = function(options: OverrideWechatPageInitOptions){
needProxyMethods.forEach(methodName=>{
// 缓存用户定义的生命周期钩子
const _originalHooks = options[methodName];
const wrapperMethod = function (...args: any[]) {
// 依次触发页面生命周期回调
pageHooksCbs.forEach((fn: OverrideWechatPageHooksCb)=>fn(methodName, options));
// 若用户有定义该生命周期钩子则执行这个钩子函数
return _originalHooks&&_originalHooks.call(this, ...args);
};
// 重写options,用新的包装函数覆盖原始钩子函数
options = {
...options,
[methodName]:wrapperMethod
};
});
// 使用新的options进行初始化操作
let res = "";
if(self.isTaro){
res = _Component(options);
}else{
_Page(options);
}
// 由于在Taro中,一切皆组件,我们需要知道当前组件是页面组件还是普通组件
// 微信小程序原生的Component执行构造函数后会直接返回当前组件的路径,如:pages/index/index
// 因此,我们可以将这个路径保存在我们的wrapper中,方便我们在外部判断当前组件是否是页面组件
options.__router__ = wrapper.__route__ = res;
options.__isPage__ = res.startsWith('pages/');
console.info(`重写微信小程序Page对象`, options, res);
return res;
};
wrapper.__route__ = '';
wrapper.__isPage__ = false;
// 重写微信原生Page|Component
if(this.isTaro){
overrideWxComponent(wrapper);
}else{
overrideWxPage(wrapper);
}
}
/**
* 重置微信原生方法
*/
public destroy(): void {
overrideWxPage(this.wechatOriginalPage);
overrideWxComponent(this.wechatOriginalComponent);
}
}
//// entry.ts
/**
* 初始化微信小程序生命周期监听
* @param {string | undefined} baseUrl 发送的日志服务器,默认为生产服务
* @param {TransporterType} transporter 采用的上传通道方案是elk还是console
* @param {string | undefined} appVersion 当前小程序版本
* @param {string | undefined} appName 当前小程序的名称
* @param {boolean} showLog 发送成功是否打印日志
* @param {number} pstInterval applet-pst事件循环上报时间间隔,默认为:5000
* @param extraData 额外参数,sdk中无法直接获取的字段,如appid等
* @param {{[p: string]: string}} extraData
*/
export function initAppletLifecycleListener(
{baseUrl,
isTaro,
transporter,
appVersion,
appName,
showLog = false,
pstInterval = 5000
}: InitAppletLifecycleOption,
extraData: { [key: string]: string } = {}
){
const logger = Logger.create('initAppletLifecycleListener');
const logStyle = 'background: green; color: #FFFFFF; padding: 5px 10px;';
const tpr = initTransporter(transporter, {
baseUrl: baseUrl,
query: {
app_name: appName,
app_version: appVersion,
ev_type: 'client_ub'
}
});
let timer = null;
const overrideWechatPage = new OverrideWechatPage(isTaro);
const prevUrl = getWxCurrentHref();
// 对页面的onLoad和onReady进行监听
overrideWechatPage.initialize(async (methodName: string, options) => {
if (!options.__isPage__) {
return;
}
// const hooksName = CompAndPageHookMap[methodName];
console.log(`dolphin-wx/entry:${methodName}-${CompAndPageHookMap[methodName]}`);
const openTime = Date.now();
const baseExtFields = getBaseExtFields(extraData);
const baseFields = await getBaseFields(extraData);
const extraExt = extraData.ext || {};
function sendPv() {
const now = Date.now();
const sendData = {
ev: 'applet-pv',
...baseFields,
...extraData,
time: now,
ext: {
...baseExtFields,
...extraExt,
time: now - openTime
}
};
tpr.send(sendData, () => showLog && logger.info(`%capplet-pv上报成功:`, logStyle, sendData));
}
function sendPst() {
const now = Date.now();
const sendPstData = {
ev: 'applet-pst',
...baseFields,
...extraData,
time: now,
ext: {
...baseExtFields,
...extraExt,
time: now - openTime,
url: getWxCurrentHref()
}
};
tpr.send(sendPstData, () => showLog && logger.info(`%capplet-pst上报成功:`, logStyle, sendPstData));
}
function sendPvOut() {
const now = Date.now();
const sendPvOutData = {
ev: 'applet-pvout',
...baseFields,
...extraData,
time: now,
pl: baseFields.url,
ext: {
...baseExtFields,
...extraExt,
time: now - openTime,
url: baseFields.url
}
};
tpr.send(sendPvOutData, () => showLog && logger.info(`%capplet-pvout上报成功:`, logStyle, sendPvOutData));
}
// console.log(`dolphin-wx/entry[${methodName}]`);
switch (methodName) {
case proxyWxLifeHooks.onReady:
case proxyWxLifeHooks.ready:
// 若触发onLoad或attached时当前url与缓存的url不一样,说明发生页面跳转,触发pvout
if(prevUrl&&prevUrl!==getWxCurrentHref()){
sendPvOut();
}
sendPv();
timer = setInterval(() => {
sendPst();
}, pstInterval);
break;
case proxyWxLifeHooks.onUnload:
case proxyWxLifeHooks.detached:
sendPvOut();
break;
}
});
}
近期有一些朋友询问我这种方法是否真实可行,再次说明一下,经过本人在公司项目中实际应用,证实是可行的,虽然不算相当完善,但是是完全能达到我们的目的的。
注:由于taro是第三方框架,版本升级不可控,因此,我们只是对特定个版本,如2.1.5进行兼容,其他版本尚未做兼容处理
本项目已经将核心功能抽离到github中,有兴趣的朋友可以去看一下,欢迎star和issue
taro-track