在 Vue3 中进行点击事件埋点

更新日期: 2021-11-24阅读量: 27标签: 埋点

函数埋点是一个常见的需求,之前进行埋点是通过 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

站长推荐

1.云服务推荐: 国内主流云服务商,各类云产品的最新活动,优惠券领取。地址:阿里云腾讯云华为云

链接: https://www.fly63.com/article/detial/10849

前端异常埋点系统初探

开发者有时会面临上线的生产环境包出现了异常:bug: ,在长期生产bug并修复bug的循环中总结出一下几个痛点:无法快速定位到发生错误的代码位置,因为脚手架构建时会用webapck自动帮我们压缩代码

数据埋点的艺术

定义数据埋点及其交接主要分为四个部分,梳理数据需求—定义数据指标—埋点整理—文档输出——埋点验收,前两个步骤在上文中已经详细描述过方法,本文不再赘述。本文较为简洁,整理了梳理埋点的方法和与开发交接的方法

vue项目埋点

通过可视化交互的手段,代替代码埋点。将业务代码和埋点代码分离,提供一个可视化交互的页面,输入为业务代码,通过这个可视化系统,可以在业务代码中自定义的增加埋点事件等等

前端埋点sdk封装

前端埋点sdk的方案十分成熟,之前用的都是公司内部统一的埋点产品,从前端埋点和数据上报后的可视化查询全链路打通。但是在最近的一个私有化项目中就遇到了问题,因为服务都是在客户自己申请的服务器上的,需要将埋点数据存放到自己的数据库中

前端埋点之曝光实现

最近有一个工作需求是曝光埋点,让我得以有机会接触相关的东西。之前实习时没有做过这方面的需求,个人项目更是和埋点扯不上关系。以至于上周开会讨论时听到“埋点”这个词就怂了。

200行代码实现前端无痕埋点

什么是无痕埋点?简单来说,就是当引入无痕埋点的库以后,用户在浏览器里所有行为和操作都会被自动记录下来,并将信息发送到后端进行统计和分析

vue项目前端埋点

埋点方案的确定,业界的埋点方案主要分为以下三类:代码埋点:在需要埋点的节点调用接口,携带数据上传。如百度统计等;可视化埋点:使用可视化工具进行配置化的埋点,即所谓的「无痕埋点」

前端监控和前端埋点方案设计

在线上项目中,需要统计产品中用户行为和使用情况,从而可以从用户和产品的角度去了解用户群体,从而升级和迭代产品,使其更加贴近用户。用户行为数据可以通过前端数据监控的方式获得,除此之外,前端还需要实现性能监控和异常监控

内容以共享、参考、研究为目的,不存在任何商业目的。其版权属原作者所有,如有侵权或违规,请与小编联系!情况属实本人将予以删除!