【面试官系列】React 中,如何在页面刷新之后保持状态?看看你知道几种~

4,422 阅读12分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 3 天,点击查看活动详情

flag:每月至少产出几篇高质量文章~

欢迎关注我的另外几篇文章:

经常在面试贴中看到这样一个问题:如何在页面刷新之后保持状态?

在React中,我们通常用useState来处理组件的状态:

import { useEffect, useState } from “react”;

export default function App() {
 const [count, setCount] = useState(0);
 
 const increaseCount = () => {
     return setCount(count + 1);
 }
 
 const decreaseCount = () => {
     return setCount(count — 1)
 }
 
 return (
     <div>
         <h1> Count {count} </h1>
         <button onClick={increaseCount}>+</button>
         <button onClick={decreaseCount}>-</button>
     </div>
 );
}

我们知道,一旦页面刷新,count的值会马上回到初始的状态:0,因为这种状态只保存内存中。但在有些场景下,我们希望在页面刷新之后,依然可以保存页面的状态。有哪些比较好的解决方案呢?

1、LocalStorage

localStorage 是一个浏览器 API,允许我们在浏览器中存储和读取数据,你可以将其看做是浏览器内部的一个数据库。

使用 localStorage,我们可以存储字符串变量,对于对象和数组等更复杂的变量,我们可以在存储的时候使用 JSON.stringify()序列化,在读取的时候使用 JSON.parse() 解析。

useEffect(() => {
    const value = window.localStorage.getItem(“count”);
    const valueParse = JSON.parse(value) ? JSON.parse(value) : 0;
    setCount(valueParse);
}, []);
  
useEffect(() => {
   window.localStorage.setItem(’count’, count);
}, [count]);

1.1 useLocalStorage

如果我们在多个场景都会使用到,那更好的方法是将其封装成一个hooks

export const useLocalStorage = (name) => {
  const getLocalStorage = () => {
    const local = localStorage.getItem(name)

    if(local != null){
      return JSON.parse(local)
    }

    return null
  }

  const setLocalStorage = (item) => {
    localStorage.setItem(name, JSON.stringify(item))
  }

  const removeLocalStorage = () => {
    return localStorage.removeItem(name)
  }

  return [getLocalStorage, setLocalStorage, removeLocalStorage]
}
  • getLocalStorage:读取本地存储的状态
  • setLocalStorage:将状态存储到本地
  • removeLocalStorage:从本地存储中删除状态

使用示例:

import { useEffect, useState } from "react";
import { useLocalStorage } from "./utils/hooks";

let initialForm = {
  name: "",
  website: "",
  contact: {
    cell: "",
    email: "",
  },
};

const App = () => {
  const [savedForm, setSavedForm, clearLocalStorage] =
    useLocalStorage("inputForm");
  const [inputFormState, setInputFormState] = useState(
    savedForm() || initialForm
  );

  const handleFormChange = (event) => {
    const { name, value } = event.target;

    if (name === "name" || name === "website") {
      setInputFormState((prev) => {
        const newForm = { ...prev };
        newForm[name] = value;
        return newForm;
      });
    }

    if (name === "cell" || name === "email") {
      setInputFormState((prev) => {
        let newForm = { ...prev };
        newForm.contact[name] = value;
        return newForm;
      });
    }
  };

  useEffect(() => {
    setSavedForm(inputFormState);
  }, [setSavedForm, inputFormState]);

  return (
    <>
      <div>
        Name:
        <input
          name="name"
          value={inputFormState?.name}
          onChange={(e) => handleFormChange(e)}
        />
      </div>

      <div>
        Website:
        <input
          name="website"
          value={inputFormState?.website}
          onChange={(e) => handleFormChange(e)}
        />
      </div>
      <div>
        Cell:
        <input
          name="cell"
          value={inputFormState?.contact?.cell}
          onChange={(e) => handleFormChange(e)}
        />
      </div>
      <div>
        Email:
        <input
          name="email"
          value={inputFormState?.contact?.email}
          onChange={(e) => handleFormChange(e)}
        />
      </div>
      <div>
        <button onClick={() => clearLocalStorage()}>Clear Cache</button>
      </div>
    </>
  );
};

export default App;

2、URL 参数

将状态保存到浏览器 URL 中,当我们初始化组件时,会从 URL 参数中读取初始值。

PS:由于 URL 长度限制,对于比较简单的数据此方法适用。

import { useEffect, useState } from "react";
import "./styles.css";
import qs from "qs";
import { createBrowserHistory } from "history";

export default function App() {
  const [count, setCount] = useState(0);

  const history = createBrowserHistory();

  useEffect(() => {
    const filterParams = history.location.search.substr(1);
    const filtersFromParams = qs.parse(filterParams);
    
    if (filtersFromParams.count) {
      setCount(Number(filtersFromParams.count));
    }
  }, []);

  useEffect(() => {
    history.push(`?count=${count}`);
  }, [count]);

  const increaseCount = () => {
    return setCount(count + 1);
  }
  const decreaseCount = () => {
    return setCount(count - 1)
  }

  return (
    <div className="App">
      <h1> Count {count} </h1>
      <button onClick={increaseCount}>+</button>
      <button onClick={decreaseCount}>-</button>
    </div>
  );
}

3、@reduxjs/toolkit 结合 Redux-persist

通过 Redux Persist 库,将 Redux 存储在持久存储中,例如LocalStorage,也可以实现在刷新浏览器后,状态保留。Redux Persist 还提供嵌套、自定义持久化和rehydrated状态的方法。

PS: 鉴于这个方案的复杂性,我这里贴出我的源码:源码

以前我们是这么配置store

import { configureStore } from "@reduxjs/toolkit";
import userReducer from "./slices/userSlice";

export const store = configureStore({
  reducer: userReducer,
  devTools: process.env.NODE_ENV !== 'production',
})

现在做一些改动:

import { configureStore } from '@reduxjs/toolkit';
import userReducer from './slices/userSlice';
import storage from 'redux-persist/lib/storage';
import {
  persistReducer,
  persistStore,
  FLUSH,
  REHYDRATE,
  PAUSE,
  PERSIST,
  PURGE,
  REGISTER,
} from 'redux-persist';
import thunk from 'redux-thunk';

const persistConfig = {
  key: 'root',
  storage,
};

const persistedUserReducer = persistReducer(persistConfig, userReducer);

export const store = configureStore({
  reducer: persistedUserReducer,
  devTools: process.env.NODE_ENV !== 'production',
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware({
      serializableCheck: {
        ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
      },
    }),
});

export const persistor = persistStore(store);
// src/store/slices/userSlice.js
import { createSlice } from '@reduxjs/toolkit';

const initialState = {
  user: {},
  isLoggedIn: false,
};

const userSlice = createSlice({
  name: 'user',
  initialState,
  reducers: {
    signIn: (state, action) => {
      state.user = { ...state.user, ...action.payload };
      state.isLoggedIn = true;
    },
    signOut: (state) => {
      state.user = {};
      state.isLoggedIn = false;
    },
  },
});

export const { signIn, signOut } = userSlice.actions;
export default userSlice.reducer;

我们将 store 中的 reducer 属性值 userReducer 通过 persistReducer 包裹一层之后变成一个增强的 reducerpersistedUserReducer

  • 除了本地存储,我们还可以使用其他存储引擎,比如:sessionStorage 和 Redux Persist Cookie Storage Adapter
  • reduxjs-toolkit-persist:这是 redux-persist 的一个分支,它实现了 @reduxjs/toolkit(替换核心 redux 依赖项)以及将各种依赖项升级到更新的版本。
  • 要使用其他的存储引擎,我们只需要修改我们要使用的存储引擎的 storage 属性值persistConfig。例如,要使用 sessionStorage 引擎,我们首先按如下方式导入它:
import storageSession from 'reduxjs-toolkit-persist/lib/storage/session'

然后前几天在浏览国外知乎Reddit的时候,发现一个说redux-persist本身已经支持了,只是没有发布而已(应该是2022年)~ image.png

所以,也可以用自带的:

import storageSession from 'redux-persist/lib/storage/session';

这里是所有的存储引擎列表:redux-persist: storage-engines

修改 persistConfig 成如下代码:

const persistConfig = { 
  key: 'root', 
  storage: storageSession,, 
}

App.js组件中使用:

import React from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import Profile from './pages/Profile';
import SignIn from './pages/SignIn';
import { useSelector } from 'react-redux';
import { Navigate } from 'react-router-dom';

function App() {
  const isLoggedIn = useSelector((state) => state.isLoggedIn);

  return (
    <BrowserRouter>
      <Routes>
        <Route
          exact
          path="/"
          element={isLoggedIn ? <Profile /> : <Navigate to="/signin" />}
        />
        <Route
          path="signin"
          element={!isLoggedIn ? <SignIn /> : <Navigate to="/" />}
        />
      </Routes>
    </BrowserRouter>
  );
}

export default App;

最后,我们在 index.js 中引入:

import React from 'react';
import ReactDOM from 'react-dom/client';
import './style.css';
import App from './App';
import { Provider } from 'react-redux';
import { persistor, store } from './store';
import { PersistGate } from 'redux-persist/integration/react';

const root = ReactDOM.createRoot(document.getElementById('root'));

root.render(
  <React.StrictMode>
    <Provider store={store}>
      <PersistGate loading={null} persistor={persistor}>
        <App />
      </PersistGate>
    </Provider>
  </React.StrictMode>
);

测试:

image.png

登陆之后,就可以在控制台看到,登录信息被存储在了本地:

image.png

3.1 Nested persists

嵌套持久化主要用于较复杂的持久化操作上,比如我们的项目中有多个 reducer 需要存储,但在这些 reducer 里又存在部分属性不能被存储。或者我们有一部分的 reducer 需要存储在 localStorage 中永久性保存,而另一部分 reducer 则需要存储在 sessionStorage 里,浏览器关闭后就失效.

下面是嵌套持久化的示例:

import { configureStore, combineReducers } from '@reduxjs/toolkit';
import userReducer from './slices/userSlice';
import notesReducer from './slices/notesSlice';
import storage from 'redux-persist/lib/storage';
import session from 'redux-persist/lib/storage/session';
import {
  persistReducer,
  persistStore,
  FLUSH,
  REHYDRATE,
  PAUSE,
  PERSIST,
  PURGE,
  REGISTER,
} from 'redux-persist';

const rootPersistConfig = {
  key: 'root',
  // 使用这个方法,数据将会被存储在localStorge中,永久有效
  storage,
};

const userPersistConfig = {
  key: 'user',
  // 使用这个方法,数据将会被存储在sessionStorage中,关闭浏览器后失效
  storage: session,
};

// 全局的持久化配置
const rootReducer = combineReducers({
  user: persistReducer(userPersistConfig, userReducer),
  notes: notesReducer,
});

const persistedReducer = persistReducer(rootPersistConfig, rootReducer);

const store = configureStore({
  reducer: persistedReducer,
  devTools: process.env.NODE_ENV !== 'production',
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware({
      serializableCheck: {
        ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
      },
    }),
});

const persistor = persistStore(store);

export { store, persistor };

middleware属性写法参考@reduxjs/toolkit官网的写法:use-with-redux-persist

测试:

image.png

登录过后,就会发现user存储在了sessionStorage里面:

image.png

notes的存储到了localStorage里面:

image.png

3.2 状态如何合并

合并涉及将持久状态保存回 Redux 存储。当应用程序启动时,取的是初始状态,随后,Redux Persist 从存储中读取持久化的状态,然后覆盖初始状态。

默认情况下,合并过程自动合并一层。假设我们有一个传入和初始状态,如下所示:

// 初始状态
{ user: {name: '', email: ''}, isLoggedIn: false, status: 'Pending'} 

// 传入状态
{user: {name: 'ian'}, isLoggedIn: true}

合并后的状态:

// 合并状态
{ user: {name: 'ian'}, isLoggedIn: true, status: 'Pending'}

在传入状态下,这些被替换而不是合并,这就是用户中的email属性丢失的原因。它做的事情类似:

const mergedState = { ...initialState };

mergedState['user'] = persistedState['user']
mergedState['isLoggedIn'] = persistedState['isLoggedIn']

这种类型的合并在 Redux Persist 中称为autoMergeLevel1,它是Redux Persist 中的默认状态合并机制:default state reconciler in Redux Persist。其他状态协调器包括 hardSet,它用传入状态完全覆盖初始状态,以及 autoMergeLevel2,它合并两层深度。

例如,如果我们要使用autoMergeLevel2,我们只需要在persistConfig中指定一个stateReconciler属性:

import autoMergeLevel2 from 'redux-persist/lib/stateReconciler/autoMergeLevel2';

const persistConfig = {
  key: 'root',
  storage,
  stateReconciler: autoMergeLevel2
}

stateReconciler有 3 个选项,即hardSetautoMergeLevel1autoMergeLevel2。这些选项基本上是您希望如何处理初始状态和传入状态中的冲突的变体。

  • hardSet:硬设置你的传入状态,使用新的数据完全覆盖旧的数据,如果某些属性在旧数据中存在而在新数据中不存在,则最终的结果会丢弃这部分数据
import hardSet from 'redux-persist/lib/stateReconciler/hardSet'

const persistConfig = {
  key: 'root',
  storage,
  stateReconciler: hardSet,
}

// action传入的新状态
{foo: {a: 3}}
// storage中的旧状态
{foo: {a: 1, b: 2}, bar: {a: 1, b: 2}}

// 合并后的状态
{foo: {a: 3}}
  • autoMergeLevel1:使用新数据与旧的数据进行一层迭代比较的合并,可以保证在合并旧数据时不会影响其它数据
import autoMergeLevel from 'redux-persist/lib/stateReconciler/autoMergeLevel1'

const persistConfig = {
  key: 'root',
  storage,
  stateReconciler: autoMergeLevel,
}

// action传入的新状态
{foo: {a: 3}}
// storage中的旧状态
{foo: {a: 1, b: 2}, bar: {a: 1, b: 2}}

// 合并后的状态
{foo: {a: 3}, bar: {a: 1, b: 2}}
  • autoMergeLevel2:使用新数据与旧的数据进行两层迭代比较的合并,可以保证在合并旧数据时不会影响其它数据
import autoMergeLevel from 'redux-persist/lib/stateReconciler/autoMergeLevel2'

const persistConfig = {
  key: 'root',
  storage,
  stateReconciler: autoMergeLevel,
}

// action传入的新状态
{foo: {a: 3}}
// storage中的旧状态
{foo: {a: 1, b: 2}, bar: {a: 1, b: 2}}

// 合并后的状态
{foo: {a: 3, b: 2}, bar: {a: 1, b: 2}}

3.3 自定义持久化内容

我们可以通过给 persistReducer 配置 blacklistwhitelist属性来自定义哪些状态需要持久化。配置blacklist属性指定状态的哪一部分不持久化,而whitelist属性则相反。

  • blacklist: 黑名单,出现在这个列表里的 reducer 将不会被存储到 storage 里,然后其它的 reducer 会被存储
const persistConfig = {
  key: 'root',
  storage: storage,
  blacklist: ['a'], // a 这个状态不会被存储
};
  • whitelist: 白名单,出现在这个列表里的 reducer 会被存储到 storage 里,然后其它的 reducer 不会被存储
const persistConfig = {
  key: 'root',
  storage: storage,
  whitelist: ['a'], // a 这个状态会被存储
};

blacklistwhitelist属性采用字符串数组。每个字符串必须与我们传递给 persistReducerreducer 管理的状态的一部分相匹配。使用blacklistwhitelist时,我们只能针对一层深度。但是,如果我们想以上述状态之一的属性为目标,我们可以利用嵌套持久化。

例如,假设userReducer初始状态如下所示:

const initialState = { 
  user : {}, 
  isLoggedIn : false , 
}  

如果我们想防止isLoggedIn被持久化,可以这么做:

const rootPersistConfig = {
  key: 'root',
  storage,
}

const userPersistConfig = {
  key: 'user',
  storage,
  blacklist: ['isLoggedIn']
}

const rootReducer = combineReducers({
  user: persistReducer(userPersistConfig, userReducer),
  notes: notesReducer
})

const persistedReducer = persistReducer(rootPersistConfig, rootReducer);

现在,isLoggedIn属性将不会被持久化。测试:

image.png

3.4 其他Redux相关的持久化库

redux-persist 毫无疑问是最流行的数据持久化库,但是,它已经很久没有升级迭代了,但为什么还是有很多人用?在国外知乎上有这么一个问题:

image.png

有一个回答是这么写的:

image.png

大致意思是:redux-persist肯定是这里最受欢迎的选择,缺乏维护并不是什么大问题,因为 Redux 本身在架构上没有发生重大变化,所以它仍然可以工作。

当然,还有其他的一些库可以实现同样的事情:

4、recoil + recoil-persist

Recoil :可以创建一个数据流,它从原子(共享状态)流经选择器(纯函数)并向下流入你的 React 组件。原子是组件可以订阅的状态单元。选择器同步或异步地转换此状态。

原子需要一个唯一的 key,用于调试、持久化和某些高级 API,让你看到所有原子的映射。

新建一个store.js:

import { atom } from "recoil";
import { recoilPersist } from "recoil-persist";

const { persistAtom } = recoilPersist();

export const Count = atom({
    key: "Count",
    default: null,
    effects_UNSTABLE: [persistAtom],
});

然后我们必须在 ./src/index.js中用 <RecoilRoot> 包装我们的 <App> 组件:

// src/index.js
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { RecoilRoot } from "recoil";
import App from "./App";

const rootElement = document.getElementById("root");
const root = createRoot(rootElement);

root.render(
    <StrictMode>
        <RecoilRoot>
            <App />
        </RecoilRoot>
    </StrictMode>
);

// App.js
import "./styles.css";
import Counter from "./Counter";

export default function App() {
    return (
        <div className="App">
            <Counter />
        </div>
    );
}

接下来是Counter组件:

import React from "react";
import { useRecoilState } from "recoil";
import { Count } from "./store";

export default function Counter() {
    const [count, setCount] = useRecoilState(Count);

    const increaseCount = () => {
        return setCount(count + 1);
    };

    const decreaseCount = () => {
        return setCount(count - 1);
    };

    return (
        <div>
            <h1> Count {count} </h1>  
            <button onClick={increaseCount}>+</button> {" "}
            <button onClick={decreaseCount}>-</button> {" "}
        </div>
    );
}

在控制台中可以看到: image.png

5、zustand(自带persist)

zustand 基于 hooks、小型、快速且可扩展的状态管理解决方案。store 是一个钩子! 可以在里面放任何东西:原始数据(string,number,bigint,boolean,null)、对象、方法。 set 方法合并状态。

5.1 zustand 基本用法

使用很简单,第一步创建一个store

import create from 'zustand'

const useCount = create(set => ({
  count: 0,
  plus: () => set(state => ({ count: state.count + 1 })),
  minus: () => set(state => ({ count: state.count - 1 })),
  reset: () => set({ count: 0 })
}))

接着在组件中使用:

function Counter() {
  // 获取count值
  const count = useCount(state => state.count)
  return <h1>{count}</h1>
}

function Controls() {
  // 更新count值
  const plus = useCount(state => state.plus)
  const minus= useCount(state => state.minus)
  const reset= useCount(state => state.reset)

  return (
     <>
       <button onClick={plus}>+</button>
       <button onClick={minus}>-</button>
       <button onClick={reset}>重置</button>
     </>
  )
}

5.2 处理异步数据

zustand 中处理异步数据很简单,只需要发出 fetch 请求和 set() 方法来设置我们的状态值:

import create from "zustand";

const useStore = create((set, get) => ({
  votes: 0,
  addVotes: () =>
    set((state) => ({
      votes: state.votes + 1
    })),
  subtractVotes: () =>
    set((state) => ({
      votes: state.votes - 1
    })),
  fetch: async (voting: any) => {
    const response = await fetch(voting);
    const json = await response.json();
    set({
      votes: json.items.length
    });
  }
}));

export { useStore };

然后在组件中使用:

import { useState } from "react";
import { useStore } from "./store";

const voting = "https://api.github.com/search/users?q=john&per_page=5";

export default function App() {
  const getVotes = useStore((state) => state.votes);
  const addVotes = useStore((state) => state.addVotes);
  const subtractVotes = useStore((state) => state.subtractVotes);
  const fetch = useStore((state) => state.fetch);

  return (
    <div className="App">
      <h1>{getVotes} People</h1>
      <button onClick={addVotes}>Cast a vote</button>
      <button onClick={subtractVotes}>Delete a vote</button>
      <button
        onClick={() => {
          fetch(voting);
        }}
      >
        Fetch votes
      </button>
    </div>
  );
}

5.3 zustand 实现状态持久化

状态管理库的一个共同特点是持久化状态,例如: 在有 form 中,你希望保存用户信息,如果用户不小心刷新了页面,会丢失所有数据记录。Zustand 提供了持久化状态以防止数据丢失的功能,我们使用 Zustand 提供的 persist 中间件,通过 localStorage 来持久化数据,这样,当我们刷新页面或者完全关闭页面时,状态不会重置:

import { persist } from "zustand/middleware"

let store = (set) => ({
  fruits: ["apple", "banana", "orange"],
  addFruits: (fruit) => {
    set((state) => ({
      fruits: [...state.fruits, fruit],
    }));
  },
});

store = persist(
    store, 
    {
        // 这是唯一必填的选项。给定的`名称`将是用于存储`state`的键,因此它必须是`唯一`的。
        name: "basket",
        // (可选)默认情况下,使用“localStorage”
        getStorage: () => sessionStorage, 
    }
)

const useStore = create(store);

在上面的代码中,我们持久化了 store 的值,localStorage 的 key 设为 basket,有了这个,我们在刷新页面时不会丢失新增的数据,永久保存(即: 在执行清除本地存储的操作之前,状态保持不变)。

image.png

image.png

另外: Zustand 提供了一个中间件来使用 Redux DevTools 扩展从浏览器查看状态值:

import devtools from 'zustand/middleware'

// ...
store = devtools(store)

更多Zustand用法可以去官网详细了解:传送门


end...

欢迎关注我的另外两篇文章