Vue3 defineModel 的 get 和 set 怎么用?双向绑定逻辑还能这么玩?
defineModel 是干啥的?和传统 v-model 写法有啥不同?
Vue 里组件间实现双向绑定,以前得靠 props 接收父组件的值,再用 emit('update:xxx') 通知父组件更新,Vue 3.4 之后推出 defineModel 语法糖,把这套流程简化了 —— 你只需要在子组件里写 const model = defineModel(),它自动帮你处理 props 接收(对应父组件 v-model 绑定的字段)和 emit 触发更新,不用手动写 props 声明和 emit 调用,代码量直接减半。
传统写法得这么写:
<!-- 子组件传统双向绑定 -->
<script setup>
const props = defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])
// 修改值时要手动 emit
const handleChange = (val) => {
emit('update:modelValue', val)
}
</script>
用 defineModel 后直接简化成:
<!-- 子组件用 defineModel -->
<script setup>
const model = defineModel()
// 直接修改 model 就会触发父组件更新
const handleChange = () => {
model.value = '新值'
}
</script>
能看出来,defineModel 把「声明 props + 声明 emit + 手动触发 emit」的重复工作全自动化了,写组件时更专注逻辑本身。
defineModel 里的 get、set 怎么理解?能解决啥问题?
defineModel 支持传入一个配置对象,里面可以写 get 和 set 函数,用来拦截「父组件值的读取」和「子组件发起的更新」,简单说:
get:当子组件要读取父组件通过v-model传过来的值时,会先经过get处理,比如父组件传的是纯数字,子组件想显示带单位的格式,get里可以返回格式化后的值。set:当子组件修改defineModel拿到的变量(model.value = 新值)时,这个新值会先经过set处理,再触发父组件更新,比如子组件输入的是带格式的字符串,set里可以解析成纯数字传给父组件。
举个实际例子:父组件用 v-model 绑定手机号,子组件是输入框,要在输入时自动加空格分隔(138 1234 5678),但父组件需要纯数字(13812345678),这时候 get 和 set 就派上用场了:
<!-- 子组件:手机号输入框 -->
<script setup>
const model = defineModel({
get(value) {
// 父组件传的 value 是纯数字,这里格式化显示
if (!value) return ''
return value.toString().replace(/(\d{3})(?=\d{4})/g, '$1 ')
},
set(formattedValue) {
// 子组件输入的是带空格的字符串,解析成纯数字给父组件
const pureNumber = formattedValue.replace(/\s/g, '')
return pureNumber // 返回给父组件的新值
}
})
</script>
<template>
<input v-model="model" placeholder="请输入手机号" />
</template>
父组件里只用正常绑定 v-model:
<template>
<PhoneInput v-model="phone" />
<p>父组件拿到的手机号:{{ phone }}</p>
</template>
<script setup>
import { ref } from 'vue'
const phone = ref('13812345678')
</script>
这样用户在子组件输入时看到的是带空格的格式,父组件存的始终是纯数字 —— 格式化逻辑被封装在子组件的 get/set 里,父组件完全不用关心格式处理,代码解耦又干净。
给 defineModel 加 get、set 时,要注意哪些逻辑边界?
虽然 get/set 能封装很多逻辑,但写的时候得避开这些「雷区」,否则容易出现双向绑定不同步、死循环之类的问题:
get 里别做「副作用操作」
get 是「取值时的拦截」,每次子组件读取 model.value 都会触发 get,如果在 get 里发请求、修改其他响应式数据,可能导致无限循环更新(get 里改了另一个变量,触发视图更新,又触发 get)。get 只用来做「值的格式化/转换」,保持纯函数性质(输入值 → 输出处理后的值,没有额外操作)。
set 要考虑「父组件值同步」的时机
set 的作用是把「子组件想更新的值」处理后传给父组件,但如果 set 里做异步操作(比如调接口后再返回值),会导致父组件更新延迟,子组件和父组件短暂不同步,如果必须异步,得手动处理中间状态(比如用 ref 存临时值,等异步完成再更新 model.value)。
避免「循环更新」
set 里直接把处理后的值又赋值给 model.value,会触发父组件更新 → 父组件更新后子组件 model 又变化 → 再触发 set …… 无限循环,解决方法是在 set 里判断「处理后的值是否和父组件当前值真的不同」,不同才返回新值。
const model = defineModel({
set(newVal) {
const processedVal = 处理新值(newVal)
// 对比父组件当前值(注意:get 处理前的原始值)
if (processedVal !== 父组件原始值) {
return processedVal
}
return // 值没变化,不触发更新
}
})
(注:父组件原始值可以通过 defineProps 单独拿,或者在 set 里用其他方式缓存,根据场景灵活处理。)
业务里哪些场景适合用 defineModel 的 get/set?举个例子深挖细节
除了刚才的手机号格式化,这些场景也特别适合:
场景1:密码显示/隐藏组件
父组件用 v-model 绑定密码字符串,子组件里有个「显示密码」开关,需求是:开关打开时,输入框显示明文;开关关闭时,显示密文(),但父组件始终要拿到真实密码。
用 get/set 实现:
<!-- 子组件:PasswordInput -->
<script setup>
import { ref } from 'vue'
const show = ref(false) // 控制是否显示明文
const model = defineModel({
get(realPwd) {
// 父组件传的 realPwd 是真实密码,根据 show 决定显示啥
return show.value ? realPwd : '*'.repeat(realPwd.length)
},
set(displayedVal) {
// 子组件输入框显示的是明文或密文,这里要解析成真实密码
// 注意:如果是密文状态,用户输入时其实是改明文,所以要区分场景?
// 实际更严谨的做法是:输入时始终操作真实密码,显示由 show 控制
// 这里简化演示,假设 displayedVal 是用户输入的明文(show 为 true 时)
return displayedVal
}
})
</script>
<template>
<input :type="show ? 'text' : 'password'" v-model="model" />
<button @click="show = !show">
{{ show ? '隐藏密码' : '显示密码' }}
</button>
</template>
父组件用起来毫无感知:
<template>
<PasswordInput v-model="pwd" />
<p>真实密码:{{ pwd }}</p>
</template>
<script setup>
import { ref } from 'vue'
const pwd = ref('')
</script>
这里 get 负责「显示层的转换」(明文/密文),set 负责「把用户输入的内容传回父组件」,子组件内部的 show 状态不影响父组件的真实值,逻辑很干净。
场景2:自定义下拉选择器(值转换)
假设业务里下拉选项存的是 value: number(1=选项A,2=选项B),但父组件 v-model 绑定的是字符串('A'/'B'),这时候子组件用 get/set 做值转换:
<!-- 子组件:CustomSelect -->
<script setup>
const options = [
{ label: '选项A', value: 1 },
{ label: '选项B', value: 2 }
]
const model = defineModel({
get(parentVal) {
// 父组件传的是字符串('A'/'B'),转成对应的 number 值
return parentVal === 'A' ? 1 : 2
},
set(childVal) {
// 子组件选的是 number(1/2),转成字符串给父组件
return childVal === 1 ? 'A' : 'B'
}
})
</script>
<template>
<select v-model="model">
<option v-for="opt in options" :value="opt.value">
{{ opt.label }}
</option>
</select>
</template>
父组件绑定字符串,完全不用关心子组件的数值逻辑:
<template>
<CustomSelect v-model="selected" />
<p>父组件拿到的选择:{{ selected }}</p>
</template>
<script setup>
import { ref } from 'vue'
const selected = ref('A')
</script>
这种「值转换」场景下,get/set 把「父组件的业务值」和「子组件的 UI 渲染值」解耦,双方都不用迁就对方的格式,维护起来更灵活。
不用 defineModel 的 get/set,自己写 props+emit 处理类似逻辑有多麻烦?
对比一下就知道 defineModel 省了多少事儿,还是拿「手机号格式化」的例子,用传统 props+emit 实现:
<!-- 子组件传统写法 -->
<script setup>
const props = defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])
// 格式化显示值(对应 defineModel 的 get)
const displayValue = computed(() => {
if (!props.modelValue) return ''
return props.modelValue.toString().replace(/(\d{3})(?=\d{4})/g, '$1 ')
})
// 处理输入事件(对应 defineModel 的 set)
const handleInput = (e) => {
const inputVal = e.target.value
const pureNumber = inputVal.replace(/\s/g, '')
emit('update:modelValue', pureNumber)
}
</script>
<template>
<input :value="displayValue" @input="handleInput" placeholder="请输入手机号" />
</template>
父组件用法一样,但子组件里要做这些事:
- 声明
props.modelValue和emit.update:modelValue; - 用
computed做「显示值」的转换(对应get); - 监听输入事件,手动处理值后
emit(对应set);
代码量比用 defineModel 多了一倍不止,而且逻辑分散在 computed 和事件处理里,如果再加上更复杂的逻辑(比如异步、多个转换规则),代码会更臃肿,还容易漏写 emit 导致双向绑定失效。
而 defineModel + get/set 把「值的读取转换」和「值的更新转换」收拢到一个配置对象里,逻辑内聚性更强,读代码时一眼能看到双向绑定的处理规则,维护成本直线下降。
defineModel 的 get/set 背后是怎么和父组件 v-model 联动的?
得从 Vue 的编译和响应式原理说起:
-
编译阶段:当子组件用
defineModel({ get, set })时,Vue 会把它编译成「带有自定义 getter/setter 的响应式对象」,这个对象的value读取时触发get,赋值时触发set。 -
父组件联动:父组件用
v-model绑定的其实是一个响应式数据(ref或reactive里的字段),当子组件调用model.value = 新值时,set先处理新值,然后子组件会触发emit('update:modelValue', 处理后的值),父组件监听到这个emit后,会更新自己的响应式数据。 -
父组件更新后子组件同步:父组件数据更新后,会把新值传给子组件的
props.modelValue(这一步是自动的,因为v-model本质是modelValue+@update:modelValue),子组件的defineModel变量会读取这个新的props值,此时触发get处理,所以子组件的显示层也会同步更新。
简单说,get 负责「父 → 子」的值转换,set 负责「子 → 父」的值转换,Vue 编译时自动帮你把这两个过程和 props、emit 关联起来,你只需要关注转换逻辑本身。
用 get/set 时遇到双向绑定不同步、死循环咋排查?
遇到这类问题,按这步排查:
第一步:检查 set 里的「值是否真的变化」
set 处理后的值和父组件当前值一样,却还返回新值,会导致父组件重复更新,甚至死循环,所以要在 set 里加判断:
const model = defineModel({
set(newVal) {
const processed = 处理(newVal)
// 对比父组件原始值(没经过 get 处理的)
if (processed !== props.modelValue) {
return processed
}
return // 值没变化,不触发更新
}
})
(注:这里要拿到「父组件的原始值」,可以用 defineProps 单独声明 modelValue,这样能直接访问 props.modelValue。)
第二步:检查 get 是否引入额外响应式依赖
get 里用了其他响应式数据(比如子组件内部的 ref),当这些数据变化时,会触发 get 重新执行,导致 model.value 变化,进而触发父组件更新,如果逻辑不需要这种联动,要避免在 get 里依赖不稳定的响应式数据。
比如子组件有个 isEdit 的 ref,get 里根据 isEdit 决定返回值:
const isEdit = ref(false)
const model = defineModel({
get(val) {
return isEdit.value ? val : '不可编辑'
}
})
当 isEdit 从 false 变 true 时,get 会重新执行,model.value 从「不可编辑」变成父组件的真实值,这会触发父组件更新(因为子组件 model.value 变化了?不,其实是子组件内部 model 的值变化,触发 emit 吗?这里容易混淆 —— get 是「子组件读取 model.value 时触发」,如果子组件模板里用了 model.value(v-model 绑定),当 isEdit 变化时,模板重新渲染会触发 get,导致 model.value 变化,进而触发 emit 给父组件,造成不必要的更新。
所以get 里尽量只依赖父组件传的 value,避免依赖子组件内部的响应式数据,除非你确实需要这种联动逻辑。
第三步:检查异步操作是否处理得当
set 里有异步操作(比如调接口验证值是否合法),直接返回异步结果会无效,因为 set 要同步返回新值给父组件,这时候得换思路:
- 用子组件内部的
ref存临时值,异步完成后再更新model.value; - 或者在父组件里做异步校验,子组件只负责传递原始值。
举个错误示例(set 用异步导致不同步):
const model = defineModel({
set(newVal) {
// 错误:set 不能返回 Promise
return new Promise((resolve) => {
setTimeout(() => {
resolve(newVal.trim()) // 异步处理后的值
}, 1000)
})
}
})
正确做法是先同步处理,异步逻辑另做:
const tempValue = ref('')
const model = defineModel({
get(val) {
return tempValue.value || val
},
set(newVal) 版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网


