Vue3开发踩坑日记,怎么精准监听Pinia/Vuex Store的数据变化?有没有什么更省心的替代方案?
最近给新公司做移动端工具类App,因为要用Pinia做状态管理,结果遇到了一堆watch store change的细碎问题——比如深层嵌套的配置对象修改了,普通watch没反应;或者只想监听某个模块下的单个属性变化,不想监听整个模块重渲染;还有时候监听函数重复触发好几次,排查半天不知道为啥,后来翻了很多社区笔记、官方文档,还有和同组做了5年前端的技术宅唠了一下午,才把这些坑一个个填上,今天就把所有遇到的问题和解决方法整理成大家能看懂的问答形式,不管你是刚接触Vue3状态管理的新手,还是用过Pinia/Vuex但没搞懂细节的老手,都能从里面找到有用的东西。
watch store change的第一个基础问题:Vue3的普通watch能直接监听store里的数据吗?有啥要注意的?
能,但必须记住两个核心前提,不然大概率会踩“数据改了页面不更新但监听函数没触发”或者“整个模块甚至app重渲染导致卡顿”的坑。
第一个核心前提,是得用响应式的方式获取store的数据,不能直接取store实例下的普通属性,也不能用非响应式的引用赋值,比如你用Pinia定义了一个叫useUserStore的模块,里面有个name属性和嵌套的info对象(info里有age和hobbies数组),如果你在组件里这样写:
// 错误示范1:直接赋值普通属性
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
const name = userStore.name
const hobbies = userStore.info.hobbies
// 错误示范2:在setup外或者非setup语法糖里用data(虽然现在很少用setup外的写法,但提一句以防万一)
export default {
data() {
const userStore = useUserStore()
return {
userInfo: userStore.info
}
}
}
不管你之后怎么改userStore.name、userStore.info.hobbies,name和hobbies变量,还有错误示范2里的userInfo,都不会有反应,普通watch自然也监听不到。
那正确的响应式获取方式有哪些?分两种情况,一种是监听单个或多个基本属性/浅层嵌套属性,另一种是监听深层嵌套属性/不想写太多变量。
第一种情况,推荐用Pinia/Vuex官方自带的toRefs,或者直接用Vue3的toRef/toRefs配合store实例,不过这里有个小细节:Pinia的toRefs是模块专属的,它只会把store里的state、getter、action都转成ref,不会影响其他属性;而Vue3原生的toRefs,如果直接转整个store实例,可能会把一些内部的非响应式属性也转进来,虽然不会出错,但代码看起来有点冗余,而且对性能影响微乎其微(同组技术宅说他测过1000个属性的store,两种方式渲染速度差0.01ms以内,完全可以忽略),所以看你习惯,我自己是习惯用Pinia/Vuex自带的toRefs的。
比如正确的基本属性/浅层嵌套属性获取写法:
// 正确示范1:Pinia自带toRefs(setup语法糖)
import { useUserStore } from '@/stores/user'
const { name, info } = useUserStore()
// 这里的info其实是ref吗?不对,Pinia的state属性如果是对象/数组,用自带的toRefs转出来的是** reactive对象的ref **?不,不对不对,刚才记错了,得仔细理清楚Vue3和Pinia的响应式机制底层。
哦对哦,刚才差点把自己绕进去了,得先插一句底层逻辑,但不用太复杂,懂原理能避免90%的坑,Vue3的响应式核心有两个:`ref`(处理基本类型数据,或者想把整个对象/数组作为单个响应式单元处理)和`reactive`(处理引用类型数据,直接修改对象/数组的属性或元素就能触发响应式),那Pinia的state是怎么实现的?其实Pinia的state默认是用`reactive`包裹的整个大对象,比如你在user store里写的state:
```javascript
// user.js store(Pinia)
export const useUserStore = defineStore('user', {
state: () => ({
name: '张三',
info: {
age: 25,
hobbies: ['打篮球', '看电影']
},
token: ''
})
})
底层会被转成类似reactive({ name: '张三', info: {...}, token: '' })的东西,那你直接在组件里用const userStore = useUserStore()拿到的,其实是这个大reactive对象的代理实例,对不对?对的,同组技术宅给我看过Pinia的简化版源码(就是去掉了devtools、持久化这些插件逻辑的核心逻辑),确实是这样的。
那刚才说的错误示范1里的const name = userStore.name为什么错?因为userStore是reactive对象,name是reactive对象的基本类型属性,当你用赋值语句把它赋给普通变量name的时候,相当于把reactive对象里的基本类型值复制了一份出来,和原来的reactive对象断开了联系——这就像你把墙上挂的日历撕下来一页放在桌上,墙上的日历翻页了,桌上的那页还是旧的,对吧?这个比喻很直观,我之前就是靠这个才彻底搞懂为什么不能直接赋值基本类型属性的。
那错误示范1里的const hobbies = userStore.info.hobbies为什么也错?虽然userStore.info.hobbies是reactive对象的引用类型属性,但你赋值给普通变量hobbies的时候,相当于把reactive对象里的引用地址复制了一份出来,对不对?那按道理说,修改hobbies.push('听音乐')应该能触发响应式啊?哦对哦,刚才这个错误示范1的后半部分我举得不太准确,得修正一下:
// 不太准确的错误示范后半部分:直接赋值reactive引用类型属性的子属性引用
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
const hobbies = userStore.info.hobbies
hobbies.push('听音乐') // 这里其实页面上如果用了userStore.info.hobbies,是会更新的!
// 那什么时候这个写法会有问题?当你直接替换整个hobbies数组的时候!比如hobbies = ['游泳'],这时候复制出来的引用地址变了,和原来的reactive对象就断开了!
哦对,刚才的例子举错了,差点误导大家,那总结一下直接赋值store实例属性的问题:
- 直接赋值reactive对象的基本类型属性:复制值,断开响应式,修改原属性不会影响普通变量,监听普通变量的watch不会触发。
- 直接赋值reactive对象的引用类型属性/子引用属性:复制引用地址,修改属性/元素不会断开响应式,但直接替换整个引用会断开响应式,替换后监听普通变量的watch不会触发。
那回到正确的响应式获取方式:不管你是要监听基本类型还是引用类型,不管你会不会替换整个引用,都推荐用toRef转单个属性或者store自带的toRefs转所有state/getter/action,因为这样转出来的都是ref对象,不管你怎么修改原属性(包括替换整个引用),ref对象都会保持响应式连接——这就像你在桌上放了一个投影仪,直接投射墙上的日历,墙上的日历不管是翻页还是换了一本新的,桌上的投影都会跟着变,对吧?这个投影仪比喻比撕日历更贴切ref的作用。
那具体的正确写法:
// 正确示范1:转单个属性(不管是基本还是引用)
import { useUserStore } from '@/stores/user'
import { toRef } from 'vue'
const userStore = useUserStore()
// 转基本类型属性name
const name = toRef(userStore, 'name')
// 转引用类型属性info,或者转info的子属性hobbies
const info = toRef(userStore, 'info')
const hobbies = toRef(userStore.info, 'hobbies')
// 之后不管你怎么改:
// userStore.name = '李四' → name.value变
// info.value.age = 26 → 页面和info.value都变
// userStore.info = { age: 27, hobbies: ['游泳'] } → info.value直接变成新对象,hobbies.value如果是之前用toRef转的旧hobbies的话,这时候会断开?哦对哦,这里又有个小细节!
// 如果你转的是userStore.info.hobbies(也就是reactive对象的子引用属性),那当你替换整个userStore.info的时候,原来的hobbies.value的引用地址还是旧的,就会断开!所以如果你可能替换整个父引用,那最好直接转父引用,或者转整个store的state里的父路径?
// 或者更简单,不管路径有多深,都用store自带的toRefs转整个userStore,然后通过属性链访问,
import { storeToRefs } from 'pinia' // Pinia自带的叫storeToRefs,别和Vue3原生的toRefs搞混!哦对哦!刚才这里是最大的坑!很多新手(包括我一开始)都用错了!
const userStore = useUserStore()
const { name, info } = storeToRefs(userStore) // 注意是storeToRefs!不是Vue3的toRefs!
// 那这时候info是ref对象,info.value是reactive对象吗?是的!因为Pinia的state里的info是reactive的,storeToRefs转出来的ref.value保持了原来的响应式类型!
// 那这时候不管你怎么改:
userStore.name = '李四' → name.value变
info.value.age = 26 → 变
userStore.info = { age: 27, hobbies: ['游泳'] } → info.value直接变成新的reactive对象(因为storeToRefs是基于整个store的state代理的,替换父属性会自动更新ref.value)
const hobbies = toRef(info.value, 'hobbies') → 这时候替换info.value的话,hobbies.value也会跟着变吗?不对,toRef是基于当前的info.value转的,替换info.value相当于换了个新对象,toRef的第二个参数的父对象变了,就会断开!所以这时候如果要监听hobbies,要么直接用info.value.hobbies(如果是reactive的话,直接监听数组本身不需要ref),要么用getter,要么用watch的getter函数写法!
哦对哦,刚才差点忘了Pinia自带的叫storeToRefs,Vuex的话,在Vue3适配的Vuex4里,也有类似的mapState转ref的写法,但在setup语法糖里,Vuex更推荐用toRefs配合useStore转出来的store实例,不过Vuex4的toRefs有没有模块专属的?好像没有,反正直接用Vue3原生的toRefs转Vuex4的store实例的state属性就行,
// Vuex4 setup语法糖正确转state的写法
import { useStore } from 'vuex'
import { toRefs, toRef } from 'vue'
const store = useStore()
// 转单个基本类型属性
const name = toRef(store.state, 'name')
// 转单个引用类型属性
const info = toRef(store.state, 'info')
// 转所有state属性(注意要先取store.state再转toRefs,别直接转整个store!)
const { name: stateName, info: stateInfo } = toRefs(store.state)
刚才说了第一个核心前提,第二个核心前提是什么?是如果你要监听store里的深层嵌套属性(比如info.hobbies[0]),或者要监听整个引用类型的变化(不管是修改属性还是替换整个引用),那普通watch需要加deep: true选项吗?还是有更高效的方法?
哦对,这个问题也是新手最常问的,先回答第一个小问题:普通watch如果直接监听ref对象(不管是基本还是引用类型转的),当ref对象的value是基本类型时,修改value会自动触发;当ref对象的value是引用类型时,只有替换整个ref.value才会自动触发,修改ref.value的属性/元素不会触发——这个时候就需要加deep: true了。
但加deep: true会有性能问题,对吧?同组技术宅给我做过一个小测试:监听一个有10层嵌套、每层有10个属性/元素的store对象,加deep: true的时候,修改任意一个深层属性,Vue3会遍历整个10层的对象树来检查变化,每次遍历大概需要0.1ms(手机端可能更久,比如0.5ms左右),如果修改频繁的话(比如输入框双向绑定到深层属性),就会造成卡顿;而如果不加deep: true,只监听单个深层属性的话,每次修改只需要检查那一个属性,大概0.001ms,差距还是很大的。
那有什么更高效的方法替代加deep: true呢?有三个常用的,按推荐程度排序:
- 使用Pinia/Vuex的getter:把你要监听的深层属性或者计算后的属性放在getter里,然后在组件里转成ref监听getter,这样不仅性能好,而且逻辑更清晰,getter还能缓存结果(只有依赖的state变化时才会重新计算)。
- 使用watch的getter函数写法:直接在watch的第一个参数里写一个箭头函数,返回你要监听的具体属性(不管路径有多深),这样不需要加
deep: true,也不需要转ref,性能和getter差不多,但如果这个逻辑在多个组件里用到的话,不如getter复用性高。 - 使用Vue3的
watchEffect:watchEffect会自动收集依赖的响应式数据,不管路径有多深,只要依赖的数据变了就会触发,不需要加deep: true,也不需要指定监听的属性,但watchEffect会在组件初始化的时候立即执行一次(普通watch可以通过immediate: true控制是否立即执行,默认不立即),而且如果依赖的数据太多的话,可能会触发不必要的更新,所以适合依赖比较少、逻辑比较简单的场景。
举个例子对比一下这四个方法(包括加deep: true的普通watch):
假设你要监听user store里的info.hobbies[0]的变化,然后在变化的时候打印出“第一个爱好变了:xxx”。
先看Pinia的store定义(之前的不变):
// user.js store(Pinia)
export const useUserStore = defineStore('user', {
state: () => ({
name: '张三',
info: {
age: 25,
hobbies: ['打篮球', '看电影']
},
token: ''
}),
getters: {
// 方法1用的getter
firstHobby: (state) => state.info.hobbies[0]
}
})
然后是组件里的四个写法:
// 组件setup语法糖
import { useUserStore } from '@/stores/user'
import { storeToRefs, watch, watchEffect } from 'pinia'
const userStore = useUserStore()
const { firstHobby } = storeToRefs(userStore)
// 写法1:加deep: true的普通watch(性能差,不推荐)
const { info } = storeToRefs(userStore)
watch(info, (newInfo) => {
console.log('第一个爱好变了:', newInfo.hobbies[0])
}, { deep: true })
// 这个写法的问题:不管修改info的任何属性(比如age、hobbies[1]、替换整个info),都会触发,但我们只需要监听hobbies[0],浪费性能。
// 写法2:使用getter转ref监听(性能好,复用性高,最推荐)
watch(firstHobby, (newHobby) => {
console.log('第一个爱好变了:', newHobby)
})
// 这个写法的优点:只有firstHobby依赖的hobbies[0]变化时才会触发,getter还会缓存,性能最好;而且如果其他组件也要用firstHobby,直接引入就行,不用重复写路径。
// 写法3:使用watch的getter函数写法(性能好,复用性低,适合单次使用)
watch(() => userStore.info.hobbies[0], (newHobby) => {
console.log('第一个爱好变了:', newHobby)
})
// 这个写法的优点:不需要加deep: true,不需要转ref,性能和写法2差不多;缺点是如果其他组件也要用这个逻辑,得重复写路径,维护起来麻烦。
// 写法4:使用watchEffect(自动收集依赖,立即执行,适合简单场景)
watchEffect(() => {
console.log('第一个爱好变了:', userStore.info.hobbies[0])
})
// 这个写法的优点:不需要指定监听的属性,自动收集;缺点是组件初始化的时候会立即执行一次(不管hobbies[0]有没有变),如果依赖的属性变多了(比如后来加了打印age),修改age也会触发,可能造成不必要的更新。
watch store change的第二个进阶问题:为什么有时候监听函数会重复触发好几次?怎么排查和解决?
这个问题我在做输入框双向绑定到store深层属性的时候遇到过,当时输入一个字,监听函数会触发2-3次,差点把后端接口搞崩(因为监听函数里写了调用后端保存配置的逻辑),后来排查了半天,才发现有三个常见的原因,每个原因都有对应的解决方法:
第一个常见原因,是加了deep: true的普通watch,同时修改了同一个引用类型里的多个属性——比如你在一个函数里同时修改了info.age和info.hobbies[0],加了deep: true的watch会遍历两次对象树?不对不对,不是遍历两次,是Vue3的响应式更新是批量异步的,对吧?那为什么会重复触发?哦对哦,我当时犯了一个低级错误:在非响应式的代码块里修改了两次属性,而且没有用nextTick或者把两次修改放在同一个响应式代码块里?不,不对不对,再仔细想一下,Vue3的批量异步更新机制是:不管你同步修改多少个响应式属性,都会把它们合并成一次更新,然后在nextTick里执行DOM更新和watch回调——那为什么我当时会重复触发?哦!哦对哦!我当时在store的action里用了async/await,修改属性是在await之后!
哦!这个才是第一个常见原因的准确说法:如果修改store属性的操作是在异步操作(比如await请求、setTimeout、setInterval)之后,而且是分多次同步修改的,那么每次修改都会触发一次批量异步更新,从而导致watch回调重复触发——因为await之后的代码是在微任务队列里执行的,每执行一次同步修改,Vue3都会检查当前的微任务队列有没有其他修改,如果没有的话,就会立即开启一次批量异步更新(或者说,在同一个微任务里的多次同步修改会合并,不同微任务里的多次同步修改会分开)。
举个例子,我当时犯的错误:
// user.js store的action(错误示范,导致watch重复触发)
export const useUserStore = defineStore('user', {
state: () => ({
name: '张三',
info: {
age: 25,
hobbies: ['打篮球', '看电影']
},
token: ''
}),
actions: {
async updateUserInfo() {
// 模拟异步请求
const res = await fetch('/api/user/info')
const data = await res.json()
// 分两次同步修改属性,在await之后的同一个微任务里吗?是的,但为什么我当时重复触发了?哦对哦,我当时在组件里也加了一个修改!
// 哦,那举个更准确的不同微任务的例子:
setTimeout(() => {
this.info.age = data.age
}, 100)
setTimeout(() => {
this.info.hobbies = data.hobbies
}, 200)
}
}
})
这个例子里,两个setTimeout是在不同的宏任务里执行的,每个宏任务里的同步修改都会触发一次批量异步更新,所以watch回调会触发两次。 那这个原因的解决方法是什么?有两个:
- 把所有修改store属性的操作放在同一个同步代码块里,或者同一个微任务里——比如把两个setTimeout合并成一个,或者在await之后一次性修改所有属性,不要分多次。
- 如果必须分多次修改,而且是在不同的微任务/宏任务里,可以在store里加一个
loading或者isUpdating的state属性,在开始修改的时候设为true,结束的时候设为false,然后在组件的watch回调里加一个判断,只有isUpdating为false的时候才执行逻辑——或者更简单,用Vue3的watch的flush选项,把flush设为'post'(默认是'pre',在DOM更新前执行;'post'在DOM更新后执行;还有'sync',同步执行,不推荐,性能差),不过这个好像不能解决不同宏任务的问题,只能解决同一个微任务里的某些特殊情况?不对,同组技术宅说flush选项主要是控制回调执行的时机,和批量更新无关,还是推荐第一个方法。
第二个常见原因,是监听的是store的getter,而getter的依赖项有多个变化——比如你有一个getter叫fullUserInfo,依赖name和info.age,如果你同时修改了name和info.age,那么getter会重新计算一次,但watch回调会触发几次?哦,默认是触发一次,因为批量异步更新嘛,但如果你在getter里用了computed的lazy: false选项?不对,Vue3的computed默认是lazy: true的,只有被访问的时候才会计算,而且依赖项变化时会标记为dirty,下次访问的时候才重新计算——那为什么会重复触发?哦!哦对哦!我当时在getter里调用了一个普通函数,而这个普通函数里访问了其他响应式数据!
举个例子:
// user.js store的getter(错误示范,导致watch重复触发)
export const useUserStore = defineStore('user', {
state: () => ({
name: '张三',
info: {
age: 25,
hobbies: ['打篮球', '看电影']
},
token: '',
theme: 'light' // 新增一个theme state
}),
getters: {
fullUserInfo: (state) => {
// 这里调用了一个普通函数,普通函数里访问了state.theme
const formatName = (name) => {
// 虽然theme和fullUserInfo无关,但这里访问了state.theme,就会被收集为fullUserInfo的依赖
console.log('当前主题:', state.theme)
return name + '同学'
}
return `${formatName(state.name)},今年${state.info.age}岁`
}
}
})
这个例子里,fullUserInfo的依赖项本来应该是name和info.age,但因为formatName函数里访问了state.theme,所以theme也被收集为依赖了——这时候如果你修改了theme,fullUserInfo会重新计算,watch回调也会触发,但我们其实不需要监听theme的变化,所以这就造成了不必要的重复触发。
那这个原因的解决方法是什么?有两个:
- 把普通函数里访问的无关响应式数据移到外面,或者用
markRaw包裹——比如把formatName函数移到getter外面,或者移到store外面,不要访问state里的无关数据;如果必须访问,可以把那个数据用markRaw包裹,但markRaw包裹的数据就不是响应式的了,要注意。 - 在getter里只访问需要的依赖项,不要调用其他可能访问无关响应式数据的函数——尽量让getter的逻辑简单、纯粹,只做计算,不做其他操作(比如打印、调用API、修改state),打印和修改state的操作放在action或者组件里,调用API的操作放在action里。
第三个常见原因,是用了Vue3的watch监听多个属性,而且用了数组的形式,同时这些属性有依赖关系——比如你监听了[name, fullUserInfo],而fullUserInfo依赖name,这时候如果你修改了name,name会变,fullUserInfo也会变,所以watch回调会触发两次?哦,默认是触发一次,因为批量异步更新嘛,但如果你在name变化后立即访问了fullUserInfo,导致fullUserInfo提前计算,然后批量更新的时候又计算了一次?不对,同组技术宅说这个情况一般不会触发两次,但如果用了watch的immediate: true选项,再加上修改属性的时机不对,可能会触发两次?或者举个更准确的例子:你监听了[info.age, info],而且加了deep: true,这时候修改info.age,info.age会变,info因为加了deep: true也会变,所以watch回调会触发两次?对!这个情况是真的会触发两次!
哦,这个例子更准确:
// 组件里的错误示范(导致watch重复触发)
import { useUserStore } from '@/stores/user'
import { storeToRefs, watch } from 'pinia'
const userStore = useUserStore()
const { info } = storeToRefs(userStore)
watch([() => userStore.info.age, info], (newVal) => {
console.log('监听的属性变了:', newVal)
}, { deep: true })
这个例子里,监听的是两个属性:一个是info.age(用getter函数写法),另一个是info(ref对象,加了deep: true)——当你修改info.age时,第一个监听项会变,第二个监听项因为加了deep: true也会变,所以watch回调会触发两次,第一次newVal是[26, { age: 26, ... }],第二次可能也是一样的,但不管怎样,都是重复触发。
那这个原因的解决方法是什么?很简单:避免监听有依赖关系的多个属性,只监听最核心的那个——比如这个例子里,只监听() => userStore.info.age或者只监听info(加deep: true),不要两个都监听。
watch store change的第三个高级问题:有没有办法监听store里某个模块的所有state变化?或者监听整个store的所有变化?有没有必要这么做?
这个问题是同组技术宅问我的,他说之前做后台管理系统的时候,想实现一个“全局状态变更日志”的功能,记录所有store的state变化,方便调试和回溯——那有没有办法实现?有,但要看你用的是Pinia还是Vuex,而且要考虑有没有必要这么做。
先讲有没有必要这么做:一般情况下,不推荐在生产环境里监听整个模块或者整个store的所有state变化,因为性能太差了,不管是加deep: true的普通watch,还是用Pinia/Vuex的插件,都会遍历整个state对象树,每次修改都会消耗一定的性能,在大型项目里可能会造成卡顿;但在开发环境里,是完全可以的,而且很有用,比如刚才说的全局状态变更日志,还有持久化插件(不过持久化插件一般只监听需要持久化的模块或者属性,不会监听整个store)。
那接下来讲具体的实现方法,分Pinia和Vuex两种:
Pinia监听整个模块/整个store的所有变化
Pinia官方提供了插件机制,可以在插件里监听所有store的所有state变化,或者单个store的所有state变化——这个比普通watch加deep: true更高效,因为Pinia的插件是直接在state的reactive代理上添加watch或者watchEffect的,而且可以在插件里过滤掉不需要监听的store或者属性。
举个例子,实现一个开发环境的全局状态变更日志插件:
// plugins/logger.js(Pinia插件,只在开发环境生效)
import { watch } from 'vue'
export function createLoggerPlugin() {
return {
// install函数是Pinia插件的入口,参数是app和options(可选)
install(app) {
// 这里的app是Pinia的app实例吗?不对,Pinia插件的install函数的参数是(pinia, app, options),哦对哦,刚才记错了,得看官方文档的简化版逻辑:
install(pinia, app, options) {
// 只在开发环境生效
if (import.meta.env.MODE !== 'development') return
// 监听所有store的创建
pinia.use(({ store, options }) => {
// store是当前创建的store实例
// options是当前store的配置(state、getters、actions)
console.log(`Store ${store.$id} 已创建`)
// 监听当前store的所有state变化
watch(
() => store.$state, // 直接监听整个$state(reactive对象)
(newState, oldState) => {
console.group(`Store ${store.$id} 状态变更`)
console.log('旧状态:', oldState)
console.log('新状态:', newState)
console.groupEnd()
},
{ deep: true, flush: 'sync' } // 加deep: true监听深层变化,flush: 'sync'同步执行,方便调试
)
// 也可以监听单个action的执行
store.$onAction(({ name, store, args, after, onError }) => {
console.log(`Store ${store.$id} 的 Action ${name} 开始执行,参数:`, args)
after((result) => {
console.log(`Store ${store.$id} 的 Action ${name} 执行成功,结果:`, result)
})
onError((error) => {
console.error(`Store ${store.$id} 的 Action ${name} 执行失败,错误:`, error)
})
})
})
}
}
}
}
然后在main.js里引入并注册这个插件:
// main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { createLoggerPlugin } from '@/plugins/logger'
import App from './App.vue'
const app = createApp(App)
const pinia = createPinia()
// 注册logger插件
pinia.use(createLoggerPlugin())
app.use(pinia)
app.mount('#app')
这样在开发环境里,不管哪个store的state变化,或者哪个action执行,都会在控制台打印出来,非常方便调试。
如果只想监听单个store的所有state变化,不需要插件,直接在store的定义里或者组件里用watch监听store.$state就行,加deep: true,
// 只监听user store的所有state变化(在组件里)
import { useUserStore } from '@/stores/user'
import { watch } from 'vue'
const userStore = useUserStore()
watch(
() => userStore.$state,
(newState, oldState) => {
console.log('User store 状态变更:', newState, oldState)
},
{ deep: true }
)
Vuex4监听整个模块/整个store的所有变化
Vuex4官方也提供了插件机制,和Pinia类似,可以在插件里监听所有state变化;Vuex4的store实例还有一个subscribe方法,可以监听所有mutation的提交(因为Vuex的state只能通过mutation修改,所以监听mutation的提交就等于监听所有state变化)——这个subscribe方法比普通watch加deep: true更高效,因为不需要遍历整个state对象树,只需要监听mutation的提交事件就行,而且可以获取到mutation的类型和参数。
举个例子,实现一个开发环境的Vuex4全局状态变更日志插件:
// plugins/vuex-logger.js(Vuex4插件,只在开发环境生效)
export function createVuexLoggerPlugin() {
return (store) => {
// store是Vuex的store实例
// 只在开发环境生效
if (import.meta.env.MODE !== 'development') return
// 监听所有mutation的提交
store.subscribe((mutation, state) => {
console.group(`Vuex Mutation ${mutation.type} 已提交`)
console.log('参数:', mutation.payload)
console.log('新状态:', state)
console.groupEnd()
})
// 也可以监听所有action的执行
store.subscribeAction((action, state) => {
console.log(`Vuex Action ${action.type} 开始执行,参数:`, action.payload)
}, { prepend: false }) // prepend: false表示在action执行后监听,true表示在执行前监听
}
}
然后在main.js里引入并注册这个插件:
// main.js
import { createApp } from 'vue'
import { createStore } from 'vuex'
import { createVuexLoggerPlugin } from '@/plugins/vuex-logger'
import App from './App.vue'
// 创建Vuex store实例,引入logger插件
const store = createStore({
plugins: [createVuexLoggerPlugin()],
// state、getters、mutations、actions的配置
state: () => ({
name: '张三',
info: {
age: 25,
hobbies: ['打篮球', '看电影']
}
})
})
const app = createApp(App)
app.use(store)
app.mount('#app')
这样在开发环境里,不管提交哪个mutation,或者执行哪个action,都会在控制台打印出来,非常方便调试。
如果只想监听单个模块的所有state变化,Vuex4可以用subscribe方法加过滤,比如只监听user模块的mutation(Vuex的mutation类型如果是模块化的,会自动加上模块名前缀,比如user/setName):
// 只监听user模块的所有state变化(在Vuex插件里或者组件里)
// 在插件里:
store.subscribe((mutation, state) => {
if (mutation.type.startsWith('user/')) {
console.group(`Vuex User Module Mutation ${mutation.type} 已提交`)
console.log('参数:', mutation.payload)
console.log('新状态:', state.user)
console.groupEnd()
}
})
// 在组件里:
import { useStore } from 'vuex'
const store = useStore()
store.subscribe((mutation, state) => {
if (mutation.type.startsWith('user/')) {
console.log('User module 状态变更')
}
})
watch store change的第四个实用问题:有没有办法监听store里state的变化,但只在变化满足某个条件的时候才触发回调?比如只有当age大于30的时候才打印?
这个问题很常见,比如做表单验证的时候,只有当输入的内容长度大于6的时候才显示错误提示;或者做数据过滤的时候,只有当过滤条件变化的时候才重新请求接口——那有没有办法实现?有,而且很简单,不需要用if判断包裹整个回调逻辑(虽然用if判断也可以,但有更优雅的方法),就是用watch的callback函数的第三个参数onCleanup?不对,或者用watch的第一个参数返回undefined或者null当条件不满足的时候?不对,或者用computed先计算一个条件满足的结果,然后监听这个computed——对!这个方法最优雅,而且逻辑最清晰,computed还能缓存结果。
举个例子,监听user store里的info.age,只有当age大于30的时候才打印“年龄超过30了”:
先看用if判断的写法(虽然可以,但不够优雅):
// 组件里的if判断写法
import { useUserStore } from '@/stores/user'
import { watch } from 'vue'
const userStore = useUserStore()
watch(() => userStore.info.age, (newAge) => {
if (newAge > 30) {
console.log('年龄超过30了')
}
})
然后看用computed的写法(更优雅,逻辑更清晰):
// 组件里的computed写法
import { useUserStore } from '@/stores/user'
import { watch, computed } from 'vue'
const userStore = useUserStore()
// 先计算一个条件满足的布尔值
const isAgeOver30 = computed(() => userStore.info.age > 30)
// 然后监听这个布尔值,只有当它从false变成true的时候才触发(默认只要变化就触发,包括true变成false)
watch(isAgeOver30, (newVal) => {
if (newVal) { // 这里加个判断是为了避免true变成false的时候也触发
console.log('年龄超过30了')
}
})
// 如果只想监听从false变成true的变化,不需要true变成false的,还可以用watch的第三个参数?不对,或者用watch的`immediate: false`(默认就是)加上判断newVal和oldVal的变化:
watch(isAgeOver30, (newVal, oldVal) => {
if (newVal && !oldVal) {
console.log('年龄超过30了')
}
})
这个写法的优点是:条件逻辑和回调逻辑分开了,代码更清晰,更容易维护;而且isAgeOver30是一个computed,可以在组件的其他地方复用(比如在模板里显示“年龄超过30了”的提示)。
还有一个方法,就是用watch的第一个参数返回条件满足时的新值,条件不满足时返回之前的旧值——这个方法可以用onCleanup来实现,不过比较复杂,不如用computed的写法优雅,所以不推荐,感兴趣的可以自己查一下。
总结一下Vue3 watch store change的所有核心要点
今天讲了四个关于Vue3 watch store change的问题,从基础到进阶到高级到实用,总结一下核心要点:
- 基础前提:要用响应式的方式获取store的数据,Pinia用
storeToRefs转state/getter,Vuex4用toRefs转store.state;不要直接赋值基本类型属性,不要直接替换复制出来的引用类型属性。 - 高效监听深层属性:优先用getter转ref监听,其次用watch的getter函数写法,尽量避免加
deep: true的普通watch。 - 避免重复触发:把所有修改放在同一个同步/微任务里,让getter逻辑简单纯粹只依赖需要的state,避免监听有依赖关系的多个属性。
- 监听整个模块/整个store:开发环境可以用Pinia/Vuex的插件,生产环境不推荐;Vuex4还可以用
subscribe方法监听mutation的提交,更高效。 - 条件触发回调:优先用computed先计算条件满足的结果,再监听这个computed,逻辑更清晰,更容易复用。
再给大家提一个小建议:不管用什么方式监听store的数据变化,都要尽量减少不必要的监听,只监听需要的属性,这样可以提高app的性能,尤其是在手机端或者大型项目里,希望今天的分享能帮到大家,如果有什么问题,欢迎在评论区留言讨论!
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网



