目 录CONTENT

文章目录

React(十七)Redux Toolkit

React(十七)Redux Toolkit:React 19 项目中的状态管理实战指南


前言:为什么要学习 Redux?

在 React 19 项目中,随着组件数量变多、页面层级变深,我们会遇到一个越来越棘手的问题:状态(state)到底该放在哪里?

  • 放得太低,多个组件需要共享时就要层层传递 props,代码变得非常啰嗦。
  • 放得太高,组件关系复杂后又容易牵一发而动全身,数据流变得难以追踪。

Redux 就是解决这类问题的主流方案之一。它的目标不是替代 React 自己的 state,而是为全局共享、跨组件通信提供一套清晰、可预测的数据流规范。而 Redux Toolkit(RTK)则是官方推荐的“最佳实践工具集”,帮我们在 React 19 中少写模板代码、少踩坑。

本节课将从“为什么需要 Redux”讲起,到 RTK 的实战用法,再到 React 19 并发特性下的注意事项,帮你建立一套完整的状态管理认知。


模块一:核心定义——理解 Redux 的设计哲学

学习目标

  1. 能用自己的话说出前端状态管理的痛点。
  2. 理解 Redux 的三大核心原则,并举例说明。
  3. 区分 Redux 与 Redux Toolkit 的关系,知道 RTK 的优势。

1.1 前端状态管理的痛点:从“传纸条”说起

想象一下,在一个班级里,老师想通知全班同学明天交作业。如果班里只有 5 个人,老师直接喊一嗓子就解决了。但如果班里有 50 个人、老师坐在教室最后一排,逐个传话就会非常麻烦:

  • 同学 A 传给同学 B,B 传给 C,C 再传给 D……消息越传越慢。
  • 中间有人没听清,消息可能失真。
  • 如果老师改主意了,还得重新传一遍。

React 里通过 props 层层传递状态,就像这个“传纸条”的过程。组件少的时候没问题,组件多了就会遇到:** prop drilling(属性钻取)**。

App 组件 → Layout 组件 → Header 组件 → UserInfo 组件
       用户状态          用户状态         用户状态

每一层组件其实都不需要这个状态,但为了传给最下层的组件,它们都必须“经手”一次。代码冗余、维护困难、改一处动全身。

Redux 的解决思路是:在教室正中间放一块公告板。老师把作业要求写上去,所有同学自己抬头看。消息只有一份,所有人都能看到最新版本,不会出现层层传话的问题。

这块“公告板”就是 Redux 中的 Store(仓库)


1.2 Redux 在 React 19 项目中的定位

在 React 19 中,状态管理并不是“一定要用 Redux”。我们仍然有这些选择:

方案 适用场景 学习成本
组件 state(useState) 单个组件内部状态,不需要共享
Context API 跨组件共享只读数据,更新较少
Redux / RTK 多组件共享、状态变化频繁、需要可预测的数据流

Redux 的核心价值

  1. 统一的数据仓库:所有全局状态集中管理,不再依赖 props 层层传递。
  2. 可预测的状态变化:状态如何改变,通过纯函数和明确的 action 来描述,便于调试和回溯。
  3. 强大的调试生态:Redux DevTools 可以查看每一次状态变化,像“时光机”一样回放。

一句话总结:Redux 适合管理“多个组件都需要读写、变化规律复杂”的全局状态;局部的、组件独享的状态仍然用 useState 即可。


1.3 Redux 的三大核心原则

Redux 的设计理念可以总结为三条原则。我们用生活化的例子来理解它们。

原则一:单一数据源(Single Source of Truth)

定义:整个应用的全部全局状态都保存在一个对象树里,这个对象树存在于唯一的 Store 中。

类比:就像一家银行只有一个总账本。无论你通过哪个窗口查询余额,看到的都是同一个账本。不会出现 A 窗口说你有 1000 元,B 窗口说你只有 800 元的情况。

// 全局状态树只有一份
{
  user: { name: "张三", id: 123 },
  todos: [
    { id: 1, text: "学习 Redux", completed: false }
  ],
  cart: { items: [], total: 0 }
}

好处:调试时一目了然,状态来源清晰,不会出现多个地方“各存一份”导致的数据不一致。


原则二:状态是只读的(State is Read-Only)

定义:唯一改变状态的方法是触发一个 action(动作)。你不能直接修改 Store 里的状态。

类比:银行的存款余额不能由顾客直接改写。你想取钱,必须填写一张取款单(action),银行柜员(reducer)审核后,才在总账本上修改余额。

// ❌ 错误:直接修改状态
store.user.name = "李四";

// ✅ 正确:通过 action 描述“我想做什么”
dispatch({ type: "user/changeName", payload: "李四" });

好处:所有的状态变化都被记录成“动作”,方便追踪、回放和排查 bug。


原则三:使用纯函数修改状态(Changes are Made with Pure Functions)

定义:描述状态如何变化的函数叫 reducer,它必须是纯函数。同样的输入,永远得到同样的输出;并且不会修改传入的参数,而是返回新的状态对象。

类比:自动售货机。你按同样的按钮、投入同样的钱,每次掉出来的饮料都是一样的。售货机不会偷偷改你口袋里的钱,只是根据你的输入给出对应的输出。

// ✅ 纯函数 reducer:不修改原状态,返回新状态
function counterReducer(state = 0, action) {
  switch (action.type) {
    case "counter/increment":
      return state + 1; // 返回新值,不是修改原值
    case "counter/decrement":
      return state - 1;
    default:
      return state; // 未知 action 返回原状态
  }
}

// ❌ 非纯函数:直接修改传入的参数
function badReducer(state, action) {
  state.value++; // 修改了原状态,严禁这样做!
  return state;
}

好处:纯函数让测试变得简单,也让 Redux DevTools 能准确记录和回放状态变化。


1.4 Redux Toolkit:官方推荐的“最佳实践工具集”

1.4.1 原生 Redux 的问题

在没有 RTK 的时代,写一个 Redux 应用需要手写很多样板代码:

// 原生 Redux 的手写 reducer
function todoReducer(state = [], action) {
  switch (action.type) {
    case "ADD_TODO":
      return [...state, action.payload];
    case "REMOVE_TODO":
      return state.filter((item) => item.id !== action.payload);
    default:
      return state;
  }
}

// 手动创建 action creator
function addTodo(text) {
  return { type: "ADD_TODO", payload: text };
}

// 手动配置 store,处理中间件
const store = createStore(
  todoReducer,
  window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__(),
);

对于初学者来说,switch-case、action creator、手动配置 DevTools 都是容易出错的地方。而且原生 Redux 默认不处理“可变数据”,一旦不小心直接修改了 state,就可能出现 bug。

1.4.2 RTK 带来了什么改进?

Redux Toolkit(RTK)是 Redux 官方团队推出的工具集,目标是:让 Redux 写起来更简单、更不容易出错。它封装了原生 Redux 中的最佳实践,主要改进有:

原生 Redux 痛点 RTK 的解决方案
手动写 action creator 和 switch-case createSlice 自动生成 action 和 reducer
手动配置中间件和 DevTools configureStore 自动集成
容易误改 state 内置 Immer,允许“看起来可变”的写法,实际生成新对象
异步逻辑分散难管理 createAsyncThunk 统一管理异步 action
样板代码多 代码量大幅减少,结构更清晰

1.4.3 核心概念区分:Redux、React-Redux、RTK

很多同学初学时容易混淆这几个概念,下面用一句话说明:

  • Redux:核心状态管理库,提供 Store、reducer、dispatch 等基础能力。
  • React-Redux:让 Redux 能和 React 组件“连接”的桥梁库,提供 ProvideruseSelectoruseDispatch 等 Hook。
  • Redux Toolkit(RTK):Redux 官方推荐的工具集,简化 Redux 开发流程,包含 createSliceconfigureStorecreateAsyncThunk 等 API。

在 React 19 项目中,标准组合是:Redux + React-Redux + Redux Toolkit。RTK 负责简化代码,React-Redux 负责把 Redux 接到 React 组件里。


1.5 模块一小结

本模块我们学习了:

  1. 前端状态管理的核心痛点是“传纸条”式的 prop drilling,Redux 通过统一 Store 解决。
  2. Redux 三大原则:单一数据源、状态只读、纯函数修改状态。
  3. RTK 是 Redux 官方推荐的工具集,能大幅减少样板代码,降低出错概率。

课后思考

  1. 你能不能举一个自己项目中“状态需要跨组件共享”的例子?
  2. 为什么说 reducer 必须是纯函数?如果直接修改 state 会有什么隐患?
  3. RTK 和原生 Redux 最本质的区别是什么?

模块二:实战使用——从零搭建 RTK 项目

学习目标

  1. 能在 React 19 项目中从零搭建 RTK 环境。
  2. 掌握 useSelectoruseDispatch 的使用方法。
  3. 能用 createAsyncThunk 完成带加载状态和错误处理的异步请求。

2.1 环境搭建:安装依赖

打开终端,进入你的 React 19 项目根目录,执行以下命令:

# 安装 Redux Toolkit 和 React-Redux
npm install @reduxjs/toolkit react-redux

安装完成后,你会在 package.jsondependencies 中看到这两个依赖:

{
  "dependencies": {
    "react": "^18.0.0",
    "react-dom": "^18.0.0",
    "@reduxjs/toolkit": "^2.0.0",
    "react-redux": "^9.0.0"
  }
}

注意:React 19 目前使用 React 18 的 API 即可,上述版本组合是当前课程环境的稳定搭配。后续 React 19 正式发布后,React-Redux 会自动兼容,写法不变。


2.2 创建 Slice:状态 + 同步 reducer + 自动 action

Slice 是 RTK 中最重要的概念之一,一个 Slice 通常对应一个业务模块的状态。它把“状态、reducer、action”集中写在一个文件里,结构非常清晰。

下面是 计数器 Slice 的完整代码,每一行都添加了注释,方便教学使用:

// src/store/counterSlice.js

// 1. 从 Redux Toolkit 中导入 createSlice
import { createSlice } from "@reduxjs/toolkit";

// 2. 定义初始状态
const initialState = {
  value: 0, // 计数器的当前值
};

// 3. 使用 createSlice 创建 Slice
const counterSlice = createSlice({
  name: "counter", // Slice 的名称,用于生成 action 的 type 前缀
  initialState, // 初始状态
  reducers: {
    // 同步的 reducer 函数集合
    // 4. increment:让 value 加 1
    increment: (state) => {
      // RTK 内部集成了 Immer,这里可以写“看起来可变”的代码
      state.value += 1;
    },
    // 5. decrement:让 value 减 1
    decrement: (state) => {
      state.value -= 1;
    },
    // 6. incrementByAmount:根据传入的 payload 增加指定数值
    incrementByAmount: (state, action) => {
      state.value += action.payload;
    },
  },
});

// 7. 导出自动生成的 action creator,组件中通过 dispatch 调用
export const { increment, decrement, incrementByAmount } = counterSlice.actions;

// 8. 导出 reducer,用于配置 Store
export default counterSlice.reducer;

代码关键点解析

  • createSlice 会根据 reducers 里的函数名,自动生成同名的 action creator。例如 increment 函数会生成 increment() 这个 action。
  • RTK 内部集成了 Immer,允许你在 reducer 中写 state.value += 1,看起来是修改了 state,但底层会自动生成不可变的新状态对象
  • 最后导出 actionsreducer,分别给组件和 Store 使用。

2.3 配置 Store:应用唯一的“公告板”

Store 是 Redux 应用的核心,整个应用只有一份 Store。在 RTK 中,我们使用 configureStore 来创建它。

// src/store/index.js

// 1. 导入 configureStore
import { configureStore } from "@reduxjs/toolkit";

// 2. 导入各个 Slice 的 reducer
import counterReducer from "./counterSlice";

// 3. 创建 Store
const store = configureStore({
  reducer: {
    // 4. 这里的 key 会决定组件中访问状态的路径
    // 例如:state.counter.value
    counter: counterReducer,
  },
});

// 5. 导出 store,供 Provider 注入
export default store;

configureStore 会自动完成原生 Redux 中需要手动做的事:

  • 集成 Redux DevTools 扩展;
  • 默认启用常用的中间件(如处理异步的 thunk 中间件);
  • 自动合并 reducer。

2.4 注入 Provider:让 React 组件连接 Redux

React-Redux 提供了 Provider 组件,我们需要把 React 应用包裹在 Provider 中,并传入 Store。

// src/main.jsx(或 src/index.js,取决于你的项目模板)

import React from "react";
import ReactDOM from "react-dom/client";
import { Provider } from "react-redux"; // 导入 Provider
import App from "./App";
import store from "./store"; // 导入刚才创建的 store

// 创建 React 19 的根节点
const root = ReactDOM.createRoot(document.getElementById("root"));

// 用 Provider 包裹整个应用,让所有组件都能访问 Redux Store
root.render(
  <Provider store={store}>
    <App />
  </Provider>,
);

这一步是“把公告板挂到学校大厅”的关键。只有包裹了 Provider,下面的组件才能通过 useSelector 读取状态、通过 useDispatch 派发 action。


2.5 在组件中读取和更新状态:useSelector 与 useDispatch

2.5.1 计数器组件示例

// src/components/Counter.jsx

import React from "react";
import { useSelector, useDispatch } from "react-redux"; // 导入两个核心 Hook
import { increment, decrement, incrementByAmount } from "../store/counterSlice";

function Counter() {
  // 1. useSelector:从 Store 中读取状态
  // 参数是一个函数,state 就是整个 Redux 状态树
  const count = useSelector((state) => state.counter.value);

  // 2. useDispatch:获取 dispatch 函数,用于派发 action
  const dispatch = useDispatch();

  return (
    <div style={{ padding: 20, textAlign: "center" }}>
      <h2>当前计数:{count}</h2>

      {/* 3. 点击按钮时,调用 dispatch 并传入 action creator */}
      <button onClick={() => dispatch(decrement())}> -1 </button>
      <button onClick={() => dispatch(increment())}> +1 </button>
      <button onClick={() => dispatch(incrementByAmount(5))}> +5 </button>
    </div>
  );
}

export default Counter;

2.5.2 状态流转流程图(文字版)

┌────────────────────────────────────────────────────────────┐
│                     Redux 状态流转示意图                      │
└────────────────────────────────────────────────────────────┘

  用户点击按钮
      │
      ▼
┌──────────────┐      ┌──────────────┐      ┌──────────────┐
│  React 组件   │ ───▶ │  dispatch()  │ ───▶ │   action     │
│  Counter.jsx  │      │  派发动作      │      │  描述要做什么   │
└──────────────┘      └──────────────┘      └──────┬───────┘
                                                    │
                                                    ▼
┌──────────────┐      ┌──────────────┐      ┌──────────────┐
│  组件重新渲染  │ ◀─── │  Store 更新   │ ◀─── │   reducer    │
│  useSelector  │      │  保存新状态    │      │  纯函数计算新状态│
└──────────────┘      └──────────────┘      └──────────────┘

流程说明

  1. 用户在组件中点击按钮,触发 dispatch(action)
  2. Redux 把 action 传给对应的 reducer。
  3. reducer 是纯函数,根据旧状态和 action 计算出新状态。
  4. Store 更新为新的状态树。
  5. 使用了 useSelector 读取该状态的组件,会接收到更新并重新渲染。

2.6 综合案例:带 TodoList 的计数器

下面是一个更贴近实际业务的组件,包含计数器和 TodoList 两个功能模块。它演示了如何读取状态、如何派发同步 action,以及如何在组件中处理表单输入。

// src/components/TodoCounter.jsx

import React, { useState } from "react";
import { useSelector, useDispatch } from "react-redux";
import { increment, decrement } from "../store/counterSlice";
import { addTodo, toggleTodo, removeTodo } from "../store/todoSlice";

function TodoCounter() {
  // 1. 读取计数器状态
  const count = useSelector((state) => state.counter.value);
  // 2. 读取待办列表状态
  const todos = useSelector((state) => state.todos.items);
  // 3. 获取 dispatch
  const dispatch = useDispatch();
  // 4. 组件本地状态:输入框内容
  const [inputValue, setInputValue] = useState("");

  const handleAdd = () => {
    if (inputValue.trim() === "") return;
    // 5. 派发 addTodo action,payload 是新的待办内容
    dispatch(addTodo(inputValue));
    setInputValue("");
  };

  return (
    <div style={{ padding: 24, maxWidth: 500, margin: "0 auto" }}>
      {/* 计数器区域 */}
      <section style={{ marginBottom: 32 }}>
        <h2>计数器:{count}</h2>
        <button onClick={() => dispatch(decrement())}>减 1</button>
        <button onClick={() => dispatch(increment())}>加 1</button>
      </section>

      {/* 待办列表区域 */}
      <section>
        <h2>待办清单</h2>
        <input
          value={inputValue}
          onChange={(e) => setInputValue(e.target.value)}
          placeholder="请输入待办事项"
        />
        <button onClick={handleAdd}>添加</button>

        <ul>
          {todos.map((todo) => (
            <li key={todo.id}>
              <span
                onClick={() => dispatch(toggleTodo(todo.id))}
                style={{
                  textDecoration: todo.completed ? "line-through" : "none",
                  cursor: "pointer",
                }}
              >
                {todo.text}
              </span>
              <button onClick={() => dispatch(removeTodo(todo.id))}>
                删除
              </button>
            </li>
          ))}
        </ul>
      </section>
    </div>
  );
}

export default TodoCounter;

配套 TodoList 的 Slice 代码:

// src/store/todoSlice.js

import { createSlice } from "@reduxjs/toolkit";

// 1. 初始状态
const initialState = {
  items: [],
};

let nextId = 1;

const todoSlice = createSlice({
  name: "todos",
  initialState,
  reducers: {
    // 2. 添加待办:payload 为待办文本
    addTodo: (state, action) => {
      state.items.push({
        id: nextId++,
        text: action.payload,
        completed: false,
      });
    },
    // 3. 切换完成状态:payload 为待办 id
    toggleTodo: (state, action) => {
      const todo = state.items.find((item) => item.id === action.payload);
      if (todo) {
        todo.completed = !todo.completed;
      }
    },
    // 4. 删除待办:payload 为待办 id
    removeTodo: (state, action) => {
      state.items = state.items.filter((item) => item.id !== action.payload);
    },
  },
});

export const { addTodo, toggleTodo, removeTodo } = todoSlice.actions;
export default todoSlice.reducer;

最后记得把 todoReducer 注册到 Store 中:

// src/store/index.js

import { configureStore } from "@reduxjs/toolkit";
import counterReducer from "./counterSlice";
import todoReducer from "./todoSlice";

const store = configureStore({
  reducer: {
    counter: counterReducer,
    todos: todoReducer,
  },
});

export default store;

2.7 异步请求实战:createAsyncThunk 与加载状态

实际业务中,状态更新往往不是瞬间完成的,比如从服务器获取数据。RTK 提供了 createAsyncThunk 来统一管理这种异步流程。

2.7.1 创建异步 Slice

// src/store/userSlice.js

import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";

// 1. 创建异步 thunk:第一个参数是 action 的 type 前缀,第二个参数是异步函数
export const fetchUserById = createAsyncThunk(
  "user/fetchUserById", // action type 前缀
  async (userId, { rejectWithValue }) => {
    // 参数:userId,thunk API 对象
    try {
      const response = await fetch(`/api/users/${userId}`);
      if (!response.ok) {
        throw new Error("请求失败");
      }
      return await response.json(); // 返回值会自动成为 fulfilled 的 payload
    } catch (error) {
      // 2. rejectWithValue 把错误信息传给 rejected 状态
      return rejectWithValue(error.message);
    }
  },
);

// 3. 初始状态,包含 loading 和 error 字段
const initialState = {
  userInfo: null, // 用户信息
  loading: false, // 加载状态
  error: null, // 错误信息
};

const userSlice = createSlice({
  name: "user",
  initialState,
  reducers: {}, // 这里可以写同步 reducer
  extraReducers: (builder) => {
    // 4. 处理异步请求的三个阶段:pending、fulfilled、rejected
    builder
      .addCase(fetchUserById.pending, (state) => {
        state.loading = true; // 开始请求,显示加载中
        state.error = null;
      })
      .addCase(fetchUserById.fulfilled, (state, action) => {
        state.loading = false; // 请求成功,隐藏加载
        state.userInfo = action.payload; // 保存返回的数据
      })
      .addCase(fetchUserById.rejected, (state, action) => {
        state.loading = false; // 请求失败,隐藏加载
        state.error = action.payload; // 保存错误信息
      });
  },
});

export default userSlice.reducer;

2.7.2 在组件中使用异步 thunk

// src/components/UserProfile.jsx

import React, { useEffect } from "react";
import { useSelector, useDispatch } from "react-redux";
import { fetchUserById } from "../store/userSlice";

function UserProfile({ userId }) {
  // 1. 读取 user 模块的状态
  const { userInfo, loading, error } = useSelector((state) => state.user);
  const dispatch = useDispatch();

  // 2. 组件挂载时发起异步请求
  useEffect(() => {
    dispatch(fetchUserById(userId));
  }, [dispatch, userId]);

  // 3. 根据 loading 和 error 渲染不同 UI
  if (loading) return <p>加载中,请稍候……</p>;
  if (error) return <p style={{ color: "red" }}>出错了:{error}</p>;
  if (!userInfo) return null;

  return (
    <div style={{ padding: 24 }}>
      <h2>用户资料</h2>
      <p>姓名:{userInfo.name}</p>
      <p>邮箱:{userInfo.email}</p>
    </div>
  );
}

export default UserProfile;

2.7.3 异步流程图(文字版)

组件挂载
    │
    ▼
dispatch(fetchUserById(userId))
    │
    ├──────────▶ pending 状态    ───▶  loading = true,显示 loading
    │
    ├──────────▶ fulfilled 状态  ───▶  loading = false,保存 userInfo
    │
    └──────────▶ rejected 状态   ───▶  loading = false,保存 error

2.7.4 与 React 19 并发特性的结合

React 19(课程环境基于 React 18)的并发渲染特性允许 React 在渲染过程中暂停、恢复和中断更新。对于 Redux 来说:

  • useSelector 默认会在每次 Store 更新时检查组件是否需要重新渲染。
  • 在并发模式下,组件渲染可能被中断。Redux 的状态读取本身不受影响,因为 Store 的更新是同步的。
  • useEffect 中发起异步请求时,不需要额外处理并发逻辑,但要保证在组件卸载时不会继续操作已卸载组件的状态(例如避免 setState on unmounted component)。

一句话:使用 RTK 的 createAsyncThunk + 组件内 useEffect 派发,就已经能很好地配合 React 19 的并发特性。重点是不要把异步副作用写在 reducer 里。


2.8 模块二小结

本模块我们学习了:

  1. 安装 @reduxjs/toolkitreact-redux 依赖。
  2. 使用 createSlice 创建包含状态、reducer 和 action 的 Slice。
  3. 使用 configureStore 创建 Store,并在 Provider 中注入。
  4. 在组件中使用 useSelector 读取状态、useDispatch 派发 action。
  5. 使用 createAsyncThunk + extraReducers 处理异步请求,管理 loading 和 error。

课后思考

  1. 如果 Store 里没有注册 counter reducer,组件中 state.counter.value 会读到什么?
  2. useSelector 返回的数据发生变化时,组件会做什么?
  3. 为什么 createAsyncThunk 的异步函数要写在 Slice 外面,而不是写在 reducers 里?

模块三:开发注意事项——React 19 中的踩坑与误区

学习目标

  1. 能识别并避免状态解构、组件外 dispatch、重渲染等常见错误。
  2. 理解 React 19 并发渲染与 Redux 配合时的注意事项。
  3. 学会判断“什么状态应该放 Redux,什么状态应该放组件本地”。

3.1 踩坑点一:状态解构导致订阅失效

错误写法

// ❌ 错误:在 useSelector 里解构整个 counter 对象
const { value } = useSelector((state) => state.counter);

// 看起来没问题,但如果 counter 里新增了其他字段,
// 或每次返回新的对象引用,会导致不必要的重渲染。

问题原因

useSelector 使用 === 严格比较来判定状态是否变化。如果 selector 函数每次都返回一个新的对象字面量,即使内容没变,引用不同也会触发重渲染。

// ❌ 错误:每次返回新的对象,引用不同,始终触发重渲染
const user = useSelector((state) => ({ ...state.user }));

正确写法

// ✅ 正确:只返回需要的原始值,确保引用稳定
const count = useSelector((state) => state.counter.value);
const userName = useSelector((state) => state.user.name);

如果需要返回多个字段,建议使用多个 useSelector 分别读取,或者使用 shallowEqual 进行浅比较:

// ✅ 使用 shallowEqual 比较对象,避免误触发重渲染
import { useSelector, shallowEqual } from "react-redux";

const { name, age } = useSelector(
  (state) => ({ name: state.user.name, age: state.user.age }),
  shallowEqual,
);

3.2 踩坑点二:在组件外调用 dispatch

错误写法

// ❌ 错误:在普通 JS 文件中直接调用 dispatch
import store from "./store";
import { increment } from "./counterSlice";

store.dispatch(increment()); // 这里能运行,但通常不推荐

问题原因

虽然直接导入 store 可以调用 dispatch,但这会导致组件与 Store 强耦合,难以测试和复用。而且普通 JS 文件不属于 React 组件树,无法享受 React-Redux 的上下文能力。

正确写法

在组件内使用 useDispatch

// ✅ 正确:在组件内通过 useDispatch 获取 dispatch
function Counter() {
  const dispatch = useDispatch();

  const handleClick = () => {
    dispatch(increment());
  };

  return <button onClick={handleClick}>+1</button>;
}

如果确实需要在组件外(例如路由守卫、拦截器)修改状态,可以通过注入 store 的方式处理,但这类场景应该严格控制,避免滥用。


3.3 踩坑点三:不必要的重渲染

问题现象

父组件中使用 useSelector 读取了一个大对象,即使子组件只依赖其中部分字段,也会跟着重新渲染。

错误写法

// ❌ 错误:把整个 user 对象传给子组件
function Parent() {
  const user = useSelector((state) => state.user);
  return <Child user={user} />;
}

function Child({ user }) {
  return <p>{user.name}</p>;
}

正确写法

// ✅ 正确:只把子组件需要的字段传下去
function Parent() {
  const userName = useSelector((state) => state.user.name);
  return <Child name={userName} />;
}

function Child({ name }) {
  return <p>{name}</p>;
}

// 或者让子组件自己使用 useSelector 读取所需状态
function Child() {
  const name = useSelector((state) => state.user.name);
  return <p>{name}</p>;
}

优化技巧总结

  1. Selector 粒度尽量细:只读取组件真正需要的最小状态片段。
  2. 避免返回新对象:不要在 useSelector 里做 return { ...state.user } 这类操作。
  3. 使用 React.memo 包裹纯展示组件:如果父组件的 props 或状态没变,子组件可以跳过渲染。
  4. 必要时使用 shallowEqual:多个字段需要一起返回时,用浅比较避免误触发。

3.4 踩坑点四:并发渲染与状态更新冲突

React 19(课程环境基于 React 18)的并发特性允许 React 中断、恢复和延迟渲染。对于 Redux 的使用者来说,需要注意以下几点:

3.4.1 不要在 render 过程中直接修改 Store

// ❌ 错误:在渲染阶段直接调用 dispatch
function BadComponent() {
  const dispatch = useDispatch();
  dispatch(someAction()); // 渲染阶段不允许有副作用!
  return <div>hello</div>;
}

正确写法

// ✅ 正确:副作用放在 useEffect 或事件处理函数中
function GoodComponent() {
  const dispatch = useDispatch();

  useEffect(() => {
    dispatch(someAction());
  }, [dispatch]);

  return <div>hello</div>;
}

3.4.2 避免在渲染阶段读取外部可变状态

// ❌ 错误:直接读取 store.getState(),可能读到不一致的状态
const state = store.getState();
function MyComponent() {
  return <div>{state.counter.value}</div>;
}

正确写法

// ✅ 正确:使用 useSelector,让 React-Redux 帮你管理订阅和更新
function MyComponent() {
  const count = useSelector((state) => state.counter.value);
  return <div>{count}</div>;
}

3.4.3 异步副作用交给 createAsyncThunk

在并发渲染下,组件可能被卸载、中断。如果异步逻辑写在组件里,需要手动处理 cleanup。使用 createAsyncThunk 可以把异步逻辑统一管理,减少出错概率。


3.5 初学者误区一:过度用 Redux 管理本地状态

错误示范

// ❌ 错误:把输入框这种纯本地状态也放到 Redux
const inputValue = useSelector((state) => state.form.inputValue);
const dispatch = useDispatch();

<input
  value={inputValue}
  onChange={(e) => dispatch(setInputValue(e.target.value))}
/>;

问题原因

输入框的值只在当前组件内部使用,不需要共享给其他组件。把它放到 Redux 里会让 Store 变得臃肿,每次输入都会触发全局状态更新和 selector 检查,得不偿失。

正确写法

// ✅ 正确:本地状态用 useState 管理
const [inputValue, setInputValue] = useState("");

<input value={inputValue} onChange={(e) => setInputValue(e.target.value)} />;

3.6 初学者误区二:Store 状态冗余

错误示范

// ❌ 错误:在 Store 里保存可以由其他状态计算得出的值
const initialState = {
  todos: [],
  completedCount: 0, // 这个字段可以从 todos 中计算出来
};

问题原因

completedCounttodos 本质上是同一数据的不同表现形式。如果每次添加、删除、切换待办时都要同步更新 completedCount,很容易出现不一致。

正确写法

// ✅ 正确:只保存原始数据,计算值在组件或 selector 中计算
const completedCount = useSelector(
  (state) => state.todos.items.filter((todo) => todo.completed).length,
);

或者使用 RTK 的 createSelector 做缓存优化:

import { createSelector } from "@reduxjs/toolkit";

const selectTodos = (state) => state.todos.items;

export const selectCompletedCount = createSelector(
  [selectTodos],
  (todos) => todos.filter((todo) => todo.completed).length,
);

3.7 状态选型判断标准

遇到一个新的状态,不知道放 Redux 还是放组件 useState?可以参考下面这张决策表:

判断维度 建议放 Redux 建议放 useState
是否需要跨组件共享
是否需要持久化或服务端同步
是否需要在多个页面之间保持一致
是否只在当前组件内部使用
是否只是 UI 临时状态(如输入框、弹窗显隐)

一句话总结:共享、持久、跨页面的状态放 Redux;局部、临时、UI 状态放 useState。


3.8 模块三小结

本模块我们学习了:

  1. 状态解构、组件外 dispatch、重渲染是 React 19 + Redux 开发中的三大常见踩坑点。
  2. 并发渲染下,dispatch 和状态读取要遵循 React 的渲染规则,避免副作用出现在 render 阶段。
  3. 不要过度使用 Redux,本地状态和可计算值不要放到 Store 里。

课后思考

  1. 你在自己的项目中有没有把本该用 useState 的状态放进了 Redux?
  2. 如果你的组件只读取 state.user.name,那么 state.user.age 变化时,该组件会重新渲染吗?
  3. 在并发渲染下,为什么 useEffect 比直接在 render 里 dispatch 更安全?

附录 A:Redux 状态流转总流程图(文字版)

┌────────────────────────────────────────────────────────────────────┐
│                        Redux 全局状态流转图                         │
└────────────────────────────────────────────────────────────────────┘

                         ┌──────────────┐
                         │   初始状态    │
                         │ initialState │
                         └──────┬───────┘
                                │
                                ▼
    ┌──────────────────────────────────────────────────────────┐
    │                         Store                           │
    │              唯一的数据仓库(单一数据源)                  │
    └──────────────────────────────────────────────────────────┘
                                │
                                │ 保存全局状态
                                ▼
    ┌──────────┐    action     ┌──────────┐    新状态     ┌──────────┐
    │  用户交互  │──────────────▶│ reducer  │─────────────▶│  Store   │
    │ 或异步回调 │               │ 纯函数   │             │  更新    │
    └──────────┘               └──────────┘             └──────────┘
                                │
                                │ 状态变化通知订阅者
                                ▼
    ┌──────────────────────────────────────────────────────────┐
    │              React 组件通过 useSelector 订阅状态            │
    │                    组件重新渲染                              │
    └──────────────────────────────────────────────────────────┘

关键规则:
1. 所有状态集中在 Store 中。
2. 改变状态必须通过 dispatch 发送 action。
3. reducer 必须是纯函数,返回新状态而不是修改原状态。
4. 组件通过 useSelector 读取,通过 useDispatch 更新。

附录 B:常见问题解答(FAQ)

Q1:Redux 和 Context 有什么区别?能不能互相替代?

:Context 适合传递“很少变化”的数据,比如主题、语言、用户信息等。Redux 适合“频繁变化、需要统一管理、有复杂更新逻辑”的状态。两者可以共存,不要二极管思维。例如全局主题可以用 Context,购物车、用户登录状态可以用 Redux。

Q2:React 19 了,还需要 Redux 吗?useReducer 不够吗?

useReducer 是 React 内置的 reducer 模式,适合管理单个组件或局部共享的状态。当状态需要跨多个页面、多个组件共享,或者有复杂的异步流程时,Redux(特别是 RTK)仍然是更好的选择。

Q3:为什么我的 useSelector 没有重新渲染?

:最常见的原因是 selector 返回了同一个引用,或者 selector 没有正确读取到变化的字段。检查:

  1. 是否读取了正确的路径?
  2. 是否返回了新的对象字面量导致引用不变但实际没变?
  3. 是否使用了 shallowEqual

Q4:createAsyncThunk rejected 时,错误信息为什么是 undefined?

:如果你在异步函数里用 throw new Error(...) 但没有使用 rejectWithValue,RTK 会把错误对象放进 action.error,而不是 action.payload。建议始终用 rejectWithValue(error.message) 返回结构化错误信息。

Q5:我可以在 reducer 里发起异步请求吗?

绝对不行。reducer 必须是纯函数,不能有副作用。异步请求请使用 createAsyncThunk 或放在组件的 useEffect 中。

Q6:Redux DevTools 怎么开启?

:使用 RTK 的 configureStore 时,Redux DevTools 扩展会自动开启。你只需在浏览器中安装 Redux DevTools 插件,打开控制台即可查看每个 action 和状态变化。

Q7:所有状态都放 Redux 会不会让项目更简单?

:不会。过度集中会让 Store 变得臃肿、组件渲染不必要地频繁。建议遵循“需要共享的放 Redux,其余的放本地 state”的原则。

Q8:React 19 的并发渲染会让 Redux 的更新丢失吗?

:不会。Redux 的 Store 更新是同步的,React 的并发渲染只是让 React 组件渲染可以中断和恢复。只要你在组件内正确使用 useSelectoruseDispatch,就不会丢失更新。


总结

全文总结

通过本节课,你应该建立起以下认知:

  1. Redux 解决的是 React 中跨组件共享状态、避免 props 层层传递的问题。
  2. Redux 的三大原则——单一数据源、状态只读、纯函数修改——决定了它的可预测性和可调试性。
  3. Redux Toolkit(RTK)是官方推荐写法,能显著减少样板代码和出错概率。
  4. 在 React 19 项目中,使用 createSliceconfigureStore、Provider、useSelectoruseDispatch 就能完成大部分状态管理需求。
  5. 异步请求用 createAsyncThunk 统一管理,配合 loading 和 error 状态做 UI 反馈。
  6. 开发时要注意:避免状态解构订阅失效、不要在组件外随意 dispatch、避免不必要的重渲染、不要把本地状态和可计算值塞进 Redux。
0
博主关闭了当前页面的评论