修复pinia使用setup语法导致$reset报错
前言
在pinia中使用setup语法是非常舒服的一件事情,但是也会有一些坑点,其中就是当我们使用store.$reset()
进行重置的时候会发生报错:
Store "xxxx" is built using the setup syntax and does not implement $reset()
意思是$reset
并没有实现,我们需要自己手动实现这个。
教程
解决办法我是从stackoverflow看到的,这里放出出处:
《Pinia: $reset alternative when using setup syntax》
我们需要创建一个插件去实现这个功能:
stores/plugins/reset.ts
import type { PiniaPlugin } from "pinia";
import { easyDeepClone } from "@/utils/tools";
export const piniaReset: PiniaPlugin = ({ store }) => {
const initialState = easyDeepClone(store.$state);
store.$reset = () => {
store.$patch(($state) => Object.assign($state, initialState));
};
};
其中easyDeepClone
是我自己写的一个简单深拷贝函数,内容如下:
/** 简易深度克隆 */
export function easyDeepClone<T = any>(target: T): T {
return JSON.parse(JSON.stringify(target));
}
修复的原理就是利用$patch整个更新state对象,其中我们甚至可以简化写法:
store.$patch(initialState);
这种形式也是可以的。
然后将插件注册激活:
import { createPinia } from "pinia";
import { piniaReset } from "./plugins/reset";
const pinia = createPinia();
// 修复setup模式$reset无效的问题
pinia.use(piniaReset);
export default pinia;
问题解决。
新的问题
最近在使用这个$reset
的时候发现了新的问题,当我们配合pinia-plugin-persistedstate
这个持久化插件的时候,会发现$reset
并不能真正的重置仓库数据。
复现情况如下:
当我们首次登录用户将用户数据存储到pinia仓库中,此时持久化插件会将其存储在本次缓存中,此时如果选择退出登录,通过调用$reset
,仓库数据确实可以被清空掉。
但是接下来的情况不就行了,登录 --> 持久化缓存 --> 刷新页面 --> 退出登录 --> 调用$reset --> 仓库数据无变化。
于是我在插件的触发时打印一下store的数据,发现确实是有数据的,也就说我在创建initialState
的时候,已经存在了用户的数据,此时我们在调用$reset去替换数据,其实是没有用的。
数据已经不干净了。
于是我在想为什么打印store时会有数据,于是下班在家突然想到,有没有可能是插件的use时机导致的。于是打开代码发现我的插件激活顺序是这样的:
import { createPinia } from "pinia";
import { createPersistedState } from "pinia-plugin-persistedstate";
import { piniaReset } from "./plugins/reset";
const pinia = createPinia();
//全局持久化配置
pinia.use(
createPersistedState({
key: (id) => `__xxxx__${id}`,
storage: localStorage
})
);
// 修复setup模式$reset无效的问题
pinia.use(piniaReset);
export default pinia;
于是我将piniaReset和createPersistedState调用了下位置,然后再去查看打印的数据,发现state拿到的时候已经是初始值了,不再是带有持久化的数据。
import { createPinia } from "pinia";
import { createPersistedState } from "pinia-plugin-persistedstate";
import { piniaReset } from "./plugins/reset";
const pinia = createPinia();
// 修复setup模式$reset无效的问题
pinia.use(piniaReset);
//全局持久化配置
pinia.use(
createPersistedState({
key: (id) => `__xxxx__${id}`,
storage: localStorage
})
);
export default pinia;
到这里我已经很完美了,但是又发现了一个新的问题,但我调用$reset去重置用户数据仓库时,他的getters状态isLogin
没有发生变化,这个getters是这么写的:
export const useUserStore = defineStore(
"user",
() => {
/** 用户数据 */
const userData: UserTypes.UserData = reactive(easyDeepClone(defaultUserData));
/** 是否已登录 */
const isLogin = computed(() => Boolean(userData.token));
return {
userData,
isLogin,
};
},
{
persist: true
}
);
所以我推断是由于$reset
是通过Object.assign($state, initialState)
去还原数据的,但是由于isLogin
拿到的是A对象的引用,当我调用store.$patch(($state) => Object.assign($state, initialState))
的时候,其实将将A对应的引用地址变更了,而不是更改了它对象内部的值。
所以我去查了下pinia的文档,官方的类型上说$patch
支持两种类型数据参数传入:
- 对象 (Object): 通过传入一个包含键值对的对象,你可以直接更新 store 中的多个状态。对象中的键是要更新的状态名称,值是更新的新值。
- 函数 (Function): 你可以传递一个函数作为参数给
$patch
方法,该函数接受当前的状态作为唯一参数,你可以在这个函数内部直接修改状态。
那么可以知道参数是没有错的,那么只有一种可能,getters这块出现了问题,要么是getters的引用断了,或者getters也被缓存了。
于是我查看了本地缓存的数据,getters并没有被缓存,那么只有一种情况了,就是对象引用发生了一些问题。
为此我将重置方法改为:
import type { PiniaPlugin } from "pinia";
import { easyDeepClone } from "@/utils/tools";
export const piniaReset: PiniaPlugin = ({ store }) => {
const initialState = easyDeepClone(store.$state);
store.$reset = () => {
store.$patch(initialState);
};
};
发现问题解决,很奇怪,想了一下,八成是Object.assign
导致的,因为他是一次浅合并,如果state的对象是如下格式:
{
userData: {
token: ""
}
}
当我调用Object.assign
进行合并的时候,新对象的userData引用地址会将仓库的userData
引用地址替换掉,但是isLogin
持有的还是旧对象的引用,于是它是不会有变化的,并且由于传入的是普通对象,即便后续监听了,也有可能丢失响应式。
而pinia中如果传入了一个对象,肯定不是调用Object.assign
这么简单,查了下源码仓库,它源码调用了mergeReactiveObjects
方法进行响应式对象数据替换,提取了一下关键内容:
function mergeReactiveObjects<
T extends Record<any, unknown> | Map<unknown, unknown> | Set<unknown>
>(target: T, patchToApply: _DeepPartial<T>): T {
// Handle Map instances
if (target instanceof Map && patchToApply instanceof Map) {
patchToApply.forEach((value, key) => target.set(key, value))
}
// Handle Set instances
if (target instanceof Set && patchToApply instanceof Set) {
patchToApply.forEach(target.add, target)
}
// no need to go through symbols because they cannot be serialized anyway
for (const key in patchToApply) {
if (!patchToApply.hasOwnProperty(key)) continue
const subPatch = patchToApply[key]
const targetValue = target[key]
if (
isPlainObject(targetValue) &&
isPlainObject(subPatch) &&
target.hasOwnProperty(key) &&
!isRef(subPatch) &&
!isReactive(subPatch)
) {
// NOTE: here I wanted to warn about inconsistent types but it's not possible because in setup stores one might
// start the value of a property as a certain type e.g. a Map, and then for some reason, during SSR, change that
// to `undefined`. When trying to hydrate, we want to override the Map with `undefined`.
target[key] = mergeReactiveObjects(targetValue, subPatch)
} else {
// @ts-expect-error: subPatch is a valid value
target[key] = subPatch
}
}
return target
}
可以看到它实际上是遍历对象的key,然后依次赋值的,关键点就在于这个判定:
if (
isPlainObject(targetValue) &&
isPlainObject(subPatch) &&
target.hasOwnProperty(key) &&
!isRef(subPatch) &&
!isReactive(subPatch)
) {} else {}
当我们传入的属性不是一个响应式对象且又是一个对象的时候,它会递归调用mergeReactiveObjects
方法,直到走到target[key] = subPatch
。pinia官方是通过深度遍历去一一替换值,从而不会丢失响应式。
这里我们还需要注意,如果传入的是一个vue3的响应式对象,它会直接将响应式对象作为值赋值到target上,此时如果在这之前你已经监听了值的响应式,是会丢失响应式的,其实这样也符合代码预期。
至此我已经明白为什么getters会丢失变化了,因为浅层替换导致了引用地址发生变化,原来的isLogin持有的还是旧值,所以导致$reset了,它也不会有变化。
解决办法
我们直接将initialState
对象作为参数传入就行了,让pinia自身提供的深度替换进行赋值,这样就不会丢失响应式了。
import type { PiniaPlugin } from "pinia";
import { easyDeepClone } from "@/utils/tools";
export const piniaReset: PiniaPlugin = ({ store }) => {
const initialState = easyDeepClone(store.$state);
store.$reset = () => {
store.$patch(initialState);
};
};
另一种做法:
我尝试将setup语法中的reactive对象全部替换成ref对象,这样userData
的引用多包了一层ref对象,当我们通过Object.assign
进行浅替换的时候,替换的是ref.value
的值,而getters订阅的也是ref.value
,此时ref本身的引用并不会发生变化,响应式效果也是有效的。
但是个人更推荐上一种做法,符合官方正统。
总结
- 自定义的$reset插件必须在第一位激活,否则会受到其他的插件的影响。
- 还原数据的时候直接将初始对象传入
$patch
,使用官方的深度替换以避免响应式丢失的问题。
本文系作者 @木灵鱼儿 原创发布在木灵鱼儿站点。未经许可,禁止转载。
暂无评论数据