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

Vue3项目中watch store state为什么没用?正确姿势有哪些?

terry 2小时前 阅读数 25 #Vue

刚上手Vue3做状态管理的朋友,肯定踩过这个坑:明明用了Vuex或者Pinia存了数据,页面组件也写了watch监听,但数据变了,回调就是纹丝不动?要么就是偶尔动偶尔不动?别慌,这种情况90%以上都不是框架bug,而是我们没摸透Vue3响应式系统和状态库结合的细节,今天就从问题根源出发,把没用的常见原因扒得明明白白,再从Vuex到Pinia、从基础监听 to 深度场景,给你一套全是实战干货的正确操作。

Vue3 watch监听store state没用的4个核心原因

首先得搞懂“没用”的本质:要么是Vue3的响应式追踪没连上你要监听的东西,要么是你的watch配置有问题,或者是store的状态更新方式触发不了追踪,先把这几个高频雷点列出来,看看你中了哪一个。

雷点1:直接监听了store的“壳子”,没解包到内部的具体值

不管是Vuex4还是Pinia,暴露给组件的都是一个经过处理的响应式对象——Vuex4是reactive()包裹的实例,Pinia默认也是reactive()包裹的store对象(除了组合式API风格下直接return的那些,但一般我们会加ref/reactive包装)。

很多新手会直接写:

// Vuex4示例
import { useStore } from 'vuex'
import { watch } from 'vue'
const store = useStore()
// ❌直接监听store,没用!
watch(store, (newVal) => {
  console.log('store变了?并没有')
})

或者Pinia里直接写监听整个useXxxStore的返回值,也是一样的问题,为什么?因为watch监听响应式对象本身时,默认只追踪“对象引用的变化”——也就是你得把整个store对象替换成另一个新对象,才会触发回调,但状态管理里我们从来不会这么干,都是改内部的属性。

那为什么有时候解包了还没用?比如Vuex4里取store.state.count,如果直接写成字符串形式的路径,或者没注意到Pinia里某些非响应式的情况?别急,后面结合正确姿势会讲。

雷点2:Vue3响应式系统的“深度盲区”——深层对象属性、数组索引的直接赋值?

其实这个雷点严格来说不是watch的问题,是响应式数据的更新方式不对,导致Vue3根本没察觉到数据变了,watch自然没反应。

很多朋友从Vue2转过来,还在用老习惯操作数据:

// Vuex4/Pinia示例,假设state里有个userInfo对象,list数组
const store = useStore()
// ❌Vue3 reactive对象的属性,如果是深层嵌套的,**直接替换整个属性没问题,但直接用下标赋值数组、直接给对象新增不存在的属性**(除非你手动触发响应式),Vue2需要Vue.set,Vue3虽然简化了,但也有情况!
// 比如这个直接给list数组的第0个赋值,有时候会漏?
store.state.list[0] = '新值'
// 比如给userInfo新增个avatar属性,老习惯直接加,没用!
store.state.userInfo.avatar = 'xxx.jpg'

这里得注意:Vue3的reactive()是基于Proxy实现的,对于数组的非length属性的直接索引赋值(比如list[0]),以及给对象新增原始不存在的属性,Proxy其实是能捕获到的?不对不对,等下,我得仔细回忆下实战里的情况——哦!不对,Pinia组合式API写法里,如果store里的list是用reactive()包裹的整个对象数组,没问题;但如果是Vuex4里,你在mutation里写这种直接索引赋值的方式,会不会有时候组件里的watch捕获不到延迟?或者更准确说,Vue2的遗留问题,有些人还是会下意识以为没用,那我们就直接养成好习惯,在mutation/Pinia actions里,统一用Array.prototype.splice()替换数组的某个元素,用Object.assign()或者直接展开语法给对象新增属性,或者如果是Vue3的setup里自己操作的响应式store状态,也可以这么干,这样100%触发响应式。

雷点3:watch的配置项没开对——immediate、deep、flush

这个也是超级高频的问题,

  • immediate没开:你想在组件初始化的时候,就执行一次watch的回调(比如从store里拿到count,立刻初始化某个本地变量),但没写immediate: true,那第一次渲染肯定没反应,很多新手会误以为是监听失败。
  • deep没开:你要监听的是store里的深层嵌套对象或数组的内部变化(比如监听userInfo里的nickname,而不是整个userInfo),但解包到内部值的时候,写成了() => store.state.userInfo,但没加deep: true——这时候只有userInfo的引用变了才会触发,内部属性变了没用。
  • flush没选对时机:默认flush是'pre',也就是在DOM更新前执行回调;如果你的回调里需要操作更新后的DOM,得选'post';如果是同步触发store更新,希望回调立刻执行,选'sync'——不过'sync'一般不推荐,会影响性能,除非特殊场景,比如有时候你在mutation里更新了数据,回调里想查DOM的offsetWidth,发现还是旧的,这时候大概率是flush的问题。

雷点4:Pinia组合式API写法下,没正确返回响应式数据

很多朋友刚用Pinia的组合式API(也就是defineStore里用setup()函数的写法),会犯这个错:

// ❌错误的Pinia组合式API写法
import { defineStore } from 'pinia'
export const useUserStore = defineStore('user', () => {
  // 直接定义了普通变量!没有ref/reactive包装!
  let count = 0
  const addCount = () => count++
  // 直接返回,组件里用watch监听count,没用!
  return { count, addCount }
})

这个太基础了,但真的很多人第一次会漏!Pinia的setup写法里,必须用ref()、reactive()、computed()这些Vue3的响应式API包装数据,返回出去的才是响应式的,否则就是普通变量,Vue3的watch根本追踪不到。


解决完雷点,来看不同状态库的正确姿势

现在雷都排完了,接下来分Vuex4Pinia(毕竟现在用Pinia的人越来越多,Vue官方也推荐了)两种情况,从基础监听单个值、到深层监听对象/数组、到监听多个值、到监听computed值,全场景覆盖。

Vuex4下watch store state的全场景操作

首先回顾下Vuex4的用法:在setup里用useStore()获取store实例,状态是store.state.xxx,getters是store.getters.xxx,mutations是store.commit(),actions是store.dispatch()

场景1:基础监听单个非嵌套状态(比如count)

这种最简单,直接用函数返回值的形式解包到具体的state值,不需要开deep,除非引用换了(但单个值都是原始类型,引用本来就会换)。

import { useStore } from 'vuex'
import { watch } from 'vue'
const store = useStore()
// ✅正确写法1:函数返回具体值,最灵活
watch(
  () => store.state.count,
  (newCount, oldCount) => {
    console.log('count变了:', oldCount, '→', newCount)
  },
  { immediate: true } // 可选,需要初始化就执行就开
)

这里为什么要用函数返回值?直接用store.state.count不行吗?

// ❌这个写法在Vue3早期版本有问题?或者说不够严谨?
watch(store.state.count, (newCount) => {})

对,最好不要这么写!因为如果store.state.count是个原始类型(比如number、string、boolean),watch接收到的是这个值的拷贝,不是响应式引用,虽然有时候在Vuex的mutation更新后,因为整个store的state是reactive的,底层可能会有特殊处理触发,但根据Vue官方的文档和最佳实践,监听响应式对象的属性时,必须用函数返回值的形式,才能正确建立依赖追踪——切记!

场景2:监听整个嵌套对象/数组的内部变化(比如userInfo、list)

这时候函数返回值要返回整个对象/数组,然后必须开deep: true,因为默认只追踪引用变化。

import { useStore } from 'vuex'
import { watch } from 'vue'
const store = useStore()
// ✅监听整个userInfo的内部属性变化
watch(
  () => store.state.userInfo,
  (newUser, oldUser) => {
    // 注意:开了deep之后,newUser和oldUser的引用是一样的!因为只改了内部属性
    // 所以如果你要对比新旧值的具体差异,得自己深拷贝oldUser保存
    console.log('userInfo内部变了', newUser.nickname)
  },
  { deep: true, immediate: true }
)

这里有个小坑:开了deep之后,oldUser和newUser是同一个对象的引用,对比是没用的,如果必须对比,比如要记录用户修改了哪些字段,那可以在immediate的时候,深拷贝一份userInfo存起来作为初始值,然后每次回调里,用新的newUser和存的那个旧值对比,对比完再更新旧值。

场景3:只监听嵌套对象/数组的某个具体属性/元素(比如userInfo.nickname、list[0])

这时候有两种写法,推荐第一种,更灵活,不用开deep。 写法1:直接在函数返回值里解包到具体属性/元素(推荐)

// ✅只监听nickname,不用开deep
watch(
  () => store.state.userInfo.nickname,
  (newNick, oldNick) => {
    console.log('昵称变了', oldNick, '→', newNick)
  }
)
// ✅只监听list的第0个元素(注意list[0]最好是原始类型,或者引用换了才会触发)
// 如果要监听list[0]的内部属性,还是得开deep,或者继续解包
watch(
  () => store.state.list[0],
  (newVal, oldVal) => {}
)

写法2:用字符串路径(仅Vue3+Vuex4支持?或者说Vue3的watch本身支持?不过推荐写法1)

// 这种写法不用函数,但要注意路径必须正确,而且字符串路径的形式有时候依赖追踪不够直观
watch(
  () => store.state, // 这里要返回整个state或者父级对象
  'userInfo.nickname', // 第二个参数是字符串路径
  (newNick, oldNick) => {}
)
// 或者更简单的,但好像更少见
// watch('store.state.userInfo.nickname', ...) —— 这个必须store在组件的顶层作用域里定义,而且依赖追踪可能有问题,不推荐

场景4:监听多个store state/getters

这时候可以用数组形式的监听源,每个监听源可以是函数返回值、ref、reactive对象(但还是推荐函数返回state/getters)。

import { useStore } from 'vuex'
import { watch, computed } from 'vue'
const store = useStore()
const totalPrice = computed(() => store.getters.totalPrice) // 也可以直接监听函数返回的getter
// ✅监听count、userInfo.nickname、totalPrice三个值
watch(
  [
    () => store.state.count,
    () => store.state.userInfo.nickname,
    totalPrice // 或者写成() => store.getters.totalPrice
  ],
  // 回调的参数也是数组,顺序和监听源一致
  ([newCount, newNick, newPrice], [oldCount, oldNick, oldPrice]) => {
    console.log('有值变了')
  },
  { immediate: true }
)

这里如果监听源里有嵌套对象,比如某个监听源是() => store.state.userInfo,那对应的数组参数里的newUser和oldUser还是引用相同,除非加deep——但如果只是监听多个单个值或嵌套属性,就不用加。


Pinia下watch store state的全场景操作

现在重点来了,Pinia是Vue3官方推荐的状态管理库,比Vuex4更轻量,更符合Vue3的组合式API风格,而且没有mutations和actions的区分(除非是需要持久化或者中间件的场景,但一般不需要,直接在actions里写同步异步都行)。

首先回顾下Pinia的两种写法:选项式API(Options API)写法组合式API(Setup API)写法——选项式写法更像Vuex,组合式写法更灵活,现在很多新项目都用组合式了,但两种写法的监听方式其实差不多,主要是获取store状态的方式略有不同。

Pinia还有个超级好用的API叫store.$subscribe(),专门用来监听整个store的状态变化,比watch更方便,而且能获取到修改的具体路径和值,后面也会讲。

场景0:先确保Pinia的组合式API写法返回的是响应式数据(超级重要!)

这个刚才在雷点里提过,但还是要再强调一遍,组合式写法的Pinia必须用ref/reactive/computed包装:

// ✅正确的Pinia组合式API写法
import { defineStore } from 'pinia'
import { ref, reactive, computed } from 'vue'
export const useUserStore = defineStore('user', () => {
  // 原始类型用ref
  const count = ref(0)
  // 复杂类型用reactive
  const userInfo = reactive({
    id: 1,
    nickname: '张三'
  })
  const list = ref([]) // 数组用ref或者reactive包裹的对象都行,ref更方便直接替换整个数组
  // 计算属性用computed
  const doubleCount = computed(() => count.value * 2)
  // 同步异步方法都叫actions,直接写
  const addCount = () => count.value++
  const fetchUserInfo = async () => {
    const res = await fetch('/api/user')
    const data = await res.json()
    // 直接给reactive对象赋值,或者替换ref的.value
    Object.assign(userInfo, data)
    // list.value = [1,2,3] —— 直接替换数组的ref.value,没问题
  }
  return { count, userInfo, list, doubleCount, addCount, fetchUserInfo }
})

选项式写法的话,不用操心,state里定义的都是自动响应式的:

// ✅正确的Pinia选项式API写法
import { defineStore } from 'pinia'
export const useUserStore = defineStore('user', {
  state: () => ({
    count: 0,
    userInfo: { id: 1, nickname: '张三' },
    list: []
  }),
  getters: {
    doubleCount: (state) => state.count * 2
  },
  actions: {
    addCount() { this.count++ },
    async fetchUserInfo() {
      const res = await fetch('/api/user')
      const data = await res.json()
      this.userInfo = data // 选项式写法里直接替换整个state的属性,没问题
    }
  }
})

场景1:Pinia基础监听单个非嵌套状态

不管是选项式还是组合式写法,获取store状态的方式都是一样的:在组件里用useXxxStore()获取store实例,组合式写法里的ref状态,Pinia会自动解包成.value,所以组件里直接用store.count就行,不用加.value!这点太爽了,很多新手不知道,还会加.value,其实没必要,Pinia做了自动解包。

那watch监听的时候呢?推荐还是用函数返回值的形式,或者如果你喜欢,也可以直接把store.count作为监听源——因为Pinia自动解包后,store.count其实是个响应式的引用?不对不对,等下,组合式写法里store返回的count是ref,Pinia的store是reactive包裹的,所以store.countreactive({ count: ref(0) }).count,Vue3的响应式系统会自动解包这个ref,所以store.count其实是个“ref解包后的响应式值”,可以直接作为watch的监听源吗?

// 选项式和组合式写法的组件里都一样
import { useUserStore } from '@/stores/user'
import { watch } from 'vue'
const userStore = useUserStore()
// ✅写法1:函数返回值,最稳妥,官方推荐
watch(
  () => userStore.count,
  (newCount, oldCount) => {}
)
// ✅写法2:直接用userStore.count,Pinia自动解包后也可以,但函数返回值更直观依赖追踪
watch(userStore.count, (newCount, oldCount) => {})

对,两种都可以,但还是推荐写法1,因为如果你要监听的是组合式写法里reactive对象的某个属性,或者嵌套属性,还是得用函数返回值,统一写法更不容易错。

场景2:Pinia监听整个嵌套对象/数组的内部变化

和Vuex4一样,函数返回整个对象/数组,加deep: true

import { useUserStore } from '@/stores/user'
import { watch } from 'vue'
const userStore = useUserStore()
// ✅监听整个userInfo的内部属性变化
watch(
  () => userStore.userInfo,
  (newUser) => {
    console.log('userInfo内部变了', newUser.nickname)
  },
  { deep: true }
)

场景3:Pinia只监听嵌套对象/数组的某个具体属性

直接在函数返回值里解包,不用开deep:

// ✅只监听nickname
watch(
  () => userStore.userInfo.nickname,
  (newNick) => {}
)

场景4:Pinia的专属监听API——store.$subscribe()

这个API是Pinia专门为监听整个store状态变化设计的,比watch更方便,因为:

  1. 不需要手动加deep: true,默认就能监听所有内部属性的变化;
  2. 回调里能获取到修改的mutation信息(比如是哪个action/commit修改的,虽然Pinia组合式写法里没有commit,但也会有类似的记录)和修改的前后state
  3. 可以设置detached: true,这样当组件销毁时,监听不会自动取消(如果是全局的监听,比如记录日志,就可以用这个)。

先看选项式写法里的$subscribe()

import { useUserStore } from '@/stores/user'
import { watchEffect } from 'vue' // 对比下watchEffect
const userStore = useUserStore()
// ✅Pinia $subscribe()的基础用法
const unsubscribe = userStore.$subscribe((mutation, state) => {
  // mutation的信息:type(direct/patch action?)、payload(如果有的话)、storeId
  console.log('mutation信息:', mutation)
  // state是修改后的整个store的state,是只读的!
  console.log('修改后的state:', state)
  // 如果要获取修改前的state,得自己在初始化的时候深拷贝一份,或者用mutation的信息
})
// 如果不需要监听了,可以手动取消
// unsubscribe()

组合式写法里的$subscribe()也是一样的用法,没有区别。

再对比下watch(() => userStore.$state, ...)$subscribe()的区别:

  • watch(() => userStore.$state, ...)需要加deep: true,而且回调里只有新旧state(新state是$state的引用,旧state如果没存的话没用);
  • $subscribe()默认deep,有mutation信息,而且state是只读的,更安全。

什么时候用$subscribe()?比如你需要做状态持久化(很多Pinia的持久化插件底层就是用这个API)、全局日志记录状态变更触发某些全局操作(比如修改了count就更新浏览器的title)。

比如用$subscribe()做个简单的状态持久化(localStorage):

// 在store的组合式/选项式写法外面,或者在main.js里初始化store后?
// 最好在main.js里,或者在App.vue的onMounted里?
import { createPinia } from 'pinia'
import { useUserStore } from '@/stores/user'
const pinia = createPinia()
const app = createApp(App)
app.use(pinia)
// 等pinia挂载后,获取store实例
const userStore = useUserStore()
// 先从localStorage里读取数据,初始化store
const savedState = localStorage.getItem('userStore')
if (savedState) {
  // 选项式写法可以直接用userStore.$state = JSON.parse(savedState)
  // 组合式写法也可以!因为Pinia的store有$state属性,不管是哪种写法
  userStore.$state = JSON.parse(savedState)
}
// 然后用$subscribe()监听状态变化,保存到localStorage
userStore.$subscribe((mutation, state) => {
  localStorage.setItem('userStore', JSON.stringify(state))
}, { detached: true }) // detached: true,这样即使App.vue销毁了(不过一般不会),监听也不会取消

这个简单的持久化就做好了,是不是比用watch方便多了?


进阶技巧:什么时候用watch,什么时候用computed,什么时候用store的getters?

很多朋友会混淆这三个东西:watch、computed、store的getters,都是和响应式数据相关的,但用途完全不同。

watch的用途

watch是用来监听数据变化,执行副作用操作的——什么是副作用?就是除了返回值之外,还会改变外部状态的操作,

  • 发送网络请求(比如修改了搜索关键词,就调用搜索接口);
  • 操作DOM(比如修改了count,就滚动到页面顶部);
  • 修改本地的非响应式变量(比如修改了count,就更新某个定时器的时间);
  • 调用浏览器API(比如修改了userInfo.nickname,就更新浏览器的title)。

computed的用途

computed是用来计算响应式数据的衍生值的——就是从一个或多个响应式数据,计算出一个新的响应式值,而且有缓存:只有当依赖的响应式数据变化时,才会重新计算,否则直接返回缓存的值。

  • 从list数组计算出过滤后的数组(比如过滤出已完成的todo);
  • 从count计算出doubleCount;
  • 从userInfo的firstName和lastName计算出fullName。

computed里不要写副作用操作!比如不要在computed里发送网络请求,不要修改DOM,因为computed是为了计算值而设计的,而且缓存机制会导致副作用操作不可控。

store getters的用途

store的getters其实就是放在store里的computed——如果这个衍生值是多个组件都需要用到的,就应该放在store的getters里,这样所有组件都能直接访问,不用每个组件都写一遍computed。

  • 整个应用都需要用到的已完成todo的数量;
  • 整个应用都需要用到的用户权限(从userInfo的role计算出来)。

如果衍生值只是某个组件单独需要的,就直接在组件里写computed就行,不用放在store里,避免store太臃肿。


Vue3 watch store state的核心要点

最后再把今天的内容总结一下,方便大家记忆:

  1. 排雷优先:先检查是不是直接监听了store的壳子、状态更新方式不对、watch配置没开、Pinia组合式写法没返回响应式数据;
  2. 统一写法:不管是Vuex4还是Pinia,监听store状态都推荐用函数返回值的形式,解包到具体的值或嵌套属性;
  3. 配置项要注意:需要初始化执行就开immediate: true,监听深层变化就开deep: true,需要操作更新后的DOM就选flush: 'post'
  4. Pinia有专属API:全局监听整个store状态变化,或者做状态持久化、日志记录,优先用store.$subscribe()
  5. 不要混淆watch、computed、getters:watch做副作用,computed做单个组件的衍生值,getters做多个组件共用的衍生值。

好了,今天的内容就到这里,如果你还有其他关于Vue3 watch store state的问题,欢迎在评论区留言讨论!

版权声明

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

热门