實現一個簡易版的vuex持久化工具

  • 2019 年 10 月 3 日
  • 筆記

背景

最近用uni-app開發小程式項目時,部分需要持久化的內容直接使用vue中的持久化插件貌似不太行,所以想著自己實現一下類似vuex-persistedstate插件的功能,想著功能不多,程式碼量應該也不會很大

初步思路

首先想到的實現方式自然是vue的watcher模式。對需要持久化的內容進行劫持,當內容改變時,執行持久化的方法。
先弄個dep和observer,直接observer需要持久化的state,並傳入get和set時的回調:

function dep(obj, key, options) {      let data = obj[key]      Object.defineProperty(obj, key, {          configurable: true,          get() {              options.get()              return data          },          set(val) {              if (val === data) return              data = val              if(getType(data)==='object') observer(data)              options.set()          }      })  }  function observer(obj, options) {      if (getType(obj) !== 'object') throw ('參數需為object')      Object.keys(obj).forEach(key => {          dep(obj, key, options)          if(getType(obj[key]) === 'object') {              observer(obj[key], options)          }      })  }

然而很快就發現問題,比如將a={b:{c:d:{e:1}}}存入storage,操作一般是xxstorage(‘a’,a),接下來無論是改了a.b還是a.b.c或是a.b.c.d.e,都需要重新執行xxstorage(‘a’,a),即當某一項的後代節點變動時,我們需要沿著變動的後代節點找到它的根節點,然後將根節點下的內容全部替換成新的。
接下來的第一個問題就是,如何找到變動節點的祖先節點。

state樹的重新構造

方案一:沿著state向下找到變動的節點,根據尋找路徑確認變動項的根節點,此方案複雜度太高。
方案二:在observer的時候,對state中的每一項增添一個指向父節點的指針,在後代節點變動時,可以沿著指向父節點的指針找到相應的根節點,此方案可行。
為避免新增的指針被遍歷到,決定採用Symbol標記指針,於是dep部分變動如下:

const pointerParent = Symbol('parent')  const poniterKey = Symbol('key')  function dep(obj, key, options) {      let data = obj[key]      if (getType(data)==='object') {          data[pointerParent] = obj          data[poniterKey] = key      }      Object.defineProperty(obj, key, {          configurable: true,          get() {              ...          },          set(val) {              if (val === data) return              data = val              if(getType(data)==='object') {                  data[pointerParent] = obj                  data[poniterKey] = key                  observer(data)              }              ...          }      })  }

再加個可以找到根節點的方法,就可以改變對應storage項了

function getStoragePath(obj, key) {      let storagePath = [key]      while (obj) {          if (obj[poniterKey]) {              key = obj[poniterKey]              storagePath.unshift(key)          }          obj = obj[pointerParent]      }      // storagePath[0]就是根節點,storagePath記錄了從根節點到變動節點的路徑      return storagePath  }

但是問題又來了,object是可以實現自動持久化了,數組用push、pop這些方法操作時,數組的地址是沒有變動的,defineProperty根本監測不到這種地址沒變的情況(可惜Proxy兼容性太差,小程式中Android直接不支援)。當然,每次操作數組時,對數組重新賦值可以解決此問題,但是用起來太不方便了。

改變數組時的雙向綁定

數組的問題,解決方式一樣是參照vue源碼的處理,重寫數組的’push’, ‘pop’, ‘shift’, ‘unshift’, ‘splice’, ‘sort’, ‘reverse’方法
數組用這7種方法操作數組的時候,手動觸發set中部分,更新storage內容

添加防抖

vuex持久化時,容易遇到頻繁操作state的情況,如果一直更新storage,性能太差

實現程式碼

最後程式碼如下:
tool.js:

/*  持久化相關內容  */  // 重寫的Array方法  const funcArr = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse']  const typeArr = ['object', 'array']  // 各級指向父節點和及父節點名字的項  const pointerParent = Symbol('parent')  const poniterKey = Symbol('key')    function setCallBack(obj, key, options) {      if (options && options.set) {          if (getType(options.set) !== 'function') throw ('options.set需為function')          options.set(obj, key)      }  }    function rewriteArrFunc(arr, options) {      if (getType(arr) !== 'array') throw ('參數需為array')      funcArr.forEach(key => {          arr[key] = function(...args) {              this.__proto__[key].apply(this, args)              setCallBack(this[pointerParent], this[poniterKey], options)          }      })  }    function dep(obj, key, options) {      let data = obj[key]      if (typeArr.includes(getType(data))) {          data[pointerParent] = obj          data[poniterKey] = key      }      Object.defineProperty(obj, key, {          configurable: true,          get() {              if (options && options.get) {                  options.get(obj, key)              }              return data          },          set(val) {              if (val === data) return              data = val              let index = typeArr.indexOf(getType(data))              if (index >= 0) {                  data[pointerParent] = obj                  data[poniterKey] = key                  if (index) {                      rewriteArrFunc(data, options)                  } else {                      observer(data, options)                  }              }              setCallBack(obj, key, options)          }      })  }    function observer(obj, options) {      if (getType(obj) !== 'object') throw ('參數需為object')      let index      Object.keys(obj).forEach(key => {          dep(obj, key, options)          index = typeArr.indexOf(getType(obj[key]))          if (index < 0) return          if (index) {              rewriteArrFunc(obj[key], options)          } else {              observer(obj[key], options)          }      })  }    function getStoragePath(obj, key) {      let storagePath = [key]      while (obj) {          if (obj[poniterKey]) {              key = obj[poniterKey]              storagePath.unshift(key)          }          obj = obj[pointerParent]      }      return storagePath  }    function debounceStorage(state, fn, delay) {      if(getType(fn) !== 'function') return null      let updateItems = new Set()      let timer = null      return function setToStorage(obj, key) {          let changeKey = getStoragePath(obj, key)[0]          updateItems.add(changeKey)          clearTimeout(timer)          timer = setTimeout(() => {              try {                  updateItems.forEach(key => {                      fn.call(this, key, state[key])                  })                  updateItems.clear()              } catch(e) {                  console.error(`persistent.js中state內容持久化失敗,錯誤位於[${changeKey}]參數中的[${key}]項`)              }          }, delay)      }  }  export function persistedState({state, setItem, getItem, setDelay=0}) {      if(getType(getItem) === 'function') {          // 初始化時將storage中的內容填充到state          try{              Object.keys(state).forEach(key => {                  if(state[key] !== undefined)                      state[key] = getItem(key)              })          } catch(e) {              console.error('初始化過程中獲取持久化參數失敗')          }      } else {          console.warn('getItem不是一個function,初始化時獲取持久化內容的功能不可用')      }      observer(state, {          set: debounceStorage(state, setItem, setDelay)      })  }  /*  通用方法  */  export function getType(para) {      return Object.prototype.toString.call(para)          .replace(/[object (.+?)]/, '$1').toLowerCase()  }

persistent.js中調用:

import {persistedState} from 'tools.js'  ...  ...  // 因為是uni-app小程式,持久化是調用uni.setStorageSync,網頁就用localStorage.setItem  // 1000僅是測試值,實際可設為200以內或直接設為0  persistedState({      state,      setItem: uni.setStorageSync,      getItem: uni.getStorageSync,      setDelay: 1000  })

經測試,持久化的state項中的內容變動時,storage會自動持久化對應的項,防抖也能有效防止state中內容頻繁變化時的性能問題。

註:
由於網頁的localStorage的setItem需要轉換成字元串,getItem時又要JSON.parse一下,網頁中使用該功能時tools.js需做如下修改:

function debounceStorage(state, fn, delay) {  ...                  updateItems.forEach(key => {                  fn.call(this, key, JSON.stringify(state[key]))              })  ...  }  function persistedState({state, setItem, getItem, setDelay=0}) {  ...                  if(state[key] !== undefined) {                  try{                      state[key] = JSON.parse(getItem(key))                  }catch(e){                      state[key] = getItem(key)                  }              }  ...  }

在網頁中,調用方式如下:

import {persistedState} from 'tools.js'  const _state = {A: '',B: {a:{b:[1,2,3]}}}  persistedState({      state:_state,      setItem: localStorage.setItem.bind(localStorage),      getItem: localStorage.getItem.bind(localStorage),      setDelay: 200  }) 

修改_state.A、_state.B及其子項,可觀察localStorage中存入數據的變化
(可直接打開源碼地址中的<網頁state持久化.html>查看)

源碼地址

https://github.com/goblin-pitcher/uniapp-miniprogram