零、前言
最近接到需求,领导希望使用微信开放平台上免费的We分析进行数据埋点,但又不希望在现有uniapp开发的微信小程序代码上做侵入式修改,笔者奉命进行了技术调研,考虑通过劫持事件的方式来实现捕获特定事件并上传分析平台的功能。
需要特别注意的是,微信小程序是不能得到document对象的,$el上挂载的也是undefined,自然也就不能通过全局addEventListener的方式来监听特定事件。在调研中想到可以通过劫持小程序的自定义组件构造器Component()来实现事件的监听。
为了便于理解,部分数据结构通过TypeScript接口形式进行描述。
一、软件环境
- HbuilderX 3.4.7.20220422
- 微信开发者工具 Stable 1.05.2203070
- 小程序基础库版本 2.24.4 [749]
二、相关分析及实现
uniapp编译微信小程序时对于事件的处理分析
部分知识via掘金:https://juejin.cn/post/6968438754180595742#heading-20
uniapp使用了uni-app runtime这个运行时将小程序发行代码进行打包,实现了Vue与小程序之间的数据及事件同步。
源Vue模板及编译产物wxml对照
uniapp的模板编译器代码在/Applications/HBuilderX.app/Contents/HBuilderX/plugins/uniapp-cli/node_modules/@dcloudio/uni-template-complier下。
首先以一个简单的Vue模板为例,观察uniapp是如何将Vue template编译为wxml的:
1 2 3
| <template> <div @click="add();subtract(2)" @touchstart="mixin($event)">{{ num }}</div> </template>
|
编译结果为:
1 2 3 4 5 6 7 8 9 10 11 12
| <view data-event-opts="{{ [ ['tap', [['add'],['subtract',[2]]] ], ['touchstart', [['mixin',['$event']]] ] ] }}" bindtap="__e" bindtouchstart="__e" class="_div"> {{num}} </view>
|
可以看到,uniapp将tap和touchstart事件绑定到__e函数上,然后将事件对应的动作放到了名为eventOpts的dataset中。
data-event-opts
data-event-opts非常重要。data-event-opts
是一个二维数组,每个子数组代表一个事件类型。事件类型有两个值,第一个表示事件类型名称,第二个表示触发事件函数的个数。事件函数又是一个数组,第一个值表述事件函数名称,第二个是参数表。下面用TypeScript的类型声明方式进行简单描述:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| const dataEventOpts: EventTypes; interface EventTypes { [index:number]: EventType; }
interface EventType { [index:number]: string | EventFuncList; } interface EventFuncList { [index:number]: EventFunc; }
interface EventFunc { [index:number]: string | Array<any>; }
|
对照模板,就可以得出如下推论:
['tap',[['add'],['subtract',[2]]]]
表示事件类型为tap
,触发函数有两个,一个为add
函数且无参数,一个为subtract
且参数为2。 ['touchstart',[['mixin',['$event']]]]
表示事件类型为touchstart
,触发函数有一个为mixin
,参数为$event
对象。
不难看出,我们在进行事件捕捉时,只需要读取到data-event-opts[i][0]
就可以得到每个事件的类型。
handleEvent事件:__e
所有的事件都会调用__e事件,也就是handleEvent。在上文的模板中,handleEvent做了如下操作:
1、拿到点击元素上的data-event-opts
属性:[['tap',[['add'],['subtract',[2]]]]
,['touchstart',[['mixin',['$event']]]]]
2、根据点击类型获取相应数组,比如bindTap
就取['tap',[['add'],['subtract',[2]]]]
,bindtouchstart
就取['touchstart',[['mixin',['$event']]]]
3、依次调用相应事件类型的函数,并传入参数,比如tap
调用this.add();this.subtract(2)
uniapp对mp-wx的相关处理在/Applications/HBuilderX.app/Contents/HBuilderX/plugins/uniapp-cli/node_modules/@dcloudio/uni-mp-weixin下。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86
| function handleEvent (event) { event = wrapper$1(event);
const dataset = (event.currentTarget || event.target).dataset; if (!dataset) { return console.warn('事件信息不存在') } const eventOpts = dataset.eventOpts || dataset['event-opts']; if (!eventOpts) { return console.warn('事件信息不存在') }
const eventType = event.type;
const ret = [];
eventOpts.forEach(eventOpt => { let type = eventOpt[0]; const eventsArray = eventOpt[1];
const isCustom = type.charAt(0) === CUSTOM; type = isCustom ? type.slice(1) : type; const isOnce = type.charAt(0) === ONCE; type = isOnce ? type.slice(1) : type;
if (eventsArray && isMatchEventType(eventType, type)) { eventsArray.forEach(eventArray => { const methodName = eventArray[0]; if (methodName) { let handlerCtx = this.$vm; if (handlerCtx.$options.generic) { handlerCtx = getContextVm(handlerCtx) || handlerCtx; } if (methodName === '$emit') { handlerCtx.$emit.apply(handlerCtx, processEventArgs( this.$vm, event, eventArray[1], eventArray[2], isCustom, methodName )); return } const handler = handlerCtx[methodName]; if (!isFn(handler)) { throw new Error(` _vm.${methodName} is not a function`) } if (isOnce) { if (handler.once) { return } handler.once = true; } let params = processEventArgs( this.$vm, event, eventArray[1], eventArray[2], isCustom, methodName ); params = Array.isArray(params) ? params : []; if (/=\s*\S+\.eventParams\s*\|\|\s*\S+\[['"]event-params['"]\]/.test(handler.toString())) { params = params.concat([, , , , , , , , , , event]); } ret.push(handler.apply(handlerCtx, params)); } }); } });
if ( eventType === 'input' && ret.length === 1 && typeof ret[0] !== 'undefined' ) { return ret[0] } }
|
微信小程序自定义组件Component
mp-wx中的Component文档:https://developers.weixin.qq.com/miniprogram/dev/reference/api/Component.html
构造器Component()
在uniapp-mp-wx中,组件的装载是通过实例化Component进行的。uniapp会默认装载如下8个参数:
1 2 3 4 5 6 7 8 9 10
| interface optionsList { options: Object | Map<any, any>, data: Object, properties: Object | Map<any, any>, behaviors: string | Array<any>, lifetimes: Object, pageLifetimes: Object, methods: Object, created: Function }
|
并且在methods中注入如下两个函数:
1 2 3 4
| methods: { __l: handleLink, __e: handleEvent }
|
劫持自定义组件构造器Component
劫持Component的构造器,在每个组件的__e中注入自定义的事件劫持器eventProxy
1 2 3 4 5 6 7 8 9 10 11 12
| const _componentProto_ = Component; Component = function(options) { Object.keys(options.methods).forEach(methodName => { if (methodName == "__e") { eventProxy(options.methods, methodName) } }) _componentProto_.apply(this, arguments); }
|
通过劫持事件处理器__e,我们可以实现触发事件时执行我们想要的逻辑了。
分析事件对象并编写事件处理器劫持函数eventProxy
微信小程序事件对象描述文档:https://developers.weixin.qq.com/miniprogram/dev/framework/view/wxml/event.html#%E4%BA%8B%E4%BB%B6%E5%AF%B9%E8%B1%A1
在上一步里我们劫持了Component,并且成功获得了事件处理器__e,那么编写针对事件处理器的劫持函数吧。
1 2 3 4 5 6 7 8 9 10 11 12
| function eventProxy(methodList, methodName) { const _funcProto_ = methodList[methodName]; methodList[methodName] = function() { _funcProto_.apply(this, arguments); let prop = {}; if (isObject(arguments[0])) { if (Object.keys(arguments[0]).length > 0) { } } } }
|
uniapp-mp-wx中,事件对象通常具有如下属性:
1
| ["type", "timeStamp", "target", "currentTarget", "mark", "detail", "touches", "changedTouches", "mut", "_userTap", "mp", "stopPropagation", "preventDefault"]
|
其中,对于数据埋点尤其有用的是如下四个属性:
1 2 3 4
| interface currentTarget { id: string, dataset: Object }
|
mark:可以使用 mark
来识别具体触发事件的 target 节点。此外, mark
还可以用于承载一些自定义数据(类似于 dataset
)。
当事件触发时,事件冒泡路径上所有的 mark
会被合并,并返回给事件回调函数。(即使事件不是冒泡事件,也会 mark
。)
如果想要得到一些详细的锚点数据,可以在代码中做一些mark标记。
1 2 3 4 5 6 7 8 9 10 11 12
| <view mark:myMark="last" bindtap="bindViewTap"> <button mark:anotherMark="leaf" bindtap="bindButtonTap">按钮</button> </view> <script> Page({ bindViewTap: function(e) { e.mark.myMark === "last" e.mark.anotherMark === "leaf" } }) </script>
|
detail:自定义事件所携带的数据,如表单组件的提交事件会携带用户的输入,媒体的错误事件会携带错误信息,详见组件定义中各个事件的定义。
点击事件的detail
带有的 x, y 同 pageX, pageY 代表距离文档左上角的距离。
这里给出tap及input事件返回的detail结构:
1 2 3 4 5 6 7 8 9 10
| interface tapDetail { x: number, y: number }
interface inputDetail { value: string, cursor: number, keyCode: number }
|
结合如上属性,简单地完善一下事件劫持器吧:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40
| function eventProxy(methodList, methodName) { const _funcProto_ = methodList[methodName]; methodList[methodName] = function() { _funcProto_.apply(this, arguments); let prop = {}; if (isObject(arguments[0])) { if (Object.keys(arguments[0]).length > 0) { const pages = getCurrentPages(); const currentPage = pages[pages.length - 1]; prop["$page_path"] = currentPage.route; prop["$page_query"] = currentPage.options || {}; const type = arguments[0]["type"]; const current_target = arguments[0].currentTarget || {}; const dataset = current_target.dataset || {}; prop["$event_type"] = type; prop["$event_timestamp"] = Date.now(); prop["$element_id"] = current_target.id; const eventDetail = arguments[0].detail; prop["$event_detail"] = eventDetail; if (!!dataset.eventOpts && type) { if (type == "tap") { const event_opts = dataset.eventOpts; if (Array.isArray(event_opts) && event_opts[0].length === 2) { let eventFunc = []; event_opts[0][1].forEach(event => { eventFunc.push({ name: event[0], params: event[1] || '' }) }) prop["$event_function"] = eventFunc; } } postWeData(prop); } } } }; }
|
三、完整代码结构
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| (function() { const isObject = function(obj) { if (obj === undefined || obj === null) { return false; } else { return toString.call(obj) == "[object Object]"; } }; const _componentProto_ = Component; Component = function(options) { Object.keys(options.methods).forEach(methodName => { if (methodName == "__e") { eventProxy(options.methods, methodName) } }) _componentProto_.apply(this, arguments); }
function eventProxy(methodList, methodName) { }
const postWeData = function(data) { console.log(data) } })()
|
使用:在项目的main.js里引入即可
1 2
| import './common/WeData/index.js'
|
四、后记
上述事件劫持器只是一个例子,实现了基本的tap事件记录。实际上笔者通过扩展配置读取的方式来完成更加便捷的埋点操作,后续只需产品给出希望收集的事件名,开发在固定的配置文件中写好代码中事件触发的函数名即可实现tap白名单记录功能。更加详细的埋点功能可以通过阅读分析事件对象小节来扩展,在此仅做抛砖引玉。