第 13 章

v-model、v-for、自定义指令的编译展开与运行时处理

第13章:v-model、v-for、自定义指令的编译展开与运行时处理

v-model 不是一个指令,它是一个语法糖——在编译阶段被展开为 :modelValue@update:modelValue。理解这个展开过程,是真正掌握双向绑定的关键。

本章核心问题v-model 是如何在原生元素和自定义组件上工作的?自定义指令的生命周期钩子与组件钩子有何对应关系?v-show 为什么不卸载 DOM?

读完本章你将理解


Level 1 · 你需要知道的(1-3年经验)

v-model 的两种展开形式

在原生 HTML 元素上

<!-- 模板写法 -->
<input v-model="message" />

<!-- 编译展开等价形式 -->
<input 
  :value="message" 
  @input="message = $event.target.value" 
/>

在 Vue 组件上

<!-- 模板写法 -->
<MyInput v-model="message" />

<!-- 编译展开等价形式 -->
<MyInput 
  :modelValue="message" 
  @update:modelValue="message = $event" 
/>

这意味着组件要支持 v-model,需要:

  1. 接受 modelValue prop
  2. 在适当时机 emit('update:modelValue', newValue)
<!-- MyInput 组件实现 -->
<script setup>
const props = defineProps({
  modelValue: String  // 必须接受 modelValue prop
})
const emit = defineEmits(['update:modelValue'])

function handleInput(e) {
  emit('update:modelValue', e.target.value)
}
</script>

<template>
  <input :value="props.modelValue" @input="handleInput" />
</template>

多个 v-model 和命名参数

Vue 3 支持在同一个组件上使用多个 v-model,通过参数名区分:

<!-- 多个 v-model -->
<MyForm 
  v-model:title="formTitle" 
  v-model:content="formContent" 
/>

<!-- 展开等价 -->
<MyForm 
  :title="formTitle"       @update:title="formTitle = $event"
  :content="formContent"   @update:content="formContent = $event"
/>

组件实现:

<script setup>
const props = defineProps({
  title: String,
  content: String
})
const emit = defineEmits(['update:title', 'update:content'])
</script>

v-model 的内置修饰符

Vue 3 内置了三个修饰符:

<!-- .lazy:将 input 事件改为 change 事件(失去焦点时触发) -->
<input v-model.lazy="msg" />
<!-- 展开:@change="msg = $event.target.value" -->

<!-- .number:自动将输入值转为数字(parseFloat) -->
<input v-model.number="age" />
<!-- 展开:value = parseFloat($event.target.value) || $event.target.value -->

<!-- .trim:自动去除首尾空格 -->
<input v-model.trim="name" />
<!-- 展开:value = $event.target.value.trim() -->

v-for 的展开形式

<!-- 模板 -->
<li v-for="(item, index) in list" :key="item.id">
  {{ item.name }}
</li>

编译展开:

renderList(list, (item, index) => {
  return withKey(
    createVNode("li", null, item.name, 1 /* TEXT */),
    item.id
  )
})

renderList 是 Vue 3 内部函数,根据数据源类型(数组、对象、数字、字符串、可迭代对象)选择不同的遍历方式。

自定义指令的使用

// 定义指令
const vFocus = {
  mounted(el) {
    el.focus()
  }
}

// 局部使用(setup 中)
// 在 <script setup> 中,以 v 开头的变量自动成为指令
</script>

<template>
  <input v-focus />
</template>

全局注册:

app.directive('focus', {
  mounted(el) {
    el.focus()
  }
})

Level 2 · 它是怎么运行的(3-5年经验)

v-model 在不同原生元素上的差异

不同类型的 input 元素,v-model 的展开方式略有不同:

<!-- text / textarea / email / search / url / tel / password -->
<input type="text" v-model="text" />
<!-- → :value + @input -->

<!-- checkbox(单个) -->
<input type="checkbox" v-model="checked" />
<!-- → :checked + @change="checked = $event.target.checked" -->

<!-- checkbox(多个,绑定数组) -->
<input type="checkbox" value="option1" v-model="checkedValues" />
<!-- → :checked(判断 checkedValues 是否包含 "option1")
    @change(根据 checked 状态从 checkedValues 添加/移除 "option1") -->

<!-- radio -->
<input type="radio" value="A" v-model="picked" />
<!-- → :checked="picked === 'A'" + @change="picked = 'A'" -->

<!-- select(单选) -->
<select v-model="selected">...</select>
<!-- → :value + @change -->

<!-- select(多选) -->
<select multiple v-model="selectedItems">...</select>
<!-- → selectedItems = 选中 option 的 value 数组 -->

checkbox 特殊处理

<input 
  type="checkbox" 
  v-model="isActive"
  true-value="yes"    <!-- 选中时的值 -->
  false-value="no"    <!-- 未选中时的值 -->
/>

这是 Vue 对 checkbox 的特殊扩展:不是 true/false,而是自定义值。

v-model 修饰符的编译细节

.lazy 修饰符

// 无修饰符:
// { onInput: handler }

// .lazy:
// { onChange: handler }  ← 事件从 input 改为 change

.number 修饰符

// 展开后的 setter(简化)
value = isNaN(parseFloat($event.target.value)) 
  ? $event.target.value 
  : parseFloat($event.target.value)

.trim 修饰符

value = $event.target.value.trim()

组合修饰符

<input v-model.lazy.trim.number="val" />

编译后,修饰符信息通过 modelModifiers prop 传给组件(如果是组件 v-model),运行时组合应用这些修饰符。

自定义组件 v-model 的修饰符传递

当在组件上使用带修饰符的 v-model 时,修饰符信息通过 modelModifiers prop 传入:

<MyInput v-model.trim.number="value" />

<!-- 展开 -->
<MyInput 
  :modelValue="value"
  :modelModifiers="{ trim: true, number: true }"
  @update:modelValue="value = $event" 
/>

组件内处理修饰符:

<script setup>
const props = defineProps({
  modelValue: [String, Number],
  modelModifiers: { default: () => ({}) }
})
const emit = defineEmits(['update:modelValue'])

function handleInput(e) {
  let value = e.target.value
  
  if (props.modelModifiers.trim) {
    value = value.trim()
  }
  
  if (props.modelModifiers.number) {
    const num = parseFloat(value)
    value = isNaN(num) ? value : num
  }
  
  emit('update:modelValue', value)
}
</script>

v-for 的 renderList 内部实现

// packages/runtime-core/src/helpers/renderList.ts

export function renderList(
  source: any,
  renderItem: (...args: any[]) => VNodeChild,
  cache?: any[],
  index?: number,
): VNodeChild[] {
  let ret: VNodeChild[]

  const cached = (cache && cache[index!]) as VNode[] | undefined

  if (isArray(source) || isString(source)) {
    // 数组或字符串:直接遍历
    ret = new Array(source.length)
    for (let i = 0, l = source.length; i < l; i++) {
      ret[i] = renderItem(source[i], i, undefined, cached && (cached[i] as VNode))
    }
  } else if (typeof source === 'number') {
    // 数字 n:生成 1 到 n 的序列
    if (__DEV__ && !Number.isInteger(source)) {
      warn(`The v-for range expect an integer value but got ${source}.`)
    }
    ret = new Array(source)
    for (let i = 0; i < source; i++) {
      ret[i] = renderItem(i + 1, i, undefined, cached && (cached[i] as VNode))
    }
  } else if (isObject(source)) {
    if (source[Symbol.iterator as any]) {
      // 可迭代对象(Set、Map 等)
      ret = Array.from(source as Iterable<any>, (item, i) =>
        renderItem(item, i, undefined, cached && (cached[i] as VNode)),
      )
    } else {
      // 普通对象:遍历键值对
      const keys = Object.keys(source)
      ret = new Array(keys.length)
      for (let i = 0, l = keys.length; i < l; i++) {
        const key = keys[i]
        ret[i] = renderItem(source[key], key, i, cached && (cached[i] as VNode))
      }
    }
  } else {
    ret = []
  }
  
  return ret
}

renderList 的灵活性说明了 Vue 3 的 v-for 支持:

数据源类型 示例 渲染项参数
数组 v-for="(item, index) in arr" (item, index)
字符串 v-for="char in str" (char, index)
数字 n v-for="n in 5" (n, index),n 从 1 到 5
普通对象 v-for="(val, key, index) in obj" (value, key, index)
可迭代对象 v-for="item in set" (item, index)

v-for 与 v-if 的优先级冲突

Vue 3 中不允许在同一元素上同时使用 v-if 和 v-for(Vue 2 中 v-for 优先级更高,Vue 3 中 v-if 优先级更高,但都不推荐混用):

<!-- ❌ Vue 3 中不推荐(且在某些情况下有性能问题) -->
<li v-for="item in list" v-if="item.active">
  {{ item.name }}
</li>

<!-- ✅ 推荐:用 template v-for 嵌套 v-if -->
<template v-for="item in list" :key="item.id">
  <li v-if="item.active">{{ item.name }}</li>
</template>

<!-- ✅ 或在 computed 中过滤 -->
<li v-for="item in activeItems" :key="item.id">
  {{ item.name }}
</li>
<!-- computed: activeItems = list.filter(item => item.active) -->

Vue 3 的处理:当同时出现时,v-if 优先级高于 v-for,这意味着 v-if 里的条件无法访问 v-for 的迭代变量(因为 v-if 先执行,此时 item 还不存在)。

自定义指令的生命周期钩子

自定义指令有 7 个生命周期钩子,与组件生命周期对应:

const myDirective = {
  // 元素被创建、属性处理之前
  created(el, binding, vnode, prevVNode) {},
  
  // 元素插入父 DOM 之前
  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) {},
}

binding 对象的结构

interface DirectiveBinding<V> {
  instance: ComponentPublicInstance | null  // 组件实例
  value: V                   // 指令绑定的值:v-my-dir="value" 中的 value
  oldValue: V | null        // 上一次的值(updated 和 beforeUpdate 中可用)
  arg: string | undefined   // 指令参数:v-my-dir:arg="value" 中的 arg
  modifiers: DirectiveModifiers  // 修饰符:v-my-dir.trim.number 中的 {trim:true, number:true}
  dir: ObjectDirective<any, V>  // 指令对象本身
}

实际应用:点击外部关闭指令

const vClickOutside = {
  mounted(el: HTMLElement, binding: DirectiveBinding) {
    const handler = (event: MouseEvent) => {
      if (!el.contains(event.target as Node)) {
        binding.value(event)  // 调用指令值(一个函数)
      }
    }
    // 把 handler 挂在 el 上,以便在 unmounted 时移除
    el._clickOutsideHandler = handler
    document.addEventListener('click', handler)
  },
  unmounted(el: HTMLElement) {
    document.removeEventListener('click', el._clickOutsideHandler)
    delete el._clickOutsideHandler
  }
}

// 使用
// <div v-click-outside="handleClose">...</div>

自定义指令的编译输出

<div v-my-dir:arg.mod1.mod2="value">内容</div>

编译结果:

import { withDirectives, resolveDirective, createElementVNode } from "vue"

// resolveDirective 从当前组件/全局注册中找到指令
const vMyDir = resolveDirective("my-dir")

withDirectives(
  createElementVNode("div", null, "内容"),
  [
    [
      vMyDir,     // 指令对象
      value,      // 绑定值
      "arg",      // 参数
      { mod1: true, mod2: true }  // 修饰符
    ]
  ]
)

withDirectives 的作用:将指令信息附加到 VNode 上,运行时会在合适的时机调用指令的各个钩子。

v-show 的实现:display 切换

v-show 是一个内置指令,不卸载 DOM,只切换 display 属性:

// packages/runtime-dom/src/directives/vShow.ts

export const vShow: ObjectDirective<VShowElement> = {
  beforeMount(el, { value }, { transition }) {
    el._vod = 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._vod : 'none'
}

v-show vs v-if 的本质区别

维度 v-if v-show
DOM 操作 创建/销毁 DOM 切换 display
子组件状态 销毁(生命周期重置) 保留
性能特点 初次渲染快,切换慢 初次渲染慢(创建所有),切换快
适用场景 很少切换的条件 频繁切换的条件
配合 keep-alive 需要 不需要(状态天然保留)

Level 3 · 设计文档与源码(资深开发者)

transformModel:v-model 的编译转换

文件路径packages/compiler-dom/src/transforms/transformModel.ts

export const transformModel: DirectiveTransform = (dir, node, context) => {
  const { exp, arg, modifiers } = dir
  
  if (!exp) {
    context.onError(createDOMCompilerError(DOMErrorCodes.X_V_MODEL_NO_EXPRESSION, dir.loc))
    return createTransformProps()
  }
  
  const tag = node.tag
  const isComponent = node.tagType === ElementTypes.COMPONENT
  
  if (isComponent) {
    // 组件 v-model:展开为 modelValue prop + update:modelValue 事件
    const propName = arg ? arg : createSimpleExpression('modelValue', true)
    const eventName = arg
      ? createCompoundExpression(['"update:"', ' + ', arg])
      : createSimpleExpression('onUpdate:modelValue', true)
    
    let assignmentExp: ExpressionNode = exp
    
    // 处理修饰符
    const modifiersKey = arg
      ? isStaticExp(arg)
        ? `${arg.content}Modifiers`
        : createCompoundExpression([arg, ' + "Modifiers"'])
      : `modelModifiers`
    
    return {
      props: [
        createObjectProperty(propName, dir.exp!),
        createObjectProperty(
          createSimpleExpression(modifiersKey, true),
          createObjectExpression(
            dir.modifiers.map(m =>
              createObjectProperty(
                createSimpleExpression(m, true),
                createSimpleExpression('true', false),
              ),
            ),
          ),
        ),
      ],
      needRuntime: context.helper(V_MODEL_TEXT),
    }
  } else {
    // 原生元素 v-model
    const domTag = tag as string
    
    if (domTag === 'input' || domTag === 'textarea' || domTag === 'select') {
      let domDirective: symbol
      let props: Property[]
      
      if (domTag === 'input') {
        const type = findProp(node, 'type')
        if (type && type.type === NodeTypes.ATTRIBUTE) {
          const typeValue = type.value && type.value.content
          
          if (typeValue === 'radio') {
            domDirective = V_MODEL_RADIO
          } else if (typeValue === 'checkbox') {
            domDirective = V_MODEL_CHECKBOX
          } else {
            domDirective = V_MODEL_TEXT
          }
        } else if (type && type.type === NodeTypes.DIRECTIVE) {
          // 动态 type,如 :type="inputType"
          domDirective = V_MODEL_DYNAMIC
        } else {
          domDirective = V_MODEL_TEXT
        }
      } else if (domTag === 'select') {
        domDirective = V_MODEL_SELECT
      } else {
        // textarea
        domDirective = V_MODEL_TEXT
      }
      
      return {
        props: [
          createObjectProperty(
            createSimpleExpression('value', true),
            dir.exp!,
          ),
        ],
        needRuntime: context.helper(domDirective),
      }
    }
  }
}

withDirectives 的运行时实现

文件路径packages/runtime-core/src/directives.ts

export function withDirectives<T extends VNode>(
  vnode: T,
  directives: DirectiveArguments,
): T {
  if (currentRenderingInstance === null) {
    __DEV__ && warn(`withDirectives can only be used inside render functions.`)
    return vnode
  }
  const instance = getExposeProxy(currentRenderingInstance) || currentRenderingInstance.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]
    
    if (isFunction(dir)) {
      // 函数形式的指令:mounted 和 updated 时都执行
      dir = {
        mounted: dir,
        updated: dir,
      } as ObjectDirective
    }
    
    if (dir.deep) {
      // 深度监听指令值的变化
      traverse(value)
    }
    
    bindings.push({
      dir,
      instance,
      value,
      oldValue: void 0,
      arg,
      modifiers,
    })
  }
  
  return vnode
}

指令钩子的调用时机:运行时在 patch 过程的不同阶段调用指令钩子:

// packages/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 (isArray(hook)) {
      hook = hook[0]
    }
    if (hook) {
      // 暂停追踪,防止指令钩子里的响应式读取干扰渲染 effect
      pauseTracking()
      callWithAsyncErrorHandling(hook, instance, ErrorCodes.DIRECTIVE_HOOK, [
        vnode.el,    // el:元素本身
        binding,     // binding:包含 value、arg、modifiers 等
        vnode,       // vnode:当前 VNode
        prevVNode,   // prevVNode:上一次的 VNode
      ])
      resetTracking()
    }
  }
}

在 renderer 的 patch 过程中,指令钩子的调用位置:

mount 阶段:
  processElement → mountElement
    → 挂载前:invokeDirectiveHook(..., 'beforeMount')
    → 元素插入 DOM
    → 挂载后(queuePostFlushCb):invokeDirectiveHook(..., 'mounted')

update 阶段:
  patchElement
    → 更新前:invokeDirectiveHook(..., 'beforeUpdate')
    → 更新元素(props、children)
    → 更新后(queuePostFlushCb):invokeDirectiveHook(..., 'updated')

unmount 阶段:
  unmountElement
    → 卸载前:invokeDirectiveHook(..., 'beforeUnmount')
    → 移除 DOM
    → 卸载后:invokeDirectiveHook(..., 'unmounted')

Level 4 · 边界与陷阱(全体适用)

陷阱1:v-model 绑定对象属性的响应式问题

<script setup>
const form = reactive({
  name: '',
  email: ''
})
</script>

<template>
  <!-- ✅ 正确:reactive 对象的属性可以被 v-model 追踪 -->
  <input v-model="form.name" />
  <input v-model="form.email" />
</template>

但是:

<script setup>
const { name, email } = reactive({ name: '', email: '' })
// ❌ 解构破坏了响应式!name 和 email 是普通字符串
</script>

<template>
  <input v-model="name" />   <!-- 不会更新,name 是普通字符串 -->
</template>

根因reactive() 的解构会丢失响应式追踪。使用 toRefs() 解构:

<script setup>
const state = reactive({ name: '', email: '' })
const { name, email } = toRefs(state)
// 现在 name 和 email 是 Ref 对象,v-model 可以正确追踪
</script>

陷阱2:自定义指令中修改 el 属性可能与 Vue 冲突

// ❌ 危险:修改了 Vue 管理的 DOM 属性
const vDanger = {
  mounted(el) {
    el.className = 'red'  // Vue 的 class binding 可能覆盖这个
  },
  updated(el, binding) {
    el.style.color = 'red'  // Vue 的 style binding 可能在之后覆盖这个
  }
}

指令钩子和 Vue 的 DOM 更新是有顺序的beforeUpdate → Vue 更新 DOM → updated。在 updated 钩子里修改 DOM 属性,Vue 不会再覆盖(因为 Vue 的更新已经完成)。在 mounted 钩子里修改,会在 Vue 完成初次渲染之后执行,通常是安全的。

建议:指令操作应该针对 Vue 不管理的 DOM 属性(如自定义 dataset、focus 状态、第三方库的初始化)。

陷阱3:v-show 与 CSS display 冲突

/* 你的 CSS 强制设置了 display */
.my-component {
  display: flex !important;
}
<!-- v-show 使用 display: none 来隐藏 -->
<div class="my-component" v-show="isVisible">
  <!-- !important 会阻止 v-show 隐藏这个元素 -->
</div>

根因v-show 直接设置 el.style.display,但 CSS 的 !important 规则优先级更高,会覆盖内联样式。

解决方案

  1. 不要在需要使用 v-show 的元素上用 !important display
  2. 或者用 v-if 代替 v-show
  3. 或者用包装元素:外层 v-show,内层有 display flex 的类

陷阱4:自定义 v-model 修饰符未正确接收

<!-- 父组件 -->
<MyInput v-model.trim.number="value" />
<!-- 子组件:常见错误 -->
<script setup>
const props = defineProps({
  modelValue: String
  // ❌ 忘记声明 modelModifiers!
})
</script>

如果子组件没有声明 modelModifiers prop,父组件传来的修饰符信息会丢失,子组件也无法处理修饰符逻辑。

正确实现

<script setup>
const props = defineProps({
  modelValue: [String, Number],
  modelModifiers: {
    default: () => ({})  // ✅ 必须声明,默认值为空对象
  }
})
</script>

对于命名 v-model(v-model:title)

<script setup>
const props = defineProps({
  title: String,
  titleModifiers: {  // ← 修饰符 prop 名为 "{参数名}Modifiers"
    default: () => ({})
  }
})
</script>

陷阱5:指令在 SSR 中的行为限制

// ❌ 只在浏览器环境有效的指令,在 SSR 中会报错
const vFocus = {
  mounted(el) {
    el.focus()   // ❌ SSR 中没有 el.focus 方法(无 DOM)
  }
}

解决方案:为指令的 SSR 版本提供 getSSRProps 函数:

// 支持 SSR 的指令
const vFocus = {
  mounted(el) {
    el.focus()
  },
  getSSRProps(binding) {
    // 在 SSR 时,这里返回的 props 会被加到元素上
    // focus 在 SSR 中没有意义,返回空对象
    return {}
  }
}

或者直接用条件检测:

const vFocus = {
  mounted(el) {
    if (typeof el.focus === 'function') {
      el.focus()
    }
  }
}

本章小结

  1. v-model 是编译期的语法糖:在原生元素上展开为 :value + @input(或对应的事件/属性),在组件上展开为 :modelValue + @update:modelValue。多个 v-model 用参数名区分,修饰符通过 modelModifiers prop 传递给组件。

  2. v-for 的 renderList 支持多种数据源:数组、字符串、数字(1-n 序列)、普通对象(键值对)、可迭代对象(Set、Map)。v-for 编译为 renderList(source, renderFn),key 在编译期被用于 VNode 的 key 属性,运行时的 keyed diff 依赖它。

  3. 自定义指令的 7 个钩子与组件生命周期对应created/beforeMount/mounted/beforeUpdate/updated/beforeUnmount/unmounted,在 renderer 的 patch 过程中被精确调用。指令可以是对象(多钩子)或函数(mounted + updated 简写)。

  4. withDirectives 是指令与 VNode 的桥梁:它将指令信息(binding)附加到 VNode 的 dirs 数组,运行时在 patch 的各个阶段通过 invokeDirectiveHook 调用相应钩子。

  5. v-show 是内置的 display 切换指令,v-if 是 Block 替换:v-show 保留 DOM 和组件状态,适合频繁切换;v-if 彻底卸载/挂载,适合很少切换但需要真正销毁的场景。二者不是简单的性能取舍,而是两种不同的"可见性管理"语义。

本章评分
4.7  / 5  (24 评分)

💬 留言讨论