Vue 3 defineModel 翻车实录:多个 v-model 绑定到底怎么写?

问题场景

同事小张从 Vue 2 迁移到 Vue 3 + Composition API,遇到一个常见需求:一个表单组件需要同时绑定「标题」「内容」「开关状态」三个数据。

Vue 2 时代他这样写:

vue 复制代码
<!-- 父组件 -->
<MyForm
  :title.sync="title"
  :content.sync="content"
  :visible.sync="visible"
/>

Vue 3 里 .sync 被废了,官方文档说用 v-model 传多个绑定。小张开写:

vue 复制代码
<!-- 父组件 -->
<MyForm
  v-model:title="title"
  v-model:content="content"
  v-model:visible="visible"
/>

子组件直接套用旧写法:

vue 复制代码
<script setup>
const props = defineProps({
  title: String,
  content: String,
  visible: Boolean,
})
const emit = defineEmits(['update:title', 'update:content', 'update:visible'])
</script>

结果跑起来没问题,但被 Code Review 打回来 ------ 你在 defineProps 里声明了默认值吗?数据流清晰吗?这里有个潜在的双向绑定「黑洞」问题。

原因分析

1. defineModel 的正确用法

Vue 3.4+ 推出了 defineModel 宏,专治 v-model 多绑定场景。先看一个简单版:

vue 复制代码
<script setup>
// 默认 v-model
const model = defineModel()
// 等价于:
// const props = defineProps({ modelValue: String })
// const emit = defineEmits(['update:modelValue'])
</script>

但大多数人不知道的是:defineModel 返回的是个 ref,可以加配置,还能定义多个绑定

2. 没有使用 defineModel 的隐患

小张那种手动 defineProps + emit 写法在简单场景没问题,但在这些场景下会翻车:

  • 双向绑定的默认值defineProps 声明 default 后,父组件首次没传值时,子组件直接改 props 会触发「不期望的突变」警告
  • 深层对象双向绑定v-model:config="config" 时,子组件改了嵌套属性,父组件不感知
  • TS 推导丢失:手动声明类型容易和运行时行为不一致

解决方案:使用 defineModel 重构

第一步 :用 defineModel 替代手动 defineProps + emit

vue 复制代码
<!-- MyForm.vue -->
<script setup lang="ts">
// 每个 v-model 对应一个 defineModel
const title = defineModel<string>('title', { required: true })
const content = defineModel<string>('content', { default: '' })
const visible = defineModel<boolean>('visible', { default: false })
</script>

<template>
  <div>
    <input v-model="title" />
    <textarea v-model="content" />
    <Switch :model-value="visible" @update:model-value="val => visible = val" />
  </div>
</template>

第二步 :在模板里直接用 v-model 绑定 defineModel 返回的 ref

特别注意:defineModel 返回的是可写的 ref ,在模板里可以直接写 v-model="title",不需要再写 :value="title" @input="..."

第三步:给每个 defineModel 指定类型和默认值

typescript 复制代码
// 带类型 + 转换器
const count = defineModel<number>('count', {
  default: 0,
  // 自定义转换:始终读成 number
  set(value: string | number) {
    return Number(value) || 0
  }
})

避坑指南

🔴 坑 1:defineModel 的名字要和 v-model 绑定对上

父组件写了 v-model:title="title",子组件里必须用 defineModel('title')名字完全一致。少一个字母都不行。

🔴 坑 2:不要混用 defineModel 和手动 props

vue 复制代码
<script setup>
// ❌ 这样会冲突
const props = defineProps({ title: String })
const titleModel = defineModel('title')

// ✅ 选一个:全用 defineModel
const title = defineModel('title')
</script>

🔴 坑 3:复杂对象的深拷贝问题

v-model 绑定一个对象时,子组件直接改对象属性不会触发父组件更新。正确做法:

vue 复制代码
<script setup>
const config = defineModel<{ theme: string; fontSize: number }>('config', {
  default: () => ({ theme: 'light', fontSize: 14 }),
})

function updateTheme(theme: string) {
  // ✅ 必须重新赋值(触发 setter)
  config.value = { ...config.value, theme }
}
</script>

🔴 坑 4:类型定义用泛型

typescript 复制代码
// ✅ 推荐:显式类型
const title = defineModel<string>('title')

// ❌ 不推荐:类型推断有时不准
const title = defineModel('title')

要点总结

场景 推荐写法
单个 v-model const model = defineModel()
多个 v-model const title = defineModel('title')
带默认值 defineModel('x', { default: 0 })
带类型 defineModel<string>('x')
自定义 setter defineModel('x', { set(val) { ... } })

一句话记忆 :Vue 3.4+ 写子组件双向绑定,别再手写 props + emitdefineModel 一键搞定,还自带类型推导和默认值支持。

相关推荐
拖孩7 小时前
一个人 + AI 做的小程序,上线 15 天赚了 10 块 5
前端·后端·微信小程序
用户93983829415997 小时前
安卓自动化脚本热更新怎么做?不重新打包 APK 也能更新代码
前端
猫七先生8 小时前
在 React 里用 PMTiles 自托管底图
前端
tanghonghanhaoli8 小时前
【图书翻译】有意义的游戏设计:桌面游戏的方法论与心理学(第1、2章)
前端·游戏·ui
光影少年8 小时前
react native打包优化、包体积瘦身、离线包/热更新方案
前端·react native·react.js
Cache技术分享8 小时前
504. Java 反射 - 创建一个简单的依赖注入框架
前端·后端
_codeOH8 小时前
# 手把手复刻 DeepSeek 官网效果:玻璃拟态 + WebGL 流体 + Spring 动画,一篇讲透
前端
名字还没想好☜8 小时前
Next.js 用 cookies()/headers() 读请求信息:动态渲染触发、缓存失效与在 Server Action 里读写 cookie
前端·javascript·缓存·react·next.js·app router
今日无bug8 小时前
从「拿来主义」到「亲手造轮子」:2 种 MCP 文件服务器写法对比
前端·node.js·mcp
xcyxiner8 小时前
flutter 运行到模拟器上
android·前端·flutter