v-model、v-for、自定义指令的编译展开与运行时处理
第13章:v-model、v-for、自定义指令的编译展开与运行时处理
v-model不是一个指令,它是一个语法糖——在编译阶段被展开为:modelValue和@update:modelValue。理解这个展开过程,是真正掌握双向绑定的关键。
本章核心问题:v-model 是如何在原生元素和自定义组件上工作的?自定义指令的生命周期钩子与组件钩子有何对应关系?v-show 为什么不卸载 DOM?
读完本章你将理解:
v-model在不同场景下的完整编译展开形式- 多个
v-model和修饰符的处理方式 v-for的renderList展开与 key 在编译和运行时的作用- 自定义指令的 7 个生命周期钩子及其编译输出
v-show的实现原理与v-if的本质区别
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,需要:
- 接受
modelValueprop - 在适当时机
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 规则优先级更高,会覆盖内联样式。
解决方案:
- 不要在需要使用
v-show的元素上用!importantdisplay - 或者用
v-if代替v-show - 或者用包装元素:外层
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()
}
}
}
本章小结
-
v-model 是编译期的语法糖:在原生元素上展开为
:value + @input(或对应的事件/属性),在组件上展开为:modelValue + @update:modelValue。多个 v-model 用参数名区分,修饰符通过modelModifiersprop 传递给组件。 -
v-for 的 renderList 支持多种数据源:数组、字符串、数字(1-n 序列)、普通对象(键值对)、可迭代对象(Set、Map)。v-for 编译为
renderList(source, renderFn),key 在编译期被用于 VNode 的key属性,运行时的 keyed diff 依赖它。 -
自定义指令的 7 个钩子与组件生命周期对应:
created/beforeMount/mounted/beforeUpdate/updated/beforeUnmount/unmounted,在 renderer 的 patch 过程中被精确调用。指令可以是对象(多钩子)或函数(mounted + updated 简写)。 -
withDirectives是指令与 VNode 的桥梁:它将指令信息(binding)附加到 VNode 的dirs数组,运行时在 patch 的各个阶段通过invokeDirectiveHook调用相应钩子。 -
v-show 是内置的 display 切换指令,v-if 是 Block 替换:v-show 保留 DOM 和组件状态,适合频繁切换;v-if 彻底卸载/挂载,适合很少切换但需要真正销毁的场景。二者不是简单的性能取舍,而是两种不同的"可见性管理"语义。