Skip to content

6.5 自定义指令:指令的生命周期与 v- 系列指令的实现

本节深入解析 Vue 3 指令系统的核心机制,包括指令的生命周期钩子、参数绑定、修饰符处理,以及内置指令(v-show、v-model、v-on)的具体实现。通过源码分析,我们将理解指令如何与组件生命周期协调工作,以及如何开发高质量的自定义指令。

——

一、指令系统架构概览

1.1 核心概念

指令(Directive)是 Vue 提供的一种特殊属性,用于在模板中声明式地绑定底层 DOM 操作。Vue 3 的指令系统由以下核心部分组成:

  • 指令定义 接口定义了指令的完整生命周期钩子
  • 指令绑定 包含指令的值、参数、修饰符等信息
  • 指令应用 函数将指令附加到 VNode 上
  • 指令执行 在适当时机调用指令钩子

关联源码:

1.2 指令类型

Vue 3 支持两种指令定义方式:

typescript
// 对象式指令(完整生命周期)
const myDirective: ObjectDirective = {
  created(el, binding, vnode, prevVNode) { /* ... */ },
  beforeMount(el, binding, vnode, prevVNode) { /* ... */ },
  mounted(el, binding, vnode, prevVNode) { /* ... */ },
  beforeUpdate(el, binding, vnode, prevVNode) { /* ... */ },
  updated(el, binding, vnode, prevVNode) { /* ... */ },
  beforeUnmount(el, binding, vnode, prevVNode) { /* ... */ },
  unmounted(el, binding, vnode, prevVNode) { /* ... */ }
}

// 函数式指令(简化版,等同于 mounted + updated)
const myDirective: FunctionDirective = (el, binding, vnode, prevVNode) => {
  // 在 mounted 和 updated 时都会调用
}

——

二、指令生命周期深度解析

2.1 生命周期钩子时序

指令的生命周期钩子与组件生命周期紧密配合:

  1. created:在绑定元素的 attribute 前或事件监听器应用前调用
  2. beforeMount:在元素被插入到 DOM 前调用
  3. mounted:在绑定元素的父组件及他自己的所有子节点都挂载完成后调用
  4. beforeUpdate:绑定元素的父组件更新前调用
  5. updated:在绑定元素的父组件及他自己的所有子节点都更新后调用
  6. beforeUnmount:绑定元素的父组件卸载前调用
  7. unmounted:绑定元素的父组件卸载后调用

2.2 钩子调用机制

函数负责在适当时机调用指令钩子:
typescript
export function invokeDirectiveHook(
  vnode: VNode,
  prevVNode: VNode | null,
  instance: ComponentInternalInstance | null,
  name: keyof ObjectDirective,
): void {
  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()
    }
  }
}

关键设计点:

  • 错误处理:使用 callWithAsyncErrorHandling 确保指令错误不会影响应用运行
  • 响应式隔离:通过 pauseTracking/resetTracking 避免在指令钩子中意外收集依赖
  • 旧值传递:自动维护 oldValue,便于指令进行增量更新

2.3 指令绑定信息

接口包含指令执行所需的完整上下文:
typescript
export interface DirectiveBinding<
  Value = any,
  Modifiers extends string = string,
  Arg extends string = string,
> {
  instance: ComponentPublicInstance | Record<string, any> | null  // 组件实例
  value: Value           // 指令的绑定值
  oldValue: Value | null // 指令绑定的前一个值
  arg?: Arg             // 传给指令的参数
  modifiers: DirectiveModifiers<Modifiers>  // 修饰符对象
  dir: ObjectDirective<any, Value>          // 指令定义对象
}

——

三、内置指令实现解析

3.1 v-show:显示/隐藏控制

指令通过控制元素的 display 样式实现显示隐藏:
typescript
export const vShow: ObjectDirective<VShowElement> & { name: 'show' } = {
  name: 'show',
  beforeMount(el, { value }, { transition }) {
    // 保存原始 display 值
    el[vShowOriginalDisplay] = 
      el.style.display === 'none' ? '' : el.style.display
    if (transition && value) {
      transition.beforeEnter(el)
    } else {
      setDisplay(el, value)
    }
  },
  mounted(el, { value }, { transition }) {
    if (transition && value) {
      transition.enter(el)
    }
  },
  updated(el, { value, oldValue }, { transition }) {
    if (!value === !oldValue) return  // 值未变化,跳过
    if (transition) {
      // 配合过渡动画
      if (value) {
        transition.beforeEnter(el)
        setDisplay(el, true)
        transition.enter(el)
      } else {
        transition.leave(el, () => {
          setDisplay(el, false)
        })
      }
    } else {
      setDisplay(el, value)
    }
  },
  beforeUnmount(el, { value }) {
    setDisplay(el, value)
  },
}

function setDisplay(el: VShowElement, value: unknown): void {
  el.style.display = value ? el[vShowOriginalDisplay] : 'none'
  el[vShowHidden] = !value
}

核心特性

  • 原始值保存:使用 Symbol 键保存元素原始的 display 值
  • 过渡集成:与 Transition 组件无缝配合
  • SSR 支持:提供 getSSRProps 方法生成服务端渲染属性

3.2 v-model:双向数据绑定

实现了文本输入的双向绑定:
typescript
export const vModelText: ModelDirective<
  HTMLInputElement | HTMLTextAreaElement,
  'trim' | 'number' | 'lazy'
> = {
  created(el, { modifiers: { lazy, trim, number } }, vnode) {
    // 获取更新函数
    el[assignKey] = getModelAssigner(vnode)
    const castToNumber = number || (vnode.props && vnode.props.type === 'number')
    
    // 监听输入事件
    addEventListener(el, lazy ? 'change' : 'input', e => {
      if ((e.target as any).composing) return  // 跳过输入法组合状态
      let domValue: string | number = el.value
      if (trim) {
        domValue = domValue.trim()
      }
      if (castToNumber) {
        domValue = looseToNumber(domValue)
      }
      el[assignKey](domValue)
    })
    
    // 处理输入法组合事件
    if (!lazy) {
      addEventListener(el, 'compositionstart', onCompositionStart)
      addEventListener(el, 'compositionend', onCompositionEnd)
      addEventListener(el, 'change', onCompositionEnd)
    }
  },
  mounted(el, { value }) {
    el.value = value == null ? '' : value
  },
  beforeUpdate(el, { value, oldValue, modifiers }, vnode) {
    el[assignKey] = getModelAssigner(vnode)
    if ((el as any).composing) return  // 避免清除未完成的输入
    
    const elValue = (number || el.type === 'number') && !/^0\d/.test(el.value)
      ? looseToNumber(el.value)
      : el.value
    const newValue = value == null ? '' : value
    
    if (elValue !== newValue) {
      el.value = newValue
    }
  }
}

关键机制

  • 修饰符支持:lazy(延迟更新)、trim(去除空格)、number(数字转换)
  • 输入法处理:通过 compositionstart/end 事件处理中文输入
  • 类型转换:支持字符串到数字的自动转换
  • 更新函数缓存:使用 Symbol 键缓存更新函数,避免重复查找

3.3 v-on:事件监听与修饰符

函数实现了事件修饰符的处理逻辑:
typescript
const modifierGuards: Record<
  ModifierGuards,
  | ((e: Event) => void | boolean)
  | ((e: Event, modifiers: string[]) => void | boolean)
> = {
  stop: (e: Event) => e.stopPropagation(),
  prevent: (e: Event) => e.preventDefault(),
  self: (e: Event) => e.target !== e.currentTarget,
  ctrl: (e: Event) => !(e as KeyedEvent).ctrlKey,
  shift: (e: Event) => !(e as KeyedEvent).shiftKey,
  alt: (e: Event) => !(e as KeyedEvent).altKey,
  meta: (e: Event) => !(e as KeyedEvent).metaKey,
  left: (e: Event) => 'button' in e && (e as MouseEvent).button !== 0,
  middle: (e: Event) => 'button' in e && (e as MouseEvent).button !== 1,
  right: (e: Event) => 'button' in e && (e as MouseEvent).button !== 2,
  exact: (e, modifiers) =>
    systemModifiers.some(m => (e as any)[`${m}Key`] && !modifiers.includes(m)),
}

export const withModifiers = <
  T extends (event: Event, ...args: unknown[]) => any,
>(
  fn: T & { _withMods?: { [key: string]: T } },
  modifiers: VOnModifiers[],
): T => {
  const cache = fn._withMods || (fn._withMods = {})
  const cacheKey = modifiers.join('.')
  return (
    cache[cacheKey] ||
    (cache[cacheKey] = ((event, ...args) => {
      for (let i = 0; i < modifiers.length; i++) {
        const guard = modifierGuards[modifiers[i] as ModifierGuards]
        if (guard && guard(event, modifiers)) return
      }
      return fn(event, ...args)
    }) as T)
  )
}

修饰符系统特点

  • 性能优化:通过函数缓存避免重复创建包装函数
  • 链式处理:修饰符按顺序执行,任一条件不满足即终止
  • 系统修饰符:支持 ctrl、shift、alt、meta 等系统键
  • 鼠标修饰符:支持 left、middle、right 鼠标按键判断

——

四、自定义指令开发实践

4.1 基础自定义指令

typescript
// 自动聚焦指令
const vFocus: ObjectDirective = {
  mounted(el: HTMLElement) {
    el.focus()
  },
  updated(el: HTMLElement, { value }) {
    if (value) {
      el.focus()
    }
  }
}

// 点击外部指令
const vClickOutside: ObjectDirective = {
  beforeMount(el, { value }) {
    el._clickOutsideHandler = (event: Event) => {
      if (!(el === event.target || el.contains(event.target as Node))) {
        value(event)
      }
    }
    document.addEventListener('click', el._clickOutsideHandler)
  },
  unmounted(el) {
    document.removeEventListener('click', el._clickOutsideHandler)
    delete el._clickOutsideHandler
  }
}

4.2 复杂指令:拖拽功能

typescript
interface DragElement extends HTMLElement {
  _dragData?: {
    startX: number
    startY: number
    initialX: number
    initialY: number
    onMouseMove: (e: MouseEvent) => void
    onMouseUp: () => void
  }
}

const vDraggable: ObjectDirective<DragElement> = {
  mounted(el, { value = {} }) {
    const { handle, onStart, onMove, onEnd } = value
    const dragHandle = handle ? el.querySelector(handle) : el
    
    if (!dragHandle) return
    
    const onMouseDown = (e: MouseEvent) => {
      e.preventDefault()
      
      const rect = el.getBoundingClientRect()
      const dragData = {
        startX: e.clientX,
        startY: e.clientY,
        initialX: rect.left,
        initialY: rect.top,
        onMouseMove: (e: MouseEvent) => {
          const deltaX = e.clientX - dragData.startX
          const deltaY = e.clientY - dragData.startY
          
          el.style.left = `${dragData.initialX + deltaX}px`
          el.style.top = `${dragData.initialY + deltaY}px`
          
          onMove?.({
            x: dragData.initialX + deltaX,
            y: dragData.initialY + deltaY,
            deltaX,
            deltaY
          })
        },
        onMouseUp: () => {
          document.removeEventListener('mousemove', dragData.onMouseMove)
          document.removeEventListener('mouseup', dragData.onMouseUp)
          el._dragData = undefined
          onEnd?.()
        }
      }
      
      el._dragData = dragData
      document.addEventListener('mousemove', dragData.onMouseMove)
      document.addEventListener('mouseup', dragData.onMouseUp)
      
      onStart?.()
    }
    
    dragHandle.addEventListener('mousedown', onMouseDown)
    el._onMouseDown = onMouseDown
  },
  
  beforeUnmount(el) {
    if (el._dragData) {
      document.removeEventListener('mousemove', el._dragData.onMouseMove)
      document.removeEventListener('mouseup', el._dragData.onMouseUp)
    }
    if (el._onMouseDown) {
      const handle = el.querySelector('[data-drag-handle]') || el
      handle.removeEventListener('mousedown', el._onMouseDown)
    }
  }
}

4.3 指令参数与修饰符处理

typescript
// 延迟执行指令:v-delay:1000.once="handler"
const vDelay: ObjectDirective = {
  mounted(el, { value, arg, modifiers }) {
    const delay = parseInt(arg || '0', 10)
    const { once, immediate } = modifiers
    
    let timeoutId: number
    
    const execute = () => {
      if (typeof value === 'function') {
        value()
      }
    }
    
    const scheduleExecution = () => {
      clearTimeout(timeoutId)
      timeoutId = setTimeout(execute, delay)
    }
    
    if (immediate) {
      execute()
    }
    
    if (once) {
      scheduleExecution()
    } else {
      el._scheduleExecution = scheduleExecution
      el.addEventListener('click', scheduleExecution)
    }
  },
  
  updated(el, { value, arg, modifiers, oldValue }) {
    // 值变化时重新调度
    if (value !== oldValue && !modifiers.once) {
      el._scheduleExecution?.()
    }
  },
  
  beforeUnmount(el) {
    if (el._scheduleExecution) {
      el.removeEventListener('click', el._scheduleExecution)
    }
  }
}

——

五、最佳实践与性能优化

5.1 指令开发原则

  1. 资源清理:在 beforeUnmount/unmounted 中清理事件监听器、定时器等资源
  2. 性能考虑:避免在 updated 钩子中进行昂贵操作,先检查值是否真正变化
  3. 错误处理:使用 try-catch 包装可能出错的操作
  4. 类型安全:为指令定义明确的 TypeScript 类型

5.2 常见陷阱

typescript
// ❌ 错误:未清理资源
const badDirective = {
  mounted(el) {
    setInterval(() => {
      // 定时器未清理,造成内存泄漏
    }, 1000)
  }
}

// ✅ 正确:完整的资源管理
const goodDirective = {
  mounted(el) {
    el._timer = setInterval(() => {
      // 定时逻辑
    }, 1000)
  },
  beforeUnmount(el) {
    if (el._timer) {
      clearInterval(el._timer)
      el._timer = null
    }
  }
}

// ❌ 错误:未检查值变化
const inefficientDirective = {
  updated(el, { value }) {
    // 每次更新都执行昂贵操作
    expensiveOperation(value)
  }
}

// ✅ 正确:增量更新
const efficientDirective = {
  updated(el, { value, oldValue }) {
    if (value !== oldValue) {
      expensiveOperation(value)
    }
  }
}

5.3 指令注册与使用

typescript
// 全局注册
app.directive('focus', vFocus)
app.directive('click-outside', vClickOutside)

// 局部注册
export default {
  directives: {
    focus: vFocus,
    'click-outside': vClickOutside
  },
  // ...
}

// 模板中使用
<template>
  <input v-focus v-model="inputValue" />
  <div v-click-outside="closeModal">Modal Content</div>
  <div v-draggable="{ onMove: handleMove }">Draggable Element</div>
  <button v-delay:1000.once="delayedAction">Delayed Action</button>
</template>

——

六、小结

Vue 3 的指令系统通过精心设计的生命周期钩子和参数绑定机制,为开发者提供了强大而灵活的 DOM 操作能力:

  • 生命周期完整:从创建到销毁的完整钩子覆盖,确保资源管理的可控性
  • 参数丰富:value、arg、modifiers 提供了丰富的配置选项
  • 性能优化:通过缓存、增量更新等机制保证执行效率
  • 错误隔离:指令错误不会影响应用的正常运行
  • 类型安全:完整的 TypeScript 支持确保开发体验

内置指令(v-show、v-model、v-on)的实现展示了最佳实践,为自定义指令开发提供了优秀的参考模板。正确使用指令系统,可以将复杂的 DOM 操作封装为可复用的声明式组件,大大提升开发效率和代码质量。


微信公众号二维码