Zustand使用idb-keyval进行持久化

Monday, October 14, 2024

背景

Zustand 是一个轻量级的状态管理库,除了直观易用的管理功能,还可以支持中间件扩展。这次需求中的持久化(页面刷新或重新加载后保留数据)即是依赖中间件完成的,可以参照官网文档的例子

如果是一般的业务需求,上面的代码已经可以基本达到效果,但由于项目需求,涉及了大量的键值对增删,出于性能(主要是删除性能优于Object)考虑,使用了许多Map类型进行数据存储。但因为Map本身不是JSON可序列化的类型(键类/顺序/方法…),所以Zustand本身并不支持Map的持久化。

基于这个问题,进行一番查阅后,发现在浏览器存储中,可以使用IndexedDB保存复杂的数据结构(Map),而且IndexedDB在性能上和存储容量上都具有优势,于是决定研究如何在Zustand中配合使用。

初痛

经过进一步的查阅后,发现了idb-keyval 这个轻量库,可以简化IndexedDB 的使用,对于此时只想尽快解决持久化问题的我来说,是一个非常好的选择。

出于习惯,第一时间我在Zustand的文档中找到了持久化自定义相关的说明(createJSONStorage),但老实说一番研究和倒腾后还是没成功的将idb-keyvalZustand结合起来,我决定用更直接的方式找找解决方法,我找到了ZustandGithub项目,在issues中直接搜索idk-keyval,果不其然,世界上有这个问题的人不止我一个,其中一条issue很符合我的困境,作者(看起来大多数是他自己在维护,也没有时间面面俱到了)在其中给出了解决思路,需要自己实现一下。

 export interface StateStorage { 
   getItem: (name: string) => string | null | Promise<string | null> 
   setItem: (name: string, value: string) => void | Promise<void> 
   removeItem: (name: string) => void | Promise<void> 
 } 

在结合这个issue与stackflow还有伟大的gpt以及我本人一次次的踩坑后(我已经不记得那天晚上尝试过什么了,反正坑很多很烦😡),我终于成功将它们链接起来了,最终写出了可用的Stroage存储。

import { StateStorage } from 'zustand/middleware';
import { get, set, del } from 'idb-keyval';

const storage: StateStorage = {
  getItem: async (name: string): Promise<string | null> => {
    console.log(name, 'has been retrieved');
    return (await get(name)) || null;
  },
  setItem: async (name: string, value: string): Promise<void> => {
    console.log(name, 'with value', value, 'has been saved');
    await set(name, value);
  },
  removeItem: async (name: string): Promise<void> => {
    console.log(name, 'has been deleted');
    await del(name);
  },
};

export default storage;

其实从最后的代码看起来,非常的简单,一眼就懂,难得是我没一直没找到正确的信息,浪费了许多时间在试其它的方案,以及研究到底是它的方案不行,还是我自己用的不对,有时信息太多也不是好事。

大痛

到此时理论上已经把问题解决了,浏览器可以很好的存储Map结构了,可以皆大欢喜commit关机了,但下播前我我随机刷新了一下F5,发现存储的数据没有读取成功,随机点了一下其它操作,数据又出现了,我就知道完蛋了。

写的有点长了,就长话短说了,直接先给出结论,是Zustand的初始化时机不对,因为每一个Store持久化恢复的需要Zustand的创建。我当时认为可以和普通的localStorage一样,我在需要用的组件中直接读取缓存中的record,然后将它填入那个组件即可,所以这个Store我可以不用在初始化的时候创建,在后续改动的时候再初始化也可以。最终在页面最开始我将会用到数据的Store都初始化了一遍,问题也顺利解决。

从结论来看,又是非常简单的一个bug😭,但那天可是真苦,我第一时间的直觉并不是初始化的方向,因为受上个问题影响太久,我也不确定上面的Stroage存储写法有没有问题,又来回试了好几遍其它的写法,后面又大开脑洞,我觉得是idb-keyvalZustand本身的持久化恢复API onRehydrateStorage 可能会有冲突,又曲线救国自己写了几个方法,直接从IndexedDB取值,尝试在初始化的时候手动create把值给Zustand植入进去,反正试过许多条乱七八糟的路线,浏览器都换了三在想是不是兼容性有问题(但用之前我是查过IndexedDB 支持的内核版本的)。后面开始思考是自己的组件写的有问题,是不是哪里的状态更新写重了,导致异步把空值更新进去了,还有许多其它的想法。最后还是清醒了点,研究为什么进行操作后,值又重新恢复了,才反推得出是Store的初始化时机不对😡(以后遇到这种问题建议先洗个澡清醒一下)。

storeManager

因业务需求,还需要每一个Store支持多版本的缓存恢复功能

import create from 'zustand';
import { useSheetStore } from './sheetStore';
import { useDirectoryStore } from './directoryStore';
import { useMapStore } from './mapStore';

export const useServiceIdStore = create((set) => ({
  serviceId: null,
  setServiceId: (id) => set(() => ({ serviceId: id })),
}));

let storesMap = {};

export const getFillStore = (storeNames) => {
  const storeInitializers = {
    directory: useDirectoryStore,
    map: useMapStore,
    sheet: useSheetStore,
  };

  const serviceId = useServiceIdStore.getState().serviceId;

  return storeNames.map((storeName) => {
    if (!storesMap[storeName]) {
      storesMap[storeName] = {};
    }
    if (!storesMap[storeName][serviceId]) {
      storesMap[storeName][serviceId] = storeInitializers[storeName](serviceId);
    }
    return storesMap[storeName][serviceId];
  });
};

export const clearAllStores = () => {
  useSheetStore.getState().resetSheetStore();
  const serviceId = useServiceIdStore.getState().serviceId;
  // 遍历所有的stores并清除
  Object.keys(storesMap).forEach((storeName) => {
    if (storesMap[storeName] && storesMap[storeName][serviceId]) {
      delete storesMap[storeName][serviceId];
    }
  });
};