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

Vue3 watch到底怎么正确监听Pinia里的响应式数据?避坑指南全在这了

terry 4小时前 阅读数 50 #Vue
文章标签 PiniaWatch

前阵子帮朋友改一个Vue3 + Pinia的后台管理小工具,朋友吐槽说改了好几天,监听用户的权限列表要么不触发,要么触发好几次,折腾得头大,其实这种问题刚接触这俩技术栈的人大概率都会碰到——毕竟Pinia的响应式逻辑虽然简化了,但和Vue2/Vuex还是有点不一样,再加上Vue3 watch的几个新特性,组合起来很容易踩坑,今天就把我整理的所有细节,用问答的方式讲透,看完绝对能解决99%的监听问题。

为什么用Vue3原生watch监听Pinia值,有时候会“失效”?

很多人第一次试,直接拿store里的state值当watch的第一个参数,比如这样:

import { ref, watch } from 'vue'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
// ❌ 第一次用大概率会这么写
watch(userStore.roleList, (newVal) => {
  console.log('权限变了', newVal)
})

然后在其他页面改userStore.roleList.push('admin'),控制台什么反应都没有?为啥会这样?

核心原因只有一个:你没理解Pinia返回的是什么,以及Vue3 watch的第一个参数“依赖”逻辑

先看Pinia的返回值——useXxxStore执行后,返回的是一个普通对象,不是ref也不是reactive(但里面的state属性其实是经过Proxy包装的reactive,这个后面说),Vue3的watch如果监听的是普通变量/非响应式引用的属性值,它只会在组件初始化时做一次依赖收集,之后如果这个变量/引用本身没换地址,watch根本不会管里面的内容变没变。

比如刚才的例子里,userStore是普通对象,userStore.roleList是reactive属性没错,但你把它直接传给watch的第一个参数,相当于传了一个“临时的属性值快照”——第一次执行watch时,Vue3会拿到roleList的初始Proxy地址,然后就没后续了?不对不对,等下,更准确的说法是:如果watch的第一个参数是“值类型的表达式”或者“非响应式容器里的属性读取”,Vue3会把它当作一个 getter函数的简化写法,但这个getter如果没有返回响应式容器本身(ref/reactive),只是返回容器里的某个值/属性,那除非你显式开启deep,否则只监听值的地址变化

哦对,刚才那个例子如果加个deep: true,会不会好?

// ❌ 加了deep暂时能用,但性能会差
watch(userStore.roleList, (newVal) => {
  console.log('权限变了', newVal)
}, { deep: true })

改完试试,push、splice都能触发了,看起来没问题?但别着急用——这个写法有两个大问题,后面会讲避坑指南的时候单独说。

正确监听Pinia响应式数据的3种主流方式,分别对应什么场景?

既然直接传属性值加deep不是最优解,那有没有性能更好、更符合Pinia设计理念的写法?当然有,而且每种写法都有它的适用场景,大家按需选就行。

第一种:传getter函数读取state属性(最通用)

这是我最推荐的写法,不管是监听单个属性、多个属性组合,还是监听getters,都能用,而且不需要纠结要不要加deep——监听单个基本类型不用,监听单个引用类型(对象/数组)的内容变化才加,监听多个属性的组合结果按需加

具体怎么写?

import { watch } from 'vue'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
// ✅ 监听单个基本类型(比如token、username),直接写属性读取的箭头函数
watch(() => userStore.token, (newToken, oldToken) => {
  if (newToken) {
    // 有token了,跳首页
  } else {
    // 没token了,跳登录
  }
})
// ✅ 监听单个引用类型的内容变化(比如roleList、userInfo),加deep但性能可控
watch(() => userStore.userInfo, (newInfo) => {
  // 更新导航栏的头像、昵称
  updateNavUser(newInfo)
}, { deep: true })
// ✅ 监听多个属性的组合结果(比如登录状态+当前路由权限)
watch(
  [() => userStore.isLogin, () => userStore.currentRoutePermission],
  ([isLogin, permission]) => {
    if (!isLogin) return
    if (!permission) {
      // 提示无权限
      showNoPermission()
    }
  }
)
// ✅ 监听Pinia里的getters(不需要deep,因为getters本身就是计算属性,依赖变化自动触发)
watch(() => userStore.permissionMenu, (newMenu) => {
  // 重新渲染侧边栏菜单
  renderSidebar(newMenu)
})

为什么这个写法最通用?因为getter函数每次都会重新执行,能准确读取到state/getters的最新值,依赖收集也更精准——Vue3会在getter执行时,把用到的所有state/getters的响应式依赖都收集起来,只要其中一个依赖变了,watch就会触发,完全不需要你手动管。

第二种:用toRefs/toRef把store的state属性转成ref再监听(适合频繁用某个属性的场景)

如果你在组件里需要频繁读取userStore.token、userStore.roleList,每次都写userStore.xxx太麻烦,这个时候可以用Vue3的toRefs或者toRef把store里的state属性转成ref,然后直接监听ref就行,性能和第一种差不多,但代码更简洁。

这里要注意一个点:只能用toRefs/toRef转store的state,不能直接解构store的state——因为Pinia的useXxxStore返回的是普通对象,直接解构的话,基本类型会丢失响应式,引用类型虽然还会保留Proxy,但依赖收集会出问题(和刚才直接传属性值类似)。

具体怎么写?

import { watch, toRefs, toRef } from 'vue'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
// ✅ 用toRefs批量转state里的所有属性(注意别转actions和getters)
const { token, userInfo } = toRefs(userStore)
// 监听单个基本类型的ref
watch(token, (newToken) => {
  // 跳页逻辑
})
// 监听单个引用类型的ref,内容变化加deep
watch(userInfo, (newInfo) => {
  // 更新导航逻辑
}, { deep: true })
// ✅ 用toRef单独转某个属性(比如只用到roleList,不想批量转)
const roleList = toRef(userStore, 'roleList')
watch(roleList, (newList) => {
  // 更新权限按钮显隐逻辑
}, { deep: true })

为什么可以用toRefs?因为toRefs会把普通对象里的所有属性都转成“指向原对象属性的ref”——当你修改ref.value的时候,其实修改的是原store的state;当原store的state变化的时候,ref.value也会自动更新,依赖收集完全正常,完全符合Vue3的响应式逻辑。

第三种:直接监听整个store对象,但必须加deep(不推荐,只有极端场景才用)

有没有见过直接监听整个userStore的写法?

// ❌ 日常开发别这么写,性能太差
watch(userStore, (newStore) => {
  console.log('store有变化', newStore)
}, { deep: true })

这个写法确实能触发,但为什么不推荐?因为Pinia的store对象里不仅有state,还有actions和getters——deep模式下,Vue3会递归遍历整个store的所有属性(包括actions里的函数、getters的缓存值),只要有任何一个属性的内容变化(哪怕是actions里的某个临时变量不小心改了?不对,actions里的临时变量不会影响store,但state里的任何一个小变化,比如username改了一个字,token过期时间变了一秒,都会触发这个watch),性能开销非常大,尤其是store里的state比较多的时候。

那什么时候才用这个极端场景?比如你需要做一个“全局store的本地持久化备份”,每次store有任何变化都立刻存到localStorage里——这个时候可能勉强能用,但也建议优化,比如只监听需要持久化的state组合,而不是整个store。

用Vue3 watch监听Pinia值时,必须避开的4个大坑?

刚才讲了正确写法,现在讲避坑——这4个坑我身边至少有5个朋友踩过,大家一定要记牢。

第一个坑:直接解构store的state属性来监听

刚才在第二种写法里提过,但还是要单独拿出来强调,因为这个坑踩的人最多。

// ❌ 错误写法:直接解构state
const userStore = useUserStore()
const { token, roleList } = userStore
// 监听token:基本类型,解构后只是一个普通字符串,完全没响应式
watch(token, () => {
  console.log('token变了?永远不会触发')
})
// 监听roleList:引用类型,解构后还是Proxy,加deep能触发,但依赖收集不精准?
// 不对,依赖收集其实也能收集到,但如果Pinia后续更新了store的结构,或者你不小心在其他地方覆盖了roleList的整个引用(比如userStore.roleList = []),这个解构出来的roleList引用就断了,watch直接失效
watch(roleList, () => {
  console.log('roleList变了,偶尔失效')
}, { deep: true })

所以永远记住:想在组件里简化store的state读取,只能用toRefs/toRef,绝对不能直接解构

第二个坑:监听引用类型时滥用/不用deep

刚才第一种写法里也提过,但很多人还是搞不清什么时候用deep,再总结一次:

  • 监听基本类型的getter/ref:不需要deep,因为基本类型的值变化就是地址变化(或者说ref.value的引用变化),Vue3默认就能监听。
  • 监听引用类型的整个引用变化(比如userStore.roleList = ['admin'],而不是push):不需要deep,因为getter/ref返回的引用变了,Vue3默认就能监听。
  • 监听引用类型的内容变化(比如push、splice、userInfo.nickname = '新昵称'):必须加deep,因为引用本身没变,只是里面的内容变了,Vue3默认只会监听引用地址。

那滥用deep有什么坏处?刚才也说了,递归遍历整个对象/数组,性能开销大——如果你的roleList有几百上千条数据,每次push都要递归检查一遍,时间久了页面会卡顿。

有没有办法不用deep也能监听引用类型的内容变化?有,但前提是你修改引用类型的时候,每次都替换整个引用,而不是修改原内容。

// 在store的actions里
const updateRoleList = (newRole) => {
  // ❌ 原内容修改,需要加deep
  // this.roleList.push(newRole)
  // ✅ 替换整个引用,不需要加deep
  this.roleList = [...this.roleList, newRole]
}

这个写法虽然不用加deep,但每次修改都要创建一个新的数组/对象,内存开销会变大——大家可以根据实际场景选:如果数组/对象很小,替换引用更省心;如果数组/对象很大,原内容修改加deep但注意watch回调里的逻辑别太重。

第三个坑:监听actions的调用结果

这个坑踩的人少,但一旦踩了很难找原因——比如你想监听某个接口请求的结果,直接去watch store里的actions函数。

// ❌ 错误写法:监听actions函数
const userStore = useUserStore()
watch(userStore.fetchUserInfo, () => {
  console.log('fetchUserInfo调用了')
})

为什么不行?因为actions函数是普通函数,不是响应式的,你调用它多少次,函数本身的引用都不会变,watch永远不会触发。

那想监听接口请求的结果怎么办?有两个办法:

  • 监听store里接口请求对应的state:比如fetchUserInfo会更新userInfo、loading、error这三个state,你可以监听其中的一个或多个。
    // ✅ 监听loading状态,显示/隐藏loading动画
    watch(() => userStore.loading, (isLoading) => {
      if (isLoading) showLoading()
      else hideLoading()
    })
  • 在调用actions的地方用await,然后处理结果:如果只需要在当前组件处理结果,不需要跨组件,直接用await更简单。
    // ✅ 直接await actions,处理结果
    const handleLogin = async () => {
      try {
        await userStore.login(formData)
        showSuccess('登录成功')
        router.push('/')
      } catch (err) {
        showError(err.message)
      }
    }

第四个坑:忘记处理watch的清理函数,导致内存泄漏或多次触发

这个坑不仅在监听Pinia的时候会有,监听Vue3的任何响应式数据都可能有,但很多人因为注意力集中在“怎么监听Pinia”上,会忽略这个点。

什么时候需要清理函数?比如你的watch回调里做了定时器、事件监听、异步请求(且组件可能在请求完成前卸载) 这些操作,就需要在清理函数里把它们清除掉,否则会导致内存泄漏,或者多次触发回调。

比如监听token变化,每次token变化都发起一个获取用户信息的请求:

import { watch } from 'vue'
import { useUserStore } from '@/stores/user'
import axios from 'axios'
const userStore = useUserStore()
// ❌ 错误写法:没有清理异步请求
watch(() => userStore.token, async (newToken) => {
  if (newToken) {
    // 发起请求
    const res = await axios.get('/api/user/info')
    userStore.setUserInfo(res.data)
  }
})

如果用户快速切换token(比如登录后立刻登出再登录),前一个请求可能还没完成,后一个请求就发出去了,而且前一个请求完成后还会更新userInfo,导致数据混乱——这个时候就需要用清理函数,取消前一个未完成的请求。

怎么加清理函数?Vue3的watch回调函数的第三个参数就是cleanup,你可以在cleanup里执行清理操作。

// ✅ 正确写法:用cleanup取消未完成的异步请求
watch(() => userStore.token, async (newToken, oldToken, cleanup) => {
  if (newToken) {
    // 创建一个AbortController,用于取消请求
    const controller = new AbortController()
    const signal = controller.signal
    // 注册清理函数:当watch再次触发或者组件卸载时,取消当前请求
    cleanup(() => {
      controller.abort()
    })
    try {
      // 发起请求时带上signal
      const res = await axios.get('/api/user/info', { signal })
      userStore.setUserInfo(res.data)
    } catch (err) {
      // 如果是AbortError(请求被取消),不做任何处理
      if (err.name !== 'AbortError') {
        showError(err.message)
      }
    }
  }
})

这个写法虽然复杂一点,但非常安全,尤其是在处理频繁变化的响应式数据时,一定要记得加。

除了Vue3原生watch,Pinia有没有自己的监听方式?

有!Pinia官方提供了一个$subscribe方法,专门用于监听store的state变化,不需要依赖Vue3的watch,也不需要纠结响应式逻辑,性能比Vue3原生watch加deep监听整个store好很多,但适用场景也比原生watch窄一点。

$subscribe的基本用法

import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
// ✅ Pinia官方的$subscribe方法
userStore.$subscribe((mutation, state) => {
  // mutation是一个对象,包含三个属性:
  // type: 'direct'(直接修改state) | 'patch object'(用$patch修改对象) | 'patch function'(用$patch修改函数)
  // storeId: store的id,也就是defineStore的第一个参数
  // payload: 如果是patch模式,payload就是你传的对象/函数;如果是direct模式,payload是undefined
  console.log('mutation类型', mutation.type)
  console.log('storeId', mutation.storeId)
  console.log('修改后的整个state', state)
  // 这里可以做全局持久化
  localStorage.setItem('userStore', JSON.stringify(state))
})

$subscribe和Vue3原生watch的区别

对比项 Vue3原生watch Pinia $subscribe
依赖 依赖Vue3的响应式系统 不依赖Vue3的响应式系统,是Pinia内置的
监听范围 可以监听单个属性、多个属性组合、getters 只能监听整个store的state变化
是否支持deep 需要显式开启 默认就是deep模式,但只遍历state
性能 按需监听性能好,滥用deep性能差 只遍历state,性能比watch整个store好
清理方式 组件卸载自动清理,也可以手动返回unwatch 组件卸载自动清理,也可以手动调用$subscribe返回的unsubscribe函数
是否有mutation信息 没有 有,可以知道是怎么修改的state

$subscribe的适用场景

刚才也说了,适用场景比较窄,主要是全局store的本地持久化全局state变化的日志记录——这两个场景需要监听整个state的变化,而且用$subscribe比watch更方便,性能也更好。

不同场景下怎么选监听方式?

最后给大家一张清晰的场景选择表,看完就能立刻上手:

场景需求 推荐监听方式
监听单个基本类型的state/getters 传getter函数读取,或toRefs转ref
监听单个引用类型的内容变化 传getter函数读取加deep,或toRefs转ref加deep,或替换整个引用不加deep
监听多个属性的组合结果 传getter函数数组
频繁读取某个state属性 toRefs/toRef转ref后直接使用+监听
全局store的本地持久化/日志记录 Pinia $subscribe
处理watch回调里的定时器、事件监听、异步请求 必须加cleanup清理函数

好啦,今天关于Vue3 watch监听Pinia值的内容就讲到这里——其实只要你理解了Pinia的响应式逻辑、Vue3 watch的依赖逻辑,再避开那4个大坑,不管什么监听场景都能轻松搞定,如果还有没讲清楚的地方,欢迎在评论区留言讨论哦!

版权声明

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

热门