首页 / 文章

React认证、数据加载与错误处理:Axios、React Router、Redux、TanStack Query

10个与身份验证、数据加载及错误处理相关的React问题,已通过Axios拦截器、React Router加载器、Redux中间件、TanStack Query以及错误边界得到解决。

发布者:REACTAPP.TOP阅读约 15 分钟3360 词

来源: Stop Polluting Your Components: The React ‘Middleware’ Guide (Axios, Routers, and State)

在后端,没有人在每个路由处理函数中检查身份验证,也不会在每个控制器中添加日志记录功能;这些工作都由中间件负责,只需配置一次即可应用于所有请求。React 项目往往缺乏这种规范,组件不得不自行处理令牌注入、401 重定向、错误日志记录以及数据获取等功能,这不仅会产生大量冗余代码,还会导致行为不一致,以及只有在用户以特定顺序操作时才会出现的错误。React 应用拥有自己的请求生命周期,API 调用、路由切换和状态更新都会通过可挂载的层级来完成。本指南介绍了 Axios 拦截器、React Router 加载器、状态管理库、错误处理机制以及 Next.js 中间件中常见的十种错误,并说明了各类功能应放置的位置。

传输层相关问题:身份验证与 Axios 拦截器

错误1:在每个组件中处理401响应

身份验证处理是导致组件结构混乱的最常见原因。每个获取数据函数都有独立的catch块,用于检测401错误、清除缓存并重新定向:

// ❌ Bad: Repeated in every component
async function fetchDashboard() {
  try {
    const res = await axios.get('/api/dashboard');
    return res.data;
  } catch (error) {
    if (error.response?.status === 401) {
      localStorage.removeItem('token');
      window.location.href = '/login';
    }
    throw error;
  }
}
async function fetchProfile() {
  try {
    const res = await axios.get('/api/profile');
    return res.data;
  } catch (error) {
    if (error.response?.status === 401) {
      localStorage.removeItem('token');
      window.location.href = '/login';
    }
    throw error;
  }
}

除了重复问题之外,这种设计还很脆弱。从本地存储改为使用Cookie意味着需要修改所有调用位置;在重定向前添加刷新令牌轮换机制则又得再次修改所有文件。而一旦遗漏某项检查,应用就会处于半登出状态。

解决办法是在共享的客户端实例上设置Axios响应拦截器,让它能在任何组件之前处理所有响应。下面的版本还会在请求发出时附加令牌,遇到401错误时会尝试刷新一次(通过_retry标志防止无限循环),然后用新令牌重新发送原始请求;只有当刷新本身失败时,才会回退到清除存储并重定向的操作。

// api/client.js
import axios from 'axios';
const api = axios.create({
  baseURL: process.env.REACT_APP_API_URL,
});
// Request interceptor: attach token
api.interceptors.request.use((config) => {
  const token = localStorage.getItem('token');
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});
// Response interceptor: handle global errors
api.interceptors.response.use(
  (response) => response,
  async (error) => {
    const originalRequest = error.config;
    if (error.response?.status === 401 && !originalRequest._retry) {
      originalRequest._retry = true;

      try {
        // Attempt token refresh
        const refreshToken = localStorage.getItem('refreshToken');
        const { data } = await axios.post('/auth/refresh', { refreshToken });

        localStorage.setItem('token', data.accessToken);
        originalRequest.headers.Authorization = `Bearer ${data.accessToken}`;

        return api(originalRequest);
      } catch (refreshError) {
        // Refresh failed, redirect to login
        localStorage.removeItem('token');
        localStorage.removeItem('refreshToken');
        window.location.href = '/login';
        return Promise.reject(refreshError);
      }
    }
    return Promise.reject(error);
  }
);
export default api;

现在的组件只需导入 api 并调用它,无需考虑 401 错误的处理;现在只有拦截器能决定如何恢复认证状态。这里有三个需要注意的点。process.env.REACT_APP_API_URL 是 Create React App 项目的约定;在 Vite 项目中则应使用 import.meta.env.VITE_API_URL。如果有多个请求同时失败,每个请求都会触发各自的刷新操作,这可能会导致轮换的刷新令牌失效;相关解决方案可在 为何并发的 401 错误会导致用户登出 中找到。此外,将令牌存储在 localStorage 中会使其容易受到 XSS 攻击,因此值得考虑使用 httpOnly Cookie。

错误 2:拦截器未返回值

拦截器形成一条链,每个环节都必须将自身的值传递给下一个环节。而这两个环节却忘记了:

// ❌ Bad: Broken chain
api.interceptors.response.use((response) => {
  console.log('Response received:', response.status);
  // Forgot to return response!
});
api.interceptors.request.use((config) => {
  config.headers['X-Request-ID'] = crypto.randomUUID();
  // Forgot to return config!
});

这种现象常被描述为请求挂起,但在当前版本的 Axios 中,故障通常更为直接,同样令人困惑。如果响应拦截器没有返回任何值,它会用 undefined 来解析调用方的承诺,因此 const { data } = await api.get('/user') 会抛出与实际原因无关的解构错误。而如果请求拦截器没有返回任何值,Axios 就会失去配置,导致请求在发送之前就失败了。无论哪种情况,错误都会出现在代码编写之处之外的地方。

务必返回实际接收到的数据,并在错误处理函数中明确拒绝请求,这样调用方的 catch 语句仍能执行:

// ✅ Good: Complete chain
api.interceptors.request.use(
  (config) => {
    config.headers['X-Request-ID'] = crypto.randomUUID();
    return config; // Critical
  },
  (error) => Promise.reject(error)
);
api.interceptors.response.use(
  (response) => {
    // Log without breaking the chain
    console.log(`[${response.config.url}] ${response.status}`);
    return response; // Critical
  },
  (error) => {
    // Always reject so the component catch block fires
    return Promise.reject(error);
  }
);

这一类比同样适用于 Express:忘记调用 next() 会阻塞 Express 请求,而忘记使用 return 则会中断拦截器链。

错误 3:手动添加认证头信息

在每次调用时都复制 Authorization 头信息,相当于将一个跨域问题当作局部问题处理。迟早会有某次调用遗漏该头信息、以不同格式呈现它,或者发送 Bearer null。下面的代码片段也展示了出错的容易程度:在 post 调用中,头信息对象被当作请求体传递,而实际数据却被当作配置参数,因为 Axios 需要的参数格式是 post(url, data, config):

// ❌ Bad: Manual headers everywhere
api.get('/user', { headers: { Authorization: `Bearer ${token}` } });
api.post('/order', { headers: { Authorization: `Bearer ${token}` } }, payload);
api.get('/history', { headers: { Authorization: `Bearer ${token}` } });

请求拦截器仅在存在令牌时,通过在一个位置添加头部信息来消除整个错误类别:

// ✅ Good: Global token injection
api.interceptors.request.use((config) => {
  const token = localStorage.getItem('access_token');

  if (token && config.headers) {
    config.headers.Authorization = `Bearer ${token}`;
  }

  return config;
});

如果你使用 TanStack Query,它的缓存还提供了用于处理副作用的全局钩子。任何查询在遇到 401 错误时,都可以通过 QueryCache 的 onError 方法进行重定向;而任何失败的变异操作,则可以通过 MutationCache 的 onError 方法显示提示信息:

// ✅ Good: QueryClient global handlers
const queryClient = new QueryClient({
  queryCache: new QueryCache({
    onError: (error) => {
      if (error.response?.status === 401) {
        window.location.href = '/login';
      }
    }
  }),
  mutationCache: new MutationCache({
    onError: (error) => {
      toast.error(error.message);
    }
  })
});

其基本原则是,认证属于传输层的问题,而非组件层的问题,因此应在传输层进行处理。

数据加载:React Router加载器与TanStack Query缓存

错误4:本应由路由加载器处理的操作却放在useEffect中执行

常见的处理流程是:组件挂载后运行效应函数、显示加载指示器、接收数据,最后进行渲染:

// ❌ Bad: Component-level fetching
function Dashboard() {
  const [data, setData] = useState(null);
  const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
    api.get('/api/dashboard')
      .then(res => setData(res.data))
      .finally(() => setIsLoading(false));
  }, []);
  if (isLoading) return <Spinner />;
  return <DashboardContent data={data} />;
}

它会形成请求流水线:路由器先渲染组件,之后效果组件才会发起请求,只有在请求返回后状态才会更新。来回导航时都会重新获取数据。较旧的 React 版本还会警告在组件卸载后设置状态;React 18 已移除该警告,但慢速响应与更快响应之间的竞争问题依然存在。

React Router 的数据 API(从 v6.4 版本的 createBrowserRouter 开始提供)允许路由指定一个在路由元素渲染前进行数据获取的 loader,以及一个在 loader 抛出异常时渲染的 errorElement:

// ✅ Good: Route-level data loading
// router.jsx
import { createBrowserRouter } from 'react-router-dom';
import { Dashboard, dashboardLoader } from './Dashboard';
const router = createBrowserRouter([
  {
    path: '/dashboard',
    element: <Dashboard />,
    loader: dashboardLoader, // Runs before render
    errorElement: <DashboardError />
  }
]);
// Dashboard.jsx
import { useLoaderData } from 'react-router-dom';
export async function dashboardLoader() {
  const response = await api.get('/api/dashboard');
  if (response.status !== 200) {
    throw new Response('Failed to load dashboard', { status: response.status });
  }
  return response.data;
}
export function Dashboard() {
  const data = useLoaderData(); // Already available, no loading state needed

  return <DashboardContent data={data} />;
}

当 Dashboard 渲染时,useLoaderData() 已经获取到了数据,因此无需组件级的加载状态处理或额外的效果代码。有两点需要注意:导航功能本身会等待加载完成,所以仍需要一个由 useNavigation 驱动的全局待处理指示器。另外,由于 Axios 默认会拒绝非 2xx 状态码的响应,该加载器中的 response.status !== 200 检查不会检测到错误状态;错误信息会直接传递到 errorElement。

对于那些不应阻塞页面的次要数据,加载器可以先等待关键请求完成,再使用 defer 将处理速度较慢的请求作为未解决的承诺返回:

export async function dashboardLoader() {
  const criticalData = await api.get('/api/summary');
  const slowData = api.get('/api/analytics'); // Promise, not awaited

  return defer({
    summary: criticalData.data,
    analytics: slowData.then(r => r.data)
  });
}

页面会在分析数据加载期间显示摘要,这些数据通常通过 Await 和 Suspense 来获取。在 React Router v7 中已不再需要 defer,加载器可以直接返回承诺对象,因此请查阅你所使用版本的文档。

错误5:为每个请求都手动编写 thunk 函数

Redux Thunk 的出现确实带来了重大进步,但如今若为每次 API 调用都编写一个 thunk 函数,就意味着要为那些现代工具已能以声明式方式处理的任务继续维护动作类型常量、动作创建函数、还原器处理逻辑以及状态标志:

// ❌ Bad: Thunk boilerplate for simple fetching
const FETCH_USER_REQUEST = 'FETCH_USER_REQUEST';
const FETCH_USER_SUCCESS = 'FETCH_USER_SUCCESS';
const FETCH_USER_FAILURE = 'FETCH_USER_FAILURE';
const fetchUser = (userId) => async (dispatch) => {
  dispatch({ type: FETCH_USER_REQUEST });
  try {
    const res = await api.get(`/users/${userId}`);
    dispatch({ type: FETCH_USER_SUCCESS, payload: res.data });
  } catch (error) {
    dispatch({ type: FETCH_USER_FAILURE, payload: error.message });
  }
};
// Reducer boilerplate...
// Component mapStateToProps...

在没有更好方案时,那种结构是合理的。但对于常规的 CRUD 操作而言,它现已变得多余。

RTK Query 和 TanStack Query 直接提供了缓存、请求去重、后台重新获取数据以及加载状态管理功能。使用 RTK Query 时,你需要定义一个基础查询,包括用于添加令牌的 prepareHeaders 钩子,并声明接口端点;系统会自动生成 useGetUserQuery 和 useUpdateUserMutation 这样的钩子:

// ✅ Good: RTK Query handles the middleware
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
export const userApi = createApi({
  reducerPath: 'userApi',
  baseQuery: fetchBaseQuery({
    baseUrl: '/api',
    prepareHeaders: (headers) => {
      const token = localStorage.getItem('token');
      if (token) headers.set('authorization', `Bearer ${token}`);
      return headers;
    }
  }),
  endpoints: (builder) => ({
    getUser: builder.query({
      query: (id) => `/users/${id}`,
    }),
    updateUser: builder.mutation({
      query: ({ id, ...body }) => ({
        url: `/users/${id}`,
        method: 'PUT',
        body,
      }),
    }),
  }),
});
export const { useGetUserQuery, useUpdateUserMutation } = userApi;

在那些钩子背后,RTK Query会为每个端点和参数缓存响应,统计有多少已挂载的组件使用了这些缓存项,并丢弃那些不再被使用的项。需要注意的是,它并不会像手动实现的实体适配器那样对实体进行规范化处理;缓存的结果会以原始形式存储。无论哪种方式,你都需要编写业务逻辑,而库则负责处理相关的实现细节。

Redux中间件与UI安全机制

错误6:在Redux中间件中修改状态

自定义Redux中间件非常适合用于日志记录、数据分析以及动作转换。但它绝不是修改状态的合适位置。中间件运行在dispatch和reducer之间,因此在此处修改状态会绕过Redux的状态变更跟踪机制,导致时间旅行调试失效,还会让DevTools出现混乱:

// ❌ Bad: State mutation in middleware
const analyticsMiddleware = store => next => action => {
  if (action.type === 'ADD_TO_CART') {
    // Directly mutating state! Never do this.
    store.getState().cart.lastAddedAt = Date.now();
  }
  return next(action);
};

这种变异也无法重新渲染任何内容,因为 Redux 及其绑定机制是通过比较引用值来检测变化的。修改现有对象中的某个字段并不会改变引用值,因此订阅者不会看到任何更新。Redux Toolkit 的开发模式下的不可变性检查会标记这类变异,这也是使用它的另一个理由。

应将中间件限制在读取、记录、转换或转发动作的功能上,而所有的状态变化都应在还原器中处理。在这里,中间件会跟踪动作并附加一个时间戳作为 meta,然后还原器利用该时间戳进行不可变更新:

// ✅ Good: Middleware only intercepts and forwards
const analyticsMiddleware = store => next => action => {
  // Log the action before it reaches the reducer
  analytics.track(action.type, {
    payload: action.payload,
    timestamp: Date.now(),
    userId: store.getState().auth.userId,
  });

  // Optionally transform the action
  if (action.type === 'ADD_TO_CART') {
    action = {
      ...action,
      meta: { addedAt: Date.now() }
    };
  }

  return next(action); // Forward to reducer
};
// Reducer handles the state update
function cartReducer(state = initialState, action) {
  switch (action.type) {
    case 'ADD_TO_CART':
      return {
        ...state,
        items: [...state.items, action.payload],
        lastAddedAt: action.meta.addedAt, // Proper immutable update
      };
    default:
      return state;
  }
}

注意这种关联: reducer会读取action.meta.addedAt,因此它依赖于所安装的中间件。在动作创建器中(或Redux Toolkit的prepare回调中)生成时间戳即可避免这种隐性依赖。一个便于理解的比喻是:中间件就像邮局,能够读取信封并贴上邮票,但它绝不会打开你的银行对账单来更改余额。

错误7:将错误边界视为事后才考虑的问题

许多应用在根节点周围只设置一个错误边界,或者根本不设置。错误边界实际上相当于组件树中的中间件:它们能够捕获子组件中的渲染错误,防止一次故障导致整个界面崩溃。如果没有错误边界,格式错误的API响应、缺失的属性或出问题的第三方脚本都可能让整个页面变为空白:

// ❌ Bad: No error boundaries
function App() {
  return (
    <div>
      <Header />
      <Sidebar />
      <Dashboard /> {/* If this crashes, everything dies */}
      <Footer />
    </div>
  );
}

在布局层和功能层设置边界,这样分析小部件出现故障时也不会影响导航功能。边界是一个类组件,它会在getDerivedStateFromError中设置错误状态,在componentDidCatch中报告错误,并渲染备用内容;通过嵌套多个边界,可使每个区域拥有独立的恢复机制:

// ✅ Good: Strategic boundary placement
class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false, error: null };
  }
static getDerivedStateFromError(error) {
    return { hasError: true, error };
  }
  componentDidCatch(error, errorInfo) {
    logErrorToService(error, errorInfo);
  }
  render() {
    if (this.state.hasError) {
      return this.props.fallback || <DefaultErrorUI error={this.state.error} />;
    }
    return this.props.children;
  }
}
function App() {
  return (
    <ErrorBoundary fallback={<GlobalErrorPage />}>
      <Header />
      <div className="layout">
        <ErrorBoundary fallback={<SidebarError />}>
          <Sidebar />
        </ErrorBoundary>
        <main>
          <ErrorBoundary fallback={<WidgetError />}>
            <AnalyticsWidget />
          </ErrorBoundary>
          <ErrorBoundary fallback={<WidgetError />}>
            <RecentActivity />
          </ErrorBoundary>
        </main>
      </div>
    </ErrorBoundary>
  );
}

请记住它们的限制:边界可以捕获在渲染过程中、其下层的生命周期方法及构造函数中抛出的错误,但无法捕获事件处理程序、定时器或未等待的承诺等异步代码中的错误,以及边界本身产生的错误。这类错误需要使用普通的 try/catch 语句或全局错误处理机制。如果使用带有服务器组件的框架,应将客户端边界与框架的路由级错误处理文件(例如 Next.js App Router 中的 error.js)结合使用,从而按路由捕获服务器错误,按组件捕获客户端错误。

性能与平台边界

错误8:拦截器中执行繁重操作

从承诺的角度来看,拦截器是异步的,但其代码仍在主线程上运行。在主线程上进行的昂贵解密、转换或解析操作会阻塞渲染过程:

// ❌ Bad: Heavy work in interceptor
api.interceptors.response.use((response) => {
  // This blocks rendering for large payloads
  response.data = heavyDecryptAndTransform(response.data);
  return response;
});

200毫秒的转换时间意味着页面在这段时间内会忽略用户输入,用在手机上时就如同应用卡住了。应将繁重的处理任务放到Web Worker中执行,或推迟所有非必要的操作。在此示例中,响应会被发送到工作线程,而拦截器则等待处理结果:

// ✅ Good: Async offloading for heavy work
api.interceptors.response.use(async (response) => {
  if (response.config.url.includes('/export')) {
    // Move heavy work off main thread
    const worker = new Worker(new URL('./transform.worker.js', import.meta.url));

    const transformed = await new Promise((resolve, reject) => {
      worker.onmessage = (e) => resolve(e.data);
      worker.onerror = reject;
      worker.postMessage(response.data);
    });

    worker.terminate();
    response.data = transformed;
  }

  return response;
});

每次响应都创建新的工作线程会带来启动成本,而且postMessage是通过结构化克隆来复制数据的,因此对于频繁调用的场景,应重用长期存在的工作线程,并考虑使用可传输的缓冲区。若需加密或压缩处理,可在工作线程中使用Web Crypto API,或将相关计算任务移至服务器端,从而保持用户界面的响应速度。关于这种权衡的更多信息,请参阅何时将CPU计算任务移至Web Worker中。

错误9:将Next.js中间件视为应用程序代码

传统上,Next.js 中间件运行在 Edge 运行时上,这是一种 V8 孤立环境而非完整的 Node.js 环境。它无法访问 React 上下文或客户端状态,也无法使用常规的数据库连接池。即便如此,人们仍经常在 middleware.ts 中看到复杂的验证逻辑、数据库查询或带状态处理的代码:

// ❌ Bad: Trying to run React logic in Edge Middleware
// middleware.ts
import { NextResponse } from 'next/server';
export async function middleware(request) {
  // This won't work: no access to React context
  const user = await db.user.findUnique(...); // No DB connection in Edge!

  // This is risky: heavy computation in Edge
  const result = complexAlgorithm(request.body);

  return NextResponse.next();
}

仅处理轻量级的请求级任务:内容重写、简单的 Cookie 存在性检查、A/B 测试分组、机器人过滤以及基于地理位置的路由。该示例会将未经过身份验证的访问者重定向到其他页面,将实验相关头部信息转发给应用,并通过 matcher 仅在需要时才在相应路径上执行操作:

// ✅ Good: Lightweight edge middleware
import { NextResponse } from 'next/server';
export function middleware(request) {
  const token = request.cookies.get('token')?.value;

  // Simple auth check: presence only
  if (!token && request.nextUrl.pathname.startsWith('/dashboard')) {
    return NextResponse.redirect(new URL('/login', request.url));
  }

  // A/B testing: lightweight header injection
  const experiment = request.cookies.get('exp')?.value || 'control';
  const requestHeaders = new Headers(request.headers);
  requestHeaders.set('x-experiment', experiment);

  return NextResponse.next({
    request: { headers: requestHeaders }
  });
}
export const config = {
  matcher: ['/dashboard/:path*', '/api/protected/:path*']
};

真正的会话验证应放在具有完整服务器访问权限的路由处理程序或服务器组件中;中间件只是入口控制,而非后台管理系统。运行时支持一直在变化:最近的 Next.js 版本为中间件增加了 Node.js 运行时选项,并修改了文件命名规范,因此请查阅对应版本的 Next.js 文档以确认当前行为。

错误 10:为小型应用过度设计

并非每个项目都需要一堆拦截器、自定义的 Redux 中间件以及工作进程。那些拥有复杂拦截器链、针对静态文本设置边界,以及为单个联系表单配备完整查询层的三页式作品集网站其实很常见:

// ❌ Bad: Over-engineered simple app
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: Infinity,
      cacheTime: Infinity,
      retry: 3,
      retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000),
      refetchOnWindowFocus: true,
      refetchOnReconnect: true,
      // ... 20 more options for a static site
    }
  }
});

那只是盲目模仿,而非真正的架构设计:每增加一层都会在调试时带来额外的内存开销、数据包重量以及间接操作。只有少量路由且仅处理单个请求的应用根本不需要缓存策略。此外其配置也存在内部矛盾:staleTime: Infinity意味着数据永远不会被视为过期,因此聚焦和重新连接重取功能就毫无作用。而在 TanStack Query v5 中,cacheTime已被重命名为 gcTime。

应根据应用规模调整相关机制:

  • 静态或简单网站:直接调用 fetch,无需拦截器。
  • 带有身份验证的小型应用:使用一个请求拦截器处理令牌,另一个响应拦截器处理 401 错误。
  • 具有路由功能的中型应用:添加路由加载器以及布局级错误处理机制。
  • 具有复杂状态的大型应用:可使用 RTK Query 或 TanStack Query 以及自定义中间件。

知道何时不应添加抽象层也是这项工作的一部分。

React 应用中的中间件思维

中间件并非仅属于后端的概念。在 React 中,它是一个让组件专注于渲染、同时将重复处理的任务集中处理的层级。

  • 将身份验证和错误处理放在传输层,即单个客户端实例上,并始终通过拦截器返回结果或拒绝请求。
  • 在加载器或查询缓存中加载路由数据,而非在组件效应中处理。
  • 仅保留 Redux 中间件的副作用功能,让 reducer 负责状态变更。
  • 使用多个错误边界,并记住它们无法捕获的情况。
  • 将繁重任务移出主线程,同时保持边缘中间件的简洁性。
  • 只有当应用程序的大小确实需要时,才添加相应的层。