DIY 一个 Vuex 持久化插件

在做 Vue 相关项目的时候,总会遇到因为页面刷新导致 Store 内容丢失的情况。复杂的项目往往涉及大量的状态需要管理,如果仅因为一次刷新就需要全部重新获取,代价也未免太大了。

那么我们能不能对这些状态进行本地的持久化呢?答案是可以的,社区里也提供了不少的解决方案,如 vuex-persistedstatevuex-localstorage 等插件,这些插件都提供了相对完善的功能。当然除了直接使用第三方插件以外,我们自己来 DIY 一个也是非常容易的。

这个持久化插件主要有2个功能:

  1. 能够选择需要被持久化的数据。
  2. 能够从本地读取持久化数据并更新至 Store。

接下来我们会从上述两个功能点出发,完成一个 Vuex 持久化插件。

Gist地址:https://gist.github.com/jrain...
在线体验地址:https://codepen.io/jrainlau/p...

一、学习写一个 Vuex 插件

引用 Vuex 官网 的例子:

Vuex 的 store 接受 plugins 选项,这个选项暴露出每次 mutation 的钩子。Vuex 插件就是一个函数,它接收 store 作为唯一参数:

const myPlugin = store => {
  // 当 store 初始化后调用
  store.subscribe((mutation, state) => {
    // 每次 mutation 之后调用
    // mutation 的格式为 { type, payload }
  })
}

然后像这样使用:

const store = new Vuex.Store({
  // ...
  plugins: [myPlugin]
})

一切如此简单,关键的一点就是在插件内部通过 store.subscribe() 来监听 mutation。在我们的持久化插件中,就是在这个函数内部对数据进行持久化操作。

二、允许用户选择需要被持久化的数据

首选初始化一个插件的主体函数:

const VuexLastingPlugin = function ({
  watch: '*',
  storageKey: 'VuexLastingData'
}) {
  return store => {}
}

插件当中的 watch 默认为全选符号 *,允许传入一个数组,数组的内容为需要被持久化的数据的 key 值,如 ['key1', 'key2'] 等。接着便可以去 store.subscribe() 里面对数据进行持久化操作了。

const VuexLastingPlugin = function ({
  watch: '*'
}) {
  return store => {
    store.subscribe((mutation, state) => {
      let watchedDatas = {}
      // 如果为全选,则持久化整个 state 
      // 否则将只持久化被列出的 state
      if (watch === '*') {
        watchedDatas = state
      } else {
        watch.forEach(key => {
          watchedDatas[key] = state[key]
        })
      }
      // 通过 localStorage 持久化
      localStorage && localStorage.setItem(storageKey, JSON.stringify(watchedDatas))
    })
  }
}

按照 Vuex 的规范,有且只有通过 mutation 才能够修改 state,于是按照上面的步骤,我们便完成了对数据进行实时持久化的工作。

这里也有一个小问题,就是写入 watch 参数的数组元素必须是 state 当中的最外层 key ,不支持形如 a.b.c 这样的嵌套 key。这样的功能显然不够完善,所以我们希望可以增加对嵌套 key 的支持。

新建一个工具函数 getObjDeepValue()

function getObjDeepValue (obj, keysArr) {
  let val = obj
  keysArr.forEach(key => {
    val = val[key]
  })
  return val
}

该函数接收一个对象和一个 key 值数组, 返回对应的值,我们来验证一下:

var obj = {
  a: {
    name: 'aaa',
    b: {
      name: 'bbb',
      c: {
        name: 'ccc'
      }
    }
  }
}

getObjDeepValue(obj, 'a.b.c'.split('.'))

// => { name: "ccc" }

验证成功以后,便可以把这个工具函数也放进 store.subscribe() 里使用了:

store.subscribe((mutation, state) => {
      let watchedDatas = {}
      if (watch === '*') {
        watchedDatas = state
      } else {
        watch.forEach(key => {
          // 形如 a.b.c 这样的 key 会被保存为 deep_a.b.c 的形式
          if (data.split('.').length > 1) {
            watchedDatas[`deep_${key}`] = getObjDeepValue(state, key.split('.'))
          } else {
            watchedDatas[key] = state[key]
          }
        })
      }
      
      localStorage && localStorage.setItem(storageKey, JSON.stringify(watchedDatas))
    })

经过这一改造,通过 watch 写入的 key 值将支持嵌套的形式,整个插件将会更加灵活。

三、从本地读取持久化数据并更新至 Store

从上面的步骤我们已经能够灵活监听 store 里的数据并持久化它们了,接下来的工作就是完成如何在浏览器刷新之后去读取本地持久化数据,并把它们更新到 store。

为插件添加一个默认为 true 的选项 autoInit,作为是否自动读取并更新 store 的开关。从功能上来说,刷新浏览器之后插件应该自动读取 localStorage 里面所保存的数据,然后把它们更新到当前的 store。关键的点就是如何把 deep_${key} 的值正确赋值到对应的地方,所以我们需要再新建一个工具函数 setObjDeepValue()

function setObjDeepValue (obj, keysArr, value) {
  let key = keysArr.shift()
  if (keysArr.length) {
    setObjDeepValue(obj[key], keysArr, value)
  } else {
    obj[key] = value
  }
}

该函数接收一个对象,一个 key 值数组,和一个 value ,设置对象对应 key 的值,我们来验证一下:

var obj = {
  a: {
    name: 'aaa',
    b: {
      name: 'bbb',
      c: {
        name: 'ccc'
      }
    }
  }
}

setObjDeepValue(obj, ['a', 'b', 'c'], 12345)

/**
obj = {
  a: {
    name: 'aaa',
    b: {
      name: 'bbb',
      c: 12345
    }
  }
}
*/

有了这个工具方法,就可以正式操作 store 了。

if (autoInit) {
      const localState = JSON.parse(storage && storage.getItem(storageKey))
      const storeState = store.state
      if (localState) {
        Object.keys(localState).forEach(key => {
          // 形如 deep_a.b.c 形式的值会被赋值到 state.a.b.c 中
          if (key.includes('deep_')) {
            let keysArr = key.replace('deep_', '').split('.')
            setObjDeepValue(storeState, keysArr, localState[key])
            delete localState[key]
          }
        })
        // 通过 Vuex 内置的 store.replaceState 方法修改 store.state
        store.replaceState({ ...storeState, ...localState })
      }
    }

上面这段代码会在页面初始化的时候读取 storage 的值,然后把形如 deep_a.b.c 的值提取并赋值到 store.state.a.b.c 当中,最后通过 store.replaceState() 方法更新整个 store.state 的值。这样便完成了从本地读取持久化数据并更新至 Store 的功能。

四、案例测试

我们可以写一个案例,来测试下这个插件的运行情况。

在线体验:https://codepen.io/jrainlau/p...

DIY 一个 Vuex 持久化插件

App.vue

<template>
  <div id="app">
    <pre>{{$store.state}}</pre>

    <button @click="updateA">updateA</button>
    <button @click="updateX">UpdateX</button>
  </div>
</template>

<script>
export default {
  name: 'app',
  methods: {
    updateA () {
      let random = Math.random()
      this.$store.commit('updateA', {
        name: 'aaa' + random,
        b: {
          name: 'bbb' + random,
          c: {
            name: 'ccc' + random
          }
        }
      })
    },
    updateX () {
      this.$store.commit('updateX', { name: Math.random() })
    }
  }
}
</script>

store.js

import Vue from 'vue'
import Vuex from 'vuex'
import VuexPlugin from './vuexPlugin'

Vue.use(Vuex)

export default new Vuex.Store({
  plugins: [VuexPlugin({
    watch: ['a.b.c', 'x']
  })],
  state: {
    a: {
      name: 'aaa',
      b: {
        name: 'bbb',
        c: {
          name: 'ccc'
        }
      }
    },
    x: {
      name: 'xxx'
    }
  },
  mutations: {
    updateA (state, val) {
      state.a = val
    },
    updateX (state, val) {
      state.x = val
    }
  }
})

从案例可以看出,我们针对 state.a.b.c 和 state.x 进行了数据持久化。在整个 state.a 都被修改的情况下,仅仅只有 state.a.b.c 被存入了 localStorage ,数据恢复的时候也只修改了这个属性。而 state.x 则整个被监听,所以任何对于 state.x 的改动都会被持久化并能够被恢复。

尾声

这个 Vuex 插件仅在浏览器环境生效,未曾考虑到 SSR 的情况。有需要的同学可以在此基础上进行扩展,就不再展开讨论了。如果发现文章有任何错误或不完善的地方,欢迎留言和我一同探讨。

相关推荐