Resolve page refresh redux data loss issue.

Keywords: Front-end JSON Database React Session

concept

For the current common "one-page application", the advantage is that the front-end can handle more complex data models in a more comfortable way, while transforming based on the data model for better interaction.

Behind good interaction is a model that corresponds to the state of a page component, which is arbitrarily called a UI model.

The data model corresponds to the business data in the back-end database, and the UI model corresponds to the state of the user's components after a series of browser operations.

The two models are not equal!

For example, in the following figure, this console (there is no so-called subpage to switch single-page routing, but a switch between portal-like components):

The one-page application we built, the back-end database, and the provided interface are the States for storing and managing data models.

However, in the User Operations Console, the opening/closing of the left panel, the items selected in the list, the opening of the editing panel, etc., the status of these UI models will not be recorded by the back end.

phenomenon

When a user forces a page refresh or closes the page and opens it again, a single-page application can pull data records from the back-end, but the state of the page component cannot be restored.

Currently, most single-page applications process pages that refresh or reopen, discarding the state of previous user actions, and entering an initial state.(Of course, if more content editing is involved, the user will be prompted to save first, etc.)

But this is clearly a compromise to interaction.

conceptual design

Technical Scenarios

Our one-page application is built on Redux+React.

Most of the state of the component (some States maintained internally by uncontrolled components are, indeed, harder to record) is recorded in the store-maintained state of Redux.
It is Redux, a global-based state management, that enables the "UI model" to emerge clearly.

So as long as the state is cached in the browser's local store, you can restore the user's last interactive interface (basically).

When to fetch

Say when to take it first, because it's good to say.

Assuming we have saved the state, there will be a serialized state object in the localStorage.

Restoring state in the interface only takes place once when Redux creates the store when the application is initialized.

...

const loadState = () => {
  try { // It's also possible to use other local storage if it doesn't support localStorage
    const serializedState = localStorage.getItem('state');
    if (serializedState === null) {
      return undefined;
    } else {
      return JSON.parse(serializedState);
    }
  } catch (err) {
    // ...error handling
    return undefined;
  }
}

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

When to save

The way to save a state is simple:

const saveState = (state) => {
  try {
    const serializedState = JSON.stringify(state);
    localStorage.setItem('state', serializedState);
  } catch (err) {
    // ...error handling
  }
};

One simple (silly) way to trigger a save is to persist every time a state is updated.This keeps the locally stored state up to date.

This is also easy to do based on Redux.After the store is created, the subscribe method is called to listen for changes to the state.

// createStore after

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

However, it is clear that this is unreasonable from a performance perspective (although it may be necessary in some scenarios).So the wise prospective student suggested that only on the onbeforeunload event would do.

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

So whenever a user refreshes or closes a page, he or she will silently record the current state.

When to Empty

Once you save and retrieve, the feature is implemented.Version comes online, users use it, and the state is cached locally, so there is no problem with the current application.

pit

But when the new version of the code is released again, the problem arises.
The state maintained by the new code is not the same structure as before. Users will inevitably make errors when they use the new code to read their locally cached old state.
However, no matter what the user does at this time, he will not clear the state of his local cache (not to mention in detail, mainly because of the logic of loadState and saveState above.)Bad states are saved over and over again, even if you manually clear the localStorage in developer tools)

solution

The solution is that state needs to be versioned, and at least one empty operation should occur when it is inconsistent with the version of the code.
In the current project, the following scenarios are used:

Using state directly, add a node to it to record the version.That is, to increase the corresponding action, reducer, just to maintain the value of version.

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

The logical changes to save the state are minor, that is, each time you save, you update the version of the current code to the state.

...
window.onbeforeunload = (e) => {
  store.dispatch({
    type: 'VERSION_UPDATE',
    payload: __VERSION__  // Code global variables, which can be handled with the project configuration.Need to update every time involved state You must update this version number.
  })
  const state = store.getState();
  saveState(state);
}
...

When reading a state, compare the version of the code with the version of the state, and handle mismatches accordingly (emptying is when the initial state passed to createStore is undefined)

export const loadState = () => {
  try {
    const serializedState = localStorage.getItem('state');
    if (serializedState === null) {
      return undefined;
    } else {
      let state = JSON.parse(serializedState);
      // Determine the locally stored state version and empty the state if it falls behind the code version
      if (state.version < __VERSION__) {
        return undefined;
      } else {
        return state;
      }
    }
  } catch (err) {
    // ...error handling
    return undefined;
  }
};

 

The following is not rotated, but written by myself.

createStore from Redux Source

Understand the Redux source createStore, code directory in redux/src/createStore.

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

/**
 * This is a private action type reserved by redux.
 * For any unknown actions, you must return to the current state.
 * If the current state is undefined, you will have to return to an initial state.
 * Do not directly reference these action types in your code.
 */
export const ActionTypes = {
  INIT: '@@redux/INIT'
}

/**
 * Create a redux store with a status tree.
 * Calling dispatch() is the only way to modify a value in the store.
 * There should be only one store in the application.To change logic for different parts of the program state
 * Together, you need to use combineReducers to put together some
 * reducers Merge into a reducer
 *
 * @param {Function} reducer A method to return to the next state tree, which needs to be provided when
 *  The previous status tree and the action to be sent.
 *
 * @param {any} [preloadedState] Initial state.
 * You can choose to specify it to save the state of the server in a common application, or to restore
 * Previously serialized user sessions.
 * If you use the `combineReducers'method to generate the final reducer.So this initial state
 * The structure of the state object must be kept in phase with the structure of the parameter passed in when the `combineReducers` method is called
 * Same.
 *
 * @param {Function} [enhancer] store Enhancer.You can optionally pass in an enhancement letter
 * Counting enhancements to store s, such as middleware, time travel, persistence.This redux is the only one that comes with it
 * Enhancer is applyMiddleware
 *
 * @returns {Store} A redux that lets you read status, publish actions, and subscription changes 
 * store
 */
export default function createStore(reducer, preloadedState, enhancer) {
  // If the preloadedState type is function and the enhancer type is undefined, then use
  // The user passes the value of preloadedState to the user without passing in preloadedState  
  // enhancer, preloadedState value set to undefined
  if (typeof preloadedState === 'function' && typeof enhancer === 'undefined') {
    enhancer = preloadedState
    preloadedState = undefined
  }
  // enhancer type must be a function
  if (typeof enhancer !== 'undefined') {
    if (typeof enhancer !== 'function') {
      throw new Error('Expected the enhancer to be a function.')
    }
    // Return to store enhanced with enhancer
    return enhancer(createStore)(reducer, preloadedState)
  }
  // reducer must be a 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

  // Copy one before each modification to the listener function array, the actual modification is the new
  // On the copied array.Make sure the listener exists before a dispatch occurs.
  // Can be triggered once after this dispatch.
  function ensureCanMutateNextListeners() {
    if (nextListeners === currentListeners) {
      nextListeners = currentListeners.slice()
    }
  }

  /**
   * Read store managed state tree
   *
   * @returns {any} Returns the current state tree in the application
   */
  function getState() {
    return currentState
  }

  /**
   * Add a change listener.It will be triggered when an action is distributed, and the number of States is a
   * Some parts may have changed.Then you can call getState to read the current one in the callback
   * State Tree.
   *    
   * You can call dispatch() from a change listener, note:
   *
   * 1.The listener array is copied before each call to dispatch().If you're listening to a letter
   * Number of subscriptions or unsubscribes, which will not affect the dispatch() currently in progress.And next time
  * dispatch()Nested calls or not use the latest modified listen list.
   * 2.The listener does not want to see all state changes, such as the state may be before the listener is called
   * The nested dispatch() may be updated too many times.But at one dispatch
   * Registered listener functions before triggering can read the latest status of store s after this diapatch
   * State.
   *
   * @param {Function} listener A callback function that is executed after each dispatch.
   * @returns {Function} Returns a function used to cancel this subscription.
   */
  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)
    }
  }

  /**
   * Send an action, which is the only way to trigger a state change.
   * Every time an action is sent, the `reducer` used to create the store is called once.Inbound on call
   * The parameters are the current state and the action being sent.The return value of will be treated as the next state
   * State, and the listener will be notified.
   *
   * The underlying implementation only supports actions for simple objects.If you wish to be able to send 
   * Promise,Observable,thunk Fire in other forms of action, you need to use the appropriate middle
   * Component encapsulates store creation functions.For example, you can refer to the documentation for the `redux-thunk` package.
   * These middleware, however, send action s in the form of simple objects through the dispatch method.
   * 
   * @param {Object} action,An object identifying what has changed.This is a good idea
   * Make sure actions are serializable so that you can record and play back user actions, or use
   * Plug-ins that allow shuttle time `redux-devtools'.An action must have a value that is not
   * `undefined`String constants are recommended as action type s.
   *
   * @returns {Object} For convenience, return the incoming action object.
   *
   * Note that if you use a custom middleware, you may return `dispatch()'.
   * The value is encapsulated in something else (for example, a Proise that can await).
   */
  function dispatch(action) {
    // Throw an exception if the action is not a simple object
    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?'
      )
    }
    
    // dispatch is not allowed to be called again inside reducer, otherwise an exception is thrown
    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
  }

  /**
   * Replace the reducer function currently used by the store.
   *
   * If your program code implements code splitting and you want to load some reducers dynamically.or
   * You will also use redux when you implement a hot load for it.
   *
   * @param {Function} nextReducer Replaced 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 })
  }

  /**
   * Interaction interface reserved for observable/reactive libraries.
   * @returns {observable} The simplest observable implementation that identifies a state change.
   * For more information, check out the observable's proposal:
   * https://github.com/tc39/proposal-observable
   */
  function observable() {
    const outerSubscribe = subscribe
    return {
      /**
       * One of the simplest observable subscription methods.
       * @param {Object} observer,Any object that can be used as an observer.
       * observer Object should contain `next'method.
       * @returns {subscription} Returns an object with an `unsubscribe'method for disassembling observable s from store s and stopping receiving values further.
       */
      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
      }
    }
  }

  // When a store is created, an INIT action is distributed so that each reducer returns
  // Initial state, which effectively populates the initial state tree.
  dispatch({ type: ActionTypes.INIT })

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


Look at the source code to discover the createStore, which can accept a change to the state, and with redux-thunk the final code is as follows:

//2, introducing redux and introducing reducer
import {createStore, applyMiddleware, compose} from 'redux';
//import reducer from './reducers';
import rootReducer from './combineReducers';
import thunk from 'redux-thunk';

//3, Create a 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) {
        // Error handling
        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;

 

Since store data changes are monitored through subscribe, the data saved in session store is the latest store data at this time

createStore is fetched from session store.Problem solving.

During this problem solving process, you used the react-persist plugin and found that its data was synchronized to sessionStorage, but the page refreshed

store data is gone and synchronized to sessionStorage, so we have to use the above method at last.

If there is a better way for the little buddies you see to welcome the message to teach you.

Posted by joad on Mon, 24 Feb 2020 19:03:00 -0800