vue3.0埋点方案初探

前言:

最近基于vue3.0进行项目开发,做了前端埋点的特性.具体方案是通过vue的自定义指令,对真实的dom节点进行事件监听.但封装后的组件,内部的事件是通过$emit触发,我们无法通过原生方式对这类事件进行捕获,以下是我寻找合适解决方案的过程.

$on方法

vue2.0中是可以通过$on方法对同一实例上出发的$emit进行捕获的,翻看了github上关于vue埋点方案,也是基于这个api进行实现的,但是遗憾的是vue3.0移除了这个实例方法.提出的替代方案mitttiny-emitter似乎也满足不了我们的需求.
奇怪的是,在我阅读3.0的源码时,居然依旧能找$on的实现:

path:packages\runtime-core\src\compat\instanceEventEmitter.ts
const eventRegistryMap = /*#__PURE__*/ new WeakMap<
  ComponentInternalInstance,
  EventRegistry
>()

export function getRegistry(
  instance: ComponentInternalInstance
): EventRegistry {
  let events = eventRegistryMap.get(instance)
  if (!events) {
    eventRegistryMap.set(instance, (events = Object.create(null)))
  }
  return events!
}

export function on(
  instance: ComponentInternalInstance,
  event: string | string[],
  fn: Function
) {
  if (isArray(event)) {
    event.forEach(e => on(instance, e, fn))
  } else {
    if (event.startsWith('hook:')) {
      assertCompatEnabled(
        DeprecationTypes.INSTANCE_EVENT_HOOKS,
        instance,
        event
      )
    } else {
      assertCompatEnabled(DeprecationTypes.INSTANCE_EVENT_EMITTER, instance)
    }
    const events = getRegistry(instance)
    ;(events[event] || (events[event] = [])).push(fn)
  }
  return instance.proxy
}
export function emit(
  instance: ComponentInternalInstance,
  event: string,
  args: any[]
) {
  const cbs = getRegistry(instance)[event]
  if (cbs) {
    callWithAsyncErrorHandling(
      cbs.map(cb => cb.bind(instance.proxy)),
      instance,
      ErrorCodes.COMPONENT_EVENT_HANDLER,
      args
    )
  }
  return instance.proxy
}
复制代码

大体上的实现,就是通过WeakMap存储了实例对象,以及实例上定义的各种事件.$on将事件推入对应的数组当中,$emit将事件对应的回调数组进行执行.
再次在代码中遨游后,我发现了__COMPAT__这个变量,似乎可以通过这个变量将$on加入到实例上

if (__COMPAT__) {
  installCompatInstanceProperties(publicPropertiesMap)
}
export function installCompatInstanceProperties(map: PublicPropertiesMap) {
   ......
  extend(map, {
    $on: i => on.bind(null, i),
    $once: i => once.bind(null, i),
    $off: i => off.bind(null, i),
  } as PublicPropertiesMap)
   ......
}
复制代码

但即使根据rollup.config.js中的代码,设置环境变量__COMPAT__=true,在demo中依旧无法生效,打开node_modules,发现vue3.0对外发布的代码是已经打包好的,这也能解释了为什么配置不生效的原因.
屏幕截图 2021-05-23 123030.png
至此,看起来没有其他办法再使用$on了.

vnode.props属性

基于上面的指令式埋点方案,在进一步探究vue源码的过程中,发现vue3.0使用的事件机制已经发生了变化,从下面这段代码中可以看出,$emit执行时,会读取当前实例的vnode下的props属性,并根据触发的事件进行查找,调用.

// packages\runtime-core\src\componentEmits.ts
export function emit(
  instance: ComponentInternalInstance,
  event: string,
  ...rawArgs: any[]
) {
  const props = instance.vnode.props || EMPTY_OBJ
  ......
  let handlerName
  let handler =
    props[(handlerName = toHandlerKey(event))] ||
    // also try camelCase event handler (#2249)
    props[(handlerName = toHandlerKey(camelize(event)))]
  // for v-model update:xxx events, also trigger kebab-case equivalent
  // for props passed via kebab-case
  if (!handler && isModelListener) {
    handler = props[(handlerName = toHandlerKey(hyphenate(event)))]
  }

  if (handler) {
    callWithAsyncErrorHandling(
      handler,
      instance,
      ErrorCodes.COMPONENT_EVENT_HANDLER,
      args
    )
  }
  ......
}
复制代码

而通过自定义指令,我们是能拿到vndoe这个对象的,这里似乎大有可为.虽然官网说除el外都应该保持只读,但都到这里了,不甘心放弃,还是打算试一试.通过对vnode.props赋值对应的事件,咦,真的成功了!!通过这个方法,的确能监听到组件内部的$emit('click').

const app = Vue.createApp({})
// 注册一个全局自定义指令 `v-focus`
app.directive('track', {
  // 当被绑定的元素挂载到 DOM 中时……
  created(el,binding,vnode ) {
    vnode.props.onClick=function(){
        console.log('测试埋点')
    }
  }
})
复制代码

当然不要高兴得太早,在测试的过程当中,发现了一个奇怪的现象,就是当有props传递的时候,直接赋值对应的事件是可以生效的.但是,当实例没有props时,我执行一下操作,最终是无法生效的!

//vnode.props===null -----true
vnode.props={}
vnode.props.onClick=function(){
  console.log('测试埋点')
}
复制代码

看来还可以继续深究~通过patchEvent-->patchProp-->hostPatchProp的反向查找,最终定位到了原因:
在最开始拿到了props的索引,当props初始化为null时,无法执行hostPatchProp,当props为object时,在invokeDirectiveHook阶段,我们对props进行了修改,hostPatchProp能读取到我们后来添加的onClick属性,并通过patchEvent存储了对应的事件或者注册原生事件.

// packages\runtime-core\src\renderer.ts

  const mountElement = (
    vnode: VNode,
    container: RendererElement,
    anchor: RendererNode | null,
    parentComponent: ComponentInternalInstance | null,
    parentSuspense: SuspenseBoundary | null,
    isSVG: boolean,
    slotScopeIds: string[] | null,
    optimized: boolean
  ) => {
    let el: RendererElement
    let vnodeHook: VNodeHook | undefined | null
    const { type, props, shapeFlag, transition, patchFlag, dirs } = vnode
       //这里就是执行指令生命周期钩子的地方
      if (dirs) {
        invokeDirectiveHook(vnode, null, parentComponent, 'created')
      }
      // props
      if (props) {
        for (const key in props) {
          if (!isReservedProp(key)) {
            hostPatchProp(
              el,
              key,
              null,
              props[key],
              isSVG,
              vnode.children as VNode[],
              parentComponent,
              parentSuspense,
              unmountChildren
            )
          }
        }
        if ((vnodeHook = props.onVnodeBeforeMount)) {
          invokeVNodeHook(vnodeHook, parentComponent, vnode)
        }
      }
    }
  }
复制代码

以上是vue3.0的埋点方案探索,其实在使用上和2.0的指令埋点是一致的,只是在寻找$on的替代方案.

decorator

前面说的,都是基于vue的能力开发的埋点方案,无法普及到其他的应用框架.虽然埋点方案有代码埋点,可视化埋点,无埋点等三类方案.但是在业务场景中,往往采用代码埋点,开发成本小,贴近需求,可行性高.但是代码埋点往往需要对事件增加额外的代码,侵入业务逻辑,这是我们不想看到的.幸好es6推出了decorator,通过装饰器的形式,我们可以很方便得去隔离埋点代码和业务代码,不至于把两者写在一个函数体中.

export default {
  methods: {
    @track(someConfig)
    onClick() {
      console.log('触发clik')
    }
  }
}
复制代码

小结

本文主要探究了在vue2.0中$on的一些相关代码,以及vue3.0下的指令埋点的一种方案.如果有更好的方案,也希望能解惑.如果文章中存在错误理解,请留言指正.

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享