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

Vue3如何正确监听Pinia store的变化?为什么有的写法没效果?

terry 1小时前 阅读数 17 #Vue

做前端开发也有一段时间了,最近用Vue3搭项目时,总会遇到新手甚至偶尔转来的老Vue2选手问起:明明把data里的逻辑抽去Pinia store了,原来的watch怎么突然“失灵”了?是我写的watch不对,还是Pinia本身的响应式和Vue3有区别?甚至有人会怀疑是不是刚学的组合式API Composition API学串了,其实这个问题没那么复杂,只要搞懂Pinia的响应式本质、Vue3 watch的各种参数选项,再避开几个新手常踩的坑,就能精准监听store里的任何变化了。

先搞懂前置知识:Pinia store的响应式是怎么实现的?

在说具体监听方法之前,得先摸清楚Pinia store的底层响应式逻辑——不然换个写法你可能又懵了,Pinia从本质上来说,就是Vue3官方提供的状态管理库,它的响应式完全基于Vue3自身的ref和reactive实现的,没有额外搞一套自己的东西,这也是它比Vuex轻量、好上手的核心原因之一。

先回忆下你平时写的Pinia store(Options Store写法或者Setup Store写法其实底层最终是一样的,Options会被转成Setup): 比如用Options Store写一个用户模块:

// src/stores/user.js
import { defineStore } from 'pinia'
export const useUserStore = defineStore('user', {
  state: () => ({
    name: '张三',
    age: 25,
    info: {
      address: '北京市朝阳区',
      phone: '13800138000'
    }
  }),
  getters: {
    fullAddress: (state) => `${state.name}的家在${state.info.address}`
  },
  actions: {
    updateName(newName) {
      this.name = newName
    }
  }
})

或者用现在更推荐的Setup Store写:

// src/stores/user.js
import { defineStore } from 'pinia'
import { ref, reactive, computed } from 'vue'
export const useUserStore = defineStore('user', () => {
  const name = ref('张三')
  const age = ref(25)
  const info = reactive({
    address: '北京市朝阳区',
    phone: '13800138000'
  })
  const fullAddress = computed(() => `${name.value}的家在${info.address}`)
  function updateName(newName) {
    name.value = newName
  }
  return { name, age, info, fullAddress, updateName }
})

不管哪种写法,State(包括state里的对象、数组等深层结构)都会被自动转换为响应式的,Options Store里的state对象最终会被Pinia用reactive()包裹;Setup Store里的ref/reactive也会原封不动地暴露给组件,computed的getter同理。

知道这个就好办了——那监听Pinia store的变化,本质上和监听Vue3组件内部的ref/reactive数据没有太大区别,但有几个细节要特别注意,不然就会踩“没效果”的坑。

新手必踩的第一个大坑:直接监听store实例本身

很多刚接触Vue3+Pinia的开发者,会直接在组件里把store实例传进watch,比如这样写:

<script setup>
import { watch } from 'vue'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
// ❌错误写法:直接监听整个store实例
watch(userStore, (newVal, oldVal) => {
  console.log('userStore变化了', newVal, oldVal)
})
</script>

这个写法为啥不行?因为Pinia的store实例是一个普通的JavaScript对象(虽然它的内部属性是响应式的),但watch默认只会监听响应式引用本身的变化——比如你把整个userStore重新赋值给另一个变量(但实际开发中谁会这么干store?),才会触发回调,而store内部的state/getter变化,并不会导致store实例本身的引用改变,所以watch就“睡过去了”。

那怎么解决这个问题?有好几种方法,我们一一说。

新手必踩的第二个大坑:监听store解构后的变量忘记加toRefs/toRef

还有一种更常见的错误:很多开发者为了写代码方便,会把store里的属性直接解构赋值给组件内部的变量,然后监听这些变量,

<script setup>
import { watch } from 'vue'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
// ❌错误写法:直接解构响应式对象/实例
const { name, info, fullAddress } = userStore
watch(name, (newVal, oldVal) => {
  console.log('name变化了', newVal, oldVal) // 改了store的name这里不会触发
})
</script>

哦不对,这里其实得分Setup Store和Options Store的情况,但不管哪种,直接解构大概率都会有问题! 我们分两种情况拆解:

  1. Options Store解构后的问题:刚才说了,Options Store的整个state会被reactive()包裹,所以userStore本身其实是一个Proxy对象(响应式对象),直接解构响应式对象的属性,得到的就是普通的JavaScript值(如果是基本类型的话),失去了响应式连接;如果是对象类型(比如info),那解构出来的info其实还是原来的响应式Proxy(因为Proxy的getter返回的是深层的响应式数据),但基本类型比如name就不行了。
  2. Setup Store解构后的问题:Setup Store暴露出来的name是ref()包裹的,直接解构的话,name本身还是一个ref对象对吧?那为什么有些情况下直接监听解构后的ref也不行?哦不对,Setup Store暴露出来的ref其实是可以直接解构监听的?等下,我刚才好像说错了,得再仔细试一下——哦对,Setup Store里return出来的ref,直接解构后拿到的就是ref,监听这个ref本身是没问题的;但Options Store里return出来的属性(其实是Pinia从reactive的state里代理出来的),直接解构的话基本类型会丢失响应式。 哦,为了避免大家混淆两种写法的区别,不管用哪种Pinia store写法,解构属性时都一定要用Vue3的toRefs/toRef!这是最稳妥的做法,不会踩任何响应式丢失的坑。

比如正确的解构写法应该是这样的:

<script setup>
import { watch, toRefs, toRef } from 'vue'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
// ✅正确写法:用toRefs解构整个store/state,toRef解构单个属性
// 方法1:解构整个store的所有响应式属性(Options Store适用,Setup Store也一样)
const { name, info, fullAddress } = toRefs(userStore)
// 方法2:只解构单个属性(不管store是哪种写法都可以,更省空间)
const nameRef = toRef(userStore, 'name')
const addressRef = toRef(userStore.info, 'address')
// 这样监听就没问题了
watch(name, (newVal, oldVal) => {
  console.log('name变化了', newVal.value, oldVal.value)
})
</script>

对,这样不管是基本类型还是对象类型,不管是Options还是Setup Store,解构出来的都是响应式的ref(即使原来的info是reactive的对象,toRefs解构出来的info也会变成一个ref对象,指向原来的reactive info),监听这些ref就没问题了。

正确的监听Pinia store变化的4种方法

刚才说了两个避坑的点,现在正式给大家整理4种常用、有效的监听方法,每种方法都有对应的使用场景,大家可以根据自己的需求选择。

方法1:用toRefs/toRef解构后监听单个属性(最常用,适合监听特定属性)

刚才其实已经提过这个方法了,这是日常开发中用得最多的,比如你只需要监听用户的年龄变化,就用这个方法。 这里再给大家举个完整的例子,包括基本类型、深层对象属性、getter的监听:

<script setup>
import { watch, toRefs, toRef } from 'vue'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
// 用toRefs解构所有需要的属性
const { age, fullAddress } = toRefs(userStore)
// 用toRef单独解构深层属性(也可以用toRefs解构info后再点,但toRef更直接)
const phoneRef = toRef(userStore.info, 'phone')
// 监听基本类型age
watch(age, (newAge, oldAge) => {
  console.log(`用户年龄从${oldAge.value}变成了${newAge.value}`)
})
// 监听深层基本类型phone
watch(phoneRef, (newPhone, oldPhone) => {
  console.log(`用户手机号从${oldPhone.value}变成了${newPhone.value}`)
})
// 监听getter fullAddress
watch(fullAddress, (newAddr, oldAddr) => {
  console.log(`用户完整地址从${oldAddr.value}变成了${newAddr.value}`)
})
// 模拟数据变化
setTimeout(() => {
  userStore.updateName('李四') // 会触发fullAddress的监听
  userStore.age++ // 触发age的监听
  userStore.info.phone = '13900139000' // 触发phoneRef的监听
}, 2000)
</script>

这个方法的优点是:监听目标明确,只在指定属性变化时触发回调,性能好;缺点是:如果需要监听多个属性,就得写多个watch,或者把它们放到一个数组里(后面会说数组的写法)。

方法2:监听store的$state属性(适合监听整个state的变化)

刚才说了,不能直接监听store实例,但Pinia给每个store实例都暴露了一个$state属性,这个属性是Options Store里的reactive state,或者Setup Store里把所有暴露的ref/reactive合并后的reactive对象(不管哪种写法,$state都是reactive的),所以直接监听store.$state,再加上deep: true选项,就能监听整个state的变化了。

<script setup>
import { watch } from 'vue'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
// ✅正确写法:监听store.$state + deep: true
watch(
  () => userStore.$state,
  (newState, oldState) => {
    console.log('userStore的整个state变化了', newState, oldState)
    // 注意:这里的oldState和newState其实是同一个对象的引用!
    // 因为reactive是浅比较引用,深比较变化,但oldState不会保存变化前的快照
    // 如果需要oldState的具体值,得用watchEffect或者其他方法
  },
  {
    deep: true, // 必须加!因为$state是对象,不加deep的话只有替换整个$state才会触发
    immediate: false // 默认就是false,不需要初始化触发
  }
)
</script>

这个方法的优点是:可以一次性监听整个state的所有变化,不需要单独解构每个属性;缺点是:

  1. 性能稍差,因为state里任何一个小变化都会触发回调;
  2. oldState和newState是同一个对象的引用,拿不到变化前的具体值(除非你用JSON.parse(JSON.stringify())在回调里手动保存,但这样性能更差,而且如果state里有函数、Symbol等不能序列化的东西,还会报错);
  3. 只能监听state,不能监听getter。

那什么时候用这个方法呢?比如你需要在整个state变化时保存到localStorage里,不需要知道具体哪个属性变了,这时候用这个方法就比较合适。

方法3:用箭头函数返回需要监听的属性/属性组合(灵活度最高,适合复杂逻辑)

Vue3的watch支持第一个参数是一个getter函数,这个函数返回一个值,watch会监听这个返回值的变化,这个方法的灵活度非常高,可以监听单个属性、多个属性的组合、深层路径的属性,甚至可以在getter里做一些简单的计算,只返回你关心的变化结果。 这里给大家举几个不同的场景:

场景1:监听单个属性(不需要toRefs/toRef,直接写getter)

<script setup>
import { watch } from 'vue'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
// ✅正确写法:用箭头函数返回单个属性
watch(
  () => userStore.name,
  (newName, oldName) => {
    console.log(`用户名从${oldName}变成了${newName}`)
    // 注意:这里的newName和oldName直接是值,不需要加.value!因为getter里解包了ref
  }
)
</script>

这个方法和toRefs/toRef解构后监听的效果一样,但不需要额外导入toRefs/toRef,代码更简洁,适合只监听几个属性的情况。

场景2:监听多个属性的组合(比如年龄超过30才触发,或者两个属性同时变化才触发?哦不对,同时变化其实watch的数组参数更合适,但组合逻辑比如年龄+姓名是否匹配可以用这个)

<script setup>
import { watch } from 'vue'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
// 监听:当用户是张三且年龄超过25时返回true,否则返回false
watch(
  () => userStore.name === '张三' && userStore.age > 25,
  (isTarget) => {
    if (isTarget) {
      console.log('触发了特定条件:张三且年龄>25')
    }
  }
)
// 模拟数据变化
setTimeout(() => {
  userStore.age = 26 // 触发条件
}, 1000)
setTimeout(() => {
  userStore.updateName('李四') // 条件不满足,回调不会执行(除非你想监听条件的变化,从true变false也会触发)
}, 2000)
</script>

哦对,这里要注意:watch的getter函数返回值的任何变化都会触发回调,不管是从true变false还是false变true。

场景3:监听深层路径的属性(不需要toRef)

<script setup>
import { watch } from 'vue'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
// ✅正确写法:用箭头函数返回深层路径的属性
watch(
  () => userStore.info.address,
  (newAddr, oldAddr) => {
    console.log(`用户地址从${oldAddr}变成了${newAddr}`)
  }
)
</script>

这个方法比用toRef单独解构深层属性更简洁,不需要额外的变量。

场景4:监听多个属性(不管单个还是多个变化都触发,oldState可以拿到每个属性的旧值)

Vue3的watch还支持第一个参数是一个数组,数组里可以放ref、reactive对象、getter函数的任意组合,只要数组里有一个元素变化,就会触发回调,newVal和oldVal也会是对应的数组,顺序和第一个参数的数组一致。

<script setup>
import { watch, toRefs } from 'vue'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
const { name } = toRefs(userStore)
// ✅正确写法:用数组监听多个属性
watch(
  [() => userStore.age, name, () => userStore.info.phone],
  ([newAge, newName, newPhone], [oldAge, oldName, oldPhone]) => {
    console.log('多个属性变化了:')
    console.log(`年龄:${oldAge} -> ${newAge}`)
    console.log(`姓名:${oldName.value} -> ${newName.value}`) // 注意:name是ref,所以加.value
    console.log(`手机号:${oldPhone} -> ${newPhone}`)
  }
)
</script>

这个方法适合需要同时监听多个属性,且需要知道每个属性具体变化情况的场景。

方法4:用watchEffect(适合不需要oldState,只需要依赖自动追踪的场景)

Vue3还有一个watchEffect函数,它不需要指定监听的目标,会自动追踪回调函数里用到的所有响应式数据,只要其中一个数据变化,就会触发回调;而且它默认会初始化执行一次(可以通过flush选项调整)。

<script setup>
import { watchEffect } from 'vue'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
// ✅正确写法:用watchEffect自动追踪依赖
watchEffect(() => {
  console.log('watchEffect触发了,当前用户信息:')
  console.log(`姓名:${userStore.name}`)
  console.log(`年龄:${userStore.age}`)
  // 自动追踪name和age,不管哪个变了都会触发
  // 默认初始化会执行一次
})
</script>

这个方法的优点是:代码更简洁,不需要手动指定监听目标,依赖自动追踪;缺点是:

  1. 拿不到oldState;
  2. 默认初始化执行一次(如果不需要的话,可以用watchEffect的第二个参数的flush: 'post',或者用watchPostEffect、watchSyncEffect,不过这些都是细节,日常开发中默认的watchEffect就行,不需要初始化执行的话还是用watch);
  3. 性能可能稍差,因为依赖自动追踪,如果回调函数里不小心用了很多不相关的响应式数据,就会导致不必要的触发。

那什么时候用这个方法呢?比如你需要在依赖变化时执行一些副作用操作(比如发送请求、更新DOM、保存localStorage),且不需要知道变化前的值,这时候用watchEffect就比较合适。

再补充几个常用的watch选项,监听Pinia store时更顺手

不管用哪种watch方法,Vue3的watch选项都是通用的,这里给大家补充几个监听Pinia store时常用的选项,让你的监听更精准、更符合需求。

选项1:deep(深层监听)

刚才在监听store.$state时提到过deep选项,它的作用是:如果监听的目标是一个对象/数组,不加deep的话只有替换整个对象/数组的引用才会触发回调;加了deep的话,对象/数组内部的任何属性/元素变化都会触发回调。 比如监听整个info对象:

<script setup>
import { watch } from 'vue'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
// 监听整个info对象,不加deep的话只有userStore.info = {}才会触发
watch(
  () => userStore.info,
  (newInfo, oldInfo) => {
    console.log('info对象变化了', newInfo, oldInfo)
  },
  {
    deep: true // 必须加
  }
)
</script>

这里同样要注意:如果监听的是对象/数组且加了deep,oldInfo和newInfo是同一个对象的引用,拿不到变化前的具体值。

选项2:immediate(初始化触发)

immediate选项的作用是:让watch在组件挂载后立即执行一次回调,不管监听的目标有没有变化,默认是false。 比如你需要在组件刚挂载时就获取一次用户的完整地址并显示在某个地方,同时监听后续的变化:

<script setup>
import { watch, ref } from 'vue'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
const displayAddr = ref('')
watch(
  () => userStore.fullAddress,
  (newAddr) => {
    displayAddr.value = newAddr
  },
  {
    immediate: true // 组件挂载后立即执行,displayAddr会被初始化为fullAddress的初始值
  }
)
</script>
<template>
  <div>用户完整地址:{{ displayAddr }}</div>
</template>

不过这里要注意:如果用watchEffect的话,默认就会初始化执行,不需要加这个选项。

选项3:flush(回调执行的时机)

flush选项的作用是:控制watch回调函数执行的时机,有三个可选值:

  1. 'pre'(默认):在组件DOM更新之前执行回调;
  2. 'post':在组件DOM更新之后执行回调;
  3. 'sync':同步执行回调(尽量少用,会影响性能)。 比如你需要在监听用户年龄变化后,获取更新后的DOM元素的高度:
    <script setup>
    import { watch, ref, nextTick } from 'vue'
    import { useUserStore } from '@/stores/user'

const userStore = useUserStore() const ageDiv = ref(null)

// 方法1:用flush: 'post' watch( () => userStore.age, () => { console.log('ageDiv的高度(flush: post):', ageDiv.value.offsetHeight) }, { flush: 'post' } )

// 方法2:用默认的flush: 'pre' + nextTick watch( () => userStore.age, async () => { await nextTick() console.log('ageDiv的高度(pre + nextTick):', ageDiv.value.offsetHeight) } )

``` 这两种方法的效果是一样的,用flush: 'post'会更简洁一些。

不同场景下应该选哪种监听方法?

最后给大家做一个总结,方便大家根据自己的需求快速选择:

  1. 只需要监听特定的1-2个属性:用方法3(箭头函数返回单个属性),或者方法1(toRefs/toRef解构后监听),两种方法都可以,看个人习惯;
  2. 需要监听多个属性,且需要知道每个属性的旧值:用方法3的数组参数
  3. 需要监听多个属性,但不需要知道旧值,依赖自动追踪更方便:用方法4(watchEffect)
  4. 需要监听整个state的所有变化(比如保存到localStorage):用方法2(监听$state + deep: true)
  5. 需要监听深层对象的属性,但不需要监听整个对象:用方法3(箭头函数返回深层路径的属性),比toRef更简洁。

一定要记住两个避坑的点:不要直接监听store实例本身;不管用哪种Pinia store写法,解构属性时最好都用toRefs/toRef,避免响应式丢失。

希望这篇文章能帮到正在用Vue3+Pinia开发的你,如果还有其他问题,欢迎在评论区留言交流!

版权声明

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

热门