React-Redux RTK Query 使用笔记

简介

RTK Query 是 Redux Toolkit 官方提供的数据获取和缓存工具,基于 Redux 构建但极大简化了异步数据管理。它将请求、缓存、状态管理封装在一个统一的 API 中。

核心优势:

  • 自动缓存和去重
  • 自动生成 React Hooks
  • 支持乐观更新
  • 内置轮询和重新获取机制
  • 无需手动编写 slice、thunk、reducer

安装

npm install @reduxjs/toolkit react-redux

基础配置

1. 创建 API Slice

// src/services/api.ts
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

// 定义数据类型
export interface Post {
  id: number;
  title: string;
  body: string;
  userId: number;
}

// 创建 API
export const api = createApi({
  // 配置基础请求
  baseQuery: fetchBaseQuery({ 
    baseUrl: 'https://jsonplaceholder.typicode.com',
    // 可添加通用 headers
    prepareHeaders: (headers, { getState }) => {
      const token = (getState() as RootState).auth.token;
      if (token) {
        headers.set('authorization', `Bearer ${token}`);
      }
      return headers;
    },
  }),
  
  // 全局标签类型(用于缓存失效)
  tagTypes: ['Post', 'User'],
  
  // 定义端点
  endpoints: (builder) => ({
    // 查询端点(GET)
    getPosts: builder.query<Post[], void>({
      query: () => '/posts',
      providesTags: ['Post'],
    }),
    
    getPostById: builder.query<Post, number>({
      query: (id) => `/posts/${id}`,
      providesTags: (result, error, id) => [{ type: 'Post', id }],
    }),
    
    // 修改端点(POST/PUT/DELETE)
    addPost: builder.mutation<Post, Partial<Post>>({
      query: (body) => ({
        url: '/posts',
        method: 'POST',
        body,
      }),
      invalidatesTags: ['Post'],
    }),
    
    updatePost: builder.mutation<Post, Partial<Post> & Pick<Post, 'id'>>({
      query: ({ id, ...patch }) => ({
        url: `/posts/${id}`,
        method: 'PUT',
        body: patch,
      }),
      invalidatesTags: (result, error, { id }) => [{ type: 'Post', id }],
    }),
    
    deletePost: builder.mutation<void, number>({
      query: (id) => ({
        url: `/posts/${id}`,
        method: 'DELETE',
      }),
      invalidatesTags: (result, error, id) => [{ type: 'Post', id }],
    }),
  }),
});

// 自动生成 hooks
export const {
  useGetPostsQuery,
  useGetPostByIdQuery,
  useAddPostMutation,
  useUpdatePostMutation,
  useDeletePostMutation,
} = api;

2. 配置 Store

// src/store/index.ts
import { configureStore } from '@reduxjs/toolkit';
import { setupListeners } from '@reduxjs/toolkit/query';
import { api } from '../services/api';
import authReducer from './authSlice';

export const store = configureStore({
  reducer: {
    [api.reducerPath]: api.reducer,
    auth: authReducer,
  },
  
  // 添加 API middleware 用于缓存、乐观更新、轮询等功能
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware().concat(api.middleware),
});

// 启用 refetchOnFocus 和 refetchOnReconnect
setupListeners(store.dispatch);

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

在组件中使用

查询数据(Query)

// 基础用法
import { useGetPostsQuery, useGetPostByIdQuery } from '../services/api';

function PostsList() {
  const { data: posts, error, isLoading, isFetching, refetch } = useGetPostsQuery();
  
  if (isLoading) return <div>加载中...</div>;
  if (error) return <div>出错了</div>;
  
  return (
    <div>
      <button onClick={refetch}>刷新</button>
      {posts?.map(post => (
        <div key={post.id}>{post.title}</div>
      ))}
    </div>
  );
}

// 带参数的查询 + 配置选项
function PostDetail({ postId }: { postId: number }) {
  const { data: post } = useGetPostByIdQuery(postId, {
    // 组件挂载时是否跳过请求
    skip: !postId,
    
    // 轮询间隔(毫秒)
    pollingInterval: 3000,
    
    // 组件重新挂载时是否重新获取
    refetchOnMountOrArgChange: true,
    
    // 焦点回归时是否重新获取
    refetchOnFocus: true,
    
    // 网络重连时是否重新获取
    refetchOnReconnect: true,
  });
  
  return <div>{post?.title}</div>;
}

修改数据(Mutation)

function AddPost() {
  const [addPost, { isLoading }] = useAddPostMutation();
  
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    try {
      const result = await addPost({ 
        title: '新文章', 
        body: '内容', 
        userId: 1 
      }).unwrap();
      console.log('添加成功:', result);
    } catch (error) {
      console.error('添加失败:', error);
    }
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <button disabled={isLoading}>
        {isLoading ? '提交中...' : '添加'}
      </button>
    </form>
  );
}

// 更新操作
function EditPost({ post }: { post: Post }) {
  const [updatePost] = useUpdatePostMutation();
  
  const handleUpdate = async () => {
    await updatePost({ ...post, title: '更新后的标题' });
  };
  
  return <button onClick={handleUpdate}>更新</button>;
}

高级特性

乐观更新

updatePost: builder.mutation<Post, Partial<Post> & Pick<Post, 'id'>>({
  query: ({ id, ...patch }) => ({
    url: `/posts/${id}`,
    method: 'PUT',
    body: patch,
  }),
  
  // 发起请求前执行(乐观更新)
  async onQueryStarted({ id, ...patch }, { dispatch, queryFulfilled }) {
    // 手动更新缓存
    const patchResult = dispatch(
      api.util.updateQueryData('getPostById', id, (draft) => {
        Object.assign(draft, patch);
      })
    );
    
    try {
      await queryFulfilled;
    } catch {
      // 请求失败时回滚
      patchResult.undo();
    }
  },
  
  invalidatesTags: (result, error, { id }) => [{ type: 'Post', id }],
}),

条件获取与懒加载

// useLazyQuery - 手动触发查询
function SearchPosts() {
  const [trigger, { data, isFetching }] = api.useLazyGetPostsQuery();
  
  const handleSearch = (keyword: string) => {
    trigger(); // 手动触发
  };
  
  return (
    <div>
      <button onClick={() => handleSearch('react')}>搜索</button>
      {isFetching ? '搜索中...' : data?.map(...)}
    </div>
  );
}

查询参数序列化

getPostsByPage: builder.query<Post[], { page: number; limit: number }>({
  query: ({ page, limit }) => `/posts?_page=${page}&_limit=${limit}`,
  
  // 自定义序列化(影响缓存 key)
  serializeQueryArgs: ({ endpointName, queryArgs }) => {
    return `${endpointName}(${JSON.stringify(queryArgs)})`;
  },
  
  // 合并分页数据(而非替换)
  merge: (currentCache, newItems) => {
    currentCache.push(...newItems);
  },
  
  // 强制重新获取的条件
  forceRefetch({ currentArg, previousArg }) {
    return currentArg !== previousArg;
  },
}),

轮询(Polling)

function LiveData() {
  // 每 5 秒自动刷新
  const { data } = useGetPostsQuery(undefined, {
    pollingInterval: 5000,
    
    // 条件轮询:只在特定条件下轮询
    skipPollingIfUnfocused: true,
  });
  
  return <div>{JSON.stringify(data)}</div>;
}

// 程序化控制轮询
function ControlledPolling() {
  const [pollingInterval, setPollingInterval] = useState(0);
  const { data } = useGetPostsQuery(undefined, { pollingInterval });
  
  return (
    <div>
      <button onClick={() => setPollingInterval(5000)}>开始轮询</button>
      <button onClick={() => setPollingInterval(0)}>停止轮询</button>
    </div>
  );
}

缓存标签系统(Tags)

标签系统是实现自动缓存失效的核心:

const api = createApi({
  baseQuery: fetchBaseQuery({ baseUrl: '/' }),
  tagTypes: ['Post', 'User'],
  endpoints: (build) => ({
    // 提供通用标签
    getPosts: build.query<Post[], void>({
      query: () => '/posts',
      providesTags: ['Post'],
    }),
    
    // 提供具体 ID 标签
    getPost: build.query<Post, number>({
      query: (id) => `/posts/${id}`,
      providesTags: (result, error, id) => [{ type: 'Post', id }],
    }),
    
    // 修改时失效特定标签
    updatePost: build.mutation<void, Post>({
      query: (post) => ({
        url: `/posts/${post.id}`,
        method: 'PUT',
        body: post,
      }),
      // 失效所有 Post 标签和特定 ID 的 Post
      invalidatesTags: (result, error, post) => [
        'Post',
        { type: 'Post', id: post.id },
      ],
    }),
    
    // 批量操作失效多个标签
    bulkDelete: build.mutation<void, number[]>({
      query: (ids) => ({
        url: '/posts/bulk-delete',
        method: 'POST',
        body: { ids },
      }),
      invalidatesTags: (result, error, ids) => [
        ...ids.map((id) => ({ type: 'Post' as const, id })),
        'User', // 可能关联用户数据也需要刷新
      ],
    }),
  }),
});

错误处理

function PostComponent() {
  const { data, error, isError } = useGetPostByIdQuery(1);
  
  // 方式 1:类型守卫
  if (isError) {
    if ('status' in error) {
      // FetchBaseQueryError
      const errMsg = 'error' in error ? error.error : JSON.stringify(error.data);
      return <div>错误: {errMsg}</div>;
    } else {
      // SerializedError
      return <div>错误: {error.message}</div>;
    }
  }
  
  return <div>{data?.title}</div>;
}

// 全局错误处理(在 API 配置中)
const api = createApi({
  baseQuery: fetchBaseQuery({ 
    baseUrl: '/',
    prepareHeaders: (headers) => headers,
  }),
  endpoints: () => ({}),
  
  // 自定义错误处理
  extractRehydrationInfo(action, { reducerPath }) {
    if (action.type === REHYDRATE) {
      return action.payload[reducerPath];
    }
  },
});

最佳实践

1. 代码分割与注入

// 主 API 文件
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query';

export const emptySplitApi = createApi({
  baseQuery: fetchBaseQuery({ baseUrl: '/' }),
  endpoints: () => ({}),
});

// 特性模块中注入端点
// postsApi.ts
import { emptySplitApi } from './api';

const postsApi = emptySplitApi.injectEndpoints({
  endpoints: (build) => ({
    getPosts: build.query<Post[], void>({
      query: () => '/posts',
    }),
  }),
  overrideExisting: false,
});

export const { useGetPostsQuery } = postsApi;

2. 自定义 BaseQuery(添加拦截器)

import { fetchBaseQuery } from '@reduxjs/toolkit/query';
import type { BaseQueryFn, FetchArgs, FetchBaseQueryError } from '@reduxjs/toolkit/query';

const baseQuery = fetchBaseQuery({
  baseUrl: '/api',
  prepareHeaders: (headers) => {
    headers.set('X-Client-Version', '1.0.0');
    return headers;
  },
});

const baseQueryWithReauth: BaseQueryFn<
  string | FetchArgs,
  unknown,
  FetchBaseQueryError
> = async (args, api, extraOptions) => {
  let result = await baseQuery(args, api, extraOptions);
  
  if (result.error && result.error.status === 401) {
    // 尝试刷新 token
    const refreshResult = await baseQuery('/refresh-token', api, extraOptions);
    if (refreshResult.data) {
      // 重试原请求
      result = await baseQuery(args, api, extraOptions);
    } else {
      // 登出处理
      api.dispatch(logout());
    }
  }
  
  return result;
};

export const api = createApi({
  baseQuery: baseQueryWithReauth,
  endpoints: () => ({}),
});

3. SSR 支持(Next.js)

// 在服务端预获取数据
import { api } from './api';
import { store } from './store';

export async function getServerSideProps() {
  store.dispatch(api.endpoints.getPosts.initiate());
  
  await Promise.all(store.dispatch(api.util.getRunningQueriesThunk()));
  
  return {
    props: {
      initialReduxState: store.getState(),
    },
  };
}

与 React Query 对比

特性RTK QueryReact Query
状态管理基于 Redux独立状态管理
DevToolsRedux DevTools 集成专用 DevTools
包体积适合 Redux 项目(已使用 Redux)独立,体积更小
缓存策略标签系统基于 queryKey
乐观更新内置手动配置
最佳场景Redux 生态项目轻量级项目

总结

RTK Query 是 Redux 生态中最强大的数据获取解决方案,特别适合:

  • 已有 Redux 项目:无需引入额外状态管理库
  • 复杂缓存需求:标签系统精确控制缓存失效
  • 团队协作:强类型支持和标准化结构

掌握其核心概念(Query/Mutation/Tags)后,可大幅减少样板代码,专注于业务逻辑开发。