首页 / React 学习笔记 / 19

REACT · Vol.III · LESSON 19 · 状态管理

Redux Toolkit 与单向数据流

进阶#状态#Redux

为什么会有 Redux:当「谁改的」变成悬案

第 18 篇的 Zustand 把「共享」解决得很漂亮——但它交到你手里的是一个谁都能调 set、action 随便命名的全局单例。三五个人这是灵活;三十个人、几百个组件,这就是悬案工厂:徽章少了一件,凶手可能是任何一行碰过 store 的代码,没有现场,没有笔录

Redux 的回答极其克制:把「改状态」收归国有:组件不得直接改状态,只能发一条「事件」,由统一的纯函数折叠出下一个状态。于是「谁改的」永远有答案——翻事件流水。

这套思路你在服务端见过。账务系统的铁律:不改余额,只记流水:

Ledger.java · 事件溯源:状态是事件折叠的结果
List<TxEvent> events = txRepo.findByAccountId(id); // 只增不改的事件日志

BigDecimal balance = events.stream()
    .reduce(BigDecimal.ZERO, (acc, e) -> acc.add(e.signedAmount()));
    // 余额是折叠出来的「视图」:可重算、可审计

Redux 把这套纪律搬到前端的每一个状态上,一句话就是:state = actions.reduce(reducer, initialState)——从初始状态出发,重放全部 action,必然得到当前状态。「可预测」三个字的全部含义就在这条公式里。

后端类比:事件溯源(Event Sourcing)+ 审计日志

这就是 Event Sourcing 的前端版:不保存最新值,保存变更事件,最新值是投影。三笔收益:变更全留痕(审计)、重放事件可重建任意时刻状态(回溯)、写入路径唯一。Redux 对前端状态的全部要求,恰好就是这三条。

核心三概念:action、reducer、store

三个概念就够:

概念是什么一句话类比
action描述「发生了什么」的普通对象:{ type, payload }一条事件、一笔流水
reducer(state, action) => newState 纯函数Stream.reduce 的折叠函数
store持有状态的单例:dispatch / getState / subscribe持有投影的仓储 + 事件入口

reducer 的「纯」是全部纪律的根,三条戒律:不许改入参(必须返回新对象)、不许有副作用(不发请求、不改外部变量)、同输入必同输出。任何一条破了,重放就失真。

counterReducer.ts · 素颜 Redux:reducer 长这样(示意)
type CounterAction =
  | { type: "counter/increment" }
  | { type: "counter/addAmount"; payload: number };

// reducer:(state, action) => newState 的纯函数
function counterReducer(state: { value: number }, action: CounterAction) {
  switch (action.type) {
    case "counter/increment":
      return { value: state.value + 1 }; // 新对象,绝不动入参
    case "counter/addAmount":
      return { value: state.value + action.payload };
    default:
      return state;                      // 不认识的事件:原样返回
  }
}
后端类比:Stream.reduce

reducer 这名字不是随便起的——把 action 流想成一个 Stream:actions.stream().reduce(initialState, (acc, e) -> fold(acc, e))——acc 是旧状态,e 是一条事件,折叠函数就是 reducer 本尊,签名分毫不差:(state, action) => newState。唯一差别:事件随时间一条条到来,每来一条折叠一次。

这套流程画出来,就是单向数据流的闭环:

UI 组件 onClick 事件源 action { type, payload } reducer 纯函数 (s, a) => newState store 新状态 唯一数据源 dispatch(只能发事件) 折叠新状态 新状态入库 useSelector 订阅 state = actions.reduce(reducer, initialState) 任何组件都不能绕过 dispatch 直接改状态
图 1单向数据流闭环:UI dispatch 发 action,reducer 折叠出新状态入 store,经 useSelector 通知 UI 重渲染

可预测性不靠自觉,靠这条单行道物理隔离:数据沿一个方向循环,没有「组件 A 顺手改掉组件 B 状态」的后门。

RTK 三件套:createSlice 砍掉大半样板

素颜 Redux 的痛点一眼可见:每加一个功能,type 常量、action 对象、switch case 三份劳动说的是同一件事。官方钦定的 Redux Toolkit(RTK)第一刀砍在这里:createSlice 把一个领域的 state、reducers、action creators 合成一次声明:

counterSlice.ts · action 与 reducer 合体
import { createSlice } from "@reduxjs/toolkit";

const counterSlice = createSlice({
  name: "counter",                     // slice 名:action type 的前缀
  initialState: { value: 0 },
  reducers: {
    increment(state) {
      state.value += 1;                // 看似「直接改」?!往下看
    },
    addAmount(state, action) {
      state.value += action.payload;
    },
  },
});

// action creator 自动生成,type 字符串不用你写
export const { increment, addAmount } = counterSlice.actions;
export default counterSlice.reducer;

注意 state.value += 1——「直接改」?!RTK 内置了 Immer:reducer 收到的 state 是一层 Proxy「草稿」,你怎么改都行;函数返回时,Immer 依据修改记录生成一棵全新的、不可变的状态树。写法自由,产出依旧不可变——纪律没破坏,只是框架代劳。

为什么死咬「不可变」不放?和 Zustand 同一个原因(第 18 篇):变更检测靠引用比较,原地改引用不变,订阅方永远不知道你改了;时间旅行更要求每一步都留下新对象,才能把状态串成一条可回放的历史线。

store.ts · configureStore:零配置装机
import { configureStore } from "@reduxjs/toolkit";
import counterReducer from "./counterSlice";

export const store = configureStore({
  reducer: { counter: counterReducer }, // 自动 combineReducers
  // 默认内置 thunk 中间件 + Redux DevTools,零配置
});

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

组件侧只剩两个 Hook:useSelector 与第 18 篇的 selector 同一思想(订阅切片、比较结果);dispatch(action) 是通往 reducer 的唯一入口:

Counter.tsx · useSelector 订阅,dispatch 派发
import { useSelector, useDispatch } from "react-redux";
import { increment } from "./counterSlice";
import type { RootState } from "./store";

function Counter() {
  const value = useSelector((s: RootState) => s.counter.value);
  const dispatch = useDispatch();
  return <button onClick={() => dispatch(increment())}>{value}</button>;
}
后端类比:Hibernate 脏检查

Immer 的体验你在 JPA 里见过:托管实体上随便 set 字段,你以为在原地改,框架记的是「脏字段」,flush 时生成最小变更——你写命令式,框架产出「新版本」。Immer 同理。另外提醒:useSelector 的比较语义同样是引用比较,返回字面量新对象照样翻车,和第 18 篇的坑一模一样。

createAsyncThunk:把异步请求写成状态机

reducer 必须纯,请求这类副作用往哪放?RTK 的答案:createAsyncThunk 生成「thunk」,dispatch 它时自动按序发出 pending / fulfilled / rejected 三条 action,reducer 只消费结果:

todosSlice.ts · 异步请求的三段式状态
import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";

export const fetchTodos = createAsyncThunk(
  "todos/fetchAll",
  async () => {
    const res = await fetch("/api/todos");
    if (!res.ok) throw new Error("fetch failed"); // 抛错 → rejected
    return (await res.json()) as Todo[];
  }
);

const todosSlice = createSlice({
  name: "todos",
  initialState: {
    items: [] as Todo[],
    status: "idle" as "idle" | "loading" | "succeeded" | "failed",
  },
  reducers: {},
  extraReducers: (builder) => {
    builder
      .addCase(fetchTodos.pending, (s) => { s.status = "loading"; })
      .addCase(fetchTodos.fulfilled, (s, a) => {
        s.status = "succeeded";
        s.items = a.payload;            // Immer:写法随意,产出不可变
      })
      .addCase(fetchTodos.rejected, (s) => { s.status = "failed"; });
  },
});

组件侧只需 dispatch(fetchTodos()) 发起,再用一个 status 驱动「加载中 / 失败 / 列表」三种视图。这就是三段式状态机:idle(没发过)→ loading(pending 到达)→ succeeded(fulfilled,items 更新)或 failed(rejected)——一次异步请求天然是个状态机,三条 action 就是三次状态迁移。第 34 篇把加载态、错误态、竞态当专门课题,这里的 status 就是它们在 Redux 世界的落点。

后端类比:异步 RPC 的三个回调

对应你调用下游服务时处理的三种结果:进行中、成功、失败。thunk 的妙处是把「我正在请求」本身也做成了 action——连副作用都进了事件流水,审计闭环。防重复提交、失败重试,都在事件流这一层加统一规则,而非散落在组件里。

为什么 reducer 里不能 await?折叠函数必须同步且纯——重放事件时,第 1024 条事件不可能「等一个网络请求」。异步的全部不确定性关进 thunk,reducer 只消费确定的结果。

请求样板写得多,还有更高层的 RTK Query——声明 endpoint,缓存、去重、失效自动管,定位相当于 Redux 官方版 TanStack Query,第 33 篇对照着讲。

单向数据流:纪律的回报与代价

把第 18 篇和这一篇放在一起,才看得清单向数据流这笔账。收益四条:

  1. 可审计:任何变更都有一条 action 记录,DevTools 里从头播到尾,复现 bug 不靠玄学。
  2. 可测试:reducer 是纯函数,expect(reducer(initial, increment())).toEqual({ value: 1 })——像单测无副作用的 Service 方法。
  3. 可协作:slice 划分、action 命名、异步位置全有官方规范,新人只学一套。
  4. 可回放:状态是事件的折叠结果,保存 action 流就能重建任意历史时刻(时间旅行调试)。
维度Zustand(第 18 篇)Redux Toolkit
上手成本一个 create,几分钟slice + store + Hooks,半天
变更约束自由,规范靠自觉强制走 action,天然全流水
甜点位中小型、快速迭代大团队、复杂联动、强规范

同一个计数器,Zustand 十行,RTK 三十行——多出的二十行,买的正是上面四条。团队小、状态简单,这是过度设计;团队大、状态联动复杂,这是救命稻草。

手里有旧 Redux 资产?渐进式迁移

接手存量项目时常见老写法:connect(mapState, mapDispatch)(Component),外加手写的 action type 常量和 switch-case reducer。好消息是 RTK 与 react-redux 完全兼容,迁移可以逐文件进行:新代码一律 createSlice + useSelector/useDispatch;老 slice 原样共存,等下次业务改动落到它头上时顺手换掉 connect。action 与 reducer 的语义自始至终没变,变的全是样板。

最忌讳的是「停下业务三个月,重写整个状态层」——迁移该跟着业务改动的节奏走,一次迁一个 slice,每一步都能独立上线。

后端类比:绞杀者模式(Strangler Fig)

和拆单体一个路数:新功能走新链路,老模块谁改动谁顺手迁,两套代码长期共存直到老的自然枯死。一次性停机重写在后端几乎总是灾难,前端同理。

核心要点
  • Redux 的本质:状态 = 事件折叠,state = actions.reduce(reducer, initialState)——前端版 Event Sourcing。
  • 三概念:action 描述「发生了什么」;reducer 是 (state, action) => newState 纯函数(≈ Stream.reduce);store 持有状态。
  • RTK 三件套:configureStore(自动合并 + 内置 thunk 与 DevTools)、createSlice(action 与 reducer 合体,Immer 让「直接改」产出不可变)、useSelector/useDispatch。
  • 异步:createAsyncThunk 自动发 pending/fulfilled/rejected,reducer 只消费结果;请求样板多时上 RTK Query(第 33 篇对照 TanStack Query)。
  • 单向数据流闭环:UI → dispatch → reducer → store → useSelector → UI。样板买的是审计、测试、协作、回放。

本章回顾

至此卷三货架上有两件客户端工具:Zustand 买「轻」,RTK 买「正」;加上第 15 篇的 Context,和「服务端数据交给 TanStack Query」的第四个选项。选错不致命,选错不肯换才致命。下一篇第 20 篇给一张照着走的决策树:何时 Context、何时 Zustand、何时为 RTK 交样板税、什么数据碰都别碰。

Comments · 评论