Vue3 的自定义指令

1,233 阅读4分钟

除了核心功能默认内置的指令 (例如 v-modelv-show),Vue 也允许注册自定义指令

自定义指令允许使用者在 虚拟节点的生命周期中 访问真实元素

注册自定义指令有两种方式:全局注册组件内注册

<div id='app'>
  <comp></comp>
</div>

<script>
  const { createApp } = Vue

  const app = createApp({})

  app.component('comp', {
    template: `<div v-global-dir v-local-dir></div>`,
    directives: {
      'local-dir': {
        mounted () {
          console.log('组件内自定义指令')
        }
      }
    }
  })

  app.directive('global-dir', {
    mounted () {
      console.log('全局自定义指令')
    }
  })

  app.mount('#app')
</script>

上述代码效果:当 <div v-global-dir v-local-dir> 元素挂载之后,会在控制台打印两个文本

注册自定义指令

全局注册

//runtime-core/src/apiCreateApp.ts
const app: App = (context.app = {
  /* ... */

  directive(name: string, directive?: Directive) {
    /* 校验注册指令名是否与内置指令名相同 */

    // 可通过 app.directive(name) 获取注册对象
    if (!directive) {
      return context.directives[name] as any
    }

    /* 重复注册会警告 */

    // 将自定义指令保存在全局上下文对象中
    context.directives[name] = directive
    return app
  },

  /* ... */
})

全局注册自定义指令就是在 全局上下文对象 中保存传进来的 directive 对象

组件内注册

//runtime-core/src/componentOptions.ts
export function applyOptions(instance: ComponentInternalInstance) {
  // 合并全局上下文的某些选项
  const options = resolveMergedOptions(instance)

  /* ... */

  const {
    /* ... */
    directives,
    /* ... */
  } = options

  // 将自定义指令保存在组件实例中
  if (directives) instance.directives = directives

  /* ... */
}

组件内注册自定义指令就是在 组件实例对象 中保存定义组件时的配置对象的 directives 属性值

注:注册组件时传入的配置对象用于创建 VNode,不是直接用于创建组件实例

虚拟节点添加绑定对象

绑定对象 保存自定义指令在元素中如何使用,其类型定义如下:

export interface DirectiveBinding<V = any> {
  instance: ComponentPublicInstance | null /* 虚拟节点所在的组件实例 */
  value: V /* 解析模板后得到 */
  oldValue: V | null /* 旧的虚拟节点的对应绑定对象的 value */
  arg?: string /* 解析模板后得到 */
  modifiers: DirectiveModifiers /* 解析模板后得到 */
  dir: ObjectDirective<any, V> /* 注册时的 directives 对象 */
}

valueargmodifiers 是描述指令如何使用,具体解析代码可查看 compile-core/src/parse.ts 中的 parseAttribute 函数

渲染函数

绑定对象的添加操作位于渲染函数中:

//comp组件的渲染函数
(function anonymous(
) {
const _Vue = Vue

return function render(_ctx, _cache) {
  with (_ctx) {
    const { resolveDirective: _resolveDirective, withDirectives: _withDirectives, openBlock: _openBlock, createElementBlock: _createElementBlock } = _Vue

    // 通过 resolveDirective 函数获取注册对象,再传递给 withDirectives 函数
    const _directive_global_dir = _resolveDirective("global-dir")
    const _directive_local_dir = _resolveDirective("local-dir")

    // 返回 withDirectives 函数调用的结果
    return _withDirectives((_openBlock(), _createElementBlock("div", null, null, 512 /* NEED_PATCH */)), [
      [_directive_global_dir],
      [_directive_local_dir]
    ])
  }
}
})

resolveDirective 函数位于 runtime-core/src/helpers/resolveAssets.ts,该函数的逻辑:先在当前组件实例对象中查询是否有所需值,最后在全局上下文对象中查询。所以之前注册自定义指令的 directive 对象会在此时被查询出来

withDirectives

withDirectives 函数做的事情就是为传进来的 vnode 添加绑定对象

//runtime-core/src/directives.ts

// Directive, value, argument, modifiers
export type DirectiveArguments = Array<
  | [Directive]
  | [Directive, any]
  | [Directive, any, string]
  | [Directive, any, string, DirectiveModifiers]
>

export function withDirectives<T extends VNode>(
  vnode: T,
  directives: DirectiveArguments
): T {
  const internalInstance = currentRenderingInstance

  /* 校验 */

  // 组件代理对象
  const instance = internalInstance.proxy

  // 当前虚拟节点的绑定对象集合
  const bindings: DirectiveBinding[] = vnode.dirs || (vnode.dirs = [])

  // 遍历需要添加的使用了的自定义指令 
  for (let i = 0; i < directives.length; i++) {
    // 指令使用的参数值
    let [dir, value, arg, modifiers = EMPTY_OBJ] = directives[i]

    // 可以传入函数注册自定义指令,会把函数添加到 mounted 和 updated
    if (isFunction(dir)) {
      dir = {
        mounted: dir,
        updated: dir
      } as ObjectDirective
    }

    if (dir.deep) {
      traverse(value)
    }

    // 添加绑定对象
    bindings.push({
      dir,
      instance,
      value,
      oldValue: void 0,
      arg,
      modifiers
    })
  }
  return vnode
}

虚拟节点执行自定义指令的时机

执行钩子的通用函数

通用函数的目的就是执行对应的钩子函数

//runtime-core/src/directives.ts
export function invokeDirectiveHook(
  vnode: VNode,
  prevVNode: VNode | null,
  instance: ComponentInternalInstance | null,
  name: keyof ObjectDirective
) {
  const bindings = vnode.dirs!
  const oldBindings = prevVNode && prevVNode.dirs!

  for (let i = 0; i < bindings.length; i++) {
    const binding = bindings[i]

    if (oldBindings) {/* 获取上一次的值 */
      binding.oldValue = oldBindings[i].value
    }

    // 获取当前绑定对象的自定义指令的对应注册的函数
    let hook = binding.dir[name] as DirectiveHook | DirectiveHook[] | undefined

    if (hook) {
      pauseTracking()
      // 执行函数,参数为:真实元素、当前绑定对象、现在的虚拟节点、之前的虚拟节点
      // 这是在自定义指令中能接收到的参数
      callWithAsyncErrorHandling(hook, instance, ErrorCodes.DIRECTIVE_HOOK, [
        vnode.el,
        binding,
        vnode,
        prevVNode
      ])
      resetTracking()
    }
  }
}

通用函数所做的事情:遍历当前虚拟节点的绑定对象,为绑定对象添加旧值,最后获取需要执行的钩子函数并执行

挂载节点时

当虚拟节点挂载时,会触发 createdbeforeMountmounted 的自定义指令钩子:

//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 (
    !__DEV__ &&
    vnode.el &&
    hostCloneNode !== undefined &&
    patchFlag === PatchFlags.HOISTED
  ) {
    el = vnode.el = hostCloneNode(vnode.el)
  } else {
    /* 创建元素 */

    /* 处理子节点 */

    if (dirs) {
      invokeDirectiveHook(vnode, null, parentComponent, 'created')
    }

    /* patchProps */
  }

  if (dirs) {
    invokeDirectiveHook(vnode, null, parentComponent, 'beforeMount')
  }

  /* 挂载元素 */

  if (
    (vnodeHook = props && props.onVnodeMounted) ||
    needCallTransitionHooks ||
    dirs
  ) {
    // 会在微任务队列中执行
    queuePostRenderEffect(() => {
      /* ... */

      dirs && invokeDirectiveHook(vnode, null, parentComponent, 'mounted')
    }, parentSuspense)
  }
}

首先会调用函数创建真实的元素,存在子节点时会遍历创建挂载子节点,触发 created;然后处理元素属性,触发 beforeMount;最后挂载创建的元素,将触发 mounted 的函数添加到微任务队列中,待当前所有渲染相关函数执行完(调用栈空)便会执行

更新节点时

当虚拟节点需要更新时,会触发 beforeUpdateupdated 的自定义指令钩子:

//runtime-core/src/renderer.ts
const patchElement = (
  n1: VNode,
  n2: VNode,
  parentComponent: ComponentInternalInstance | null,
  parentSuspense: SuspenseBoundary | null,
  isSVG: boolean,
  slotScopeIds: string[] | null,
  optimized: boolean
) => {
  const el = (n2.el = n1.el!)

  /* ... */

  if (dirs) {
    invokeDirectiveHook(n2, n1, parentComponent, 'beforeUpdate')
  }

  /* 更新子节点 */

  /* patchProps */

  if ((vnodeHook = newProps.onVnodeUpdated) || dirs) {
    // 会在微任务队列中执行
    queuePostRenderEffect(() => {
      /* ... */

      dirs && invokeDirectiveHook(n2, n1, parentComponent, 'updated')
    }, parentSuspense)
  }
}

一开始便会触发 beforeUpdate,然后遍历更新子节点;最后将触发 updated 的函数添加到微任务队列

卸载节点时

const unmount: UnmountFn = (
  vnode,
  parentComponent,
  parentSuspense,
  doRemove = false,
  optimized = false
) => {
  const {
    type,
    props,
    ref,
    children,
    dynamicChildren,
    shapeFlag,
    patchFlag,
    dirs
  } = vnode
  
  /* ... */

  const shouldInvokeDirs = shapeFlag & ShapeFlags.ELEMENT && dirs

  /* ... */

    if (shouldInvokeDirs) {
      invokeDirectiveHook(vnode, null, parentComponent, 'beforeUnmount')
    }

    /* 卸载子节点 */

    /* 卸载元素 */

  if (
    (shouldInvokeVnodeHook &&
      (vnodeHook = props && props.onVnodeUnmounted)) ||
    shouldInvokeDirs
  ) {
    queuePostRenderEffect(() => {
      /* ... */
      
      shouldInvokeDirs &&
        invokeDirectiveHook(vnode, null, parentComponent, 'unmounted')
    }, parentSuspense)
  }
}

在卸载子节点之前触发 beforeUnmount,最后卸载当前节点的元素;再将触发 unmounted 的函数添加到微任务队列

总结

能使用自定义指令的原因是 Vue 解析生成渲染函数过程中会保留相关的代码

使用者通过注册对应的自定义指令让渲染函数能够查询到对应配置对象,并将其添加到对应虚拟节点

在虚拟节点的生命周期中触发相应钩子,使使用者能够访问真实的元素信息,搭配绑定对象可以让使用者能够在合适的时机进行个性的直接对真实元素的操作