前言

在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支持两种类型数据参数传入:

  1. 对象 (Object): 通过传入一个包含键值对的对象,你可以直接更新 store 中的多个状态。对象中的键是要更新的状态名称,值是更新的新值。
  2. 函数 (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] = subPatchpinia官方是通过深度遍历去一一替换值,从而不会丢失响应式。

这里我们还需要注意,如果传入的是一个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本身的引用并不会发生变化,响应式效果也是有效的。

但是个人更推荐上一种做法,符合官方正统。

总结

  1. 自定义的$reset插件必须在第一位激活,否则会受到其他的插件的影响。
  2. 还原数据的时候直接将初始对象传入$patch,使用官方的深度替换以避免响应式丢失的问题。
分类: vue 项目实战 标签: piniasetup$reset持久化插件$patchmergeReactiveObjects

评论

暂无评论数据

暂无评论数据

目录