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

Vue3开发必问,watch和v-model到底怎么选?新手踩坑全解析+进阶用法干货

terry 1小时前 阅读数 27 #Vue
文章标签 model选择model进阶

我最近半年帮几个初创公司搭Vue3的后台和C端小程序,面试和群里聊技术的时候,这个问题出现频率太高了——要么是刚从Vue2转Vue3的开发者搞不清watchEffect/watch/watchPostFlush的区别顺便连v-model的新语法糖都没搞透,要么是完全零基础入门的,不知道什么时候该监听数据什么时候该用双向绑定,甚至有人把watch放在v-model的回调里用,结果死循环调试了一下午,今天咱们就彻底掰开揉碎讲清楚这俩兄弟(或者说表亲?)的事儿,从基础定义、核心区别、新手常见的7个坑、到后台表单、C端搜索栏这种真实场景的搭配,还有我自己最近琢磨出来的带防抖节流的双向绑定小技巧,全给你整明白。

先别慌选!把两者的本质捏准了再说

很多人学技术总喜欢先背语法再硬套,这很容易踩死,不管是Vue3还是其他前端框架,搞懂“工具是为了解决什么问题而存在的”才是关键,咱们先分别摸透watch和v-model的底层逻辑,区别自然就出来了。

v-model的本质:双向绑定的语法糖合集

很多Vue2转过来的人会觉得Vue3的v-model变复杂了,一会儿冒出来个:model-value一会儿冒出来个@update:model-value,其实换汤不换药,本质还是“父组件传一个prop,子组件抛一个带新值的事件,父组件接收事件把新值赋给prop对应的变量”——对,就是单向数据流的包装,为了让你少写两行代码而已。

不过Vue3确实给v-model加了不少有用的升级,不是简单换名字:

  1. 不再局限于input/textarea/select这些原生表单组件:自定义组件随便用,不管是单选多选框封装,还是表格行内编辑,甚至是C端的点赞收藏组件(对,你没看错,点赞本质也是个0和1的双向绑定)。
  2. 支持多个v-model绑定:这个太香了!比如后台的日期范围选择器,Vue2你可能得传个startTime和endTime,然后子组件抛update:start和update:end,父组件要写两个监听或者把变量合并,Vue3直接v-model:start="dateRange.start" v-model:end="dateRange.end",一行代码搞定双向。
  3. 可以自定义修饰符:原生有.trim/.lazy/.number,现在自定义组件也能自己造,比如后台的金额输入框,你可以造个.money的修饰符,自动把输入的数字转成两位小数,或者自动加千分位,非常方便。

再给你提个醒——v-model虽然是双向的,但底层还是单向数据流!子组件绝对不能直接修改prop,否则Vue3开发环境会直接给你报错,生产环境虽然可能不报错但会有各种不可预知的问题,这个是新手踩坑最多的点之一,后面单独说。

watch的本质:数据变化的“监听器+触发器”

watch的核心逻辑只有一个:“当某个特定的数据发生变化时,执行一段你定义好的代码”,这里的“特定数据”可以是单个ref/reactive,也可以是reactive里的某个属性,甚至可以是一个返回值的函数(比如你想监听某个计算属性的变化,或者想同时监听多个数据中只要有一个变就触发);这里的“变化”也有讲究,默认是浅监听(reactive里的属性如果是对象/数组,只换地址才触发,改属性里的内容不触发),你可以传deep: true改成深监听,也可以传immediate: true让它初始化的时候就执行一次。

Vue3比Vue2多了个watchEffect,很多人搞不清watch和watchEffect的区别:watch是“明确告诉Vue我要监听谁,等它变了再执行”,watchEffect是“自动追踪代码里用到的所有响应式数据,不管是谁变了都执行”,举个简单的例子,你有个搜索栏,里面有输入的关键词、筛选的分类、选择的页码,三个都要变了才发请求——watch需要把这三个都放在数组里当监听源,watchEffect直接在回调里写请求的代码,自然就追踪到这三个变量了,不过watchEffect有时候会有“多余触发”的问题,比如你不小心在回调里用了一个无关的响应式变量,它也会跟着跑,这时候watch的精准性就体现出来了,另外Vue3还有watchPostFlush和watchSyncFlush,前者是DOM更新后执行,后者是同步执行(一般别用,会阻塞渲染),这些进阶用法后面也会讲。

新手踩过的7个“致命”坑,你中了几个?

我整理了最近半年遇到的新手问题,挑出了最典型、最容易浪费时间的7个,咱们一个一个排雷。

踩坑1:子组件直接修改v-model对应的prop

这个绝对是第一名!昨天还在群里看到一个应届生问:“为什么我改了子组件里的modelValue,父组件没反应,开发环境还报错?”原因刚才说了,Vue是单向数据流,子组件只能读prop,不能直接改,必须抛@update:modelValue事件,举个错误例子和正确例子对比一下: 错误代码(自定义输入框组件):

<!-- 错误!直接修改prop -->
<template>
  <input type="text" v-model="modelValue" />
</template>
<script setup>
const props = defineProps(['modelValue'])
</script>

正确代码:

<!-- 正确!抛事件 -->
<template>
  <input 
    type="text" 
    :value="modelValue" 
    @input="$emit('update:modelValue', $event.target.value)" 
  />
</template>
<script setup>
const props = defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])
</script>

或者你可以用computed的getter和setter来简化,这样更像用v-model:

<!-- 用computed简化自定义v-model -->
<template>
  <input type="text" v-model="inputValue" />
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])
const inputValue = computed({
  get() {
    return props.modelValue
  },
  set(val) {
    emit('update:modelValue', val)
  }
})
</script>

踩坑2:把v-model绑定到reactive的根对象上

比如你写了个自定义登录框组件,父组件传了个reactive的userInfo对象:

<!-- 父组件错误绑定 -->
<template>
  <LoginForm v-model="userInfo" />
</template>
<script setup>
import { reactive } from 'vue'
import LoginForm from './LoginForm.vue'
const userInfo = reactive({ username: '', password: '' })
</script>

子组件里这么写:

<!-- 子组件错误接收 -->
<template>
  <input type="text" v-model="userInfo.username" />
  <input type="password" v-model="userInfo.password" />
</template>
<script setup>
const props = defineProps(['modelValue'])
</script>

乍一看好像没问题?开发环境也没报错?因为你直接修改了prop里的对象属性,Vue3开发环境对这种情况是“半警告半放行”的——它只禁止直接替换prop的对象/数组地址,不禁止修改属性内容,但这依然违反单向数据流!万一以后你父组件把userInfo从reactive改成ref对象(比如为了reset的时候更方便,直接userInfo.value = { ... }),子组件就炸了,正确的做法还是刚才说的,要么传单个属性,要么用多个v-model,要么子组件里computed转换。

踩坑3:watch监听ref的时候忘了加.value

这个坑主要出现在刚从Vue2转Vue3的人身上,Vue2的data里的变量直接用,Vue3的ref要加.value,但要注意!在模板里不用加,在setup函数里的普通JavaScript代码里要加,在watch的监听源里分情况:

  • 如果是单个ref,监听源可以直接写ref变量,Vue3会自动解包,不用加.value;
  • 如果是ref里的属性(比如你有个ref的userInfo对象,想监听userInfo.value.username),或者是返回ref属性的函数,那必须加.value或者把整个逻辑放在函数里;
  • 如果是数组里的ref,比如监听多个ref,直接放数组里就行,不用加.value。 举个例子:
    <script setup>
    import { ref, watch } from 'vue'
    const count = ref(0)
    const userInfo = ref({ username: '张三', age: 18 })

// 单个ref,正确,自动解包 watch(count, (newVal, oldVal) => { console.log('count变了', newVal, oldVal) })

// 监听ref里的属性,第一种写法:函数返回属性值,正确 watch(() => userInfo.value.username, (newVal, oldVal) => { console.log('username变了', newVal, oldVal) })

// 监听ref里的属性,第二种写法:直接加.value,也正确 watch(userInfo.value.age, (newVal, oldVal) => { console.log('age变了', newVal, oldVal) })

// 多个ref,正确,自动解包 watch([count, userInfo], ([newCount, newUserInfo], [oldCount, oldUserInfo]) => { console.log('多个数据变了', newCount, newUserInfo, oldCount, oldUserInfo) })

```

踩坑4:watch监听reactive的根对象时deep: true没用

很多新手会遇到这种情况:监听reactive的根对象,默认浅监听,换对象地址才触发,加了deep: true,改对象里的属性也触发了——但这不是deep: true的功劳!因为Vue3对reactive的根对象默认就是深监听的!那为什么会有人觉得没用呢?因为他监听的是“解构后的reactive属性”!

<script setup>
import { reactive, watch } from 'vue'
const userInfo = reactive({ username: '张三', age: 18 })
const { username, age } = userInfo // 解构后变成普通变量了,不是响应式的!
// 解构后的变量,不是响应式的,加deep: true也没用
watch(username, (newVal, oldVal) => {
  console.log('username变了', newVal, oldVal) // 永远不会触发
})
</script>

那如果非要解构reactive的属性怎么办?用toRefs或者toRef!toRefs把reactive的所有属性都转成ref,toRef只转单个:

<script setup>
import { reactive, toRefs, toRef, watch } from 'vue'
const userInfo = reactive({ username: '张三', age: 18 })
const { username: usernameRef } = toRefs(userInfo)
const ageRef = toRef(userInfo, 'age')
// 现在是ref了,正确监听
watch(usernameRef, (newVal, oldVal) => {
  console.log('username变了', newVal, oldVal)
})
</script>

踩坑5:watch和v-model同时用导致死循环

这个坑也很常见,比如你写了个搜索栏,输入关键词后要把关键词转成小写,然后再执行搜索,你可能会这么写:

<!-- 错误!死循环 -->
<template>
  <input type="text" v-model="keyword" />
</template>
<script setup>
import { ref, watch } from 'vue'
const keyword = ref('')
// 监听keyword,转小写后重新赋值
watch(keyword, (newVal) => {
  keyword.value = newVal.toLowerCase()
  // 然后执行搜索...
})
</script>

乍一看好像没问题?但当你输入大写字母的时候,比如输入“A”,keyword.value变成“A”,触发watch,把keyword.value改成“a”,又触发watch,又把“a”改成“a”——哦,不对,第二次改成“a”的时候,newVal和oldVal都是“a”,应该不会触发吧?但有些情况下(比如用了computed或者其他响应式数据干扰),或者你转成的不是完全一样的字符串(比如转小写的时候带了空格处理,第一次带空格,第二次不带,又触发),就会导致死循环,正确的做法是在监听的时候加个判断,或者直接用computed的getter:

<!-- 正确!加判断 -->
<template>
  <input type="text" v-model="keyword" />
</template>
<script setup>
import { ref, watch } from 'vue'
const keyword = ref('')
watch(keyword, (newVal) => {
  const lowerKeyword = newVal.toLowerCase()
  if (lowerKeyword !== newVal) {
    keyword.value = lowerKeyword
  }
  // 然后执行搜索...
})
</script>

或者用computed更优雅:

<!-- 正确!用computed -->
<template>
  <input type="text" v-model="inputKeyword" />
</template>
<script setup>
import { ref, computed, watch } from 'vue'
const inputKeyword = ref('')
const lowerKeyword = computed(() => inputKeyword.value.toLowerCase())
// 直接监听lowerKeyword执行搜索
watch(lowerKeyword, (newVal) => {
  // 执行搜索...
})
</script>

踩坑6:自定义v-model修饰符的时候忘了接收modifiers

比如你想造个.money的修饰符,自动把输入的数字转成两位小数,你可能会这么写:

<!-- 父组件正确绑定修饰符 -->
<template>
  <MoneyInput v-model.money="price" />
</template>
<script setup>
import { ref } from 'vue'
import MoneyInput from './MoneyInput.vue'
const price = ref(0)
</script>

然后子组件里直接判断:

<!-- 错误!没接收modifiers -->
<template>
  <input type="number" :value="modelValue" @input="handleInput" />
</template>
<script setup>
const props = defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])
const handleInput = (e) => {
  let val = Number(e.target.value)
  // 错误!props里没有money修饰符的判断
  if (props.money) {
    val = val.toFixed(2)
  }
  emit('update:modelValue', val)
}
</script>

哦,对了,自定义v-model修饰符的时候,父组件传的修饰符会放在props的modelModifiers里!如果是带参数的v-model,比如v-model:start.money,修饰符会放在startModifiers里!正确的子组件代码:

<!-- 正确!接收modelModifiers -->
<template>
  <input type="number" :value="modelValue" @input="handleInput" />
</template>
<script setup>
const props = defineProps({
  modelValue: {
    type: [Number, String],
    default: 0
  },
  modelModifiers: {
    type: Object,
    default: () => ({}) // 默认是空对象
  }
})
const emit = defineEmits(['update:modelValue'])
const handleInput = (e) => {
  let val = Number(e.target.value)
  if (props.modelModifiers.money) {
    val = val.toFixed(2)
  }
  emit('update:modelValue', val)
}
</script>

踩坑7:watchEffect没有清理副作用

这个坑可能新手暂时遇不到,但做C端项目或者后台带实时数据更新的项目的时候很容易踩,比如你写了个倒计时组件,或者写了个实时获取位置的组件,或者写了个定时发送请求的组件,用watchEffect自动追踪倒计时结束的变量,那你必须在watchEffect的回调里写清理函数,否则组件卸载后,定时器/请求/位置监听还在跑,会导致内存泄漏! 举个实时获取位置的例子:

<!-- 错误!没有清理副作用 -->
<template>
  <div>当前位置:{{ location }}</div>
</template>
<script setup>
import { ref, watchEffect } from 'vue'
const location = ref('正在获取...')
watchEffect(() => {
  const watchId = navigator.geolocation.watchPosition(
    (position) => {
      location.value = `经度:${position.coords.longitude.toFixed(4)},纬度:${position.coords.latitude.toFixed(4)}`
    },
    (error) => {
      location.value = `获取失败:${error.message}`
    }
  )
  // 错误!组件卸载后watchId还在
})
</script>

正确的做法是在watchEffect的回调里返回一个清理函数,当组件卸载或者watchEffect的依赖变化重新执行之前,Vue会自动调用这个清理函数:

<!-- 正确!有清理副作用 -->
<template>
  <div>当前位置:{{ location }}</div>
</template>
<script setup>
import { ref, watchEffect } from 'vue'
const location = ref('正在获取...')
watchEffect((onCleanup) => {
  const watchId = navigator.geolocation.watchPosition(
    (position) => {
      location.value = `经度:${position.coords.longitude.toFixed(4)},纬度:${position.coords.latitude.toFixed(4)}`
    },
    (error) => {
      location.value = `获取失败:${error.message}`
    }
  )
  // 返回清理函数
  onCleanup(() => {
    navigator.geolocation.clearWatch(watchId)
    console.log('位置监听已清理')
  })
})
</script>

真实场景下的搭配技巧:什么时候该用谁?

现在咱们把本质和坑都搞清楚了,接下来聊聊实际开发中怎么选、怎么搭,我总结了3个最常用的场景,给你具体的解决方案。

场景1:后台表单开发:v-model为主,watch为辅

后台表单是Vue3用得最多的场景之一,比如登录注册、商品编辑、订单修改,这里的核心逻辑是“用户输入→实时更新表单数据→提交时验证并发送请求”。

  • 什么时候用v-model:所有的表单输入元素(包括自定义的日期范围选择器、文件上传组件、富文本编辑器),都应该用v-model绑定,这样能大大减少代码量。

  • 什么时候用watch:主要有两个场景:

    1. 表单联动:比如选择商品分类后,自动加载该分类下的商品属性;或者选择省份后,自动加载该省份下的城市和区县。
    2. 表单实时验证:比如输入用户名时,实时检查用户名是否已被注册;或者输入密码时,实时检查密码强度。 举个商品编辑表单联动的例子:
      <!-- 商品编辑表单联动 -->
      <template>
      <div>
      <label>商品分类:</label>
      <select v-model="form.categoryId">
       <option value="">请选择分类</option>
       <option v-for="category in categories" :key="category.id" :value="category.id">
         {{ category.name }}
       </option>
      </select>

    <button @click="submit">提交

```

场景2:C端搜索栏开发:带防抖的v-model + watch

C端搜索栏的核心逻辑是“用户输入→防抖延迟→发送搜索请求→显示搜索结果”,这里如果不用防抖,用户每输入一个字符就发一次请求,会给服务器造成很大的压力,也会影响用户体验。 这里有两种解决方案:

  1. 用watch + 自定义防抖函数
  2. 我自己最近琢磨出来的带防抖的v-model自定义组件。 先给你看第一种解决方案,然后再给你看第二种更优雅的。
解决方案1:watch + 自定义防抖函数
<!-- C端搜索栏:watch + 自定义防抖函数 -->
<template>
  <div>
    <input type="text" v-model="keyword" placeholder="请输入搜索关键词" />
    <div>
      <div v-for="result in searchResults" :key="result.id">
        {{ result.title }}
      </div>
    </div>
  </div>
</template>
<script setup>
import { ref, watch } from 'vue'
const keyword = ref('')
const searchResults = ref([])
let timer = null // 防抖定时器
// 自定义防抖函数
const debounce = (fn, delay) => {
  return (...args) => {
    clearTimeout(timer)
    timer = setTimeout(() => {
      fn.apply(this, args)
    }, delay)
  }
}
// 监听keyword变化,防抖延迟500ms后发送请求
const search = debounce(async (newKeyword) => {
  if (!newKeyword.trim()) {
    searchResults.value = []
    return
  }
  // 这里假设已经有个search的API函数
  const res = await search(newKeyword)
  searchResults.value = res.data
}, 500)
watch(keyword, (newKeyword) => {
  search(newKeyword)
})
// 组件卸载时清理定时器
import { onUnmounted } from 'vue'
onUnmounted(() => {
  clearTimeout(timer)
})
</script>
解决方案2:带防抖的v-model自定义组件(更优雅,可复用)

这个组件可以直接在任何需要防抖输入的地方用,不管是C端搜索栏还是后台的实时筛选,非常方便。

<!-- DebounceInput.vue:带防抖的v-model自定义组件 -->
<template>
  <input 
    type="text" 
    :value="innerValue" 
    @input="handleInput" 
    :placeholder="placeholder"
  />
</template>
<script setup>
import { ref, computed, watch, onUnmounted } from 'vue'
const props = defineProps({
  modelValue: {
    type: String,
    default: ''
  },
  delay: {
    type: Number,
    default: 500
  },
  placeholder: {
    type: String,
    default: ''
  }
})
const emit = defineEmits(['update:modelValue'])
const innerValue = ref(props.modelValue)
let timer = null
// 监听modelValue变化,同步到innerValue
watch(() => props.modelValue, (newVal) => {
  innerValue.value = newVal
})
const handleInput = (e) => {
  innerValue.value = e.target.value
  clearTimeout(timer)
  timer = setTimeout(() => {
    emit('update:modelValue', innerValue.value)
  }, props.delay)
}
// 组件卸载时清理定时器
onUnmounted(() => {
  clearTimeout(timer)
})
</script>

然后在父组件里直接用就行,代码非常简洁:

<!-- 父组件使用DebounceInput -->
<template>
  <div>
    <DebounceInput v-model="keyword" placeholder="请输入搜索关键词" :delay="300" />
    <div>
      <div v-for="result in searchResults" :key="result.id">
        {{ result.title }}
      </div>
    </div>
  </div>
</template>
<script setup>
import { ref, watch } from 'vue'
import DebounceInput from './DebounceInput.vue'
const keyword = ref('')
const searchResults = ref([])
// 直接监听keyword变化发送请求,不用再写防抖了!
watch(keyword, async (newKeyword) => {
  if (!newKeyword.trim()) {
    searchResults.value = []
    return
  }
  const res = await search(newKeyword)
  searchResults.value = res.data
})
</script>

场景3:C端点赞收藏组件:多个v-model + watchPostFlush

这个场景可能用v-model的人不多,但用了之后会非常方便,点赞收藏组件的核心逻辑是“用户点击按钮→切换点赞/收藏状态→发送请求更新服务器→请求成功/失败后处理UI”。

  • 为什么用多个v-model:因为点赞和收藏是两个独立的状态,用v-model:liked和v-model:collected可以直接在父组件里控制初始状态,也可以直接获取最新状态。
  • 为什么用watchPostFlush:因为我们希望先更新UI(给用户反馈,比如点赞按钮变红色),然后再发送请求,这样用户体验更好。 举个例子:
    <!-- LikeCollectButton.vue:点赞收藏组件 -->
    <template>
    <div>
      <button 
        :class="{ active: liked }" 
        @click="toggleLike"
      >
        👍 {{ likeCount }}
      </button>
      <button 
        :class="{ active: collected }" 
        @click="toggleCollect"
      >
        📦 {{ collectCount }}
      </button>
    </div>
    </template>
``` 然后在父组件里直接用: ```vue ```

最后总结一下:记住这5句话,再也不用纠结怎么选

  1. v-model是双向绑定的语法糖,本质是单向数据流,子组件只能抛事件不能直接改prop
  2. watch是数据变化的监听器+触发器,精准监听特定数据,适合表单联动、实时验证、副作用处理
  3. watchEffect自动追踪响应式数据,适合依赖多个数据的场景,但要注意清理副作用
  4. 后台表单用v-model为主,watch为辅
  5. C端交互场景可以用自定义v-model组件简化代码,搭配watchPostFlush提升用户体验。 就讲到这里,如果你还有其他Vue3的问题,或者想了解更多的进阶用法,可以在评论区留言,我会一一回复,觉得有用的话,别忘了点赞收藏转发哦!

版权声明

本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。

热门
最新文章
标签列表