在 Vue3 中进行点击事件埋点
函数埋点是一个常见的需求,之前进行埋点是通过 babel 进行解析插入或者手工进行添加
前几天在某 babel 群里有人提出疑问:如何在 Vue 中对每个点击事件插入一个函数?由于 .vue
文件是将 <template>
、<script>
和 <style>
分开进行单独解析,所以不能通过 babel 将监听函数准确解析出来(或许自己没好好看文档,不知道????♂️。要补补 webpack 了)。于是想了如下方法:
通过修改源码的方式添加额外函数
如果公司有自己魔改的 Vue 框架,还是挺方便的。源码版本为:3.2.20,位于 runtime-dom/src/modules/events.ts:
export function patchEvent( el: Element & { _vei?: Record<string, Invoker | undefined> }, rawName: string, prevValue: EventValue | null, nextValue: EventValue | null, instance: ComponentInternalInstance | null = null ) { const invokers = el._vei || (el._vei = {}) const existingInvoker = invokers[rawName] //TODO: 添加额外函数 if (nextValue && existingInvoker) { // 更新监听函数 existingInvoker.value = nextValue } else { const [name, options] = parseName(rawName) if (nextValue) { // 添加监听函数 const invoker = (invokers[rawName] = createInvoker(nextValue, instance)) addEventListener(el, name, invoker, options) } else if (existingInvoker) { // 移除监听函数 removeEventListener(el, name, existingInvoker, options) invokers[rawName] = undefined } } } 复制代码
在 patchEvent
方法中,通过 createInvoker
为监听函数封装了一层用于执行额外任务,随后添加至元素的事件回调队列中:
function createInvoker( initialValue: EventValue, instance: ComponentInternalInstance | null ) { const invoker: Invoker = (e: Event) => { const timeStamp = e.timeStamp || _getNow() if (skipTimestampCheck || timeStamp >= invoker.attached - 1) { callWithAsyncErrorHandling( patchStopImmediatePropagation(e, invoker.value), instance, ErrorCodes.NATIVE_EVENT_HANDLER, [e] ) } } invoker.value = initialValue invoker.attached = getNow() return invoker } 复制代码
patchStopImmediatePropagation
的作用是:在 invoker.value
是数组的情况下生成新的监听函数数组,检查 e._stopped
决定是否执行监听函数
callWithAsyncErrorHandling
的作用是:执行函数队列,函数的参数为传入的 第四个参数的解构
实现功能
function createInvokerValueWithSecretFunction (rawName: string, v: EventValue | null) { if (!v) return v const targetName = 'click' const [name] = parseName(rawName) const newValue: EventValue = isArray(v) ? v : [v] if (name === targetName) { newValue.unshift(insertFunction) } return newValue function insertFunction (e: Event) { console.log('Hello Click') } } /** * 完成之前的TODO * - //TODO: 添加额外函数 * + nextValue = createInvokerValueWithSecretFunction(rawName, nextValue) */ 复制代码
通过 Vue 插件的方式添加额外函数
目的是在 生在渲染函数 时增加额外的转换,将类似 @click="fn"
修改为 @click="[insertFn, fn]"
,更多细节待读者完善。由于是在生成渲染函数时转换,所以 自定义的渲染函数组件不支持 添加额外函数
增加额外编译转换函数可以直接修改实例配置,考虑和 babel 插件功能相似还是选择单独写成 Vue 插件
相关源码介绍,源码版本为:3.2.20,位于 runtime-core/src/component.ts:
export function finishComponentSetup( instance: ComponentInternalInstance, isSSR: boolean, skipOptions?: boolean ) { /* ... */ if (!instance.render) { if (!isSSR && compile && !Component.render) { /* ... */ if (template) { /* ... */ const { isCustomElement, compilerOptions } = instance.appContext.config const { delimiters, compilerOptions: componentCompilerOptions } = Component const finalCompilerOptions: CompilerOptions = extend( extend( { isCustomElement, delimiters }, compilerOptions ), componentCompilerOptions ) /* ... */ Component.render = compile(template, finalCompilerOptions) } } /* ... */ } /* ... */ } 复制代码
在生成渲染函数前,会从 instance.appContext.config
即 Vue 实例的全局上下文的配置中获取编译配置对象 compilerOptions
,也会从当前组件的配置对象获取编译配置对象 compilerOptions
位于 runtime-core/src/compile.ts:
export function baseCompile( template: string | RootNode, options: CompilerOptions = {} ): CodegenResult { /* ... */ const ast = isString(template) ? baseParse(template, options) : template /* ... */ transform( ast, extend({}, options, { prefixIdentifiers, nodeTransforms: [ ...nodeTransforms, ...(options.nodeTransforms || []) // user transforms ], directiveTransforms: extend( {}, directiveTransforms, options.directiveTransforms || {} // user transforms ) }) ) return generate( ast, extend({}, options, { prefixIdentifiers }) ) } 复制代码
在生成完抽象语法树后,通过 transform
进行源码级别的转换,并可以从中看到会合并使用者传入的选项:options.nodeTransforms
和 options.directiveTransforms
,两者的区别在于前者是针对所有抽象语法树的每个节点,后者是仅针对内置指令
实现功能
// 可自行修改,这里简单描述 type Options = { event: string fn?: (e: Event) => void } function nodeTransformPluginForInsertFunction (app:App, options: Options = { event: 'click' }) { const { event, fn } = options if (typeof fn !== 'undefined' && typeof fn !== 'function') { console.warn(/* 警告 */) return } // 全局添加统一函数,用于渲染函数获取 const globalInsertFnName = '$__insertFunction__' app.config.globalProperties[globalInsertFnName] = fn || defaultInsertFunction const transformEventOfElement: NodeTransform = (node, context) => { if (node.type === NodeTypes.ELEMENT /* 1 */ && node.props && node.props.length > 0) { for (let i = 0; i < node.props.length; i++) { const prop = node.props[i] if ( /* 指令 */ prop.type === NodeTypes.DIRECTIVE /* 7 */ && prop.name === 'on' /* 符合条件的指令 */ && prop.arg && prop.arg.type === NodeTypes.SIMPLE_EXPRESSION /* 4 */ && prop.arg.content === event /* 确保 exp 存在 */ && prop.exp && prop.exp.type === NodeTypes.SIMPLE_EXPRESSION /* 4 */ && prop.exp.content.trim() ) { let trimmedContent = prop.exp.content.trim() // 将类似 `@click="fn"` 修改为 `@click="[insertFn, fn]"` // 还需要考虑其他情况,文章这里就简单处理了 if (trimmedContent[0] === '[') { trimmedContent = `[${globalInsertFnName}, ${trimmedContent.substr(1)}` } else { trimmedContent = `[${globalInsertFnName}, ${trimmedContent}]` } prop.exp.content = trimmedContent } } } } // 增加编译转换函数 const nodeTransforms: NodeTransform[] = (app.config.compilerOptions as any).nodeTransforms|| ((app.config.compilerOptions as any).nodeTransforms = []) nodeTransforms.push(transformEventOfElement) function defaultInsertFunction (e: Event) {} } 复制代码
还有很多细节可以完善,可以参考内置的转换函数让自身的函数更加健壮
手动的方式添加额外函数
作者:渣渣富
链接:https://juejin.cn/post/7033701776419356680