经常在面试贴中看到这样一个问题:如何在页面刷新之后保持状态?
在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
包裹一层之后变成一个增强的 reducer
:persistedUserReducer
。
- 除了本地存储,我们还可以使用其他存储引擎,比如: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年)~
所以,也可以用自带的:
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> );
测试:
登陆之后,就可以在控制台看到,登录信息被存储在了本地:
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
测试:
登录过后,就会发现user
存储在了sessionStorage
里面:
而notes
的存储到了localStorage
里面:
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 个选项,即hardSet
、autoMergeLevel1
和autoMergeLevel2
。这些选项基本上是您希望如何处理初始状态和传入状态中的冲突的变体。
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
配置 blacklist
和whitelist
属性来自定义哪些状态需要持久化。配置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 这个状态会被存储 };
blacklist
和whitelist
属性采用字符串数组。每个字符串必须与我们传递给 persistReducer
的 reducer
管理的状态的一部分相匹配。使用blacklist
和whitelist
时,我们只能针对一层深度。但是,如果我们想以上述状态之一的属性为目标,我们可以利用嵌套持久化。
例如,假设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
属性将不会被持久化。测试:
3.4 其他Redux相关的持久化库
redux-persist
毫无疑问是最流行的数据持久化库,但是,它已经很久没有升级迭代了,但为什么还是有很多人用?在国外知乎上有这么一个问题:
有一个回答是这么写的:
大致意思是:redux-persist
肯定是这里最受欢迎的选择,缺乏维护并不是什么大问题,因为 Redux 本身在架构上没有发生重大变化,所以它仍然可以工作。
当然,还有其他的一些库可以实现同样的事情:
- 一份关于redux持久化的库清单:redux-ecosystem-links/blob/master/store-persistence
- rtk-query-loader 现在也支持,具体看这里:github.com/ryfylk...
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> ); }
在控制台中可以看到:
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
,有了这个,我们在刷新页面时不会丢失新增的数据,永久保存(即: 在执行清除本地存储的操作之前,状态保持不变)。
另外: Zustand
提供了一个中间件来使用 Redux DevTools
扩展从浏览器查看状态值:
import devtools from 'zustand/middleware' // ... store = devtools(store)
end...