Appearance
第5.5节:v-model:从 .sync 废弃到 defineModel 的演进之路
引言
v-model 是 Vue.js 中实现双向数据绑定的核心指令,它经历了从 Vue 2 到 Vue 3 的重大演进。本节将深入分析 v-model 的实现原理,探讨从 .sync 修饰符到 defineModel API 的演进历程,以及编译时转换和运行时处理的完整机制。
5.5.1 v-model 演进历程
Vue 2 的 .sync 修饰符
在 Vue 2 中,除了 v-model 外,还提供了 .sync 修饰符来实现双向绑定:
vue
<!-- Vue 2 中的 .sync 用法 -->
<child-component :title.sync="pageTitle"></child-component>
<!-- 等价于 -->
<child-component
:title="pageTitle"
@update:title="pageTitle = $event"
></child-component>Vue 3 的 v-model 统一
Vue 3 废弃了 .sync 修饰符,统一使用 v-model 来处理所有双向绑定场景:
vue
<!-- Vue 3 中的 v-model -->
<child-component v-model:title="pageTitle"></child-component>
<!-- 支持多个 v-model -->
<user-name
v-model:first-name="first"
v-model:last-name="last"
></user-name>5.5.2 编译时转换机制
核心转换逻辑
v-model 的编译转换由 transformModel 函数处理,位于 compiler-core/src/transforms/vModel.ts:
typescript
export const transformModel: DirectiveTransform = (dir, node, context) => {
const { exp, arg } = dir
if (!exp) {
context.onError(
createCompilerError(ErrorCodes.X_V_MODEL_NO_EXPRESSION, dir.loc),
)
return createTransformProps()
}
const rawExp = exp.loc.source.trim()
const expString =
exp.type === NodeTypes.SIMPLE_EXPRESSION ? exp.content : rawExp
// 检查绑定类型
const bindingType = context.bindingMetadata[rawExp]
// 检查是否绑定到 props
if (
bindingType === BindingTypes.PROPS ||
bindingType === BindingTypes.PROPS_ALIASED
) {
context.onError(createCompilerError(ErrorCodes.X_V_MODEL_ON_PROPS, exp.loc))
return createTransformProps()
}
// 生成 prop 名称和事件名称
const propName = arg ? arg : createSimpleExpression('modelValue', true)
const eventName = arg
? isStaticExp(arg)
? `onUpdate:${camelize(arg.content)}`
: createCompoundExpression(['"onUpdate:" + ', arg])
: `onUpdate:modelValue`
// 生成赋值表达式
let assignmentExp: ExpressionNode
const eventArg = context.isTS ? `($event: any)` : `$event`
if (maybeRef) {
if (bindingType === BindingTypes.SETUP_REF) {
// v-model 用于已知的 ref
assignmentExp = createCompoundExpression([
`${eventArg} => ((`,
createSimpleExpression(rawExp, false, exp.loc),
`).value = $event)`,
])
} else {
// v-model 用于可能的 ref 绑定
const altAssignment =
bindingType === BindingTypes.SETUP_LET ? `${rawExp} = $event` : `null`
assignmentExp = createCompoundExpression([
`${eventArg} => (${context.helperString(IS_REF)}(${rawExp}) ? (`,
createSimpleExpression(rawExp, false, exp.loc),
`).value = $event : ${altAssignment})`,
])
}
} else {
assignmentExp = createCompoundExpression([
`${eventArg} => ((`,
exp,
`) = $event)`,
])
}
const props = [
// modelValue: foo
createObjectProperty(propName, dir.exp!),
// "onUpdate:modelValue": $event => (foo = $event)
createObjectProperty(eventName, assignmentExp),
]
// 处理修饰符
if (dir.modifiers.length && node.tagType === ElementTypes.COMPONENT) {
const modifiers = dir.modifiers
.map(m => m.content)
.map(m => (isSimpleIdentifier(m) ? m : JSON.stringify(m)) + `: true`)
.join(`, `)
const modifiersKey = arg
? isStaticExp(arg)
? `${arg.content}Modifiers`
: createCompoundExpression([arg, ' + "Modifiers"'])
: `modelModifiers`
props.push(
createObjectProperty(
modifiersKey,
createSimpleExpression(
`{ ${modifiers} }`,
false,
dir.loc,
ConstantTypes.CAN_CACHE,
),
),
)
}
return createTransformProps(props)
}DOM 元素的特殊处理
对于 DOM 元素,compiler-dom 提供了专门的转换逻辑:
typescript
export const transformModel: DirectiveTransform = (dir, node, context) => {
const baseResult = baseTransform(dir, node, context)
// 组件 v-model 只需要 props
if (!baseResult.props.length || node.tagType === ElementTypes.COMPONENT) {
return baseResult
}
const { tag } = node
const isCustomElement = context.isCustomElement(tag)
if (
tag === 'input' ||
tag === 'textarea' ||
tag === 'select' ||
isCustomElement
) {
let directiveToUse = V_MODEL_TEXT
let isInvalidType = false
if (tag === 'input' || isCustomElement) {
const type = findProp(node, `type`)
if (type) {
if (type.type === NodeTypes.DIRECTIVE) {
// :type="foo"
directiveToUse = V_MODEL_DYNAMIC
} else if (type.value) {
switch (type.value.content) {
case 'radio':
directiveToUse = V_MODEL_RADIO
break
case 'checkbox':
directiveToUse = V_MODEL_CHECKBOX
break
case 'file':
isInvalidType = true
context.onError(
createDOMCompilerError(
DOMErrorCodes.X_V_MODEL_ON_FILE_INPUT_ELEMENT,
dir.loc,
),
)
break
default:
// text type
__DEV__ && checkDuplicatedValue()
break
}
}
}
} else if (tag === 'select') {
directiveToUse = V_MODEL_SELECT
}
// 注入运行时指令
if (!isInvalidType) {
baseResult.needRuntime = context.helper(directiveToUse)
}
}
// 移除原生 v-model 的 modelValue prop
baseResult.props = baseResult.props.filter(
p =>
!(
p.key.type === NodeTypes.SIMPLE_EXPRESSION &&
p.key.content === 'modelValue'
),
)
return baseResult
}5.5.3 运行时处理机制
文本输入的 v-model
vModelText 处理 input 和 textarea 元素:
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
// 处理 trim 修饰符
if (trim) {
domValue = domValue.trim()
}
// 处理 number 修饰符
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: { lazy, trim, number } }, 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
}
},
}复选框的 v-model
vModelCheckbox 处理复选框的特殊逻辑:
typescript
export const vModelCheckbox: ModelDirective<HTMLInputElement> = {
deep: true,
created(el, _, vnode) {
el[assignKey] = getModelAssigner(vnode)
addEventListener(el, 'change', () => {
const modelValue = (el as any)._modelValue
const elementValue = getValue(el)
const checked = el.checked
const assign = el[assignKey]
if (isArray(modelValue)) {
const index = looseIndexOf(modelValue, elementValue)
const found = index !== -1
if (checked && !found) {
assign(modelValue.concat(elementValue))
} else if (!checked && found) {
const filtered = [...modelValue]
filtered.splice(index, 1)
assign(filtered)
}
} else if (isSet(modelValue)) {
const cloned = new Set(modelValue)
if (checked) {
cloned.add(elementValue)
} else {
cloned.delete(elementValue)
}
assign(cloned)
} else {
assign(getCheckboxValue(el, checked))
}
})
},
mounted: setChecked,
beforeUpdate(el, binding, vnode) {
el[assignKey] = getModelAssigner(vnode)
setChecked(el, binding, vnode)
},
}单选框的 v-model
vModelRadio 处理单选框:
typescript
export const vModelRadio: ModelDirective<HTMLInputElement> = {
created(el, { value }, vnode) {
el.checked = looseEqual(value, vnode.props!.value)
el[assignKey] = getModelAssigner(vnode)
addEventListener(el, 'change', () => {
el[assignKey](getValue(el))
})
},
beforeUpdate(el, { value, oldValue }, vnode) {
el[assignKey] = getModelAssigner(vnode)
if (value !== oldValue) {
el.checked = looseEqual(value, vnode.props!.value)
}
},
}选择框的 v-model
vModelSelect 处理 select 元素:
typescript
export const vModelSelect: ModelDirective<HTMLSelectElement, 'number'> = {
deep: true,
created(el, { value, modifiers: { number } }, vnode) {
const isSetModel = isSet(value)
addEventListener(el, 'change', () => {
const selectedVal = Array.prototype.filter
.call(el.options, (o: HTMLOptionElement) => o.selected)
.map((o: HTMLOptionElement) =>
number ? looseToNumber(getValue(o)) : getValue(o),
)
el[assignKey](
el.multiple
? isSetModel
? new Set(selectedVal)
: selectedVal
: selectedVal[0],
)
el._assigning = true
nextTick(() => {
el._assigning = false
})
})
el[assignKey] = getModelAssigner(vnode)
},
mounted(el, { value }) {
setSelected(el, value)
},
beforeUpdate(el, _binding, vnode) {
el[assignKey] = getModelAssigner(vnode)
},
updated(el, { value }) {
if (!el._assigning) {
setSelected(el, value)
}
},
}5.5.4 defineModel API
defineModel 的设计理念
defineModel 是 Vue 3.4+ 引入的组合式 API,简化了组件中 v-model 的使用:
typescript
export function defineModel<T, M extends PropertyKey = string, G = T, S = T>(
options: ({ default: any } | { required: true }) &
PropOptions<T> &
DefineModelOptions<T, G, S>,
): ModelRef<T, M, G, S>
export function defineModel<T, M extends PropertyKey = string, G = T, S = T>(
options?: PropOptions<T> & DefineModelOptions<T, G, S>,
): ModelRef<T | undefined, M, G | undefined, S | undefined>
export function defineModel<T, M extends PropertyKey = string, G = T, S = T>(
name: string,
options: ({ default: any } | { required: true }) &
PropOptions<T> &
DefineModelOptions<T, G, S>,
): ModelRef<T, M, G, S>
export function defineModel<T, M extends PropertyKey = string, G = T, S = T>(
name: string,
options?: PropOptions<T> & DefineModelOptions<T, G, S>,
): ModelRef<T | undefined, M, G | undefined, S | undefined>
export function defineModel(): any {
if (__DEV__) {
warnRuntimeUsage('defineModel')
}
}ModelRef 类型定义
typescript
export type ModelRef<T, M extends PropertyKey = string, G = T, S = T> = Ref<
G,
S
> &
[ModelRef<T, M, G, S>, Record<M, true | undefined>]
export type DefineModelOptions<T = any, G = T, S = T> = {
get?: (v: T) => G
set?: (v: S) => any
}使用示例
vue
<script setup>
// 默认 model (通过 v-model 使用)
const modelValue = defineModel<string>()
modelValue.value = "hello"
// 带选项的默认 model
const modelValue = defineModel<string>({ required: true })
// 指定名称的 model (通过 v-model:count 使用)
const count = defineModel<number>('count')
count.value++
// 带默认值的指定名称 model
const count = defineModel<number>('count', { default: 0 })
// 带 getter/setter 的 model
const str = defineModel<string>('str', {
get(value) {
return value?.toUpperCase()
},
set(value) {
return value?.toLowerCase()
}
})
</script>5.5.5 修饰符系统
内置修饰符
Vue 提供了多个内置修饰符来处理不同的输入场景:
.trim 修饰符
自动过滤用户输入的首尾空白字符:
vue
<input v-model.trim="message" />运行时处理:
typescript
if (trim) {
domValue = domValue.trim()
}.number 修饰符
自动将用户的输入值转为数值类型:
vue
<input v-model.number="age" type="number" />运行时处理:
typescript
if (castToNumber) {
domValue = looseToNumber(domValue)
}.lazy 修饰符
取代 input 监听 change 事件:
vue
<input v-model.lazy="msg" />运行时处理:
typescript
addEventListener(el, lazy ? 'change' : 'input', handler)自定义修饰符
组件可以定义自己的修饰符:
vue
<!-- 父组件 -->
<my-component v-model.capitalize="myText" />
<!-- 子组件 -->
<script setup>
const [model, modifiers] = defineModel({
set(value) {
if (modifiers.capitalize) {
return value.charAt(0).toUpperCase() + value.slice(1)
}
return value
}
})
</script>编译时会生成修饰符对象:
javascript
// 编译结果
const props = {
modelValue: myText,
'onUpdate:modelValue': $event => (myText = $event),
modelModifiers: { capitalize: true }
}5.5.6 多个 v-model
语法支持
Vue 3 支持在同一个组件上使用多个 v-model:
vue
<user-name
v-model:first-name="first"
v-model:last-name="last"
/>编译转换
每个 v-model 都会生成对应的 prop 和事件:
javascript
// 编译结果
const props = {
firstName: first,
'onUpdate:firstName': $event => (first = $event),
lastName: last,
'onUpdate:lastName': $event => (last = $event)
}组件实现
vue
<script setup>
const firstName = defineModel('firstName')
const lastName = defineModel('lastName')
</script>
<template>
<input v-model="firstName" placeholder="First name" />
<input v-model="lastName" placeholder="Last name" />
</template>5.5.7 性能优化策略
编译时优化
- 静态提升:修饰符对象在编译时被标记为可缓存
- 事件处理器缓存:不引用作用域变量的处理器会被缓存
- 类型推断:TypeScript 模式下提供完整的类型推断
运行时优化
- 事件委托:合理使用事件监听器
- 值比较:避免不必要的更新
- 异步更新:使用 nextTick 优化 DOM 更新时机
最佳实践
vue
<script setup>
// ✅ 推荐:使用 defineModel
const modelValue = defineModel<string>()
// ✅ 推荐:明确指定类型
const count = defineModel<number>('count', { default: 0 })
// ✅ 推荐:使用计算属性进行复杂转换
const formattedValue = computed({
get: () => modelValue.value?.toUpperCase() || '',
set: (val) => { modelValue.value = val.toLowerCase() }
})
// ❌ 避免:在模板中进行复杂计算
// <input v-model="value.toUpperCase()" />
</script>5.5.8 与其他系统的集成
表单验证集成
vue
<script setup>
import { useField } from 'vee-validate'
const { value, errorMessage } = useField('email', 'required|email')
const email = defineModel('email')
// 同步验证状态
watch(email, (newVal) => {
value.value = newVal
})
watch(value, (newVal) => {
email.value = newVal
})
</script>状态管理集成
vue
<script setup>
import { useStore } from 'vuex'
const store = useStore()
const searchQuery = defineModel('searchQuery', {
get() {
return store.state.search.query
},
set(value) {
store.commit('search/setQuery', value)
}
})
</script>总结
v-model 从 Vue 2 的 .sync 修饰符到 Vue 3 的统一设计,再到 defineModel API 的引入,体现了 Vue.js 在双向数据绑定方面的不断演进和优化。通过深入理解编译时转换、运行时处理、修饰符系统等核心机制,开发者可以更好地利用 v-model 构建高效、可维护的 Vue 应用。
关键要点:
- 统一设计:Vue 3 用 v-model 统一了所有双向绑定场景
- 编译优化:编译时进行智能转换和优化
- 类型安全:defineModel 提供完整的 TypeScript 支持
- 灵活扩展:支持自定义修饰符和多个 v-model
- 性能优化:通过缓存、事件委托等策略提升性能
这些特性使得 v-model 成为 Vue.js 生态系统中最强大和灵活的双向绑定解决方案。
