刷新页面掉登录这个坑做 React 的人几乎都踩过。明明登录成功了token 也拿到手了按一下 F5瞬间被踢回登录页接口跟着一片 401。更糟的是这种问题往往不是必现的偶尔出现一次排查起来特别费劲。我自己在好几个项目里都遇到过这个场景从最早的 React 16 Redux 经典组合到后来用 Redux Toolkit TypeScript 的新项目底层逻辑其实都一样Redux store 是内存态一刷新就归零。这篇文章会围绕 React Redux LocalStorage 这套组合把 token 持久化从登录写入、初始化恢复、请求注入、401 统一处理到 Hook 封装的完整链路讲清楚。不会只贴代码重点是解释每一步为什么这么做、坑在哪里。适合正在写 React 中后台项目、或者刚接手一个刷新就掉登录老项目的同学参考看完基本能自己动手把整套逻辑理顺。1. 问题拆解刷新页面后 Token 为什么消失先别急着写 localStorage得先把问题发生的机制搞清楚。好多同学卡在为什么我 token 明明存了刷新还是丢本质上是没理解前后端各层状态的生命周期。1.1 Redux store 的内存态本质Redux 把整个应用的状态放在一个 JavaScript 对象树里由 store 统一管理。这个对象树活在浏览器当前页面的运行时内存中。你路由跳转、组件挂载卸载、派发 action所有状态都在这份内存里变来变去。可一旦你按下 F5浏览器会销毁当前页面的整个运行时环境——JS 引擎重新启动脚本重新加载store 重新执行createStore或configureStorestate 回到初始化值。打个比方store 就像一块白板你在上面写了当前用户已登录只要不擦掉页面内的任意操作都能看到。但刷新页面相当于换了一块新的白板原来写的东西全没了。Redux 从设计上就没打算替你跨页面保存数据它解决的是页面内部状态混乱的问题不是持久化问题。所以问题不在 Redux 本身而在于我们把登录态只放进了内存态里没有同步落地到浏览器提供的持久化存储中。1.2 Token 消失后的连锁反应Token 一旦在刷新后丢失后果不是重新登录一次那么简单而是一连串的连锁反应受保护的路由组件拿到isAuthenticated false直接重定向到登录页用户刚才填了一半的表单全丢了即使路由没拦请求拦截器也拿不到 token发出的请求不带Authorization头后端返回 401如果项目里做了 401 全局处理比如自动跳登录页用户会被莫名其妙地踢下线体验极差更隐蔽的是很多项目在main.tsx或App.tsx里异步请求用户信息token 没了这个请求 401用户信息渲染不出来页面出现空白或报错。这些问题往往不是单点故障而是从状态恢复缺失开始一层层往外扩散。理解了这一整条链路才能明白为什么刷新后恢复登录态是必须优先解决的问题而不是一个锦上添花的优化项。2. 方案选型为什么是 Redux LocalStorage登录态要跨刷新保存方案其实不止一种。选 LocalStorage 不是因为它最好而是因为它在实现成本和适用场景之间最平衡。下面按实际项目里的取舍标准拆一下。2.1 几种前端存储方案横向对比存储方案生命周期跨标签页容量安全性适用场景内存变量页面刷新即销毁否不限最高不进磁盘临时状态sessionStorage标签页关闭即销毁否约 5MB中会话级临时数据localStorage手动清除才销毁是约 5MB中可被 XSS 读取持久化登录态、偏好设置cookie可设置 Expires跨标签页共享约 4KB可设 HttpOnly服务端会话标识从这张表能看出几个关键点。sessionStorage 的问题是标签页隔离——你在这个标签页登录了开新标签页还得重新登录用户会觉得莫名其妙。cookie 的问题是容量小且有 CSRF 风险虽然可以用 HttpOnly 缓解但在纯前端 SPA 项目里操作起来麻烦。localStorage 容量足够跨标签页共享API 简单到只有getItem/setItem/removeItem配合 Redux 做状态恢复非常直接。2.2 LocalStorage 的安全边界与妥协说实话把 token 放 localStorage 并不是绝对安全的方案。它的最大软肋是XSS 攻击如果项目里不小心引入了第三方脚本或者有dangerouslySetInnerHTML渲染不可信内容攻击者就能执行脚本读取 localStorage 里的 token然后拿这个 token 冒充用户调用接口。但现实是大部分中后台项目里的 token 不是业务敏感数据本身而是一个访问凭证。真正敏感的数据放在服务端接口会做权限校验。所以 localStorage 存 token 是行业里极其普遍的做法属于可接受的妥协。如果对安全性有更高要求可以退一步做分层access token 留在内存里refresh token 放在 HttpOnly cookie 里。刷新页面后先拿 refresh token 换新的 access token再写回内存。这样 XSS 拿不到 refresh tokentoken 续签也不会因为页面刷新而中断。不过这套方案的复杂度会明显上升需要后端配合适合对安全有硬性要求的项目。如果当前项目规模不大、主要面向内部系统先做好 localStorage 统一拦截处理已经能解决 90% 的问题。3. 完整落地Redux LocalStorage 持久化实现方案定下来之后动手实现。这一整节讲的是标准链路每一步我都标注了为什么要这么做方便你在自己项目里对应着改。3.1 先封装一个干净的 token 存储工具很多人图省事直接在业务代码里到处写localStorage.getItem(token)。一开始没什么等你要换 key 名前缀、加版本号、或者在测试里 mock 存储的时候就会发现处处都是散落的字符串改起来想哭。我习惯第一步先封装一个独立模块所有存储操作都走这个模块// utils/tokenStorage.js const TOKEN_KEY myapp_access_token; const USER_KEY myapp_user_info; export const tokenStorage { getToken() { try { return localStorage.getItem(TOKEN_KEY); } catch (e) { return null; } }, setToken(token) { try { localStorage.setItem(TOKEN_KEY, token); } catch (e) { // 隐私模式或配额超限时静默失败 } }, removeToken() { localStorage.removeItem(TOKEN_KEY); }, getUserInfo() { try { const raw localStorage.getItem(USER_KEY); return raw ? JSON.parse(raw) : null; } catch (e) { return null; } }, setUserInfo(info) { localStorage.setItem(USER_KEY, JSON.stringify(info)); }, clear() { localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(USER_KEY); }, };几个设计细节解释一下key 名加项目前缀。中后台项目经常和多套系统共用同一个域名下的存储空间token这种通用 key 很容易互相覆盖。加个myapp_前缀成本极低能避免一类很诡异的 bugJSON 序列化统一处理。用户信息是对象直接存进去只会得到[object Object]封装层里统一处理JSON.stringify和JSON.parse业务侧不用关心这些try/catch 包裹。localStorage 在某些隐私模式下会直接抛异常不加保护的话一个getItem就能让整个应用白屏只暴露方法不暴露 key。这样后续改 key 名、迁移存储位置只动这个文件就行。3.2 登录成功后写入 store 和 localStorage登录流程的典型做法是用 Redux Toolkit 的createAsyncThunk发登录请求拿到 token 后同时写入 store 和 localStorage。写入 store 是为了当前页面立刻能用写入 localStorage 是为了下一次刷新能恢复。// store/authSlice.js import { createSlice, createAsyncThunk } from reduxjs/toolkit; import { loginApi } from ../api/auth; import { tokenStorage } from ../utils/tokenStorage; export const login createAsyncThunk( auth/login, async ({ username, password }) { const data await loginApi({ username, password }); return data; // 形如 { token, userInfo } } ); const authSlice createSlice({ name: auth, initialState: { token: null, userInfo: null, isAuthenticated: false, loading: false, }, reducers: { setAuth(state, action) { state.token action.payload.token; state.userInfo action.payload.userInfo || null; state.isAuthenticated Boolean(action.payload.token); }, clearAuth(state) { state.token null; state.userInfo null; state.isAuthenticated false; }, }, extraReducers: (builder) { builder .addCase(login.pending, (state) { state.loading true; }) .addCase(login.fulfilled, (state, action) { state.token action.payload.token; state.userInfo action.payload.userInfo || null; state.isAuthenticated true; state.loading false; // 直接在这里持久化避免业务代码里重复写 tokenStorage.clear(); tokenStorage.setToken(action.payload.token); if (action.payload.userInfo) { tokenStorage.setUserInfo(action.payload.userInfo); } }) .addCase(login.rejected, (state) { state.loading false; }); }, }); export const { setAuth, clearAuth } authSlice.actions; export default authSlice.reducer;这里有个值得养成的习惯写入持久化放在异步请求的 fulfilled 回调里而不是登录组件的.then()里。原因很简单状态更新、持久化这两件事和 UI 无关属于业务状态流转的一部分放进 reducer 生命周期里任何地方调用dispatch(login(...))都能得到一致的行为不会出现这里记得存、那里忘了存的情况。3.3 应用初始化时恢复登录态这是刷新不掉登录最关键的一步。应用每次启动store 创建后立刻从 localStorage 里读取 token派发setAuth把状态填回去。// store/index.js import { configureStore } from reduxjs/toolkit; import authReducer, { setAuth } from ./authSlice; import { tokenStorage } from ../utils/tokenStorage; const store configureStore({ reducer: { auth: authReducer, }, }); // 关键在 store 创建后立即恢复登录态 const token tokenStorage.getToken(); const userInfo tokenStorage.getUserInfo(); if (token) { store.dispatch(setAuth({ token, userInfo })); } export default store;这一步背后的逻辑是store 的初始化不只是空状态而应该是从持久层恢复的状态。把恢复逻辑放在store/index.js这个模块的顶层执行能保证在任何组件渲染之前isAuthenticated就已经是正确值。路由守卫、页面组件第一次读取状态时拿到的就是恢复后的登录态不会先渲染一帧未登录再跳转。关于用户信息的恢复有一点要特别注意localStorage 里的旧用户信息可能是过期的比如用户资料改了、权限变了。所以有些项目选择只恢复 token然后发一个getUserInfo请求拉最新资料。我自己的经验是先从 localStorage 恢复一份缓存用着让首屏先渲染同时异步请求最新用户信息请求回来后用新数据覆盖 store 和 localStorage。这样既保证了页面不闪白又不会用陈旧数据太久。路由守卫侧的做法是用一个React.memo包裹的路由组件加载状态为loading时渲染空白或 loading 页等isAuthenticated确定后再决定放行还是重定向// router/RequireAuth.jsx import { Navigate } from react-router-dom; import { useSelector } from react-redux; export default function RequireAuth({ children }) { const isAuthenticated useSelector((state) state.auth.isAuthenticated); const loading useSelector((state) state.auth.loading); if (loading) { return div加载中.../div; } if (!isAuthenticated) { return Navigate to/login replace /; } return children; }3.4 请求拦截器统一注入 token 并处理 401Token 恢复只是第一步后续每个接口请求都得带上 token。用 Axios 拦截器做统一注入是避免每个api.get(...)都手动传 header 的最优解。// api/client.js import axios from axios; import { tokenStorage } from ../utils/tokenStorage; import store from ../store; import { clearAuth } from ../store/authSlice; const api axios.create({ baseURL: /api, timeout: 15000, }); // 请求拦截器注入 token api.interceptors.request.use( (config) { const token tokenStorage.getToken(); if (token) { config.headers.Authorization Bearer ${token}; } return config; }, (error) Promise.reject(error) ); // 响应拦截器401 统一处理 api.interceptors.response.use( (response) response, (error) { if (error.response error.response.status 401) { // 清理本地状态 tokenStorage.clear(); store.dispatch(clearAuth()); // 避免重复跳转判断当前不在 /login 再跳 if (!window.location.pathname.startsWith(/login)) { window.location.href /login; } } return Promise.reject(error); } ); export default api;这个拦截器有三个容易踩的细节token 从 localStorage 读取而不是从 store 读取。原因在于模块加载顺序和闭包问题拦截器在 store 更新前就可能被调用从 localStorage 读取永远拿得到最新值401 清理时要判断当前路由。如果不判断登录页本身发出的登录请求如果返回 401会被重定向到 /login形成自我跳转页面会闪一下。判断一下能省掉这个异常体验跳转用window.location.href还是navigate这里要分场景如果是同应用路由用navigate更顺滑能保留页面状态如果是 token 失效需要强制重新登录我倾向于window.location.href直接整页刷新把内存里可能残留的状态彻底清干净避免跳过去了但内存里还有旧数据的尴尬。3.5 退出登录的清理顺序退出登录看起来就是清数据但顺序有讲究。一种常见的 bug 是先清 store后清 localStorage结果清 store 时组件监听到了状态变化触发了什么副作用又去读 localStorage读到了还没删掉的 token导致状态反复横跳。我的做法是固定顺序先调后端登出接口如果有→ 清理 localStorage → 再 dispatch clearAuth。清理 localStorage 的动作要放在 dispatch 之前这样从清除的那一刻起任何副作用都拿不到旧 token。export const logout createAsyncThunk(auth/logout, async (_, { dispatch }) { try { await logoutApi(); } finally { tokenStorage.clear(); dispatch(clearAuth()); } });4. Hook 使用中的坑与避坑指南状态恢复解决了接下来是 Hook 层面的坑。实际项目里很多刷新后 token 消失的诡异现象根因往往不是存储方案的问题而是 useEffect、useSelector 用得不对导致状态被错误覆盖或依赖链断裂。4.1 闭包陷阱useEffect 里读到了过期 token这是我见过最多的坑。一个典型的场景页面加载时判断 token 是否有效无效就跳登录页。// 错误示例 const token useSelector((state) state.auth.token); useEffect(() { if (token isTokenExpired(token)) { logout(); } }, []);问题在于useEffect的回调只会在挂载时执行一次它捕获的是挂载那一刻的token值。如果 token 的初始化恢复发生在 useEffect 执行之后比如异步恢复回调里拿到的token就是null判断逻辑直接失效。即使恢复同步完成了如果后续 token 变了比如刷新了 token这个 useEffect 也感知不到。正确做法是把判断放进状态流转里而不是组件生命周期里// 推荐把 token 有效性判断交给拦截器 // 组件里只需要订阅最终状态 const isAuthenticated useSelector((state) state.auth.isAuthenticated); useEffect(() { if (isAuthenticated) { // 拉取业务数据 } }, [isAuthenticated]);核心原则是不要把状态判断写在空依赖数组的 useEffect 里让它依赖真实的订阅值。如果你确实需要监听某个变量的变化就要把它写进依赖数组。如果你担心依赖数组导致重复执行问题大概率出在依赖值不稳定比如每次 render 都生成新对象而不是不该监听。4.2 useEffect 执行时机与无限渲染循环另一个高频坑是 useEffect 配合 useSelector 导致的无限循环。看这段代码const userInfo useSelector((state) state.auth.userInfo); useEffect(() { if (!userInfo) { dispatch(fetchUserInfo()); } }, [userInfo, dispatch]);这段代码的本意是用户信息为空时拉取一次。看起来很合理但有个隐藏问题useSelector的默认比较方式是引用相等。如果 reducer 返回了新的对象引用比如state.userInfo action.payload每次都是新对象那么每次 dispatch 后userInfo都会变useEffect 又依赖它于是dispatch fetch → userInfo 更新 → effect 触发 → 判断 userInfo 不为空不再 fetch。看起来没问题但如果 fetch 接口返回的数据每次都不同或者 reducer 里做了某种 transformuserInfo 永远不会稳定就会无限循环。更隐蔽的版本是 selector 返回一个新派生对象// 千万别这样写 const authInfo useSelector((state) ({ token: state.auth.token, isAuthenticated: state.auth.isAuthenticated, }));每次 redux store 更新即使token和isAuthenticated都没变authInfo也都是新对象导致所有依赖authInfo的 useEffect 每次都执行、所有 memo 组件每次都重渲染。正确做法是用useSelector的浅比较特性分开选择基本类型值或者用createSelector做记忆化import { createSelector } from reduxjs/toolkit; const selectAuthInfo createSelector( (state) state.auth.token, (state) state.auth.isAuthenticated, (token, isAuthenticated) ({ token, isAuthenticated }) ); const authInfo useSelector(selectAuthInfo);4.3 自定义 Hook 统一封装登录态逻辑与其在每个组件里散落各种useSelector和useDispatch不如封装一个自定义 Hook把登录态相关逻辑收敛到一起。这是我的项目里实际在用的一个简化版// hooks/useAuth.js import { useCallback } from react; import { useDispatch, useSelector } from react-redux; import { login as loginThunk, logout as logoutThunk } from ../store/authSlice; import { tokenStorage } from ../utils/tokenStorage; export function useAuth() { const dispatch useDispatch(); const token useSelector((state) state.auth.token); const userInfo useSelector((state) state.auth.userInfo); const isAuthenticated useSelector((state) state.auth.isAuthenticated); const loading useSelector((state) state.auth.loading); const login useCallback( (params) { return dispatch(loginThunk(params)).unwrap(); }, [dispatch] ); const logout useCallback(() { return dispatch(logoutThunk()).unwrap(); }, [dispatch]); const updateToken useCallback( (newToken) { tokenStorage.setToken(newToken); dispatch(setAuth({ token: newToken, userInfo })); }, [dispatch, userInfo] ); return { token, userInfo, isAuthenticated, loading, login, logout, updateToken, }; }封装带来的好处是业务组件里不再直接依赖 Redux 的 API后续要切换状态管理方案比如换 Zustand只需要改这一个 Hook。而且login返回 Promise组件里可以很方便地做表单 loading、错误提示不用自己再监听 loading 状态。5. 常见问题排查实录与实用经验最后这部分是我的踩坑实录也是每次处理此类问题时的排查清单。按表格整理出来方便以后遇到类似问题直接对着查。5.1 高频问题速查表现象可能原因排查方向解决方案刷新后立刻跳登录页初始化恢复逻辑缺失或写在组件里检查 store 创建后是否读取 localStorage 并 dispatch在store/index.js顶层执行恢复逻辑刷新后能进页面但接口全部 401请求拦截器没有统一注入 token检查 request interceptor 是否从存储读取确认注入Authorization从 localStorage 读取登录成功后立即刷新token 仍有但用户信息没恢复没有持久化用户信息或恢复逻辑不完整检查 localStorage 里是否有 userInfo增加setUserInfo和恢复逻辑多个标签页登录状态不同步没有监听 storage 事件在另一个标签页登录后当前页状态未更新加storage事件监听并 dispatch setAuthToken 过期后接口 401但页面没有反应响应拦截器没有统一处理 401检查 response interceptor401 时清理状态并跳登录清除 localStorage 后 store 里还有旧状态清理顺序不对检查 logout 逻辑先清存储再 dispatch clearAuth组件里 token 更新了但界面不刷新useSelector 选择了派生对象导致重渲染异常检查是否返回新对象用 createSelector 或分开选择基础值无限请求用户信息接口useEffect 依赖了新对象检查依赖数组和 selector用 useCallback 和记忆化 selector5.2 几个值得长期坚持的设计习惯排查过太多这类问题之后我总结出几个如果一开始就做好后面能少走很多弯路的习惯。第一坚持单一数据源原则。token 的真值在 localStoragestore 是它的镜像。任何地方修改 token都必须先操作 localStorage再同步派发 action。不要出现某些地方改 store、某些地方改 localStorage的双写混乱否则一旦顺序错位刷新后状态必然对不上。第二key 名带上项目标识和版本号。比如myapp_v2_access_token。上线新版本时如果存储结构变了可以通过改版本号来避免旧数据残留导致解析失败。很多诡异的登录不了问题排查到最后都是新旧数据结构不兼容。第三用 storage 事件同步多标签页。用户经常在多个标签页间切换。这个标签页退出了另一个标签页还停留在登录状态。加一段全局监听window.addEventListener(storage, (e) { if (e.key myapp_access_token) { if (e.newValue) { store.dispatch(setAuth({ token: e.newValue, userInfo: null })); } else { store.dispatch(clearAuth()); } } });注意storage事件只在其他标签页修改 localStorage 时触发当前标签页自己改不触发所以不会造成多余渲染。这段代码放在main.jsx或store/index.js里即可。第四凡是操作 localStorage 的地方都做好异常兜底。隐私模式、存储配额、测试环境都可能让setItem抛异常。不加 try/catch 的话一个存储异常能让整个功能不可用这不该发生。5.3 token 续签和失效时间的一个处理思路最后补充一个和持久化强相关但容易被忽略的点token 过期了怎么办。很多项目只做了过期后 401 → 跳登录页用户体验就是干坐了一会儿突然被踢下线。稍微好一点的做法是 request 拦截器里判断 token 是否将过期快过期时用 refresh token 静默续签成功就自动重放原请求。这个逻辑实现起来不复杂核心是用一个队列缓存待重放的请求防止同时多个请求各自刷新 token 造成重复请求let isRefreshing false; let pendingQueue []; async function refreshTokenAndRetry(error) { const { config } error; if (!isRefreshing) { isRefreshing true; try { const newToken await requestRefreshToken(); // 调用刷新接口 tokenStorage.setToken(newToken); store.dispatch(setAuth({ token: newToken, userInfo: store.getState().auth.userInfo })); pendingQueue.forEach((cb) cb(newToken)); pendingQueue []; config.headers.Authorization Bearer ${newToken}; return api(config); } catch (refreshError) { pendingQueue.forEach((cb) cb(null)); pendingQueue []; tokenStorage.clear(); store.dispatch(clearAuth()); window.location.href /login; return Promise.reject(refreshError); } finally { isRefreshing false; } } return new Promise((resolve, reject) { pendingQueue.push((newToken) { if (newToken) { config.headers.Authorization Bearer ${newToken}; resolve(api(config)); } else { reject(error); } }); }); }这段代码完整落地需要后端配合提供 refresh_token 接口。如果你的后端暂不支持也可以先用401 时清状态跳登录的简化方案但心里要清楚真正的用户友好方案是静默续签而不是让用户重新登录。刷新丢 token 这个问题说起来就一句话——内存状态没有持久化——但真正落地时牵扯到登录写入、初始化恢复、请求注入、异常清理、Hook 依赖管理一整条链路。我个人在实际项目里的体会是先把存储和状态之间的映射关系彻底理清楚再动手写代码比什么技巧都管用。理清了这条线你再去接 Zustand、接 Vue 的 Pinia或者换成 sessionStorage本质都是一样的套路只是 API 变了而已。最后分享一个小建议做完持久化改造后别只在页面刷新这一个场景下验证多试几个场景——登录后快速刷新、多个标签页同时操作、token 手动改坏、清掉 localStorage 再刷新。这几个场景能帮你把状态恢复和清理逻辑中的边角漏网全部揪出来。改完之后你会发现F5 后面露出的獠牙其实并不可怕真正的问题永远藏在状态生命周期和存储生命周期没有对齐的地方。