v-model, v-for and Custom Directives: Compilation Output and Runtime Handling
Chapter 13: v-model, v-for, Custom Directives โ Compiler Expansion and Runtime Handling
v-modelis not a directive โ it's syntactic sugar. During compilation it's expanded into:modelValueand@update:modelValue. Understanding this expansion is the key to truly mastering two-way binding.
Core Question: How does v-model work on native elements vs custom components? How do custom directive lifecycle hooks correspond to component hooks? Why doesn't v-show unmount the DOM?
After Reading This Chapter, You Will Understand:
- The complete compilation expansion of
v-modelin different scenarios - How multiple
v-modelinstances and modifiers are handled v-for'srenderListexpansion and the role of key in compilation and runtime- The 7 lifecycle hooks of custom directives and their compiled output
- The implementation principle of
v-showand its fundamental difference fromv-if
Level 1 ยท What You Need to Know (1โ3 Years Experience)
v-model's Two Expansion Forms
On native HTML elements:
<!-- Template syntax -->
<input v-model="message" />
<!-- Compilation expansion equivalent -->
<input
:value="message"
@input="message = $event.target.value"
/>
On Vue components:
<!-- Template syntax -->
<MyInput v-model="message" />
<!-- Compilation expansion equivalent -->
<MyInput
:modelValue="message"
@update:modelValue="message = $event"
/>
For a component to support v-model:
- Accept a
modelValueprop emit('update:modelValue', newValue)at the appropriate time
<!-- MyInput component implementation -->
<script setup>
const props = defineProps({ modelValue: String })
const emit = defineEmits(['update:modelValue'])
function handleInput(e) {
emit('update:modelValue', e.target.value)
}
</script>
<template>
<input :value="props.modelValue" @input="handleInput" />
</template>
Multiple v-model and Named Arguments
Vue 3 supports multiple v-model on the same component, distinguished by argument names:
<!-- Multiple v-model -->
<MyForm v-model:title="formTitle" v-model:content="formContent" />
<!-- Expansion equivalent -->
<MyForm
:title="formTitle" @update:title="formTitle = $event"
:content="formContent" @update:content="formContent = $event"
/>
Component implementation:
<script setup>
const props = defineProps({ title: String, content: String })
const emit = defineEmits(['update:title', 'update:content'])
</script>
Built-in v-model Modifiers
Vue 3 has three built-in modifiers:
<!-- .lazy: changes input event to change event (fires on blur) -->
<input v-model.lazy="msg" />
<!-- .number: converts input value to number (parseFloat) -->
<input v-model.number="age" />
<!-- .trim: auto-trims leading/trailing whitespace -->
<input v-model.trim="name" />
v-for Expansion Form
<li v-for="(item, index) in list" :key="item.id">
{{ item.name }}
</li>
Compiled expansion:
renderList(list, (item, index) => {
return withKey(
createVNode("li", null, item.name, 1 /* TEXT */),
item.id
)
})
renderList is Vue 3's internal function that handles different source types (array, object, number, string, iterables).
Custom Directives
// Define a directive
const vFocus = {
mounted(el) {
el.focus()
}
}
// In <script setup>, variables starting with 'v' auto-become directives
<template>
<input v-focus />
</template>
Global registration:
app.directive('focus', {
mounted(el) { el.focus() }
})
Level 2 ยท How It Actually Works (3โ5 Years Experience)
v-model Differences Across Native Element Types
Different input types expand differently:
<!-- text / textarea / email / password etc. -->
<input type="text" v-model="text" />
<!-- โ :value + @input="text = $event.target.value" -->
<!-- checkbox (single) -->
<input type="checkbox" v-model="checked" />
<!-- โ :checked + @change="checked = $event.target.checked" -->
<!-- checkbox (multiple, bound to array) -->
<input type="checkbox" value="option1" v-model="checkedValues" />
<!-- โ :checked (checks if checkedValues includes "option1")
@change (adds/removes "option1" from checkedValues based on checked state) -->
<!-- radio -->
<input type="radio" value="A" v-model="picked" />
<!-- โ :checked="picked === 'A'" + @change="picked = 'A'" -->
<!-- select (single) -->
<select v-model="selected">...</select>
<!-- โ :value + @change -->
<!-- select (multiple) -->
<select multiple v-model="selectedItems">...</select>
<!-- โ selectedItems = array of selected option values -->
Special checkbox extension:
<input type="checkbox" v-model="val" true-value="yes" false-value="no" />
Vue extends checkbox to support custom values instead of true/false.
v-model Modifier Compilation Details
.lazy modifier: Changes event from input to change
.number modifier: parseFloat(value) or original string if NaN
.trim modifier: value.trim()
Passing modifiers to component v-model:
<MyInput v-model.trim.number="value" />
<!-- Expands to -->
<MyInput
:modelValue="value"
:modelModifiers="{ trim: true, number: true }"
@update:modelValue="value = $event"
/>
Component handles modifiers:
<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's renderList Internal Implementation
// packages/runtime-core/src/helpers/renderList.ts
export function renderList(
source: any,
renderItem: (...args: any[]) => VNodeChild,
): VNodeChild[] {
let ret: VNodeChild[]
if (isArray(source) || isString(source)) {
// Array or string: direct iteration
ret = new Array(source.length)
for (let i = 0, l = source.length; i < l; i++) {
ret[i] = renderItem(source[i], i)
}
} else if (typeof source === 'number') {
// Number n: generates sequence 1 to n
ret = new Array(source)
for (let i = 0; i < source; i++) {
ret[i] = renderItem(i + 1, i)
}
} else if (isObject(source)) {
if (source[Symbol.iterator as any]) {
// Iterable (Set, Map, etc.)
ret = Array.from(source as Iterable<any>, (item, i) => renderItem(item, i))
} else {
// Plain object: iterate key-value pairs
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)
}
}
} else {
ret = []
}
return ret
}
renderList source type support:
| Source Type | Template Example | Render Params |
|---|---|---|
| Array | v-for="(item, index) in arr" |
(item, index) |
| String | v-for="char in str" |
(char, index) |
| Number n | v-for="n in 5" |
(n, index), n from 1 to 5 |
| Plain object | v-for="(val, key, index) in obj" |
(value, key, index) |
| Iterable | v-for="item in set" |
(item, index) |
v-if and v-for Priority Conflict
Do not use v-if and v-for on the same element in Vue 3 (v-if has higher priority in Vue 3, meaning the v-if condition cannot access v-for's iteration variable):
<!-- โ Not recommended: v-if can't access 'item' -->
<li v-for="item in list" v-if="item.active">{{ item.name }}</li>
<!-- โ
Recommended: wrap with template v-for -->
<template v-for="item in list" :key="item.id">
<li v-if="item.active">{{ item.name }}</li>
</template>
<!-- โ
Or filter in computed -->
<li v-for="item in activeItems" :key="item.id">{{ item.name }}</li>
Custom Directive Lifecycle Hooks
Custom directives have 7 lifecycle hooks corresponding to component lifecycle:
const myDirective = {
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) {},
}
The binding object structure:
interface DirectiveBinding<V> {
instance: ComponentPublicInstance | null // Component instance
value: V // Bound value: the "value" in v-my-dir="value"
oldValue: V | null // Previous value (available in updated/beforeUpdate)
arg: string | undefined // Argument: the "arg" in v-my-dir:arg="value"
modifiers: DirectiveModifiers // {trim: true, number: true} from v-my-dir.trim.number
dir: ObjectDirective<any, V> // The directive object itself
}
Practical example: click-outside directive:
const vClickOutside = {
mounted(el: HTMLElement, binding: DirectiveBinding) {
const handler = (event: MouseEvent) => {
if (!el.contains(event.target as Node)) {
binding.value(event) // Call the bound function
}
}
el._clickOutsideHandler = handler
document.addEventListener('click', handler)
},
unmounted(el: HTMLElement) {
document.removeEventListener('click', el._clickOutsideHandler)
delete el._clickOutsideHandler
}
}
// Usage: <div v-click-outside="handleClose">...</div>
Custom Directive Compiled Output
<div v-my-dir:arg.mod1.mod2="value">Content</div>
Compiled output:
import { withDirectives, resolveDirective, createElementVNode } from "vue"
const vMyDir = resolveDirective("my-dir")
withDirectives(
createElementVNode("div", null, "Content"),
[
[
vMyDir, // Directive object
value, // Bound value
"arg", // Argument
{ mod1: true, mod2: true } // Modifiers
]
]
)
v-show implementation โ display toggle:
// 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)
}
},
updated(el, { value, oldValue }, { transition }) {
if (!value === !oldValue) return // No change
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 comparison:
| Dimension | v-if | v-show |
|---|---|---|
| DOM operation | Create / destroy DOM | Toggle display |
| Child component state | Destroyed (lifecycle resets) | Preserved |
| Performance | Fast first render, slow toggle | Slow first render, fast toggle |
| Best for | Rarely-toggled conditions | Frequently-toggled conditions |
| With keep-alive | Needed | Not needed (state preserved naturally) |
Level 3 ยท Design Docs and Source Code (Senior Developers)
transformModel: v-model Compilation Transform
File: packages/compiler-dom/src/transforms/transformModel.ts
For component v-model, the transform generates:
- A prop for the value (
modelValueor named arg) - A prop for modifiers (
modelModifiersor named arg +Modifiers) - Returns
needRuntime: V_MODEL_TEXT(or the appropriate DOM directive symbol)
For native element v-model, it dispatches to the correct runtime directive based on element type and input type:
input[type=radio]โV_MODEL_RADIOinput[type=checkbox]โV_MODEL_CHECKBOXinput(other) /textareaโV_MODEL_TEXTselectโV_MODEL_SELECTinputwith dynamic:typeโV_MODEL_DYNAMIC
withDirectives Runtime Implementation
File: packages/runtime-core/src/directives.ts
export function withDirectives<T extends VNode>(
vnode: T,
directives: DirectiveArguments,
): T {
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)) {
// Function form: equivalent to { mounted: dir, updated: dir }
dir = { mounted: dir, updated: dir } as ObjectDirective
}
bindings.push({ dir, instance, value, oldValue: void 0, arg, modifiers })
}
return vnode
}
Directive hook invocation timing in the renderer:
mount phase:
mountElement
โ invokeDirectiveHook(..., 'created') [before props processing]
โ invokeDirectiveHook(..., 'beforeMount') [before DOM insertion]
โ DOM insertion
โ queuePostFlushCb: invokeDirectiveHook(..., 'mounted')
update phase:
patchElement
โ invokeDirectiveHook(..., 'beforeUpdate') [before DOM update]
โ update DOM (props, children)
โ queuePostFlushCb: invokeDirectiveHook(..., 'updated')
unmount phase:
unmountElement
โ invokeDirectiveHook(..., 'beforeUnmount') [before DOM removal]
โ remove from DOM
โ invokeDirectiveHook(..., 'unmounted')
invokeDirectiveHook Source
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 | undefined
if (hook) {
// Pause tracking: prevent reactive reads in directive hooks
// from interfering with the render effect
pauseTracking()
callWithAsyncErrorHandling(hook, instance, ErrorCodes.DIRECTIVE_HOOK, [
vnode.el, // el: the DOM element
binding, // binding: value, arg, modifiers, etc.
vnode, // vnode: current VNode
prevVNode, // prevVNode: previous VNode
])
resetTracking()
}
}
}
Level 4 ยท Edge Cases and Traps (All Experience Levels)
Trap 1: Destructuring reactive() Breaks v-model Reactivity
<script setup>
const form = reactive({ name: '', email: '' })
</script>
<template>
<!-- โ
Works: reactive object properties are tracked -->
<input v-model="form.name" />
</template>
But:
<script setup>
// โ Destructuring breaks reactivity โ name and email are plain strings
const { name, email } = reactive({ name: '', email: '' })
</script>
<template>
<input v-model="name" /> <!-- Will NOT update reactively -->
</template>
Solution: Use toRefs():
<script setup>
const state = reactive({ name: '', email: '' })
const { name, email } = toRefs(state) // โ
Ref objects, v-model works
</script>
Trap 2: Custom Directive Mutating Vue-Managed DOM Attributes Can Conflict
// โ Risky: modifying class may be overwritten by Vue's class binding
const vDanger = {
mounted(el) {
el.className = 'red' // Vue's :class binding may overwrite this
},
}
Recommended pattern: Direct DOM attributes that Vue doesn't manage:
- Custom
datasetattributes - Focus state
- Third-party library initialization (scrollbar, tooltip, etc.)
Use the updated hook (fires after Vue finishes DOM updates) when you need to react to changes.
Trap 3: v-show Conflicts with CSS display !important
.my-component {
display: flex !important;
}
<!-- v-show uses inline style (lower specificity than !important) -->
<div class="my-component" v-show="isVisible">
<!-- !important prevents v-show from hiding this element -->
</div>
Root cause: v-show sets el.style.display (inline style), but !important in CSS overrides inline styles.
Solutions:
- Don't use
!importantdisplay on elements that usev-show - Use
v-ifinstead - Wrap with an outer element: outer has
v-show, inner has the flex class
Trap 4: Custom v-model Modifiers Not Received by Component
<!-- Parent -->
<MyInput v-model.trim.number="value" />
<!-- Child: common mistake -->
<script setup>
const props = defineProps({
modelValue: String
// โ Missing modelModifiers declaration!
})
</script>
Without declaring modelModifiers, the modifier information is lost.
Correct implementation:
<script setup>
const props = defineProps({
modelValue: [String, Number],
modelModifiers: {
default: () => ({}) // โ
Always declare with default empty object
}
})
</script>
For named v-model (v-model:title):
<script setup>
const props = defineProps({
title: String,
titleModifiers: { // โ Modifier prop name = "{argName}Modifiers"
default: () => ({})
}
})
</script>
Trap 5: Directive Limitations in SSR
// โ DOM-only operations fail in SSR
const vFocus = {
mounted(el) {
el.focus() // โ No DOM in SSR โ el.focus doesn't exist
}
}
Solution: Provide getSSRProps for SSR-compatible directives:
const vFocus = {
mounted(el) {
el.focus()
},
getSSRProps(binding) {
// Return props to add to element during SSR rendering
// focus has no SSR meaning, return empty
return {}
}
}
Or use conditional check:
const vFocus = {
mounted(el) {
if (typeof el?.focus === 'function') {
el.focus()
}
}
}
Chapter Summary
-
v-model is compile-time syntactic sugar: On native elements it expands to
:value + @input(or element-appropriate equivalents); on components it expands to:modelValue + @update:modelValue. Multiple v-model instances use argument names to distinguish; modifiers are passed to components viamodelModifiersprops. -
v-for's renderList handles multiple source types: Arrays, strings, numbers (1-to-n sequences), plain objects (key-value pairs), and iterables (Set, Map). v-for compiles to
renderList(source, renderFn); keys are used as VNodekeyattributes at compile time, enabling keyed diff at runtime. -
Custom directives have 7 hooks corresponding to the component lifecycle:
created/beforeMount/mounted/beforeUpdate/updated/beforeUnmount/unmounted, precisely invoked during the renderer's patch process. Directives can be objects (multiple hooks) or functions (shorthand for mounted + updated). -
withDirectivesis the bridge between directives and VNodes: It attaches directive information (bindings) to the VNode'sdirsarray. The runtime calls the appropriate hooks viainvokeDirectiveHookat each patch stage, with tracking paused to prevent interference with render effects. -
v-show is a built-in display-toggle directive; v-if is Block replacement:
v-showpreserves DOM and component state (great for frequent toggling);v-ifcompletely unmounts/mounts (right for rarely-toggled conditions or when true destruction is needed). They represent two different "visibility management" semantics, not simply a performance trade-off.