刷新页面掉登录,这个坑做 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或configureStore,state 回到初始化值。
打个比方,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 token,token 续签也不会因为页面刷新而中断。不过这套方案的复杂度会明显上升,需要后端配合,适合对安全有硬性要求的项目。如果当前项目规模不大、主要面向内部系统,先做好 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_前缀成本极低,能避免一类很诡异的 bug; - JSON 序列化统一处理。用户信息是对象,直接存进去只会得到
[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 并处理 401
Token 恢复只是第一步,后续每个接口请求都得带上 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 里做了某种 transform,userInfo 永远不会稳定,就会无限循环。
更隐蔽的版本是 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 setAuth |
| Token 过期后接口 401,但页面没有反应 | 响应拦截器没有统一处理 401 | 检查 response interceptor | 401 时清理状态并跳登录 |
| 清除 localStorage 后 store 里还有旧状态 | 清理顺序不对 | 检查 logout 逻辑 | 先清存储再 dispatch clearAuth |
| 组件里 token 更新了但界面不刷新 | useSelector 选择了派生对象导致重渲染异常 | 检查是否返回新对象 | 用 createSelector 或分开选择基础值 |
| 无限请求用户信息接口 | useEffect 依赖了新对象 | 检查依赖数组和 selector | 用 useCallback 和记忆化 selector |
5.2 几个值得长期坚持的设计习惯
排查过太多这类问题之后,我总结出几个"如果一开始就做好,后面能少走很多弯路"的习惯。
第一,坚持单一数据源原则。token 的"真值"在 localStorage,store 是它的镜像。任何地方修改 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 后面露出的獠牙"其实并不可怕,真正的问题永远藏在状态生命周期和存储生命周期没有对齐的地方。