用localStorage快取Redux的state

deggs7發表於2017-10-31

問題

概念

對於目前普遍的“單頁應用”,其中的好處是,前端可以從容的處理較複雜的資料模型,同時基於資料模型可以進行變換,實現更為良好的互動操作。

良好的互動操作背後,其實是基於一個對應到頁面元件狀態的模型,隨便稱其為UI模型

資料模型對應的是後端資料庫中的業務資料,UI模型對應的是使用者在瀏覽器一系列操作後元件所呈現的狀態。

這兩個模型不是對等的!

比如下圖中這個管控臺(不存在所謂的子頁面,來進行單頁路由的切換,而是一個類似portal的各塊元件的切換):

screenshot.png

我們構建的這個單頁應用,後端的資料庫和提供的介面,是儲存和管理資料模型的狀態。

但是使用者操作管控臺中,左側皮膚的開啟/關閉、列表選中的專案、編輯皮膚的開啟等,這些UI模型的狀態均不會被後端記錄。

現象

當使用者強制進行頁面重新整理,或者關閉頁面後又再次開啟時,單頁應用雖然能從後端拉取資料記錄,但是頁面元件的狀態已經無法恢復了。

目前,多數的單頁應用的處理,就是在頁面重新整理或重新開啟後,拋棄之前使用者操作後的狀態,進到一個初始狀態。(當然,如果涉及較多內容編輯的,會提示使用者先儲存等等)

但這樣,顯然是 對互動的一種妥協

方案設計

技術場景

我們的單頁應用是基於Redux+React構建。

元件的 大部分狀態 (一些非受控元件內部維護的state,確實比較難去記錄了)都記錄在Redux的store維護的state中。
正是因為Redux這種基於全域性的狀態管理,才讓“UI模型”可以清晰浮現出來。

所以,只要在瀏覽器的本地儲存(localStorage)中,將state進行快取,就可以(基本)還原使用者最後的互動介面了

何時取

先說何時取,因為這塊好說。

假設我們已經存下了state,localStorage中就會存在一個序列化後的state物件。

screenshot.png

在介面中還原state,只需要在應用初始化的時候,Redux建立store的時候取一次就可以。

...

const loadState = () => {
  try { // 也可以容錯一下不支援localStorage的情況下,用其他本地儲存
    const serializedState = localStorage.getItem(`state`);
    if (serializedState === null) {
      return undefined;
    } else {
      return JSON.parse(serializedState);
    }
  } catch (err) {
    // ... 錯誤處理
    return undefined;
  }
}

let store = createStore(todoApp, loadState())
...

何時存

儲存state的方式很簡單:

const saveState = (state) => {
  try {
    const serializedState = JSON.stringify(state);
    localStorage.setItem(`state`, serializedState);
  } catch (err) {
    // ...錯誤處理
  }
};

至於何時觸發儲存,一種簡(愚)單(蠢)的方式是,在每次state發生更新的時候,都去持久化一下。這樣就能讓本地儲存的state時刻保持最新狀態。

基於Redux,這也很容易做到。在建立了store後,呼叫subscribe方法可以去監聽state的變化。

// createStore之後

store.subscribe(() => {
  const state = store.getState();
  saveState(state);
})

但是,顯然,從效能角度這很不合理(不過也許在某些場景下有這個必要)。所以機智的既望同學,提議只在onbeforeunload事件上就可以。

window.onbeforeunload = (e) => {
  const state = store.getState();
  saveState(state);
};

所以,只要使用者重新整理或者關閉頁面時,都會默默記下當前的state狀態。

何時清空

一存一取做到後,特性就已實現。版本上線,使用者使用,本地快取了state,當前的應用毫無問題。

但是當再次釋出新版本程式碼後,問題就來了。
新程式碼維護的state和之前的結構不一樣,使用者用新的程式碼,讀取自己本地快取的舊的state,難免會出錯。
然而使用者此時無論怎麼操作,都不會清楚掉自己本地快取的state(不詳細說了,主要就是因為上面loadState和saveState的邏輯,導致。。。錯誤的state會一直被反覆儲存,即使在developer tools中手動清除localStorage也不會有效果)

解決就是,state需要有個版本管理,當和程式碼的版本不一致時,至少進行個清空操作。
目前專案中,採用的以下方案:

直接利用state,在其中增加一個節點,來記錄version。即增加對應的action、reducer,只是為了維護version的值。

...
// Actions
export function versionUpdate(version = 0.1) {
  return {
    type    : VERSION_UPDATE,
    payload : version
  };
}
...

儲存state的邏輯改動較小,就是在每次儲存的時候,要把當前程式碼的version更新到state。

...
window.onbeforeunload = (e) => {
  store.dispatch({
    type: `VERSION_UPDATE`,
    payload: __VERSION__  // 程式碼全域性變數,隨工程配置一起處理即可。每次涉及需要更新state的時候,必須更新此版本號。
  })
  const state = store.getState();
  saveState(state);
}
...

讀取state的時候,則要比較程式碼的版本和state的版本,不匹配則進行相應處理(清空則是傳給createStore的初始state為undefined即可)

export const loadState = () => {
  try {
    const serializedState = localStorage.getItem(`state`);
    if (serializedState === null) {
      return undefined;
    } else {
      let state = JSON.parse(serializedState);
      // 判斷本地儲存的state版本,如果落後於程式碼的版本,則清空state
      if (state.version < __VERSION__) {
        return undefined;
      } else {
        return state;
      }
    }
  } catch (err) {
    // ...錯誤處理
    return undefined;
  }
};

其他參考


相關文章