對於目前廣泛的「單頁應用」,其中的好處是,前端能夠從容的處理較複雜的數據模型,同時基於數據模型能夠進行變換,實現更爲良好的交互操做。前端
良好的交互操做背後,實際上是基於一個對應到頁面組件狀態的模型,隨便稱其爲UI模型。react
數據模型對應的是後端數據庫中的業務數據,UI模型對應的是用戶在瀏覽器一系列操做後組件所呈現的狀態。git
這兩個模型不是對等的!github
好比下圖中這個管控臺(不存在所謂的子頁面,來進行單頁路由的切換,而是一個相似portal的各塊組件的切換):數據庫
咱們構建的這個單頁應用,後端的數據庫和提供的接口,是存儲和管理數據模型的狀態。redux
可是用戶操做管控臺中,左側面板的打開/關閉、列表選中的項目、編輯面板的打開等,這些UI模型的狀態均不會被後端記錄。後端
當用戶強制進行頁面刷新,或者關閉頁面後又再次打開時,單頁應用雖然能從後端拉取數據記錄,可是頁面組件的狀態已經沒法恢復了。數組
目前,多數的單頁應用的處理,就是在頁面刷新或從新打開後,拋棄以前用戶操做後的狀態,進到一個初始狀態。(固然,若是涉及較多內容編輯的,會提示用戶先保存等等)瀏覽器
但這樣,顯然是 對交互的一種妥協。緩存
咱們的單頁應用是基於Redux+React構建。
組件的 大部分狀態 (一些非受控組件內部維護的state,確實比較難去記錄了)都記錄在Redux的store維護的state中。
正是由於Redux這種基於全局的狀態管理,才讓「UI模型」能夠清晰浮現出來。
因此,只要在瀏覽器的本地存儲(localStorage)中,將state進行緩存,就能夠(基本)還原用戶最後的交互界面了。
先說什麼時候取,由於這塊好說。
假設咱們已經存下了state,localStorage中就會存在一個序列化後的state對象。
在界面中還原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/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裏了,最後只好用了以上的辦法了。
看到的小夥伴若是有更好的辦法歡迎留言指教哇。