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

Vue3 watch监听form表单数据变化的正确姿势

terry 50分钟前 阅读数 23 #Vue

你是不是刚接触Vue3就踩过form表单监听的坑?明明已经写了watch,要么全表单数据动一下就疯狂触发,要么改了嵌套的输入框完全没反应,要么重置表单还跟着报错?别慌,今天这篇问答式内容,咱们把Vue3监听form表单的所有场景、正确写法、避坑技巧,连带着优化方案都聊透。

为什么Vue3原生form监听会出问题?和Vue2有啥不一样?

先得搞懂根因,不然换再多写法也白搭,很多人直接把Vue2里监听form对象的经验搬过来,这就错了——Vue2默认用的是响应式API的Object.defineProperty,只能监听到对象已有属性的直接修改,嵌套属性得加deep:true;可Vue3用Proxy代理整个对象,理论上能直接监听嵌套属性,但为啥还是容易踩坑?

第一个原因是监听对象本身和监听对象引用的区别,Vue3里如果用ref定义form,你要监听的是form.value的变化,但很多人漏写.value直接写watch(form),触发条件就变成了form这个ref的引用地址被替换(比如重置时给form重新赋值{},不是清空属性),改输入框值完全没反应;如果用reactive定义form还好点,但重置的场景又不一样了。

第二个原因是响应式代理的层级细节,Vue3的Proxy确实能深度代理,但如果你的form里有数组元素嵌套对象,或者动态添加/删除属性(比如先定义form={name:''},后来又加了form.age=18),早期写法可能需要用watchEffect或者特定的watch选项?不对,后来Vue3.3+优化了很多,但基础细节还是要注意。

第三个原因是防抖动节流的应用场景混淆,全表单监听加防抖动没问题,但如果你要监听单个必填项触发状态判断,或者监听特定字段调用后端接口验证唯一性,这时候全局加防抖反而会拖慢交互。

Vue3中定义form表单推荐用ref还是reactive?

聊监听之前得先选好表单的定义方式,不然监听方案得跟着变,现在主流社区推荐用ref包裹普通对象+解构赋值toRefs?不对,或者直接用reactive?各有优劣,咱们分场景说:

如果你用Element Plus、Ant Design Vue这类UI组件库的Form组件,官方推荐直接用reactive,因为UI组件库的v-model绑定对象属性更自然,不需要每次加.value,而且组件内部可能对reactive对象有优化处理;但重置的时候不能直接给form赋值{},因为这样会破坏Proxy代理,正确做法是用Object.assign(form, {初始对象})或者组件自带的resetFields()方法(前提是Form组件绑定了model和ref)。

如果你只是做一个简单的原生HTML表单,或者要频繁解构form的单个属性给子组件传值,推荐用ref包裹普通对象,然后用toRefs把每个属性转成ref传给子组件——这样既能保持响应式,又避免了子组件修改父组件reactive对象带来的“隐式修改”问题,代码更清晰。

举个reactive定义Element Plus Form的例子吧,这个最常用:

<template>
  <el-form ref="formRef" :model="loginForm" :rules="loginRules" label-width="80px">
    <el-form-item label="用户名" prop="username">
      <el-input v-model="loginForm.username" placeholder="请输入用户名"></el-input>
    </el-form-item>
    <el-form-item label="密码" prop="password">
      <el-input v-model="loginForm.password" type="password" placeholder="请输入密码"></el-input>
    </el-form-item>
  </el-form>
</template>
<script setup>
import { reactive, ref } from 'vue'
// 用reactive定义表单数据
const loginForm = reactive({
  username: '',
  password: ''
})
// 用ref定义表单实例,方便调用resetFields等方法
const formRef = ref(null)
// 这里可以加rules,暂时先省略
</script>

单个字段监听:比如用户名输入时验证唯一性?

这是最常见的单个场景,比如电商平台注册时,输入完用户名离开焦点或者实时显示“该用户名已被占用”,这时候别监听整个form,只监听单个属性,效率最高。

如果用reactive定义的form,直接写watch(() => loginForm.username, callback)就行——注意这里用了箭头函数返回属性值,这是Vue3监听reactive对象单个属性的标准写法,比直接写loginForm.username效率高吗?其实现在性能优化得差不多了,但箭头函数的方式更明确,也避免了一些边界情况(比如loginForm本身被替换?不过reactive一般不建议替换)。

如果用ref包裹的form,那可以直接写watch(() => form.value.username, callback),或者先const { username } = toRefs(form.value),再watch(username, callback)——两种方式效果一样,看你怎么方便。

单个字段监听可以加哪些选项?常用的有immediate:true(组件初始化时就执行一次,比如编辑页面回显数据后直接显示必填状态)、flush:'post'(等DOM更新完再执行,比如需要获取输入框的scrollTop)、防抖节流函数。

重点说下防抖节流在单个字段接口验证中的应用:接口验证不能每次敲键盘都发,太浪费服务器资源,一般用防抖(debounce),延迟300-500ms发一次;节流(throttle)一般不用在表单单个字段验证,用在比如搜索框滚动加载这类场景,Vue3里没有内置防抖节流函数,你可以自己写一个简单的,或者用第三方库比如lodash-es的debounce/throttle——记得用lodash-es,因为它是ES模块,支持Tree Shaking,不会把整个lodash包打包进来。

举个用lodash-es防抖验证用户名的例子:

import { reactive, watch } from 'vue'
import { debounce } from 'lodash-es'
// 模拟后端验证接口
const checkUsernameUnique = async (username) => {
  if (!username) return false
  // 这里替换成你的真实接口
  const res = await fetch(`/api/check-username?username=${username}`)
  const data = await res.json()
  return data.isUnique
}
// 定义表单
const loginForm = reactive({
  username: '',
  password: ''
})
// 定义验证状态
const usernameUnique = reactive({
  loading: false,
  isUnique: true,
  message: ''
})
// 防抖验证函数
const debouncedCheck = debounce(async (newVal) => {
  if (!newVal) {
    usernameUnique.loading = false
    usernameUnique.isUnique = true
    usernameUnique.message = ''
    return
  }
  usernameUnique.loading = true
  usernameUnique.message = '正在验证...'
  try {
    const isUnique = await checkUsernameUnique(newVal)
    usernameUnique.isUnique = isUnique
    usernameUnique.message = isUnique ? '用户名可用' : '该用户名已被占用'
  } catch (err) {
    usernameUnique.isUnique = false
    usernameUnique.message = '验证失败,请稍后重试'
  } finally {
    usernameUnique.loading = false
  }
}, 500)
// 监听username变化,组件销毁时记得取消防抖!
watch(() => loginForm.username, debouncedCheck)

哦对了,这里有个避坑点:如果用了lodash-es的debounce,组件销毁时必须手动调用debouncedCheck.cancel(),否则防抖函数的定时器不会被清理,可能会在组件销毁后还执行接口请求,导致控制台报错或者内存泄漏,那怎么在setup里处理组件销毁?用onUnmounted钩子,别忘了引入:

import { onUnmounted } from 'vue'
// 组件销毁时取消防抖
onUnmounted(() => {
  debouncedCheck.cancel()
})

全表单监听:比如自动保存草稿?

全表单监听最典型的场景就是自动保存草稿了,比如写博客、填问卷的时候,隔一段时间自动保存到localStorage或者后端,防止刷新页面数据丢失,这时候就需要监听整个form对象的所有变化。

全表单监听的写法也要分form是ref还是reactive:如果是reactive,直接写watch(loginForm, callback, { deep: true });如果是ref,直接写watch(form, callback, { deep: true })——这里不用加箭头函数也不用加.value吗?对,因为watch会自动解包ref的第一层,监听其.value的变化,但如果是嵌套属性,不管是ref还是reactive都必须加deep:true。

不过这里有个性能问题:全表单加deep:true,只要有任何一个嵌套属性变化,watch都会触发,哪怕是一个字符的修改,如果表单很大,嵌套很深,这会影响页面的响应速度,那怎么优化?

第一个优化方案是减少监听范围:只监听需要保存的字段,比如博客的标题、内容、标签,而不需要监听提交状态、验证状态这类临时字段,这时候可以用箭头函数返回一个包含需要保存字段的对象,然后加deep:true:

watch(
  () => ({ blogForm.title,
    content: blogForm.content,
    tags: blogForm.tags
  }),
  debouncedSaveDraft,
  { deep: true, immediate: true } // immediate:true组件初始化时加载localStorage的草稿
)

第二个优化方案是用watchEffect替代watch加deep:true?不对,watchEffect也是深度监听吗?不,watchEffect默认只跟踪回调函数中用到的响应式依赖,不会深度监听整个对象——比如你在watchEffect里只取了blogForm.title,那它只会监听title的变化;但如果你取了整个blogForm或者blogForm.content(如果content是数组或对象),那它会深度监听,所以如果只监听几个字段,watch和watchEffect都可以,看你习惯;如果是复杂的依赖收集,watchEffect更方便。

举个用localStorage自动保存博客草稿的例子,结合优化方案:

import { reactive, watch, onMounted } from 'vue'
import { debounce } from 'lodash-es'
// 初始草稿对象
const INITIAL_DRAFT = { '',
  content: '',
  tags: [],
  category: ''
}
// 从localStorage加载草稿
const loadDraft = () => {
  const savedDraft = localStorage.getItem('blog-draft')
  return savedDraft ? JSON.parse(savedDraft) : INITIAL_DRAFT
}
// 定义博客表单
const blogForm = reactive(loadDraft())
// 定义是否有未保存的修改(可选,给用户提示)
const hasUnsavedChanges = reactive(false)
// 防抖保存草稿到localStorage
const debouncedSave = debounce((newVal) => {
  localStorage.setItem('blog-draft', JSON.stringify(newVal))
  hasUnsavedChanges = false
  console.log('草稿已自动保存')
}, 2000)
// 监听需要保存的字段,加immediate组件初始化时如果有旧草稿先显示hasUnsavedChanges?不对,初始化时加载的旧草稿不算未保存的,所以可以在回调里加判断:
watch(
  () => ({ blogForm.title,
    content: blogForm.content,
    tags: blogForm.tags,
    category: blogForm.category
  }),
  (newVal, oldVal) => {
    // 只有newVal和oldVal不一样的时候才设置hasUnsavedChanges和保存
    // 这里用JSON.stringify比较简单,但如果有函数、Symbol、undefined这类属性会有问题,表单一般没有,所以可以用
    if (JSON.stringify(newVal) !== JSON.stringify(oldVal)) {
      hasUnsavedChanges = true
      debouncedSave(newVal)
    }
  },
  { deep: true }
)
// 手动保存草稿的方法(可选,配合提交按钮前面的“保存草稿”按钮)
const saveDraftManually = () => {
  const toSave = { blogForm.title,
    content: blogForm.content,
    tags: blogForm.tags,
    category: blogForm.category
  }
  localStorage.setItem('blog-draft', JSON.stringify(toSave))
  hasUnsavedChanges = false
  console.log('草稿已手动保存')
}
// 提交成功后清除草稿
const submitBlog = async () => {
  // 这里替换成你的真实提交接口
  await fetch('/api/submit-blog', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(blogForm)
  })
  localStorage.removeItem('blog-draft')
  hasUnsavedChanges = false
  console.log('博客提交成功')
}
// 页面刷新前给用户提示(可选,用window.addEventListener)
onMounted(() => {
  window.addEventListener('beforeunload', (e) => {
    if (hasUnsavedChanges) {
      e.preventDefault()
      e.returnValue = ''
      return ''
    }
  })
})

这里加了一个hasUnsavedChanges的判断,很有用——一是给用户在页面上显示“有未保存的修改”的提示,二是防止watch初始化时就触发保存(因为immediate设为false,只有修改了才触发),三是配合beforeunload事件防止用户不小心关闭页面。

还有一个更高级的优化方案:用shallowRef包裹不需要深度监听的字段,比如表单里的一个静态对象(只是展示,不会修改的),用shallowRef包裹后,Vue3只会监听其引用地址的变化,不会深度监听内部属性,能提升性能,不过这个方案用得比较少,一般表单都是动态的,了解一下就行。

重置表单时watch会触发吗?怎么避免?

这也是一个常见的避坑点:比如用Element Plus的resetFields()方法重置表单,或者用Object.assign(form, INITIAL_DRAFT)重置,这时候全表单的watch会触发吗?

答案是:!因为不管是resetFields()还是Object.assign,都是修改form对象的属性值,不是替换引用地址(如果是reactive的话替换引用地址会破坏代理),所以加了deep:true的watch会触发,导致自动保存的草稿被覆盖成初始值,这可不是我们想要的。

那怎么避免?有两个方法:

第一个方法是加一个“重置中”的标志位,在重置前把标志位设为true,重置回调里判断标志位,如果是true就不执行保存操作,重置完再把标志位设为false,举个例子:

import { reactive, watch, ref } from 'vue'
// 加一个重置标志位
const isResetting = ref(false)
// 重置表单的方法
const resetForm = () => {
  isResetting.value = true
  // 这里用Element Plus的resetFields()或者Object.assign
  Object.assign(blogForm, INITIAL_DRAFT)
  // 或者 formRef.value.resetFields()
  // 重置完再把标志位设为false
  isResetting.value = false
}
// 修改watch回调,加判断
watch(
  () => ({ blogForm.title,
    content: blogForm.content,
    tags: blogForm.tags,
    category: blogForm.category
  }),
  (newVal, oldVal) => {
    if (isResetting.value) return
    if (JSON.stringify(newVal) !== JSON.stringify(oldVal)) {
      hasUnsavedChanges = true
      debouncedSave(newVal)
    }
  },
  { deep: true }
)

第二个方法是在重置时用nextTick延迟执行标志位的设置,或者清空localStorage的草稿后再重置——不过第一个方法更稳妥,适用于所有场景。

动态添加/删除form属性时,watch能监听到吗?

这个问题在Vue2里很麻烦,因为Object.defineProperty不能监听动态添加/删除的属性,必须用$set/$delete;但在Vue3里,Proxy可以直接监听动态添加/删除的属性,不管是ref还是reactive定义的form,只要加了deep:true(如果是监听整个对象的话)。

不过如果你只监听单个属性,动态添加的属性当然监听不到,因为watch里根本没跟踪这个依赖;但如果你用watchEffect,并且在回调里用到了整个form对象,那动态添加的属性变化也会触发watchEffect。

举个动态添加删除form属性的例子,比如一个可以动态添加联系方式的表单:

<template>
  <el-form :model="contactForm" label-width="80px">
    <el-form-item label="姓名" prop="name">
      <el-input v-model="contactForm.name"></el-input>
    </el-form-item>
    <div v-for="(contact, index) in contactForm.contacts" :key="index">
      <el-form-item :label="`联系方式${index+1}`">
        <el-input v-model="contact.value" style="width: 300px; margin-right: 10px;"></el-input>
        <el-button type="danger" @click="removeContact(index)">删除</el-button>
      </el-form-item>
    </div>
    <el-button type="primary" @click="addContact">添加联系方式</el-button>
  </el-form>
</template>
<script setup>
import { reactive, watch } from 'vue'
// 定义动态表单,contacts是数组
const contactForm = reactive({
  name: '',
  contacts: [
    { type: 'phone', value: '' } // 初始一个电话联系方式
  ]
})
// 添加联系方式
const addContact = () => {
  contactForm.contacts.push({ type: 'email', value: '' }) // 可以根据需要改type
}
// 删除联系方式
const removeContact = (index) => {
  contactForm.contacts.splice(index, 1)
}
// 监听整个contactForm,包括动态添加的contacts元素和属性
watch(contactForm, (newVal) => {
  console.log('表单变化了:', newVal)
}, { deep: true })
</script>

你可以试试这个代码,添加/删除联系方式、修改联系方式的值、修改姓名,watch都会触发,完全没问题。

总结一下Vue3 watch form的最佳实践

聊了这么多场景,最后给大家整理一下最佳实践,方便以后直接套用:

  1. 定义表单优先选reactive,尤其是配合UI组件库的Form组件,用resetFields()重置很方便;如果要频繁解构给子组件传值,用ref包裹普通对象+toRefs。
  2. 单个字段监听用箭头函数返回属性值,UI组件库验证可以加flush:'post',接口验证必须加防抖和手动取消防抖。
  3. 全表单监听优先减少监听范围,只监听需要保存/判断的字段,加deep:true,用标志位避免重置时触发,加hasUnsavedChanges给用户提示。
  4. 动态添加/删除属性不用额外处理,Vue3的Proxy已经帮你搞定了,只要加了deep:true或者用了watchEffect跟踪整个对象。
  5. 避免滥用deep:true,只在必要的时候加,减少不必要的性能消耗。

按照这些最佳实践来写Vue3的form监听,基本不会再踩坑了,如果你还有其他Vue3监听相关的问题,欢迎在评论区留言讨论。

版权声明

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

热门