likes
comments
collection
share

🍍 [Pinia-Plugin] 动动手你也能实现一个持久化存储插件

作者站长头像
站长
· 阅读数 34

🍍 [Pinia-Plugin] 动动手你也能实现一个持久化存储插件

嘿JYM大家好,好久不见甚是想念. 不知道大家都用上Pinia没有,反正我没有,在公司还是vue2+vuex一把梭。不过为了跟得上速度,花了一点时间把文档给翻阅了一遍,同时了解了一下如何实现一个Pinia插件,为了巩固插件实现找了github开源的一个持久化存储的插件pinia-plugin-persistedstate来学习,也把相关源码看完并对其进行了流程绘制和实现总结。希望这篇文章能够给你的学习带来帮助

Pinia-Plugin-Persistedstate

简单介绍一下:pinia-plugin-persistedstate是一个pinia状态同步存储的一个插件,可以自定义存储方式,自定义序列化配置和存储目标,可以对每个store进行单独配置也支持默认全局配置。

实现逻辑分析图

🍍 [Pinia-Plugin] 动动手你也能实现一个持久化存储插件 可以停留下放大图片多看几眼,接下来带大家动动手一起实现一下(如果看不懂,可能是我画的图太烂了...)

温馨小提示:本文希望你在看完Pinia官网插件章节的知识再来阅读更合适,同时动手实现可以加强巩固知识点

核心原理

我们知道pinia提供了一个全局的状态管理,而pinia-plugin-persistedstate核心原理是使用特定的Storage(如浏览器的localStoragesessionStorage等API)来存储Pinia状态,使得页面刷新或者关闭后,重新打开页面状态人能够被恢复, 主要使用的Pinia的两个核心API:

  • store.$patch 将相关数据设置到state进行状态恢复
  • store.$subscribestate被修改时同步数据到指定存储里

初始阶段

初始化阶段
Storage.getItem获取存储数据
store$patch将数据同步到state

数据检测阶段

store.$subscribe
数据变更
Storage.setItem持久化存储

简单实现

那么接下来我们根据以上的步骤明确我们的目标简单实现指定相关模块持久化存储到localStorage,以下基于vite创建一个vue + typescript项目(你也可以创建一个非ts)

由于代码存在相关的typescript类型声明,有些朋友可能没用过,不过可以先忽略其类型声明聚焦在具体逻辑实现上,但还是希望你能够学一下相关知识,毕竟ts确实太好用了

目录列表

├── stores
│   ├── index.ts
│   ├── plugins
│   │   └── persistedstate
│   │       ├── index.ts
│   │       ├── types.d.ts
│   │       └── utils.ts

stores/index.ts内创建pinia实例并导出

  // stores/index.ts
  import { createPinia } from "pinia";
  const pinia = createPinia();
  export default pinia;

依据官方所描述的,Pinia插件是一个函数,提供了context参数,context包含以下内容,而我们主要使用到storeoptions

  • pinia:createPinia() 创建的 pinia
  • app: 用 createApp() 创建的当前应用(仅 Vue 3)
  • store: 该插件想扩展的 store
  • options: 定义传给 defineStore() 的 store的对象

我们根据上文的流程先初步实现一下:

  1. 依据某个定义的某个store设置配置persist,这样我们就可以在插件中的options.persist来判断是否需要持久化存储
defineStore({
    // ...
    persist: true
})
// or
defineStore(() => {
 // ...
}, { persist: true })
  1. 进入到stores/plugins/persistedstate/index.ts创建一个名为createPersistedState的函数并返回一个插件函数(返回一个插件函数的原因是后面我们可以基于该工厂函数设置默认全局参数)
// persistedstate/index.ts
import { PiniaPluginContext, StateTree, Store } from "pinia";

// 这块声明主要是为了能够支持typescript类型提醒,拓展store的参数
declare module "pinia" {
  export interface DefineStoreOptionsBase<S, Store> {
    persist?: boolean | PersistedStateOptions;
  }
}

export default function createPersistedState() {
  return (context: PiniaPluginContext) => {
    // option可以获取到persist
    const { options, store } = context;
    const { persist } = options;
    // 判断persist, falsy值直接退出
    if (!persist) return;
    // 解析同步到state
    // $store.id:获取store的唯一标识符从storage获取数据并注入state
    hydrateStore(store, store.$id);

    // 监听数据变化
    store.$subscribe(
      (mutation, state) => {
        // 持久化存储状态
        // 以store.$id作为key,将数据存储到stroage
        persistState(state, store.$id);
      },
      {
        // 组件销毁时,当前监听不会被销毁
        detached: true,
      }
    );
  };
}

// 解析数据同步到state
function hydrateStore(store: Store, key: string) {
  const fromStorage = localStorage.getItem(key);
  if (fromStorage) {
    try {
      store.$patch(JSON.parse(fromStorage));
    } catch (err) {
      console.error(err);
    }
  }
}
// 序列化之后存储到storage
function persistState(state: StateTree, key: string) {
  try {
    localStorage.setItem(key, JSON.stringify(state));
  } catch (err) {
    console.error(err);
  }
}

// 使用伪代码
// pinia.use(createPersistedState())
// const useXXStore = defineStore({
//       state: ()=> ({}),
//       persist: true
// })



github分支: base

你可以复制以上代码到插件文件里面,尝试一下,或者到我的github克隆一下项目通过一个小todolList demo来进行理解。每实现一个小功能都会新增一个分支方便你切换查看

功能拓展

上面的代码已经完成我们的核心功能,但也并不满足我们业务需求的情况,所以我们进一步对perisit进行拓展

情景需求

  1. 【key】 有时候我们可能不想使用defineStore(key, options)key作为持久化存储的key,希望可以自定义或者添加前/后缀
  2. 【storage】 我们并不想使用LocalStorage来持久化存储,可能想使用SessionStorage在关闭窗口或标签页之后将会删除这些数据,又或者自定义Storage(自主实现包含类localStorageAPI)进行相关存储
  3. 【paths】 只是挑出某个state中指定的对象属性
  4. 【serializer】 自定义序列化/反序列化数据逻辑设置其存储或者store
  5. 【beforeRestore, afterRestore】 在store同步前后提供相关Hook控制
  6. 【debug】 持久化/恢复 Store 时可能发生的任何错误都将使用 console.error 输出
  7. 【Array数组对象类型的persiste】有一些数据想存储在localStorage,一些存储在sessionStorage, 比如如下场景:
defineStore('xxx', {
    state:() => {
       return {
           a: {
               b: {
                  c: {
                    d: 4444,
                  },
                },
               f: {
                 g: 343,
               },
            }
        }
    },
    persist: [
        {
            // 存储到sessionStorage
            paths: ["a.b.c.d"],
            storage: sessionStorage
        },
        {
            // 存储到localStorage
            paths: ["a.f.g"],
            storage: localStorage
        }
    ]
})

perisite拓展

由于我们在基础实现中是以boolean来判断是否存储的,所以我们需要对其进行类型扩展:

  • 支持Object 根据对象类型,根据相关情景需求自定义设置某个配置
  • 支持Object[] 支持情景需求第7种情况

下面我们persiste的参数选项进行拓展类型

// persistedstate/utils.ts
// 该方法首先对传入的第一个对象进行规范化成对象赋值到options
// 紧接着返回一个代理对象,如果options获取不到值,则从第二个参数globalOptions进行获取,key除外
// example: 
// true -> {}
// object -> [object]
// [object] -> [object]
export function normalizeOptions(options: boolean | PersistedStateOptions, globalOptions: PersistedStateFactoryOptions): PersistedStateOptions {
  options = (typeof options === "boolean" ? Object.create(null) : options) as PersistedStateOptions;
  return new Proxy(options, {
    get(target, key, receiver) {
      if (key === "key") {
        return Reflect.get(target, key, receiver);
      }
      return Reflect.get(target, key, receiver) || Reflect.get(globalOptions, key, receiver);
    },
  });
}

// persistedstate/index.ts
export default function createPersistedState() {
  return (context: PiniaPluginContext) => {
    const { options, store } = context;
    const { persist } = options;
    if (!persist) return;
    // normalizeOptions 看下文放在了 utils.ts
    // 由于需要支持Array和object,我们统一将其转换成Array方便后面处理
    // 这个方法你可以理解为persist类型进行参数规范化,同时在第一个
    // 参数获取不到值时到第二个参数获取,由于还没考虑到全局配置,这里先忽略normalizeOptions的第二个参数
    let persistOptions = (Array.isArray(persist) ? persist.map((p) => normalizeOptions(p, {})) : [normalizeOptions(persist, {})]).map((option) => {
      // 如果外部对persist设置的对象参数中存在空值的进行默认设置
      const {
        // 默认以localStorage为存储方式
        storage = localStorage,
        beforeRestore,
        afterRestore,
        // 默认以JSON的方法序列化和反序列化
        serializer = {
          serialize: JSON.stringify,
          deserialize: JSON.parse,
        },
        // 默认存储的key为定义defineStore的第一个参数
        key = store.$id,
        paths,
        debug = false,
      } = option;
      return {
        storage, beforeRestore, afterRestore, serializer, key, paths, debug
      };
    });

    // 遍历persist数组进行数据同步
    persistOptions.forEach((option) => {
      const { beforeRestore, afterRestore } = option;
      // 在同步数据时调用前置钩子
      beforeRestore?.(context);
      // 解析同步到state
      hydrateStore(store, option);
      // 同步结束后调用后置钩子
      afterRestore?.(context);

      store.$subscribe(
        (_mutation, state) => {
          persistState(state, option);
        },
        {
          detached: true,
        }
      );
    });
  };
}

解释一下以上代码:

  • 为了persist能够支持传入Boolean,Object,Array,我们在拿到其参数非Falsy值之后,对其转换成最终的Array(内部包含多个Object配置选项),经过normalizeOptions转换之后再遍历一次配置,将为空的配置选项进行默认设置,最终配置对象包含storage, beforeRestore, afterRestore, serializer, key, paths, debug

  • 由于我们最终生成的配置是一个数组,所以我们需要将之前的数据检测同步到state的代码进行调整,改成遍历方式;与此同时我们直接传入option配置对象到hydrateStorepersistState可以通过外部传入自定义。也就是不再写死storage和序列化和反序列化处理

// persistedstate/index.ts
// 解析数据同步到state
function hydrateStore(store: Store, option: Persistence) {
  const { storage, serializer , debug} = option;
  const fromStorage = storage.getItem(option.key);
  if (fromStorage) {
    try {
      store.$patch(serializer.deserialize(fromStorage));
    } catch (error) {
      // 当设置debug为true时,打印出错误
      if (debug) console.error(error);
    }
  }
}
// 序列化之后存储到storage
function persistState(state: StateTree, optons: Persistence) {
  const { storage, serializer, debug } = optons;
  try {
    storage.setItem(optons.key, serializer.serialize(state));
  } catch (error) {
    // 当设置debug为true时,打印出错误
    if (debug) console.error(error);
  }
}

这样我们基本完成了storage,serializer,debug,beforeRestore,afterRestore,剩余keypaths。由于到时候在全局参数配置时也要介绍key参数,所以放到后面再讲,先来完成paths的实现逻辑

首先我们先确认下我们的目标【从某对象中取出某个对象属性】

// 伪代码
// state对象
const state = {
    a: {
       b: {
          c: {
            d: 4444,
          },
        },
       f: {
         g: 343,
       },
    },
}
// 检出指定某个属性
// 取出obj对象中的c属性
const paths = ['b.c.d', '....']
const result = pick(state, paths)
// 结果: { a: { b: { c: { d: 444 } } } }

实现一下该方法

// persistedstate/utils.ts
function pick(state, paths) {
  // 创建一个空对象来进行设置
  return paths.reduce((prev, path) => {
    const pathArr = path.split(".");
    return set(prev, pathArr, get(state, pathArr));
  }, {});
}

// 从对象中获取相应的属性值
// 从对象中根据path遍历paths依次获取值,直至最后一个path并返回
function get(state, paths) {
  return paths.reduce((prev, path) => {
    return prev?.[path];
  }, state);
}

// 给指定对象设置某个属性下的值
function set(state, paths, value) {
  // slice(0,1)只需要遍历前面的path值填充即可,后面再给最后一个path设置值
  const res = paths.slice(0, -1).reduce((prev, p) => {
    // 开始直接从state取到值,接下来的每一次根据path是否存在对应的值,有直接引用赋值,不存在时直接创建一个空对象并返回,这里可能比较不好理解,可以用几个数据测试一下有助理解
    return (prev[p] = prev[p] || {});
  }, state);
  // 给最后的一个path进行设置值并返回
  res[paths[paths.length - 1]] = value;
  return state;
}

这样我们就实现了一个从对象中获取指定属性,回到persistState重新调整一下逻辑

// persistedstate/index.ts
// 同步到指定存储空间
function persistState(state, option) {
  const { paths, storage, serializer, key, debug } = option;
  try {
    // 在其位置判断是否传入paths进而过滤选出需要存储的数据,否则直接整个state进行储存
    const result = Array.isArray(paths) ? pick(state, paths) : state;
    storage.setItem(key, serializer.serialize(result));
  } catch (error) {
    if (debug) console.error(error);
  }
}

github分支: options

全局参数

由于我们除了key属性,其他功能基本都实现了一遍,现在新增一个全局参数设置主要目的是为了不需要为每一个定义的store进行配置,如果不配置默认读取全局配置和默认配置进行设置即可(key比较特殊)。有特殊场景需要给指定store不同配置,直接到目标store配置即可,以下是配置读取的优先级关系:

store配置
全局配置
默认配置

关于全局参数支持如下:

  • 默认都持久化存储,storage默认存储方式,默认序列化和反序列化方式,默认添加beforeRestoreafterRestore钩子
  • 对给每一个storekey进行重写或者添加前缀后缀

在前面你应该知道我们把函数写成了一个工厂函数目的就是为了传入全局参数globalOptions对象并返回一个插件函数,支持全局参数传入的有以下几个:

  1. key 函数(必须返回一个新的key值)
  2. storage
  3. serializer
  4. beforeRestore
  5. afterRestore
  6. auto 根据true 或者 false 判断默认是否需要持久化存储

实际上除了keyauto,其他的我们都在store配置实现了,剩下的工作只是在于store配置没有设置的时候直接从globalOptions中取即可。还记得我们之前的normalizeOptions的第二个参数吗?我们只需要将{}替换成globalOptions即可。

  • auto: 对于默认全局配置,我们只需要在globalOptions中获取auto并设置到persist中即可
// persistedstate/index.ts
export default function createPersistedState(globalOptions) { 
    // 默认每一个都不要持久化存储
    const { auto = false } = globalOptions
    return (context: PiniaPluginContext) => { 
        const { options, store } = context; 
        // 设置到persist默认值即可
        const { persist = auto } = options; 
        // ...
}
  • key: 对store构建key的时候,判断是否传入全局的key函数,是直接处理,否则创建一个默认key函数返回从store配置的key或者默认的store.$id
// persistedstate/index.ts
let persistOptions = (Array.isArray(persist) ? persist.map((p) => normalizeOptions(p, globalOptions)) : [normalizeOptions(persist, globalOptions)]).map((option) => {
  const {
    key = store.$id,
    // ...
  } = option;
  // 创建一个默认的key函数返回key的默认值(store.$id)
  const defualtKeyFn = (k) => k;
  // 如果有传入全部key函数,则将由全局key函数处理并返回
  const key = globalOptions.key ? globalOptions.key(key) : defaultKeyFn(key)
  return {
    key,
    // 其他配置参数...
  };
});

github分支: main

其他

可能有看过pinia-plugin-persistedstate的源码的朋友会发现还差$persist$hydrate呀,其实这并没有什么难度,在这里可以让大家自行实现一下(没错,我太懒了哈哈哈),或者到github查看下官方实现,在这里就不展开讲了了~嘿嘿。

总结

以上就是一个持久化存储插件的实现,其实只是用到pinia插件提供的context的几个方法的使用就实现了,关于其他的使用那就看各位的大展身手了。最后的最后也非常感谢各位能看完,希望能够对大家有帮助,期待在未来能够使用到在座各位的插件 😃。