背景
在一個比較成熟微信小程序中,爲了統計每個頁面的行爲,如統計頁面PV、UV、對頁面元素點擊等事件進行監聽,並且上報到我們自己的數據統計服務器上,目前網上能找到的大部分方案是通過手動埋點的方式實現,這種方式效率較低,來一個頁面就要加一個統計邏輯,對代碼的侵入較多。且網上的方案都是基於原生微信小程序的解決方案,對於使用Taro進行開發的項目來說,有點力不從心,因此,通過一段時間的研究與實驗,整理出這篇文章,用於梳理在使用Taro如何實現無侵入或低侵入(只需要在app.tsx中調用一個方法即可實現對所有的頁面生命週期進行監聽)。
思路
我們要想要實現無侵入或低侵入的監控頁面聲明周期函數,對於原生的微信小程序,我們可以參考網上的一個現有解決方案:小程序從手動埋點到自動埋點。
其實現原理主要是:通過代理微信小程序的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;
}
});
}