陌上人如玉
公子世无双

3.9 Redux Toolkit

3.9 Redux Toolkit

一句话:Redux Toolkit(RTK)是官方推荐的 Redux 写法——用 configureStore 内置常用中间件、用 createSlice 同时生成 reducer 与 action(并内置 Immer 可直接「改」状态)、用 createAsyncThunk / RTK Query 处理异步。

一、RTK 解决了什么问题

原生 Redux 的三个痛点:

① 配置繁琐
   createStore + applyMiddleware(thunk, logger) + composeWithDevTools + ...

② 样板代码多
   每个功能要写:action types 常量、action creator、reducer 的 switch
   一个「加一个 todo」要写 20 行

③ 不可变更新难写
   深嵌套状态要「逐层展开」,容易出错

RTK 的解法:
① configureStore()      → 一键配置(默认含 thunk + DevTools + 开发期检查)
② createSlice()         → reducer 与 action 一起定义(样板代码减少约 70%)
③ 内置 Immer            → 可以直接「改」state(底层自动生成不可变副本)
④ createAsyncThunk()    → 异步请求的标准化写法(自动生成三态 action)
⑤ createEntityAdapter() → 实体列表的标准化管理(增删改查 + 选择器)
⑥ RTK Query             → 数据请求 + 缓存(替代手写 thunk + 状态管理)

二、configureStore

// app/store.ts
import { configureStore } from '@reduxjs/toolkit';
import todosReducer from '@/features/todos/todosSlice';
import { api } from '@/features/api/apiSlice';

export const store = configureStore({
  reducer: {
    todos: todosReducer,
    [api.reducerPath]: api.reducer,          // RTK Query 的 reducer
  },
  // ⭐ 下面这些是「默认就有」的,通常不用写:
  // middleware: (getDefault) => getDefault().concat(api.middleware),  // 需要追加时
  // devTools: true,                         // 默认开发环境开启
  // preloadedState: undefined,              // 用于 SSR 注入状态
});

// ⭐ 从 store 推导出类型(不要手写 RootState 类型)
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
// app/hooks.ts —— 类型化的 hooks(推荐做法)
import { useDispatch, useSelector, type TypedUseSelectorHook } from 'react-redux';
import type { RootState, AppDispatch } from './store';

export const useAppDispatch: () => AppDispatch = useDispatch;
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
对比 原生 Redux RTK
创建 store createStore(reducer, applyMiddleware(...)) configureStore({ reducer })
DevTools 手动 composeWithDevTools 默认开启
thunk 手动安装 内置
序列化检查 无 默认开启(开发期警告)
不可变检查 无 默认开启(检测误改状态)

三、createSlice

// features/todos/todosSlice.ts
import { createSlice, nanoid, type PayloadAction } from '@reduxjs/toolkit';

export interface Todo {
  id: string;
  text: string;
  done: boolean;
}

interface TodosState {
  items: Todo[];
  filter: 'all' | 'active' | 'done';
}

const initialState: TodosState = { items: [], filter: 'all' };

const todosSlice = createSlice({
  name: 'todos',                       // ⭐ action type 的前缀(如 'todos/add')
  initialState,
  reducers: {
    // ⭐ 直接「改」state(RTK 内置 Immer,自动生成不可变副本)
    addTodo: {
      reducer(state, action: PayloadAction<Todo>) {
        state.items.push(action.payload);
      },
      // 如果 action 需要「准备参数」,用 prepare
      prepare(text: string) {
        return { payload: { id: nanoid(), text, done: false } };
      },
    },
    removeTodo(state, action: PayloadAction<string>) {
      state.items = state.items.filter((t) => t.id !== action.payload);
    },
    toggleTodo(state, action: PayloadAction<string>) {
      const todo = state.items.find((t) => t.id === action.payload);
      if (todo) todo.done = !todo.done;              // ✅ 直接改,Immer 处理
    },
    setFilter(state, action: PayloadAction<TodosState['filter']>) {
      state.filter = action.payload;
    },
  },
});

// ⭐ action creator 自动生成(不需要手写 action types 常量)
export const { addTodo, removeTodo, toggleTodo, setFilter } = todosSlice.actions;

// ⭐ selector 一起定义(便于复用)
export const selectTodos = (state: { todos: TodosState }) => state.todos.items;
export const selectFilter = (state: { todos: TodosState }) => state.todos.filter;

export default todosSlice.reducer;
// 组件里使用
function TodoApp() {
  const items = useAppSelector(selectTodos);
  const filter = useAppSelector(selectFilter);
  const dispatch = useAppDispatch();

  return (
    <>
      <button onClick={() => dispatch(addTodo('新任务'))}>添加</button>
      <ul>
        {items.map((t) => (
          <li key={t.id} onClick={() => dispatch(toggleTodo(t.id))}>
            {t.done ? '✅' : '⬜'} {t.text}
          </li>
        ))}
      </ul>
    </>
  );
}
createSlice 的三个关键点:

① name 会成为 action type 的前缀:
   todosSlice 里的 addTodo → action type 是 'todos/addTodo'
   这样不同 slice 的同名 action 不会冲突

② 可以直接「改」state:这是 Immer 的能力
   ⚠️ 但必须遵守 Immer 规则:
      · 不能「既改 draft 又 return 新值」
      · 不能把 draft 存到外部
      · 裸 `state = xxx`(重新赋值整个 state)需要 return:
        setAll(state, action) { return action.payload; }   ← 整体替换要 return

③ prepare 用于「自定义 payload 结构」
   让 action creator 的调用更简洁:dispatch(addTodo('文本')) 而不是手动构造对象

四、createAsyncThunk

// features/todos/todosThunks.ts
import { createAsyncThunk } from '@reduxjs/toolkit';

export const fetchTodos = createAsyncThunk<
  Todo[],                                   // 返回类型(fulfilled 的 payload)
  string,                                   // 参数类型
  { rejectValue: string }                   // thunkAPI 的类型
>(
  'todos/fetch',                            // ⭐ action type 前缀
  async (userId, { rejectWithValue, signal, getState, dispatch }) => {
    try {
      const res = await api.getTodos(userId, { signal });   // ⭐ 支持取消
      return res.data;
    } catch (err) {
      return rejectWithValue(String(err));
    }
  }
);
// ⭐ 自动生成三个 action(不需要手写)
// 'todos/fetch/pending'
// 'todos/fetch/fulfilled'
// 'todos/fetch/rejected'
// 在 slice 里处理这三态
const todosSlice = createSlice({
  name: 'todos',
  initialState: { items: [] as Todo[], status: 'idle' as Status, error: null as string | null },
  reducers: { /* ... */ },
  extraReducers: (builder) => {
    builder
      .addCase(fetchTodos.pending, (state) => {
        state.status = 'loading';
        state.error = null;
      })
      .addCase(fetchTodos.fulfilled, (state, action) => {
        state.status = 'success';
        state.items = action.payload;               // ✅ Immer 可直接赋值
      })
      .addCase(fetchTodos.rejected, (state, action) => {
        state.status = 'error';
        state.error = action.payload ?? action.error.message ?? 'unknown';
      });
  },
});
// 组件里使用
function TodoList() {
  const { items, status, error } = useAppSelector((s) => s.todos);
  const dispatch = useAppDispatch();

  useEffect(() => {
    // ⭐ 返回 Promise 的 unwrap 方法可以拿到结果 / 处理错误
    dispatch(fetchTodos('me')).unwrap().catch((e) => toast.error(String(e)));
  }, [dispatch]);

  if (status === 'loading') return <Skeleton />;
  if (status === 'error') return <ErrorView message={error ?? ''} />;
  return <List items={items} />;
}
thunkAPI 的常用成员 作用
dispatch 派发其它 action
getState 读取当前状态(用类型化的 RootState)
signal AbortSignal(支持取消请求)
rejectWithValue(v) 自定义 rejected 的 payload(默认是 error.message)
fulfillWithValue(v) 自定义 fulfilled 的 payload
extra configureStore 里传入的 extraArgument

五、RTK Query(数据请求的现代方案)

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

export const api = createApi({
  reducerPath: 'api',
  baseQuery: fetchBaseQuery({
    baseUrl: '/api',
    prepareHeaders: (headers, { getState }) => {
      const token = (getState() as RootState).auth.token;
      if (token) headers.set('authorization', `Bearer ${token}`);
      return headers;
    },
  }),
  tagTypes: ['Todo', 'User'],                // ⭐ 用于「失效」的标签
  endpoints: (builder) => ({
    // 查询(GET)—— 自动缓存、自动生成 useXxxQuery Hook
    getTodos: builder.query<Todo[], string>({
      query: (userId) => `todos?userId=${userId}`,
      providesTags: (result) =>
        result ? [...result.map(({ id }) => ({ type: 'Todo' as const, id })), 'Todo'] : ['Todo'],
    }),

    getUser: builder.query<User, string>({
      query: (id) => `users/${id}`,
      providesTags: (_, __, id) => [{ type: 'User', id }],
    }),

    // 变更(POST/PUT/DELETE)—— 自动生成 useXxxMutation Hook
    addTodo: builder.mutation<Todo, Partial<Todo>>({
      query: (body) => ({ url: 'todos', method: 'POST', body }),
      invalidatesTags: ['Todo'],             // ⭐ 自动让相关查询失效并重新请求
    }),

    deleteTodo: builder.mutation<void, string>({
      query: (id) => ({ url: `todos/${id}`, method: 'DELETE' }),
      invalidatesTags: (_, __, id) => [{ type: 'Todo', id }],
    }),
  }),
});

export const {
  useGetTodosQuery,
  useGetUserQuery,
  useAddTodoMutation,
  useDeleteTodoMutation,
} = api;
// 组件里使用(不再需要手写 loading / error / 缓存)
function TodoList({ userId }: { userId: string }) {
  const { data, isLoading, isError, error, refetch } = useGetTodosQuery(userId, {
    pollingInterval: 30000,                  // 轮询
    skip: !userId,                           // 条件跳过
    selectFromResult: ({ data }) => ({ data }),   // 只订阅需要的字段
  });

  const [addTodo, { isLoading: isAdding }] = useAddTodoMutation();

  if (isLoading) return <Skeleton />;
  if (isError) return <ErrorView error={error} onRetry={refetch} />;

  return (
    <>
      <button onClick={() => addTodo({ text: '新任务' })} disabled={isAdding}>
        添加
      </button>
      <ul>{data?.map((t) => <li key={t.id}>{t.text}</li>)}</ul>
    </>
  );
}
RTK Query 提供的能力(不用手写):
① 缓存(同一请求参数复用同一份数据,多个组件共享)
② loading / error / data 三态
③ 自动失效与重新请求(invalidateTags / providesTags)
④ 轮询(pollingInterval)与乐观更新(onQueryStarted + updateQueryData)
⑤ 分页与无限滚动(通过 merge 自定义缓存合并策略)
⑥ 请求去重、条件请求(skip)、手动刷新(refetch)
方案 适合
RTK Query 已经在用 Redux、想要「一个库解决状态+请求」
TanStack Query 没用 Redux、或想要更专注的查询库(生态更成熟)
手写 thunk 逻辑特殊的请求(不适合缓存)

六、createEntityAdapter(实体列表标准化)

import { createEntityAdapter } from '@reduxjs/toolkit';

const todosAdapter = createEntityAdapter<Todo>({
  sortComparer: (a, b) => a.text.localeCompare(b.text),   // 可选:排序
});

const initialState = todosAdapter.getInitialState({ status: 'idle' as Status });
// 结构:{ ids: ['1','2'], entities: { '1': {...}, '2': {...} }, status: 'idle' }

const todosSlice = createSlice({
  name: 'todos',
  initialState,
  reducers: {
    addOne: todosAdapter.addOne,             // ⭐ 直接用内置的增删改
    addMany: todosAdapter.addMany,
    updateOne: todosAdapter.updateOne,
    removeOne: todosAdapter.removeOne,
    setAll: todosAdapter.setAll,
  },
  extraReducers: (builder) => {
    builder.addCase(fetchTodos.fulfilled, todosAdapter.setAll);
  },
});

// ⭐ 自动生成的选择器
export const {
  selectAll: selectAllTodos,
  selectById: selectTodoById,
  selectIds: selectTodoIds,
  selectTotal: selectTodoTotal,
} = todosAdapter.getSelectors((state: RootState) => state.todos);
为什么用 EntityAdapter:
① 用「归一化」结构(ids + entities 字典)而不是数组
   → 按 id 查询是 O(1)(不用遍历数组)
② 更新单条数据时不用「map 整个数组」
③ 内置的选择器自带记忆化(引用稳定)
④ 适合「列表 + 频繁更新单项」的场景

七、目录结构建议

src/
├── app/
│   ├── store.ts            configureStore + RootState / AppDispatch 类型
│   └── hooks.ts            useAppDispatch / useAppSelector(类型化)
└── features/               按功能划分(feature-first)
    ├── api/
    │   └── apiSlice.ts     RTK Query 的 createApi
    ├── todos/
    │   ├── todosSlice.ts   createSlice
    │   ├── todosThunks.ts  createAsyncThunk
    │   ├── todosSelectors.ts  记忆化选择器
    │   ├── TodoList.tsx    组件
    │   └── TodoList.test.tsx
    └── auth/
        └── ...
两点说明:
① 官方推荐「按功能组织(feature folder)」而不是「按类型组织」
   理由:一个功能的 slice / 组件 / 选择器 / 测试放在一起,改需求时只动一个目录
② 通用组件(Button、Modal)放 components/,与 features/ 平级

八、常见坑

// ① 在 reducer 里「既改又返回」
reducers: {
  bad(state, action) {
    state.items.push(action.payload);
    return { ...state, items: action.payload };      // ❌ Immer 会报错
  },
}

// ② 直接重新赋值整个 state 却忘了 return
reducers: {
  bad2(state, action) {
    state = action.payload;                          // ❌ 无效(需要 return)
  },
  good(state, action) {
    return action.payload;                           // ✅
  },
}

// ③ 把 Immer 的 draft 存到外部
reducers: {
  bad3(state, action) {
    someExternalVar = state.items;                   // ❌ draft 在 produce 后失效
  },
}

// ④ 在 reducer 里做副作用
reducers: {
  bad4(state, action) {
    localStorage.setItem('x', 'y');                  // ❌
  },
}

// ⑤ 在 action 里放「不可序列化的值」
dispatch(addTodo({ id: nanoid(), createdAt: new Date() }));   // ⚠️ devTools 警告
// ✅ 用时间戳(number / ISO 字符串)

// ⑥ 手写 RootState 类型(应该用 ReturnType 推导)
interface RootState { todos: TodosState }            // ⚠️ 容易漏
type RootState = ReturnType<typeof store.getState>;  // ✅

// ⑦ 在 extraReducers 里用「字符串」而不是 action creator
builder.addCase('todos/fetch/fulfilled', ...)        // ⚠️ 丢失类型推导
builder.addCase(fetchTodos.fulfilled, ...)           // ✅

// ⑧ 用 RTK Query 还手写 thunk 做同样的请求(重复)
// ⑨ 忘记 invalidatesTags → 变更后列表不更新
// ⑩ 在组件里直接 api.endpoints.getTodos.select(...) 取缓存(应该用生成的 Hook)
// ⑪ createSlice 的 name 重复(不同 slice 用同一个 name → action type 冲突)
// ⑫ 在 RTK Query 的 selectFromResult 里返回新对象(会每次都重渲染)
// ⑫ 的正确写法
const { data } = useGetTodosQuery(userId, {
  selectFromResult: ({ data }) => ({ data }),      // ⭐ 只取原始引用
});
// ❌ selectFromResult: ({ data }) => ({ todos: data?.filter(...) })  → 每次新数组

九、面试延伸

  1. 「RTK 相比原生 Redux 有什么改进?」
    四点:① 配置简化——configureStore 一键搞定 thunk、DevTools、开发期检查(不可变检查、序列化检查);② 样板代码大幅减少——createSlice 用「一个对象」同时生成 reducer 与 action creator,不再手写 action types 与 switch;③ 内置 Immer——可以直接「改」状态(底层自动生成不可变副本),解决了深层嵌套更新的痛苦;④ 异步标准化——createAsyncThunk 自动生成 pending/fulfilled/rejected 三态 action,RTK Query 直接提供请求缓存。

  2. 「createSlice 里为什么可以直接修改 state?」
    因为 RTK 的 createSlice 内置了 Immer。Immer 用 Proxy 拦截你对 draft 的修改,然后基于修改记录生成一份新的不可变对象——所以「写法是直接改,效果是不可变更新」。这既避免了深层展开的繁琐,又保证了 Redux 依赖的「引用变化」。注意两个限制:① 不能「既改 draft 又 return 新值」;② 不能把 draft 保存到外部(它在 produce 结束后失效)。

  3. 「createAsyncThunk 是怎么工作的?」
    它接收「一个 type 前缀」和一个「返回 Promise 的 payload creator」,然后:① 自动生成三个 action——xxx/pending、xxx/fulfilled、xxx/rejected;② 返回一个 thunk action creator——调用它会先派发 pending,然后执行你的异步函数,成功派发 fulfilled(payload 是返回值),失败派发 rejected(payload 是 error);③ 提供 thunkAPI(dispatch、getState、signal、rejectWithValue 等)。返回的 action 上还有 .unwrap(),让调用方可以 await 结果或捕获错误。

  4. 「RTK Query 和 TanStack Query 有什么区别?」
    能力上非常接近(都提供缓存、失效、重试、乐观更新、分页),主要差异是定位与集成:① RTK Query 是 Redux Toolkit 的一部分——如果项目已经在用 Redux,它能让「状态管理 + 数据请求」统一到一个生态(缓存也在 store 里,可用 DevTools 观察);② TanStack Query 是独立的查询库——不依赖 Redux,生态更成熟(插件、DevTools、SSR 支持都很完善),适合「不想引入 Redux」的项目。选哪个主要看项目是否已有 Redux。

  5. 「createEntityAdapter 有什么用?」
    它把「实体列表」标准化为归一化结构({ ids: [...], entities: { id: entity } }),并提供内置的增删改 reducer 与自带记忆化的选择器。好处是:① 按 id 查询是 O(1)(不用遍历数组);② 更新单项不用「map 整个数组」;③ 选择器返回的引用稳定(避免无谓重渲染);④ 代码量大幅减少。适合「列表 + 频繁更新单项」的场景(如聊天消息、表格行编辑)。

  6. 「RTK 的错误处理怎么做?」
    三种方式:① 在 createAsyncThunk 里用 rejectWithValue(value)——自定义 rejected action 的 payload(默认只有 error.message,不够结构化);② 在 extraReducers 的 rejected 分支里更新错误状态(state.error = action.payload);③ 在组件里用 .unwrap() 捕获——dispatch(fetchX()).unwrap().catch(e => ...),适合「需要就地提示」的场景(如 toast)。此外可以用 middleware 做「全局错误上报」。

  7. 「为什么推荐用 useAppSelector / useAppDispatch?」
    因为它们是类型化的封装:useAppSelector: TypedUseSelectorHook<RootState> 让 state 参数自动有完整类型(不需要每次手写 (state: RootState) => ...),useAppDispatch: () => AppDispatch 让 dispatch 支持 thunk(否则 TS 只认识「普通对象 action」,派发 thunk 会报类型错误)。这是官方推荐做法,能显著减少类型样板代码与错误。

  8. 「RTK 里怎么做乐观更新?」
    两种路径:① 手写——在 extraReducers 里处理 xxx/pending 时先「本地改」状态,在 rejected 时回滚(需要保存旧值);② 用 RTK Query 的 onQueryStarted——在 mutation 里 updateQueryData 立即修改缓存,然后在 catch 里 patch.undo() 回滚。RTK Query 的方式更简洁,也自带「查询失效后自动重新拉取」的能力。

一句话速记

RTK = 官方推荐写法:configureStore(内置 thunk + DevTools + 不可变/序列化检查)、createSlice(reducer 与 action 一起生成,内置 Immer 可直接改 state,命名以 name 为前缀)、createAsyncThunk(自动生成 pending/fulfilled/rejected 三态,支持 rejectWithValue 与 .unwrap())、RTK Query(请求缓存 + providesTags/invalidatesTags 自动失效 + 轮询 + 乐观更新)、createEntityAdapter(归一化列表 + 自带记忆化选择器);类型用 ReturnType<typeof store.getState> 推导,用 useAppSelector/useAppDispatch 避免样板;Immer 的两条禁令是「不能既改又 return」「不能外传 draft」。

赞(0) 打赏
未经允许不得转载:陌上寒 » 3.9 Redux Toolkit

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫