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

Vue3里怎么高效watch Pinia的state?直接套watch咋踩坑又该怎么填?

terry 13小时前 阅读数 156 #Vue

之前做后台权限管理系统重构时,团队刚从Vue2+Vuex转过来,一开始全是直接抄Vue2的watch用法,要么监听没反应,要么重复触发几十次API请求,改个用户信息整个页面都抖得慌——后来踩了不少坑,也翻了好多社区讨论和官方文档补原理,才摸透Vue3和Pinia结合的watch逻辑,今天就把踩过的雷、用过的实用方法,还有性能优化的小技巧串起来说,看完你不管是监听单个数据、嵌套对象,还是监听多个store的联动,都不会再犯低级错误。

第一个问题:直接把store实例扔进watch,或者只写store.xxx为啥有时候没反应?

刚接触的人大概率会这么写:直接import useUserStore,然后const userStore = useUserStore(),最后watch(userStore, () => console.log('变了!')),或者watch(userStore.name, () => ...),前者有时候会偶尔触发但大部分时候漏,后者可能连毛反应都没有。

为啥会这样?得先回忆一下Vue3的watch和Pinia store的底层结构——哦不对,别太底层,用大白话讲:Pinia的store本质上是一个响应式对象的组合式API封装版,但它有自己的“身份标识”(类似Vuex的store模块实例但更轻),直接watch整个实例的话,其实watch监听的是这个封装对象本身的引用变化,而不是里面state的内容变化;直接写userStore.name的话呢,因为你在watch的第一个参数里写了非响应式引用的原始值表达式——比如第一次取userStore.name是“张三”,这就是个纯字符串,Vue3没法追踪字符串的变化,除非你用getter或者ref把它包起来。

那基础的正确写法是什么?有两种入门级的,分别对应不同场景: 第一种,单个响应式属性的直接监听(用getter包裹原始值),如果只需要监听store里的某个具体数据,比如用户名、角色ID这种简单值,不要直接写属性,要写一个箭头函数return这个属性——相当于给Vue3递了个“钩子”,让它能通过箭头函数追踪到属性的响应式依赖,举个例子:

// 错误写法(递了原始值,无响应)
watch(userStore.roleId, (newVal) => {
  if (newVal !== 'admin') hideAdminMenu()
})
// 正确写法1(箭头函数钩子,通用)
watch(() => userStore.roleId, (newVal) => {
  if (newVal !== 'admin') hideAdminMenu()
})

第二种,监听整个state的变化(但不能直接监听store实例,要监听store.$state),Pinia官方文档里有提这个特殊属性,$state就是store里所有state数据组成的纯响应式对象(没有actions、getters的干扰),直接watch它就能监听到所有state的修改,不过这个方法慎用,后面性能优化部分会说原因,举个简单的例子:

// 监听整个state,每次有修改就打印所有新值
watch(
  () => userStore.$state,
  (newState) => console.log('当前用户状态:', newState),
  { deep: true } // 注意这里必须加deep,否则嵌套对象变了不会触发
)

第二个问题:监听嵌套对象时,为什么加了deep还是重复触发?或者只想监听嵌套对象的某个子属性?

这也是高频踩坑点,比如用户信息里有个address对象,里面有province、city、street三个子属性,很多人会写成:

watch(
  () => userStore.address,
  (newAddr) => {
    saveUserAddressToLocal(newAddr)
    // 或者调后端接口更新
  },
  { deep: true }
)

结果发现,哪怕只改了street里的一个字,province没动,save函数还是会触发;更糟的是,如果store里的address是通过某个action异步修改整个对象赋值的(比如userStore.$patch({ address: {...userStore.address, street: '新地址' } })),哦不对,$patch整个对象还好,如果是userStore.address.street = '新地址'——这种通过ref/reactive直接修改嵌套属性的方式,加上deep之后,确实只会触发一次,但如果你的嵌套结构有五六层,或者多个地方同时改同一个嵌套对象的不同子属性,Vue3的deep监听会遍历整个对象树,非常消耗性能;还有,如果你只想监听province变化来触发city的联动选择,deep监听显然是多余的。

那针对嵌套对象的场景,怎么精准又高效地监听? 只监听嵌套对象的某个具体子属性,不管其他——还是用入门级的箭头函数钩子,直接return到子属性那一层就行,连deep都不用加,因为箭头函数已经把依赖精准绑定到子属性上了,比如联动选择省市区:

// 只监听省的变化,重新获取对应市的列表
watch(
  () => userStore.address.province,
  async (newProvince) => {
    if (newProvince) {
      const cities = await fetchCitiesByProvince(newProvince)
      useAddressStore().updateCityList(cities)
      // 这里还可以顺便把当前选中的市和区清空,防止数据冲突
      userStore.updateAddress({ city: '', district: '' })
    }
  }
)

这个写法非常轻量,Vue3只会追踪province这一个响应式节点,其他子属性(city、street)变了完全不会触发,性能最好。

监听嵌套对象的几个特定子属性,不是全部也不是单个——这时候可以用Vue3 watch的第一个参数的“数组形式”,把要监听的几个箭头函数钩子放进去就行,数组里的任意一个值变了,都会触发回调,回调里的newVal和oldVal也是按数组顺序对应每个监听值的,比如监听用户的“手机号”和“邮箱”,只要其中一个变了就更新“用户联系方式已修改”的提示状态:

const contactChanged = ref(false)
watch(
  [() => userStore.phone, () => userStore.email],
  ([newPhone, newEmail], [oldPhone, oldEmail]) => {
    // 这里可以加个判断,防止和旧值一样也触发(比如手动输入又删掉原来的)
    if (newPhone !== oldPhone || newEmail !== oldEmail) {
      contactChanged.value = true
    }
  },
  { immediate: true } // 这里加immediate是为了页面刚加载时,如果用户填过信息就先检查一遍
)

必须监听整个嵌套对象,但不想因为嵌套层级深导致性能差,也不想因为直接赋值子属性+deep重复遍历——这时候可以用Vue3的watchEffect?不对,watchEffect是自动追踪依赖,和deep无关;哦对了,Pinia官方推荐的,或者说更适合这种“想监听对象整体变化但不关心具体子属性变了哪几个”的场景,是用JSON.parse(JSON.stringify())做个浅拷贝或者深拷贝?不,别用JSON转译,它有很多缺陷(比如不能处理Date、undefined、Symbol、循环引用);Vue3官方有toRawmarkRaw,但这里应该用toRefs解构store的state之后,再监听整个解构出来的对象?不对,应该用Vue3响应式系统里的shallowRef或者shallowReactive结合Pinia的$subscribe?或者换个思路:如果你的嵌套对象是不可变数据(每次修改都是通过替换整个对象的引用,而不是直接改子属性),那连deep都不用加!

这个不可变数据的思路非常重要,是Vue3+Pinia性能优化的核心之一,Pinia提供了两种修改state的方式:一种是直接修改(比如userStore.name = '李四'userStore.address.street = '新街道'),另一种是$patch方法,$patch可以传对象(会自动合并state,相当于浅替换),也可以传函数(函数里可以直接修改,相当于批量修改),那如果我们对嵌套对象的所有修改,都通过$patch传对象替换整个嵌套对象的引用,那监听整个嵌套对象的时候,就不用加deep了——Vue3只需要监听引用变化,速度快很多,举个例子:

// 定义store时的state
import { defineStore } from 'pinia'
export const useUserStore = defineStore('user', {
  state: () => ({
    name: '张三',
    address: {
      province: '广东',
      city: '深圳',
      street: '南山区科技园'
    }
  }),
  actions: {
    // 错误的修改方式(直接改子属性,会触发deep监听的对象树遍历)
    updateStreetWrong(newStreet) {
      this.address.street = newStreet
    },
    // 正确的修改方式(用$patch替换整个address的引用,不需要deep)
    updateStreetRight(newStreet) {
      this.$patch({
        address: {
          ...this.address, // 展开原来的address,保留省市区
          street: newStreet // 只替换street
        }
      })
    },
    // 或者用$patch传函数,批量修改多个属性,同时替换嵌套对象的引用
    updateContactAndAddress(newPhone, newCity) {
      this.$patch((state) => {
        state.phone = newPhone
        state.address = {
          ...state.address,
          city: newCity
        }
      })
    }
  }
})
// 组件里的监听(不需要deep!)
watch(
  () => userStore.address,
  (newAddr) => saveUserAddressToLocal(newAddr)
)

这种写法不仅性能好,还符合函数式编程的“不可变数据”原则,代码的可预测性也更强——因为你每次修改嵌套对象,都是生成一个新的对象,不会影响之前引用过这个对象的地方(除非你手动替换)。

第三个问题:怎么监听多个store的联动?比如用户登录状态变了,同时要更新购物车的数量、收藏夹的列表?

这个场景在电商、内容平台这类应用里非常常见,Vue3和Pinia的组合式API设计,本来就支持跨store调用,那监听跨store的联动其实也很简单,还是用Vue3 watch的第一个参数的“数组形式”,把不同store的箭头函数钩子放进去就行;或者如果联动逻辑比较复杂,也可以在某个store的action里调用另一个store的action,或者用Pinia的插件?插件的话适合全局的、通用的联动(比如所有store的state变化都要保存到localStorage或者IndexedDB),但普通的业务联动还是用watch更灵活,不用修改store的代码,耦合度更低。

举个电商场景的例子:用户登录后,需要从后端获取登录前的本地购物车(用localStorage存的)和登录后的云端购物车,合并后更新到购物车store里;同时还要获取收藏夹列表,那我们可以在App.vue或者登录页面的逻辑里监听useUserStore的isLoggedIn属性:

// 先导入所有需要的store
import { useUserStore } from '@/stores/user'
import { useCartStore } from '@/stores/cart'
import { useFavoriteStore } from '@/stores/favorite'
// 在setup里
const userStore = useUserStore()
const cartStore = useCartStore()
const favoriteStore = useFavoriteStore()
// 监听isLoggedIn的变化,true的时候触发联动
watch(
  () => userStore.isLoggedIn,
  async (isLoggedInNow) => {
    if (isLoggedInNow) {
      // 先显示loading状态,防止用户重复操作
      cartStore.setLoading(true)
      favoriteStore.setLoading(true)
      try {
        // 1. 获取本地购物车和云端购物车
        const localCart = JSON.parse(localStorage.getItem('localCart') || '[]')
        const cloudCart = await fetchCloudCart(userStore.userId)
        // 2. 合并购物车(这里可以写个合并逻辑,比如相同商品取数量多的,或者最新加入的)
        const mergedCart = mergeCart(localCart, cloudCart)
        // 3. 更新购物车store,并清空本地购物车
        cartStore.updateCart(mergedCart)
        localStorage.removeItem('localCart')
        // 4. 获取收藏夹列表
        const favoriteList = await fetchFavoriteList(userStore.userId)
        favoriteStore.updateFavoriteList(favoriteList)
      } catch (error) {
        console.error('联动更新失败:', error)
        // 这里可以加个全局提示
        ElMessage.error('登录后数据同步失败,请刷新页面重试')
      } finally {
        // 不管成功失败,都关闭loading状态
        cartStore.setLoading(false)
        favoriteStore.setLoading(false)
      }
    } else {
      // 登出的时候,清空购物车和收藏夹的store,同时保存未登录时的购物车到localStorage
      localStorage.setItem('localCart', JSON.stringify(cartStore.cartList))
      cartStore.clearCart()
      favoriteStore.clearFavoriteList()
    }
  },
  { immediate: true } // 页面刚加载时,如果已经登录了就直接同步数据
)
// 合并购物车的简单逻辑(仅供参考)
function mergeCart(local, cloud) {
  const map = new Map()
  // 先遍历云端购物车,把商品ID作为key
  cloud.forEach(item => map.set(item.productId, item))
  // 再遍历本地购物车,如果有相同ID就更新数量(取云端+本地的和,或者只取云端,看业务需求)
  local.forEach(item => {
    const existing = map.get(item.productId)
    if (existing) {
      existing.quantity += item.quantity
    } else {
      map.set(item.productId, item)
    }
  })
  // 最后把map转成数组
  return Array.from(map.values())
}

这种写法的好处是,所有的联动逻辑都集中在一个地方(App.vue或者单独的组合式API文件),不需要在userStore、cartStore、favoriteStore之间互相import,耦合度非常低,维护起来也方便——比如以后要加一个“登录后获取历史订单预览”的功能,只需要在watch的回调里加几行代码就行,不用修改其他store的代码。

第四个问题:watch监听Pinia state时,有哪些性能优化的小技巧?

刚才讲嵌套对象的时候提到了不可变数据和避免deep,其实还有几个更实用的小技巧,适合中大型应用: 把常用的store state用toRefs解构出来,然后直接监听ref,不用写箭头函数——Pinia的store实例如果是组合式API写法(defineStore的第二个参数是setup函数),那直接解构会失去响应式;但如果是选项式API写法(defineStore的第二个参数是state、actions、getters),或者组合式API写法里用了return { ...toRefs(state) },那在组件里用toRefs解构store实例的话,就能得到保持响应式的ref,直接监听这些ref就行,不用写箭头函数,代码更简洁,性能也差不多,举个选项式API store的例子:

// 选项式API store
export const useUserStore = defineStore('user', {
  state: () => ({
    name: '张三',
    roleId: 'user'
  }),
  // ...
})
// 组件里
const userStore = useUserStore()
// 用toRefs解构,得到nameRef和roleIdRef,都是保持响应式的ref
const { name: nameRef, roleId: roleIdRef } = toRefs(userStore)
// 直接监听ref,不用写箭头函数
watch(roleIdRef, (newVal) => {
  if (newVal !== 'admin') hideAdminMenu()
})

如果是组合式API写法的store,记得在setup函数的最后return { ...toRefs(state) },否则toRefs解构出来的ref会失去响应式:

// 组合式API写法的store(必须return toRefs(state))
export const useUserStore = defineStore('user', () => {
  const state = reactive({
    name: '张三',
    roleId: 'user'
  })
  const updateName = (newName) => {
    state.name = newName
  }
  // 必须return toRefs(state),否则组件里解构的ref会失去响应式
  return {
    ...toRefs(state),
    updateName
  }
})

用watch代替watchEffect,除非你真的需要自动追踪所有依赖——watchEffect是自动追踪回调函数里用到的所有响应式数据,一旦有一个变了就会触发;而watch是手动指定要监听的数据,只有指定的数据变了才会触发,watch的可控性更强,性能也更好,因为它不会追踪你不需要的数据,比如刚才的登录联动场景,用watch手动指定监听isLoggedIn,就不会因为userStore里的name变了而触发同步购物车的逻辑;但如果用watchEffect,回调函数里用到了userStore.isLoggedIn、userStore.userId、cartStore.cartList、favoriteStore.favoriteList,那只要其中一个变了,就会触发整个回调,这显然是多余的,甚至会导致死循环(比如cartStore.updateCart之后,cartList变了,watchEffect又触发,又调用updateCart,循环往复)。

如果回调函数里有异步操作,记得用abortController取消上一次的请求——这个技巧和Pinia无关,但和Vue3 watch+异步操作有关,非常实用,比如刚才的监听省变化获取市列表的场景,如果用户快速切换了两个省(比如先选广东,马上又选湖南),那第一次请求广东的市列表的异步操作还没完成,第二次请求湖南的已经发出去了,这时候如果第一次请求的响应比第二次晚到,就会把市列表覆盖成广东的,导致数据错误,用abortController就能解决这个问题:每次触发watch的回调时,先取消上一次的abortController,再创建一个新的,这样上一次的请求就会被浏览器取消,举个例子:

// 定义一个abortController的ref,用来保存上一次的控制器
let lastAbortController = null
watch(
  () => userStore.address.province,
  async (newProvince) => {
    // 先取消上一次的请求
    if (lastAbortController) {
      lastAbortController.abort()
    }
    // 如果新省为空,清空市列表并返回
    if (!newProvince) {
      useAddressStore().updateCityList([])
      useAddressStore().updateDistrictList([])
      return
    }
    // 创建新的abortController
    const controller = new AbortController()
    lastAbortController = controller
    try {
      const cities = await fetchCitiesByProvince(newProvince, {
        signal: controller.signal // 把signal传给fetch
      })
      useAddressStore().updateCityList(cities)
      useAddressStore().updateDistrictList([])
    } catch (error) {
      // 如果是abort的错误,不用处理;其他错误可以加提示
      if (error.name !== 'AbortError') {
        console.error('获取市列表失败:', error)
        ElMessage.error('获取市列表失败,请稍后重试')
      }
    }
  }
)

对于全局的、通用的state变化监听(比如所有store的state变化都要保存到localStorage),用Pinia的插件代替多个watch——Pinia的插件可以在每个store创建的时候注册一个$subscribe监听器,$subscribe是Pinia官方提供的监听state变化的方法,比Vue3的watch更适合监听store的state变化,因为它能拿到mutation对象(里面有修改的类型、路径、参数等信息),而且它的回调函数默认是deep的,但性能比Vue3的watch加deep好一点?或者差不多,但更方便统一处理,举个全局保存state到localStorage的插件例子:

// plugins/piniaPersist.js
export default function piniaPersistPlugin(context) {
  const { store } = context
  // 从localStorage里读取之前保存的state
  const savedState = localStorage.getItem(`pinia-${store.$id}`)
  if (savedState) {
    // 合并到当前store的state里
    store.$patch(JSON.parse(savedState))
  }
  // 注册$subscribe监听器,每次state变化都保存到localStorage
  store.$subscribe((mutation, state) => {
    // 这里可以加个白名单或者黑名单,只保存需要的store的state
    const persistStores = ['user', 'cart']
    if (persistStores.includes(store.$id)) {
      localStorage.setItem(`pinia-${store.$id}`, JSON.stringify(state))
    }
  },
  { detached: true } // detached: true表示组件卸载后监听器不会被销毁,适合全局插件
  )
}
// main.js里注册插件
import { createPinia } from 'pinia'
import piniaPersistPlugin from './plugins/piniaPersist'
const pinia = createPinia()
pinia.use(piniaPersistPlugin)
app.use(pinia)

这个插件可以自动保存user和cart两个store的state到localStorage,页面刷新后也能恢复,不用在每个组件里写watch,非常方便。

Vue3 watch Pinia state的核心要点

  1. 单个简单值的监听:用箭头函数return属性,或者用toRefs解构后直接监听ref;
  2. 整个state的监听:用() => store.$state加deep,或者用不可变数据替换引用后不用deep;
  3. 嵌套对象的监听:尽量用箭头函数return到具体子属性,避免deep;必须监听整个嵌套对象的话,用不可变数据替换引用;
  4. 多个数据/多个store的联动:用watch的数组形式;
  5. 性能优化:用不可变数据、避免deep、用watch代替watchEffect、用abortController取消异步请求、用Pinia插件处理全局监听。

其实只要掌握了Vue3响应式系统的核心(追踪的是响应式节点的变化,不是原始值的变化),再结合Pinia的特殊属性($state、$subscribe),watch Pinia state就不会再踩坑了,如果还有其他问题,可以在评论区留言哦~

版权声明

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

热门