做React项目的人,十有八九都遇到过这种哭笑不得的情况:订单表单填了一半,手滑按了一下F5,或者切换App回来页面自动刷新,刚填的姓名、电话、地址全没了。脾气暴躁一点的,可能当场想把电脑合上。这个问题的技术本质一点不玄乎——React组件里用useState、useReducer、Redux管的状态,全都放在JavaScript进程的内存里,浏览器刷新时整个运行时环境被重建,内存里的数据也就跟着清零。要让数据刷新后还能回来,唯一靠谱的思路就是在状态变化时把数据同步到浏览器本地存储,下次页面加载时再原样取回来塞进React状态,这个操作圈内叫持久化,前端社区更常说的是状态回填。
这篇东西就围绕“React + LocalStorage + 持久化 + 状态回填 + 页面刷新”展开。我会从最简单的工具函数讲起,到手写自定义Hook,再到Redux、Zustand等大型状态库的接入方案,把状态回填的时序坑、跨标签页同步、SSR场景安全访问、扩容与清理策略一次说清。不管是刚写React没多久的新人,还是准备React面试想系统梳理这块知识点的人,又或者是中后台项目里被“刷新丢权限”“表单内容丢了”折磨过的朋友,都能在这里找到能直接抄作业的代码和思路。
1. 刷新丢数据的根源:React状态活在内存里
1.1 内存态与持久层的区别
先打个比方。React里的状态就像你写在草稿纸上的演算过程,草稿纸一揉、一换,字没了;localStorage则像一本固定在桌上的笔记本,页面关了、刷新了,本子还在,下次来还能翻到上一次记的内容。
浏览器运行时分为两部分:JavaScript引擎只负责当前页面的执行,页面关闭或刷新后,整个执行上下文销毁,所有变量、闭包、状态管理库里的store全部归零。这就是为什么你在Redux DevTools里明明看到数据都在,一刷新就干干净净。React的状态更新机制再怎么强大,它解决的只是视图与数据同步,不是数据持久化。
1.2 不是所有状态都值得持久化
把数据存进localStorage之前要先想清楚:这个数据在刷新后还有没有意义?
我见过不少团队为了“不丢数据”口号,把一堆不该持久化的东西全塞进去:某个弹窗的开合状态、Form里临时受控的输入值、Loading标志位……这些数据生命周期短,还容易因为过期值污染页面。真正值得持久化的通常是这么几类:
- 用户填到一半的表单草稿,比如下单地址、发布文章的正文
- 筛选条件和查询关键词,让用户刷新后还能保持上次浏览的上下文
- 登录凭证,比如token、用户基本信息,但敏感数据要额外注意安全
- 购物车、收藏列表这类交互结果,临时存一份能明显提升体验
- 按钮权限、角色标记等配置数据,刷新后能快速恢复界面结构
反过来,组件内部状态、临时弹窗、一次性校验结果,都别往里存。持久化的核心准则是“用户可感知的中间产物”,不是“所有数据”。
1.3 为什么是localStorage而不是sessionStorage、cookie、IndexedDB
市面上能存数据的浏览器方案不少,选型时很多新手会犹豫。我把这些方案一次说清,方便你做选择和应对面试。
| 方案 | 容量 | 生命周期 | 同步/异步 | 适用场景 |
|---|---|---|---|---|
| localStorage | 通常5MB左右 | 不主动清除就一直在 | 同步 | 键值对、中量结构数据、需要快速恢复的状态 |
| sessionStorage | 通常5MB左右 | 当前标签页关闭即失效 | 同步 | 临时表单、单页内跳转中间态 |
| cookie | 约4KB | 可自行设置过期时间 | 同步,每次请求自动携带 | 服务端会话标识,不适合存业务数据 |
| IndexedDB | 可达数百MB以上 | 持久存在 | 异步,API较重 | 大对象、文件、离线缓存,如音视频资源 |
| Web SQL | 已废弃 | 持久 | 异步 | 基本不考虑 |
单说“页面刷新后恢复状态”这个需求,localStorage是成本最低的解。API同步,读写立即生效;容量一般够用;数据不随标签页关闭而消失。cookie的4KB装不下什么结构化数据,又会在每次HTTP请求时附加传输,浪费带宽。IndexedDB虽然空间大,但那套基于事务和游标的异步API用起来繁琐,一个订单草稿或者权限数组根本没必要动用它。sessionStorage倒是和localStorage很接近,可它只对当前标签页有效,用户开新标签页就找不到了,多数场景并不合适。
2. 手写一套地方便的localStorage封装
2.1 核心就从JSON序列化说起
localStorage本身只能存字符串,你要存对象、数组、布尔值,就得用JSON.stringify转成字符串写进去,读取时再用JSON.parse还原。这个序列化动作是所有持久化方案的基石,几乎所有框架封装都是在它之上加了一层容错和生命周期处理。
举个例子:
localStorage.setItem('token', 'abc123'); localStorage.setItem('userInfo', JSON.stringify({ name: '张三', age: 18 })); const raw = localStorage.getItem('userInfo'); const userInfo = raw ? JSON.parse(raw) : null;只要没搞懂“只能存字符串”这几个字,后面一切封装都是空中楼阁。很多报错“JSON is not defined”、“Unexpected token”之类,根源就是拿JSON字符串直接当对象用,或者直接JSON.parse(null)。
2.2 带前缀、带容错的工具函数
你可能会说,直接用原生API不就行了嘛,为什么还要封装一层?因为现实项目远比Demo复杂。
第一,key冲突风险。一个系统里可能同时存在多个业务线,页面还可能被嵌进别人的平台,裸用token、user这种名字容易被其他模块覆盖。统一增加项目前缀,比如myapp:orderForm,是成本最低的命名空间隔离。
第二,隐私模式或容量写满时,localStorage.setItem会直接抛异常。不接住异常,页面可能当场白屏。第三,解析JSON时数据可能早就被篡改或属于旧版本,不做容错就可能让整个应用崩溃。
我习惯把读写删集中到一个模块里:
// storage.js const PREFIX = 'myapp'; export const storage = { get(key, fallback = null) { try { const raw = localStorage.getItem(`${PREFIX}:${key}`); return raw === null ? fallback : JSON.parse(raw); } catch (e) { console.warn(`[storage] 读取失败: ${key}`, e); return fallback; } }, set(key, value) { try { localStorage.setItem(`${PREFIX}:${key}`, JSON.stringify(value)); } catch (e) { console.warn(`[storage] 写入失败: ${key}`, e); // 可在这里上报监控或降级到内存 } }, remove(key) { localStorage.removeItem(`${PREFIX}:${key}`); }, clearByPrefix(prefix = PREFIX) { const keys = []; for (let i = 0; i < localStorage.length; i++) { const k = localStorage.key(i); if (k && k.startsWith(prefix)) keys.push(k); } keys.forEach((k) => localStorage.removeItem(k)); } };这里的clearByPrefix对应了很多人说的“根据id删除localStorage数据”——实际项目里经常要按前缀批量清理缓存或用户数据,比如用户退出登录时清掉该用户命名空间下的所有内容。当然,更细粒度地删除某个具体数据,直接用storage.remove('orderForm')就好。
2.3 用懒初始化避免页面闪烁
封装完读写,下一个关键问题是:React组件挂在页面上时,怎么把本地数据“回填”到state里?
新手最容易写出的代码是这样的:
// 错误示范 const [formData, setFormData] = useState({ name: '', phone: '' }); useEffect(() => { const saved = storage.get('orderForm'); if (saved) setFormData(saved); }, []);这段代码不是完全不能用,但有一个体验问题:首次渲染时会先用初始值{ name: '', phone: '' }渲染一次,然后等useEffect执行完了才改成恢复值。用户在页面上一瞬间会看到表单是空的,或显示的是默认值,然后“闪”一下变成真实数据。这种闪烁在弱网、复杂表单场景下非常明显,给人感觉就是页面特别不稳。
正确做法是利用useState的“懒初始化”机制,把读localStorage的操作放进初始化函数里,让第一次渲染就拿到的就是持久化数据:
const [formData, setFormData] = useState(() => { return storage.get('orderForm', { name: '', phone: '' }); });懒初始化函数只会在组件挂载时执行一次,这既保证了回填的及时性,又避免了在渲染函数体里直接同步读取localStorage造成的重复计算。这是状态回填里最基础、也是最重要的一个优化点。
2.4 抽成useLocalStorage,一步到位
既然“读+写”两个动作已经明确,就可以封装成自定义Hook。它的核心逻辑是:初始化时从localStorage读取一次,之后每次value变化,自动写回localStorage。这样组件里不需要再单独关心持久化细节。
// useLocalStorage.js import { useState, useEffect, useCallback } from 'react'; import { storage } from './storage'; export function useLocalStorage(key, initialValue) { const [value, setValue] = useState(() => { return storage.get(key, initialValue); }); useEffect(() => { storage.set(key, value); }, [key, value]); const remove = useCallback(() => { storage.remove(key); setValue(initialValue); }, [key, initialValue]); return [value, setValue, remove]; }用法很直观:
function OrderForm() { const [formData, setFormData, clearFormData] = useLocalStorage('orderForm', { name: '', phone: '', address: '' }); const handleChange = (e) => { setFormData((prev) => ({ ...prev, [e.target.name]: e.target.value })); }; return ( <> <input name="name" value={formData.name} onChange={handleChange} placeholder="联系人" /> <input name="phone" value={formData.phone} onChange={handleChange} placeholder="手机号" /> <textarea name="address" value={formData.address} onChange={handleChange} placeholder="收货地址" /> <button onClick={clearFormData}>重置</button> </> ); }这个Hook用起来跟useState几乎一样,只是多了一个清除并重置的方法。它已经能覆盖绝大多数中后台页面的草稿恢复需求,也是后面聊Redux、Zustand方案的一个基础原型。
3. 状态回填最容易踩的三个时序坑
3.1 初始化读一次,别让“默认值”覆盖旧数据
自定义Hook看起来简单,但有个隐藏的时序问题我踩过:如果某个状态在初始化时读到了旧数据,但同时组件里另一个effect在挂载后立刻把这个状态重置为新的默认值,那么旧数据就会被覆盖。这种问题极其隐蔽,因为你打开页面时可能看到“一瞬间有数据,然后正常渲染”,事后不仔细查根本发现不了数据已经没存进去。
常见场景是:页面加载时同时存在两个数据源,比如localStorage里有草稿,接口也返回了服务端草稿。如果代码先调用了setFormData(serverData),而接口数据是空的,那本地草稿就被空值覆盖了。我的处理原则是:初始化时localStorage的优先级高于接口默认值,除非接口明确返回“已保存版本”,否则别轻易用接口数据覆盖本地回填。
3.2 StrictMode下Effect执行两次,写入要保证幂等
React 18开始,开发环境的StrictMode会让组件挂载、卸载、再挂载,useEffect会执行两次。大多数人没意识到这会让storage.set被连续调用两次。如果只是幂等写入,问题不大;但如果你的effect里做了“追加数组”“写入计数器”“上报统计”这类非幂等操作,就会被重复执行,产生重复数据。
处理办法有两个:写入函数保持纯幂等,也就是“无论调用多少次,最终结果一致”;数组追加这类操作,不要直接localStorage.setItem,而是先在内存里完成合并,再一次写入。说白了,不要让localStorage变成操作日志的追加目标,它只该保存“最终快照”。
3.3 异步回填会污染用户正在输入的内容
还有一种更烦的情况:数据不是同步从localStorage读,而是等接口返回后再回填。比如页面初始化store是空的,等异步请求拿到用户权限、草稿后再一次性set进去。这期间用户可能已经动手填了新内容,接口的返回一回来,用户刚打的字被覆盖了。
我给这类需求定的规矩是:异步回填只做“首屏阶段”的空状态补充,一旦用户产生过交互,禁止无脑覆盖。实现时可以在store里加一个hydrated标记,只有标记为false时才允许异步数据写入:
state: { formData: {}, hydrated: false, setFormData(data) { set({ formData: data, hydrated: true }); }, hydrateFromServer(data) { if (!get().hydrated) set({ formData: data, hydrated: true }); } }把“用户是否已经操作”作为第一优先级,这是状态回填里最重要的自我保护机制。
4. 大型应用:从手动同步到Redux/Zustand持久化
4.1 Redux手动中间件,自己写其实很薄
在小项目里手写useLocalStorage就够了,但项目一大,状态分散在各个组件里,逐个挂Hook会累死人。这时候应该把持久化下沉到状态管理层。
Redux的做法并不复杂:在reducer处理完action后,store已经更新完毕,这时把需要持久化的字段拿出来写进localStorage。初始化store时再从localStorage读出作为preloadedState。文字描述有点绕,代码一看就懂:
// 自定义中间件 const PREFIX = 'myapp'; const persistenceMiddleware = (store) => (next) => (action) => { const result = next(action); const state = store.getState(); const toPersist = { token: state.auth?.token, userInfo: state.user?.info, permissions: state.user?.permissions, cart: state.cart, }; try { localStorage.setItem(`${PREFIX}:redux`, JSON.stringify(toPersist)); } catch (e) { console.warn('持久化失败', e); } return result; };store初始化时这么做:
let preloadedState = {}; try { const saved = localStorage.getItem(`${PREFIX}:redux`); if (saved) preloadedState = JSON.parse(saved); } catch (e) { // 容错 } const store = createStore(rootReducer, preloadedState, applyMiddleware(persistenceMiddleware));这个方案的好处是完全透明,业务代码里几乎不需要感知持久化逻辑。坏处是,Redux的reducer逻辑本身要保证从preloadedState恢复后状态结构依然正确,一旦数据结构变动,旧数据就可能报错——所以版本迁移能力必不可少(后面讲)。
4.2 redux-persist:配置、白名单与版本迁移
手动写中间件适合轻量场景,复杂度上来后还得上红牛级方案redux-persist。它提供了一套完整的持久化能力:自动序列化、白名单/黑名单、版本迁移、rehydration加载状态。
接入其实不难,核心就这么几步:
// store.js import { createStore } from 'redux'; import { persistStore, persistReducer } from 'redux-persist'; import storage from 'redux-persist/lib/storage'; import rootReducer from './reducers'; const persistConfig = { key: 'root', storage, whitelist: ['auth', 'user', 'cart'], // 只持久化这些模块 version: 2, migrate: async (state, version) => { if (version === 1) { // 将旧版本状态迁移到新格式 if (state?.user) { state.user = { ...state.user, permissions: state.user.roles || [] }; } return state; } return state; }, }; const persistedReducer = persistReducer(persistConfig, rootReducer); export const store = createStore(persistedReducer); export const persistor = persistStore(store);入口处配合PersistGate,避免在状态恢复完成前渲染会造成闪烁的组件:
import { PersistGate } from 'redux-persist/integration/react'; <Provider store={store}> <PersistGate loading={null} persistor={persistor}> <App /> </PersistGate> </Provider>这段配置最大的价值是“白名单”,它逼着你思考哪些状态值得持久化。不该存的放blacklist或者干脆不写进whitelist,比如ui、loading这类临时状态。版本迁移机制则是我前面反复强调的数据结构兼容问题的正规解法,发布新版本时如果改了状态结构,就别指望旧数据还能正常解析,交给migrate统一处理。
4.3 Zustand persist中间件,日常开发更省心
如果你还在用Redux,可能只是习惯问题。我自己现在做新项目更多用Zustand,它的persist中间件把持久化简化到了极致,配置基本就是一片JSON:
import { create } from 'zustand'; import { persist, createJSONStorage } from 'zustand/middleware'; export const useUserStore = create( persist( (set) => ({ token: '', userInfo: null, permissions: [], setUser: (info) => set({ userInfo: info, token: info.token }), setPermissions: (permissions) => set({ permissions }), logout: () => set({ token: '', userInfo: null, permissions: [] }), }), { name: 'myapp-user', storage: createJSONStorage(() => localStorage), version: 1, partialize: (state) => ({ token: state.token, userInfo: state.userInfo, permissions: state.permissions }), } ) );name指定存储key,partialize控制只持久化指定字段,和redux-persist的白名单很像。这套配置在日常业务里已经相当够用,不需要引入额外依赖来处理恢复流程,组件里直接useUserStore()拿到的就是回填后的状态。
4.4 权限类数据的存储红线
热词里有一条非常典型:“definestore 保存了按钮权限,为什么第二天刷新页面按钮不显示了。” 这类问题十有八九是这两个原因之一:一是持久化配置里没把permissions字段纳入partialize或whitelist,刷新后store重新初始化,权限数组被清空;二是权限和token绑定,token过期后触发清理逻辑,把permissions一并删了,但页面没有按“未登录”状态渲染。
解决的思路是:权限数据确实可以持久化,但必须和服务端校验配合。localStorage里的permissions只能作为“上次加载的结果”,真正页面级权限判断仍然要以接口返回为准。换句话说,前端存权限是为了减少请求、提升速度,不是安全边界。别把路由守卫、按钮级别的校验完全建立在localStorage数据上,一旦被篡改或过期,问题很难排查。
所以项目规范里应该加一条:涉及token、用户信息、按钮权限等敏感数据,持久化时统一走封装好的store模块,不直接在业务组件里裸操作localStorage。
5. 进阶玩法:多标签同步、SSR与过期清理
5.1 storage事件实现跨标签页实时同步
localStorage有一个比较冷门但很实用的API监听:window.addEventListener('storage', handler)。它的特点是,当其他标签页修改了localStorage时,当前页会收到事件,而修改当前页的那个标签页本身不触发。利用这个机制,可以实现“用户在A标签页登录,B标签页自动更新用户态”,不用再做复杂的跨页通信。
封装在自定义Hook里很简单:
function useStorageSync(key, onChange) { useEffect(() => { const handler = (e) => { if (e.key === `${PREFIX}:${key}` && e.newValue !== null) { try { onChange(JSON.parse(e.newValue)); } catch (err) { // 忽略解析失败 } } }; window.addEventListener('storage', handler); return () => window.removeEventListener('storage', handler); }, [key, onChange]); }需要注意的是,事件回调拿到的newValue是字符串,依然要JSON.parse。另外,浏览器只在“其他标签页”触发事件,这是刻意设计的,主要为了避免同一页面内事件循环死锁。如果需要在当前页也实时响应,那就直接调用store的set方法,不需要依赖storage事件。
5.2 SSR/Next.js场景下localStorage的安全访问
在Next.js这类支持SSR的框架里,直接访问localStorage会直接抛错——服务端渲染时根本没有window对象。很多新手把“读取持久化数据”的代码直接写在组件顶层,导致SSR日常白屏。
标准做法是判断是否处于浏览器环境,或者利用React 18的useSyncExternalStore把“客户端环境”作为状态来源:
const isBrowser = typeof window !== 'undefined'; function usePersistedUser() { return useSyncExternalStore( subscribe, // 订阅storage变化 () => (isBrowser ? storage.get('user') : null), // 客户端取快照 () => null // 服务端用这个快照 ); }另一个更粗暴但常见的做法是useEffect里读取,因为effect只会在浏览器端执行。缺点是会有一帧的闪烁,但配合Loading图标其实也能接受。原则就是:服务端渲染永远不给初始值,客户端首次渲染前从localStorage拿数据。
5.3 容量告警与过期清理
localStorage容量一般是5MB左右,看似不小,但你要把多模块状态、用户信息、列表缓存全塞进去,很快会碰到QuotaExceededError。我的经验是把数据“按模块分key存”,而不是全部塞进一个超大的JSON,这样单点损坏不会拖垮全局,而且清理时可以按模块精确删除。
为了不让旧数据一直占空间,可以给数据加一个过期时间。封装一个带过期时间的get/set:
export const storageWithExpiry = { set(key, value, ttl) { const wrapped = { value, expireAt: Date.now() + ttl, }; storage.set(key, wrapped); }, get(key, fallback = null) { const wrapped = storage.get(key); if (!wrapped) return fallback; if (wrapped.expireAt && wrapped.expireAt < Date.now()) { storage.remove(key); return fallback; } return wrapped.value; }, };比如订单草稿可以设置30天过期,用户token可以设置跟随后端session时长,权限数据可以设置1小时过期。过期机制能避免“第二天数据还在但已经无效”的尴尬。
6. 常见问题与排查实录
6.1 刷新后老数据导致页面报错
最典型的错误就是页面升级后,localStorage里残留的是上一版数据结构,JSON.parse能成功,但结构完全对不上新代码,比如state.user.name变成state.user.userName,渲染时直接undefined.map(...)报错,页面白屏。
排查思路分三步:先打开DevTools的Application面板,看Local Storage里到底存了什么;再对比当前代码期望的数据结构;最后写一个数据迁移函数或直接把对应key清除。遇到线上用户历史脏数据,最稳妥的方式就是给版本号加一,让migrate函数来兜底,而不是让用户手动清缓存。
6.2 数据莫名消失、写入失败与卡顿
数据消失的原因通常是这几个:key写错了(比如大小写、前缀遗漏)、代码用了sessionStorage但自己以为是localStorage、Safari隐私模式下写入直接被拒、容量写满抛了异常但没人捕获。我最推荐的做法是统一走封装后的storage模块,所有读写都带前缀、带try/catch、带错误日志,排查问题时会省一大半精力。
至于卡顿,localStorage读写是同步操作,理论上单次在毫秒级,但如果你每次组件render都同步读一遍,或者把几百KB的大对象频繁写入,还是会明显掉帧。优化方向是减少写入频率,比如给useEffect加防抖,或者在状态变更后通过requestIdleCallback做延迟写入。
6.3 状态和存储“两个人各管各的”
有时候会发现页面状态和localStorage里的内容不一致,比如状态已经变了,但刷新后恢复的是旧值。这通常是因为组件卸载时状态还没触发写入,或者写入逻辑依赖了某个立即失效的闭包。React 18下尤其要注意,state更新是异步批处理的,不要在setState之后立刻在同一个函数里读localStorage,你以为写进去了,其实还没有。所有写入应该交给专门的effect或中间件,而不是散落在事件回调里手动setItem。
6.4 常见问题速查表
| 现象 | 常见原因 | 处理办法 |
|---|---|---|
| 刷新后数据全没了 | 用了sessionStorage而非localStorage;key不一致;隐私模式写入失败没捕获 | 统一走storage封装;检查key;捕获setItem异常 |
| 刷新后页面白屏/报错 | 旧数据结构和现有代码不兼容;JSON.parse抛异常 | 加版本号+migrate;解析前try/catch;必要时清key |
| 按钮权限第二天不显示 | persist配置里漏了permissions;token过期连带清理权限 | 检查whitelist/partialize;权限判定结合接口 |
| 多标签页数据不同步 | 没监听storage事件;只在当前页改没有广播 | 添加storage事件监听并同步store |
| 写入失败QuotaExceededError | 容量写满、超大对象 | 分模块存储;加过期机制;写入降级 |
| 表单闪烁再变成旧值 | 用了普通useState+useEffect回填 | 改用懒初始化useState(() => ...) |
| SSR项目白屏 | 服务端渲染时访问window | 用typeof window判断或useSyncExternalStore |
| 接口数据覆盖用户输入 | 异步回填优先级不明 | 用hydrated标记,用户操作后禁止覆盖 |
最后补充一个个人习惯:我现在不管项目大小,都会在初始化时给localStorage做一次“自检”,把当前版本的key、持久化版本号、数据大小等记到一个myapp:meta里。真出问题,打开DevTools一眼就能看到是哪个包、哪一版数据、占了多大空间。这个小动作看起来不起眼,但在排障时比翻代码猜快得多。
做过好几个中后台和移动H5项目之后,我对持久化的态度从“用了就行”变成了“收口、可控、可清理”。状态持久化本身不难,难点在于把读写路径收拢到一处、把数据结构的兼容想清楚、把过期和清理策略做完善,并且始终对敏感数据保持警惕。照着上面的做法落地,至少能帮你避开“刷新丢数据”这类问题的绝大多数坑。