Code前端首页关于Code前端联系我们

Vue3 defineModel 的 get 和 set 怎么用?双向绑定逻辑还能这么玩?

terry 4小时前 阅读数 57 #Vue
文章标签 defineModel

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 支持传入一个配置对象,里面可以写 getset 函数,用来拦截「父组件值的读取」和「子组件发起的更新」,简单说:

  • get:当子组件要读取父组件通过 v-model 传过来的值时,会先经过 get 处理,比如父组件传的是纯数字,子组件想显示带单位的格式,get 里可以返回格式化后的值。
  • set:当子组件修改 defineModel 拿到的变量(model.value = 新值)时,这个新值会先经过 set 处理,再触发父组件更新,比如子组件输入的是带格式的字符串,set 里可以解析成纯数字传给父组件。

举个实际例子:父组件用 v-model 绑定手机号,子组件是输入框,要在输入时自动加空格分隔(138 1234 5678),但父组件需要纯数字(13812345678),这时候 getset 就派上用场了:

<!-- 子组件:手机号输入框 -->
<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>

父组件用法一样,但子组件里要做这些事:

  1. 声明 props.modelValueemit.update:modelValue
  2. computed 做「显示值」的转换(对应 get);
  3. 监听输入事件,手动处理值后 emit(对应 set);

代码量比用 defineModel 多了一倍不止,而且逻辑分散在 computed 和事件处理里,如果再加上更复杂的逻辑(比如异步、多个转换规则),代码会更臃肿,还容易漏写 emit 导致双向绑定失效。

defineModel + get/set 把「值的读取转换」和「值的更新转换」收拢到一个配置对象里,逻辑内聚性更强,读代码时一眼能看到双向绑定的处理规则,维护成本直线下降。

defineModel 的 get/set 背后是怎么和父组件 v-model 联动的?

得从 Vue 的编译和响应式原理说起:

  1. 编译阶段:当子组件用 defineModel({ get, set }) 时,Vue 会把它编译成「带有自定义 getter/setter 的响应式对象」,这个对象的 value 读取时触发 get,赋值时触发 set

  2. 父组件联动:父组件用 v-model 绑定的其实是一个响应式数据(refreactive 里的字段),当子组件调用 model.value = 新值 时,set 先处理新值,然后子组件会触发 emit('update:modelValue', 处理后的值),父组件监听到这个 emit 后,会更新自己的响应式数据。

  3. 父组件更新后子组件同步:父组件数据更新后,会把新值传给子组件的 props.modelValue(这一步是自动的,因为 v-model 本质是 modelValue + @update:modelValue),子组件的 defineModel 变量会读取这个新的 props 值,此时触发 get 处理,所以子组件的显示层也会同步更新。

简单说,get 负责「父 → 子」的值转换,set 负责「子 → 父」的值转换,Vue 编译时自动帮你把这两个过程和 propsemit 关联起来,你只需要关注转换逻辑本身。

用 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 里依赖不稳定的响应式数据。

比如子组件有个 isEditrefget 里根据 isEdit 决定返回值:

const isEdit = ref(false)
const model = defineModel({
  get(val) {
    return isEdit.value ? val : '不可编辑'
  }
})

isEditfalsetrue 时,get 会重新执行,model.value 从「不可编辑」变成父组件的真实值,这会触发父组件更新(因为子组件 model.value 变化了?不,其实是子组件内部 model 的值变化,触发 emit 吗?这里容易混淆 —— get 是「子组件读取 model.value 时触发」,如果子组件模板里用了 model.valuev-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前端网发表,如需转载,请注明页面地址。

热门