Skip to content

第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 性能优化策略

编译时优化

  1. 静态提升:修饰符对象在编译时被标记为可缓存
  2. 事件处理器缓存:不引用作用域变量的处理器会被缓存
  3. 类型推断:TypeScript 模式下提供完整的类型推断

运行时优化

  1. 事件委托:合理使用事件监听器
  2. 值比较:避免不必要的更新
  3. 异步更新:使用 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 应用。

关键要点:

  1. 统一设计:Vue 3 用 v-model 统一了所有双向绑定场景
  2. 编译优化:编译时进行智能转换和优化
  3. 类型安全:defineModel 提供完整的 TypeScript 支持
  4. 灵活扩展:支持自定义修饰符和多个 v-model
  5. 性能优化:通过缓存、事件委托等策略提升性能

这些特性使得 v-model 成为 Vue.js 生态系统中最强大和灵活的双向绑定解决方案。


微信公众号二维码