解決頁面刷新redux數據丟失問題。

概念

對於目前廣泛的「單頁應用」,其中的好處是,前端能夠從容的處理較複雜的數據模型,同時基於數據模型能夠進行變換,實現更爲良好的交互操做。前端

良好的交互操做背後,實際上是基於一個對應到頁面組件狀態的模型,隨便稱其爲UI模型react

數據模型對應的是後端數據庫中的業務數據,UI模型對應的是用戶在瀏覽器一系列操做後組件所呈現的狀態。git

這兩個模型不是對等的!github

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

screenshot.png

咱們構建的這個單頁應用,後端的數據庫和提供的接口,是存儲和管理數據模型的狀態。redux

可是用戶操做管控臺中,左側面板的打開/關閉、列表選中的項目、編輯面板的打開等,這些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;
  }
};

 

如下不是轉的,是本身寫的。

Redux 源碼之 createStore

解讀 redux 源碼之 createStore,代碼目錄在 redux/src/createStore。

import isPlainObject from 'lodash/isPlainObject'
import $$observable from 'symbol-observable'

/** * 這是 redux 保留的私有的 action types。 * 對於任何未知的 actions,你必需要返回當前的狀態。 * 若是當前的狀態是沒有定義的,你都要返回一個初始的狀態。 * 不要在你的代碼中直接引用這些 action types。 */
export const ActionTypes = {
  INIT: '@@redux/INIT'
}

/** * 建立一個持有狀態樹的 redux store。 * 調用dispatch() 是惟一的一種方式去修改 store中的的值。 * 應用中應該只有一個 store。爲了將程序狀態中不一樣部分的變動邏輯 * 組合在一塊兒,你須要使用 combineReducers 將一些 * reducers 合併成一個reducer * * @param {Function} reducer 一個返回下一個狀態樹的方法,須要提供當 * 前的狀態樹和要發送的 action。 * * @param {any} [preloadedState] 初始的狀態。 * 您能夠選擇指定它來保存通用應用程序中服務器的狀態,或者恢復 * 之前序列化的用戶會話。 * 若是你使用了`combineReducers`方法來生成最終的reducer。那麼這個初始狀 * 態對象的結構必須與調用`combineReducers`方法時傳入的參數的結構保持相 * 同。 * * @param {Function} [enhancer] store加強器。你能夠選擇性的傳入一個加強函 * 數取加強 store,例如中間件,時間旅行,持久化。這 redux 惟一一個自帶的 * 加強器是的 applyMiddleware * * @returns {Store} 一個可讓你讀狀態,發佈 actions 和訂閱變化的 redux * store */
export default function createStore(reducer, preloadedState, enhancer) {
  // 若是 preloadedState類型是function,enhancer類型是undefined,那認爲用
  // 戶沒有傳入preloadedState,就將preloadedState的值傳給 
  // enhancer,preloadedState值設置爲undefined
  if (typeof preloadedState === 'function' && typeof enhancer === 'undefined') {
    enhancer = preloadedState
    preloadedState = undefined
  }
  // enhancer類型必須是一個function
  if (typeof enhancer !== 'undefined') {
    if (typeof enhancer !== 'function') {
      throw new Error('Expected the enhancer to be a function.')
    }
    // 返回使用enhancer加強後的store
    return enhancer(createStore)(reducer, preloadedState)
  }
  // reducer必須是一個function
  if (typeof reducer !== 'function') {
    throw new Error('Expected the reducer to be a function.')
  }

  let currentReducer = reducer
  let currentState = preloadedState
  let currentListeners = []
  let nextListeners = currentListeners
  let isDispatching = false

  // 在每次修改監聽函數數組以前複製一份,實際的修改的是新
  // 複製出來的數組上。確保在某次 dispatch 發生前就存在的監聽器,
  // 在該次dispatch以後都能被觸發一次。
  function ensureCanMutateNextListeners() {
    if (nextListeners === currentListeners) {
      nextListeners = currentListeners.slice()
    }
  }

  /** * 讀取 store 管理的狀態樹 * * @returns {any} 返回應用中當前的狀態樹 */
  function getState() {
    return currentState
  }

  /** * 添加一個改變監聽器。它將在一個action被分發的時候觸發,而且狀態數的某 * 些部分可能已經發生了變化。那麼你能夠調用 getState 來讀取回調中的當前 * 狀態樹。 * * 你能夠從一個改變的監聽中調用 dispatch(),注意事項: * * 1.在每一次調用 dispatch() 以前監聽器數組都會被複制一份。若是你在監聽函 * 數中訂閱或者取消訂閱,這個不會影響當前正在進行的 dispatch()。而下次 * dispatch()是不是嵌套調用,都會使用最新的修改後的監聽列表。 * 2.監聽器不但願看到哦啊全部狀態的改變,如狀態可能在監聽器被調用前可能 * 在嵌套 dispatch() 可能更新過屢次。可是,在某次dispatch * 觸發以前已經註冊的監聽函數均可以讀取到此次diapatch以後store的最新狀 * 態。 * * @param {Function} listener 在每次 dispatch 以後會執行的回調函數。 * @returns {Function} 返回一個用於取消此次訂閱的函數。 */
  function subscribe(listener) {
    if (typeof listener !== 'function') {
      throw new Error('Expected listener to be a function.')
    }

    let isSubscribed = true

    ensureCanMutateNextListeners()
    nextListeners.push(listener)

    return function unsubscribe() {
      if (!isSubscribed) {
        return
      }

      isSubscribed = false

      ensureCanMutateNextListeners()
      const index = nextListeners.indexOf(listener)
      nextListeners.splice(index, 1)
    }
  }

  /** * 發送一個 action,這是惟一一種觸發狀態改變的方法。 * 每次發送 action,用於建立 store 的 `reducer` 都會被調用一次。調用時傳入 * 的參數是當前的狀態以及被髮送的 action。的返回值將被看成下一次的狀 * 態,而且監聽器將會被通知。 * * 基礎實現只支持簡單對象的 actions。若是你但願能夠發送 * Promise,Observable,thunk火氣其餘形式的 action,你須要用相應的中間 * 件把 store建立函數封裝起來 。例如,你能夠參閱 `redux-thunk`包的文檔。 * 不過這些中間件仍是經過 dispatch 方法發送簡單對象形式的 action。 * * @param {Object} action,一個標識改變了什麼的對象。這是一個很好的點子 * 保證 actions 可被序列化,這樣你就能夠記錄而且回放用戶的操做,或者使用 * 能夠穿梭時間的插件 `redux-devtools`。一個 action 必須有一個值不爲 * `undefined`的type屬性,推薦使用字符串常量做爲 action types。 * * @returns {Object} 爲了方便起見,返回傳入的 action 對象。 * * 要注意的是,若是你使用一個自定義的中間件,可能會把`dispatch()`的返回 * 值封裝成其餘內容(好比,一個能夠await的Promise)。 */
  function dispatch(action) {
    // 若是 action不是一個簡單對象,拋出異常
    if (!isPlainObject(action)) {
      throw new Error(
        'Actions must be plain objects. ' +
        'Use custom middleware for async actions.'
      )
    }

    if (typeof action.type === 'undefined') {
      throw new Error(
        'Actions may not have an undefined "type" property. ' +
        'Have you misspelled a constant?'
      )
    }
    
    // reducer內部不容許再次調用dispatch,不然拋出異常
    if (isDispatching) {
      throw new Error('Reducers may not dispatch actions.')
    }

    try {
      isDispatching = true
      currentState = currentReducer(currentState, action)
    } finally {
      isDispatching = false
    }

    const listeners = currentListeners = nextListeners
    for (let i = 0; i < listeners.length; i++) {
      const listener = listeners[i]
      listener()
    }

    return action
  }

  /** * 替換 store 當前使用的 reducer 函數。 * * 若是你的程序代碼實現了代碼拆分,而且你但願動態加載某些 reducers。或 * 者你爲 redux 實現一個熱加載的時候,你也會用到它。 * * @param {Function} nextReducer 替換後的reducer * @returns {void} */
  function replaceReducer(nextReducer) {
    if (typeof nextReducer !== 'function') {
      throw new Error('Expected the nextReducer to be a function.')
    }

    currentReducer = nextReducer
    dispatch({ type: ActionTypes.INIT })
  }

  /** * 爲 observable/reactive庫預留的交互接口。 * @returns {observable} 標識狀態變動的最簡單 observable兌現。 * 想要得到更多的信息,能夠查看 observable的提案: * https://github.com/tc39/proposal-observable */
  function observable() {
    const outerSubscribe = subscribe
    return {
      /** * 一個最簡單的 observable 訂閱方法。 * @param {Object} observer,任何的能夠被做爲observer使用的對象。 * observer對象應該包含`next`方法。 * @returns {subscription} 返回一個 object 帶有用於從store 解除 observable而且進一步中止接收 值 的`unsubscribe`方法的對象。 */
      subscribe(observer) {
        if (typeof observer !== 'object') {
          throw new TypeError('Expected the observer to be an object.')
        }

        function observeState() {
          if (observer.next) {
            observer.next(getState())
          }
        }

        observeState()
        const unsubscribe = outerSubscribe(observeState)
        return { unsubscribe }
      },

      [$$observable]() {
        return this
      }
    }
  }

  // 當一個store建立好,一個 "INIT" 的 action 就會分發,以便每一個 reducer返回
  // 初始的狀態,這有效填充初始的狀態樹。
  dispatch({ type: ActionTypes.INIT })

  return {
    dispatch,
    subscribe,
    getState,
    replaceReducer,
    [$$observable]: observable
  }
}


查看源碼發現createStore,能夠接受一個更改的state,配合redux-thunk最後的代碼以下:

//二、引入redux和引入reducer
import {createStore, applyMiddleware, compose} from 'redux';
//import reducer from './reducers';
import rootReducer from './combineReducers';
import thunk from 'redux-thunk';

//三、建立store

const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
let store = null;

const loadState = () => {
    try {
        const serializedState = sessionStorage.getItem('state');
        if (serializedState === null) {
            return undefined;
        } else {
            return JSON.parse(serializedState);
        }
    } catch (err) {
        // ... 錯誤處理
        return undefined;
    }
}
if(process.env.NODE_ENV === 'development'){
    store = createStore(rootReducer,loadState(), composeEnhancers(
        applyMiddleware(thunk)
    ));
}else{
    store = createStore(rootReducer,loadState(),applyMiddleware(thunk))
}

export default store;

 

因爲store的數據變化會經過subscribe來監聽,因此這時候保存到sessionStorage裏的數據是最新的store數據

createStore的時候會從sessionStorage裏取。問題解決。

在本次解決問題的過程當中,使用過react-persist這個插件,發現它的數據確實也同步給sessionStorage了,可是頁面刷新

store數據沒了,也同步給sessionStorage裏了,最後只好用了以上的辦法了。

看到的小夥伴若是有更好的辦法歡迎留言指教哇。

相關文章
相關標籤/搜索