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

Vue3 用 TypeScript 写 watch 时总踩坑?这几个核心细节搞懂就丝滑了

terry 4小时前 阅读数 49 #Vue

日常用 Vue3 开发,尤其是加上 TypeScript 做类型安全保障后,watch 这个高频 API 经常给开发者“挖坑”:要么监听的响应式数据类型识别不对报错,要么深层属性监听和对象监听搞混,要么监听回调的参数类型不够精确,要么是和 computed 或者 watchEffect 的使用场景傻傻分不清楚,作为一个从 Vue2 转过来、踩坑踩了半年才把 TS 版 watch 用明白的前端开发,今天就结合日常开发的真实场景,把这些问题挨个拆解清楚,最后再给大家一套我自己整理的“TS 版 watch 最佳实践清单”。

核心细节一:搞懂 Vue3 响应式数据在 TS 里的类型标注,是 watch 用对的前提

很多时候 watch 报错,根本不是 watch 本身的问题,而是你要监听的数据,类型标注得不对——Vue3 里的响应式数据,ref、reactive、shallowRef、shallowReactive、toRef、toRefs 这些,在 TypeScript 里的类型完全不一样,标错一个,后续的 watch 肯定要出问题。

先从最常用的 ref 和 reactive 说起吧,ref 是用来包装基本类型(string、number、boolean),或者需要单独响应式变化的复杂类型(比如一个临时创建的、不需要整体解构的对象)的,在 TS 里,ref 可以通过泛型来标注类型,也可以利用 TypeScript 的类型推断——但要注意,ref 的初始值是 null 或者 undefined,必须显式加泛型,不然类型会推断成 never,后续 watch 根本没法传回调参数,举个例子:

// ❌ 错误示例:初始值是 null,不加泛型的话,nameRef 的类型是 Ref<never>
const nameRef = ref(null)
// 后续 watch 回调里的 newName 和 oldName 都是 never,根本用不了
watch(nameRef, (newName, oldName) => {
  console.log(newName.length) // 直接报错:never 上没有 length 属性
})
// ✅ 正确示例:显式给 ref 加泛型,说明它最终是 string 或者 null
const nameRef = ref<string | null>(null)
// 后续 watch 回调里的参数类型就对了
watch(nameRef, (newName, oldName) => {
  if (newName) {
    console.log(newName.length) // 没问题,这里 newName 已经被类型守卫处理成 string 了
  }
})

然后是 reactive,它是用来包装需要整体解构、多个属性联动变化的复杂类型(比如表单数据、页面状态对象)的,reactive 在 TS 里不能用泛型吗?也能,但更多时候是靠初始值的类型推断,或者给初始值加一个接口/类型别名——但要注意,reactive 包装的对象,不能直接被赋值为另一个对象,不然会丢失响应性,这时候 TypeScript 虽然不会报错(因为对象本身的类型还是对的),但 watch 监听整个 reactive 对象就会失效,那怎么办?要么用 Object.assign 或者展开运算符合并对象,要么把整个对象用 ref 包装起来——这时候 ref 的类型就是 Ref,后续赋值可以直接替换,watch 也能正常监听。

再补充两个容易搞混的:shallowRef 和 shallowReactive,shallowRef 是只监听 ref 的.value 本身的变化,不管.value 内部的属性变化;shallowReactive 是只监听对象第一层属性的变化,不管深层属性的变化,这两个 API 在 TS 里的类型标注和 ref、reactive 差不多,但用 watch 的时候,监听策略一定要和它们的响应式层级匹配——比如用 shallowRef 包装了一个表单对象,你想监听表单里的某个输入框的值变化,就不能直接 watch(shallowForm.value.input),除非你用 toRef 把 input 单独提出来变成一个响应式引用,不然 shallowRef 只会在 shallowForm.value 被整体替换时触发 watch。

核心细节二:watch 的三种监听源写法,在 TS 里分别要注意什么?

Vue3 的 watch 支持三种监听源:单个响应式数据、多个响应式数据组成的数组、返回响应式数据的 getter 函数,这三种写法在 Vue2 里也有,但在 TS 里,监听源的类型会直接影响 watch 回调参数的类型,所以必须分清楚。

第一种:单个响应式数据,这是最简单的写法,监听源可以是 ref、computed、shallowRef、toRef 这些生成的响应式引用,在 TS 里,watch 会自动根据监听源的类型,推断出回调参数 newVal 和 oldVal 的类型——但要注意,如果监听的是 reactive 对象的某个属性(比如直接写 reactiveState.input),那其实是把这个属性的当前值传给了 watch,后续 reactiveState.input 变化时,watch 根本不会触发!那怎么监听 reactive 对象的单个属性?要么用 toRef 把它单独提出来,要么用第三种监听源写法:返回属性的 getter 函数。

第二种:多个响应式数据组成的数组,这时候监听源里的每个元素,都必须是第一种写法里的合法监听源——不能有普通的非响应式数据,在 TS 里,watch 会自动把回调参数 newVal 和 oldVal 推断成一个元组(Tuple),元组里每个元素的类型,和监听源数组里对应位置的响应式数据的类型一一对应,这时候 TypeScript 的优势就体现出来了:你不用像在 Vue2 里那样,自己去记数组里第几个元素是什么类型,TS 会自动提示你,而且如果你写错了索引,直接就会报错,举个例子:

interface User {
  name: string
  age: number
}
const nameRef = ref<string>('张三')
const ageRef = ref<number>(18)
// ✅ 正确示例:监听源是数组,回调参数是元组,类型自动对应
watch([nameRef, ageRef], ([newName, newAge], [oldName, oldAge]) => {
  console.log(`新名字:${newName},新年龄:${newAge}`) // newName 是 string,newAge 是 number,不会错
})

第三种:返回响应式数据的 getter 函数,这个写法的灵活性最高——可以监听 reactive 对象的单个或多个深层属性,可以监听多个响应式数据的组合结果,可以监听非响应式数据但结合响应式数据计算出来的值……但在 TS 里,这个写法最容易踩坑:因为 getter 函数返回的类型,必须是可以被 Vue3 监听到变化的类型,而且如果返回的是对象或数组,默认情况下 watch 只会监听引用的变化,不会监听内部属性的变化,那怎么让它监听内部属性的变化?加第三个参数 options 里的 deep: true 就行——但要注意,deep: true 会降低性能,所以除非万不得已,不要随便加,尤其是监听非常大的深层嵌套对象的时候。

用 getter 函数监听 reactive 对象的单个深层属性时,在 TS 里类型推断也很方便——举个例子:

interface User {
  name: string
  age: number
  address: {
    province: string
    city: string
    district: string
  }
}
const userState = reactive<User>({
  name: '张三',
  age: 18,
  address: {
    province: '广东省',
    city: '深圳市',
    district: '南山区'
  }
})
// ✅ 正确示例:用 getter 函数监听 userState.address.city,不加 deep: true 也能触发
// 因为 address.city 虽然是深层属性,但 getter 函数返回的是这个属性的具体值(基本类型 string)
// Vue3 会自动追踪这个 getter 函数里用到的响应式数据,所以不用加 deep
watch(() => userState.address.city, (newCity, oldCity) => {
  console.log(`新城市:${newCity}`) // newCity 自动推断成 string
})

这里一定要敲黑板划重点:getter 函数返回的是基本类型或者简单的非响应式组合值,不用加 deep: true;如果返回的是对象、数组、Set、Map 这些引用类型,而且你想监听内部属性/元素的变化,才需要加 deep: true——这一点不管是在 Vue2 还是 Vue3 里都一样,但在 TS 里,你加不加 deep,对回调参数的类型没有影响,只会对 watch 的触发时机有影响。

核心细节三:watch 的第三个参数 options,在 TS 里有哪些类型安全的用法?

Vue3 的 watch 第三个参数 options,有几个常用的属性:deep、immediate、flush、once,这些属性在 Vue2 里也有,但在 TS 里,Vue3 官方给 options 定义了一个 WatchOptions 接口,所以你可以放心地用这些属性,不用怕拼写错误或者属性值不对——TS 会自动给你提示。

先说说 immediate: true,这个属性的作用是让 watch 在组件挂载(或者 watch 创建)的时候,立即执行一次回调函数,这时候 oldVal 的值是什么?如果是单个基本类型的 ref 或者 getter 函数返回的基本类型,且初始值有显式标注,那 oldVal undefined;如果是 ref 初始值不是 null/undefined,或者是 reactive 对象的属性,或者是 shallowRef 包装的对象,那 oldVal 就是初始值,在 TS 里,不管 immediate 是不是 true,oldVal 的类型都会和 newVal 一样——但如果 immediate 是 true,你在回调函数里用 oldVal 之前,最好加一个类型守卫,判断它是不是 undefined,不然会报错(如果监听源的类型里没有包含 undefined 的话)。

再说说 flush 属性,这个属性是 Vue3 新增的,用来控制 watch 回调函数的执行时机,有三个可选值:'pre'(默认,组件更新前执行)、'post'(组件更新后执行,可以访问到更新后的 DOM)、'sync'(同步执行,只要监听源变化就立即执行,性能最差,慎用),在 TS 里,Vue3 官方把 flush 的可选值定义成了一个联合类型 'pre' | 'post' | 'sync',所以你拼写错了或者填了其他值,TS 都会直接报错。

然后说说 once: true,这个属性也是 Vue3 新增的,作用是让 watch 只执行一次回调函数,执行完之后自动销毁 watch,这个属性在日常开发中很有用,比如监听某个路由参数的变化,只需要在第一次变化时做初始化操作,之后就不需要了——这时候加个 once: true,就不用自己手动去调用 stop 函数销毁 watch 了。

最后补充一个容易被忽略的细节:watch 的返回值是一个 stop 函数,用来手动销毁 watch,在 TS 里,这个 stop 函数的类型是 () => void,所以你可以放心地把它赋值给一个变量,然后在组件卸载或者不需要监听的时候调用它——比如在 onUnmounted 生命周期钩子函数里调用,防止内存泄漏。

核心细节四:TS 版 watch 和 computed、watchEffect 的使用场景,到底怎么区分?

很多开发者在 Vue3 里会搞混 watch、computed、watchEffect 的使用场景,尤其是加上 TypeScript 之后,觉得它们好像都能做类型安全的响应式逻辑——其实不然,它们三个的使用场景是完全不同的,搞清楚之后,代码的可读性和可维护性都会大大提升。

先说说 computed,computed 是用来做有返回值的、依赖其他响应式数据的计算属性的,它的特点是“依赖追踪+缓存”:只有当它依赖的响应式数据变化时,它才会重新计算,否则会直接返回上次的缓存值,在 TS 里,computed 也可以通过泛型来标注返回值的类型,也可以利用类型推断——但要注意,如果是异步 computed(也就是返回 Promise 的 computed),必须显式加泛型,不然类型会推断成 ComputedRef<Promise>,后续使用的时候要 await,computed 的使用场景很明确:比如根据用户的输入自动计算总价,根据用户的年龄自动计算是否成年,根据多个筛选条件自动计算筛选后的列表……这些都是有明确返回值、需要缓存的场景。

再说说 watchEffect,watchEffect 是 Vue3 新增的 API,它的作用是自动追踪回调函数里用到的所有响应式数据,只要其中任何一个变化,就立即执行回调函数,它的特点是“自动依赖追踪+没有明确的监听源+没有 oldVal 参数”,在 TS 里,watchEffect 的回调函数里用到的响应式数据,类型会自动推断,不用显式标注——但要注意,watchEffect 是在组件挂载(或者 watchEffect 创建)的时候立即执行一次的,相当于 watch 加了 immediate: true,watchEffect 的使用场景也很明确:比如根据用户的搜索关键词自动发起 API 请求,根据响应式数据自动同步到 localStorage 或 sessionStorage,根据响应式数据自动修改 DOM 的样式或属性……这些都是没有明确返回值、不需要 oldVal 参数、需要自动追踪多个响应式数据的场景。

最后就是我们今天的主角 watch,watch 的作用是监听一个或多个明确的响应式数据,只有当这些数据变化时,才执行回调函数,它的特点是“明确的监听源+有 oldVal 参数+可以控制 deep、immediate、flush、once 这些属性”,在 TS 里,watch 是这三个 API 里类型安全做得最好的——因为有明确的监听源,所以回调参数的类型会自动精准推断,watch 的使用场景也很明确:比如需要对比新旧值做逻辑处理(比如表单输入框的防抖节流、用户操作的日志记录),比如需要监听深层嵌套对象的属性变化(比如表单数据的某个深层字段),比如需要控制回调函数的执行时机(比如组件更新后执行、只执行一次)……这些都是需要明确监听源、需要 oldVal 参数、需要控制触发条件的场景。

我的 TS 版 watch 最佳实践清单

为了方便大家在日常开发中快速上手,我整理了一份自己常用的“TS 版 watch 最佳实践清单”,大家可以直接参考:

  1. 先标对响应式数据的类型:ref 初始值是 null/undefined 时必须显式加泛型,reactive 最好给初始值加接口/类型别名,避免直接赋值替换整个 reactive 对象。
  2. 优先选择合适的监听源写法:单个基本类型或单独的引用用第一种写法,多个响应式数据用第二种写法,reactive 对象的单个/多个深层属性用第三种写法(getter 函数)。
  3. 慎用 deep: true:只有当 getter 函数返回引用类型且需要监听内部属性/元素变化时才加,其他情况尽量不用,尤其是监听非常大的深层嵌套对象时,会严重降低性能。
  4. 合理使用 immediate: true 和 once: true:需要立即执行回调时加 immediate: true,只需要执行一次时加 once: true(加了 once: true 就不用手动调用 stop 函数了)。
  5. 根据需要选择 flush 属性:需要访问更新后的 DOM 时加 flush: 'post',默认用 'pre',慎用 'sync'。
  6. 及时手动销毁不需要的 watch:watch 不是组件级别的(比如在某个函数里动态创建的),或者组件卸载后不需要继续监听,记得把 watch 的返回值赋值给一个变量,然后在合适的时机调用 stop 函数,防止内存泄漏。
  7. 用类型守卫处理回调参数的可选值:如果监听源的类型里包含 null、undefined 或者联合类型,在回调函数里用 newVal/oldVal 之前,最好加一个类型守卫(if (newVal) 或者 typeof newVal === 'string'),避免 TS 报错。
  8. 不要和 computed、watchEffect 搞混:有明确返回值、需要缓存的用 computed,没有明确返回值、不需要 oldVal、需要自动追踪的用 watchEffect,需要明确监听源、需要 oldVal、需要控制触发条件的用 watch。

Vue3 用 TypeScript 写 watch 时,只要搞懂这几个核心细节:响应式数据的类型标注、三种监听源的写法、第三个参数 options 的用法、和 computed/watchEffect 的使用场景区分,再配合我整理的最佳实践清单,就能完全避开那些常见的坑,写出类型安全、可读性高、可维护性强的代码。

Vue3 和 TypeScript 结合起来,最大的优势就是类型安全——它能在开发阶段就帮你发现很多潜在的 bug,比如拼写错误、类型不匹配、索引越界等等,大大降低了后期调试的成本,所以如果你还没有在 Vue3 项目里用上 TypeScript,赶紧去试试吧,试过之后你就会发现,真的回不去了!

版权声明

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

热门