Skip to content

v-model:双向绑定的实现原理与演进

v-model 是 Vue 中用于实现双向数据绑定的指令。它本质上是一个“语法糖”,简化了父子组件之间的数据传递和事件监听。

本节我们将深入 v-model 的编译和运行时源码,分析它的工作原理,以及它从 Vue 2 的 .sync 到 Vue 3.4 defineModel 的演进过程。


v-model 的本质:一个 Prop + 一个 Event

v-model 的核心是“一个 prop + 一个 event”的约定。

  • 默认 v-modelv-model="myState" 等价于: :modelValue="myState" @update:modelValue="myState = $event"

  • 带参数的 v-model(取代了 Vue 2 的 .sync): v-model:title="myTitle" 等价于: :title="myTitle" @update:title="myTitle = $event"

Vue 3 废弃了 .sync 修饰符,统一使用 v-model,并支持在同一个组件上使用多个 v-model,增强了灵活性。

编译时(transformModel):转换指令

当编译器在模板中遇到 v-model 指令时,它的工作是v-model 转换成等价的 propevent

这个工作由 transformModel 插件(compiler-core/src/transforms/vModel.ts)完成。

typescript
// core/packages/compiler-core/src/transforms/vModel.ts
export const transformModel: DirectiveTransform = (dir, node, context) => {
  const { exp, arg } = dir // 'exp' 是绑定的值 'myTitle','arg' 是参数 'title'

  if (!exp) {
    // 必须有 v-model="expression"
  }

  // 1. 【确定 Prop 名称】
  // 如果是 v-model:title,propName 是 'title'
  // 如果是 v-model,propName 是 'modelValue'
  const propName = arg ? arg : createSimpleExpression('modelValue', true)

  // 2. 【确定 Event 名称】
  // v-model:title -> 'onUpdate:title'
  // v-model -> 'onUpdate:modelValue'
  const eventName = arg
    ? isStaticExp(arg)
      ? `onUpdate:${camelize(arg.content)}`
      : createCompoundExpression(['"onUpdate:" + ', arg])
    : `onUpdate:modelValue`

  // 3. 【创建赋值表达式】
  // 生成 $event => (myTitle = $event) 对应的 AST 节点
  const assignmentExp = createCompoundExpression([
    `$event => ((`, exp, `) = $event)`
  ])

  // 4. 【返回 props】
  // 将 v-model 转换成两个 prop
  const props = [
    // :title="myTitle"
    createObjectProperty(propName, dir.exp!),
    // @update:title="myTitle = $event"
    createObjectProperty(eventName, assignmentExp),
  ]
  
  // ... (处理 .lazy, .trim, .number 等修饰符,
  // 它们会被编译成 "modelModifiers" prop 传给子组件)
  
  return createTransformProps(props)
}

transformModel 是理解 v-model 的第一步。它在编译时就将 v-model 转换成了组件通用的 propsevents 接口。


子组件(setup):实现 v-model 接口的演进

transformModel 在父组件完成了转换。那么在子组件中,我们如何“实现”这个 propevent 的约定呢?

传统方式:props + emit

在 Vue 3.4 之前,我们必须手动实现这个约定:

vue
<script setup>
const props = defineProps({
  modelValue: String // 1. 接收 :modelValue prop
})

const emit = defineEmits([
  'update:modelValue' // 2. 声明 'update:modelValue' event
])

function onInput(e) {
  // 3. 在输入时,emit 事件,将新值传回父组件
  emit('update:modelValue', e.target.value)
}
</script>

<template>
  <input :value="modelValue" @input="onInput" />
</template>

这个 props + emit 的“样板代码”虽然清晰,但比较繁琐。

现代方式:defineModel (Vue 3.4+)

defineModel 是一个编译器宏,它自动帮我们生成了上述所有的“样板代码”。

vue
<script setup>
// 这一行代码,自动完成了:
// 1. defineProps({ modelValue: ... })
// 2. defineEmits(['update:modelValue'])
// 3. 返回一个 ref,它 get 时读取 prop,set 时 emit 事件
const modelValue = defineModel()
</script>

<template>
  <input v-model="modelValue" />
</template>

defineModelv-model 演进的最新成果,它极大地简化了子组件的实现。它还支持多个 v-model 和修饰符:

javascript
// 对应 v-model:title.capitalize="myTitle"
const [title, modifiers] = defineModel('title', {
  set(value) {
    // 自动处理修饰符
    if (modifiers.capitalize) {
      return value.charAt(0).toUpperCase() + value.slice(1)
    }
    return value
  }
})

特例:v-model 用于原生 DOM 元素

v-model 用在组件上时,它被转换为 prop/event。但当它用在原生元素(如 <input>, <select>)上时,编译器会进行不同的处理。

为什么? 因为原生 DOM 元素的 v-model 逻辑更复杂:

  • <input type="text">:需要处理输入法(IME)的 composition 事件。
  • <input type="checkbox">:需要处理数组true-value/false-value)的 push/splice
  • <input type="radio">:需要处理值的匹配
  • .lazy:需要将 input 事件切换change 事件。
  • .trim, .number:需要转换值。

transformModel 会检测 node.tagType。如果它是原生 ELEMENTcompiler-dom 会介入:

typescript
// core/packages/compiler-dom/src/transforms/vModel.ts
export const transformModel: DirectiveTransform = (dir, node, context) => {
  // 1. 先获取组件 v-model 的基础 props (modelValue, onUpdate:modelValue)
  const baseResult = baseTransform(dir, node, context)
  
  // 2. 如果是组件,到此为止,返回 prop/event
  if (node.tagType === ElementTypes.COMPONENT) {
    return baseResult
  }

  // 3. 【关键】如果是 <input>, <select>, <textarea>
  if (tag === 'input' || tag === 'select' || tag === 'textarea') {
    let directiveToUse = V_MODEL_TEXT // 默认

    // 4. 根据 <input> 的 type 切换运行时指令
    if (tag === 'input') {
      const type = findProp(node, 'type')
      if (type.value.content === 'checkbox') {
        directiveToUse = V_MODEL_CHECKBOX
      } else if (type.value.content === 'radio') {
        directiveToUse = V_MODEL_RADIO
      }
    }

    // 5. 【注入】告诉 codegen,需要 "v-model-text" 这个运行时指令
    baseResult.needRuntime = context.helper(directiveToUse)
    
    // 6. 【移除】移除 :modelValue prop,
    //    因为 vModelText 指令会接管 value 的设置
    baseResult.props = baseResult.props.filter(...)
    
    return baseResult
  }
}

运行时:vModelText 等指令的实现

编译器最终生成的代码,不会有 :modelValue,而是: _withDirectives(_createElementVNode("input"), [ [_vModelText, _ctx.msg] ])

vModelTextruntime-dom/src/directives/vModel.ts)是一个运行时指令,它在 createdbeforeUpdate 钩子中,手动完成了 DOM 的双向绑定:

typescript
// core/packages/runtime-dom/src/directives/vModel.ts
export const vModelText: ModelDirective = {
  // 元素创建时
  created(el, { modifiers: { lazy, trim } }, vnode) {
    // 1. 获取父组件的 "setter" (即 $event => (foo = $event))
    const assign = getModelAssigner(vnode)
    
    // 2. 【处理 .lazy】决定监听 'input' 还是 'change'
    const event = lazy ? 'change' : 'input'

    addEventListener(el, event, e => {
      // 3. 【处理 IME】如果正在中文输入,直接返回
      if ((e.target as any).composing) return
      
      let domValue = el.value
      // 4. 【处理 .trim】
      if (trim) domValue = domValue.trim()
      // ... (处理 .number) ...
      
      // 5. 【执行更新】调用 setter,更新父组件状态
      assign(domValue)
    })
    
    // 6. 监听 IME 事件
    if (!lazy) {
      addEventListener(el, 'compositionstart', onCompositionStart)
      addEventListener(el, 'compositionend', onCompositionEnd)
    }
  },
  
  // 数据更新时
  mounted(el, { value }) {
    // 7. 【数据 -> 视图】将父组件的值设置到 DOM 上
    el.value = value == null ? '' : value
  },

  // 视图更新时
  beforeUpdate(el, { value }, vnode) {
    // 8. 确保 DOM 上的值与状态同步
    if (el.value !== newValue) {
      el.value = newValue
    }
  },
}

vModelCheckbox, vModelRadio, vModelSelect 也是类似的运行时指令,它们各自处理了更复杂的数组、Set 和 multiple 逻辑。


总结

v-model 的实现展现了 Vue“编译时优化,运行时灵活”的特点:

  1. 核心约定v-model 始终是 prop + event 的简写。Vue 3 统一了 .sync,使其支持多绑定。
  2. 路径一:组件
    • 编译时transformModelv-model 转换:modelValue@update:modelValue
    • 子组件实现defineModel 宏(Vue 3.4+)是实现此约定的最佳实践,它自动生成了 propsemits 的样板代码。
  3. 路径二:原生 DOM
    • 编译时transformModel(DOM 版)拦截 v-model,将其转换运行时指令(如 vModelText)。
    • 运行时vModelText 指令通过其生命周期钩子,手动处理 DOM 事件(如 input/change/IME)、应用修饰符(.lazy/.trim),并调用 setter 更新父组件状态。

微信公众号二维码

Last updated: