首页 / 文章

从 First Slice 到异步数据:Redux Toolkit 的实用思维模型

通过计数器、购物车和产品列表,了解 Redux Toolkit 的 store、slices、Immer 驱动的 reducer、selector 以及 createAsyncThunk 是如何相互配合的。

发布者:REACTAPP.TOP阅读约 9 分钟2008 词

来源: Redux Toolkit: A Complete Guide from Basics to Production

随着 React 应用规模的扩大,诸如用户信息、购物车、偏好设置、筛选条件以及 API 数据等共享状态会分散在多个页面中,通过 props 传递这些数据会变得十分脆弱。Redux 通过中央存储来解决这一问题,而 Redux Toolkit(RTK)则是目前编写 Redux 代码的推荐方式。本指南将从计数器功能逐步讲解到包含计算总价的购物车以及基于 API 的列表功能,帮助你自信地阅读和编写 RTK 代码。

首先确认你是否真的需要使用存储:仅凭 prop drilling 并不足以成为安装 Redux 的理由。

Redux 的核心理念

Redux 将所有共享状态集中存储在一个仓库中,并且仅通过 reducer 处理的动作来修改这些状态,因此每次更新都是可预测的。如果没有 Redux,数据只能逐层通过 props 传递。

Component
   ↓
Props
   ↓
Component
   ↓
Props
   ↓
Component

通过商店,组件可以读取所需数据并发送动作以请求状态变更。

             Redux Store
                  ↑
                  │
        ┌─────────┴─────────┐
        │                   │
    Component A         Component B
        │                   │
        └──── dispatch ─────┘

Redux Toolkit 的优势

RTK 汇集了用于常见 Redux 任务的实用工具。

Redux Toolkit
│
├── configureStore()
├── createSlice()
├── createAsyncThunk()
├── createSelector()
└── RTK Query

传统 Redux 需要将某个功能分散在多个文件中实现。

Action Types
Action Creators
Reducers
Constants
Store
Middleware

createSlice() 允许将大部分功能集中在一处实现。

数据在 RTK 应用中的流动方式

组件使用 useSelector() 读取数据,使用 useDispatch() 发送动作;还原器则用于生成新的状态。

                 React Application
                        │
                        ↓
                 useSelector()
                        │
                        ↓
                 Redux Store
                        ↑
                        │
                 useDispatch()
                        ↑
                        │
                    Actions
                        │
                        ↓
                    Reducer
                        │
                        ↓
                   New State

核心流程如下:

UI
 ↓
dispatch(action)
 ↓
Reducer
 ↓
State update
 ↓
Subscribed components
 ↓
UI re-render

安装与创建商店

需要安装两个包:

npm install @reduxjs/toolkit react-redux

@reduxjs/toolkit 包含 Redux 的 API;react-redux 则负责将其与 React 相连接。

useSelector()
useDispatch()
Provider

示例计数器应用是按功能模块来组织的。

src/
│
├── app/
│   └── store.js
│
├── features/
│   └── counter/
│       └── counterSlice.js
│
├── App.jsx
└── main.jsx

configureStore() 接收一个 slice reducer 的映射表;每个键对应一个顶层状态分支。

import { configureStore } from "@reduxjs/toolkit";
import counterReducer from "../features/counter/counterSlice";

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

最终得到的状态为:

{
  counter: {
    value: 0
  }
}

Slices:一个功能,一个文件

每个 slice 负责状态中的一个逻辑部分,而应用通常会包含多个这样的 slice。

authSlice
cartSlice
productSlice
userSlice
notificationSlice

计数器 slice 会指定名称、初始状态以及每种操作对应的 reducer,随后导出生成的 action 创建函数和 reducer。

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

const initialState = {
  value: 0,
};

const counterSlice = createSlice({
  name: "counter",

  initialState,

  reducers: {
    increment(state) {
      state.value += 1;
    },

    decrement(state) {
      state.value -= 1;
    },

    incrementByAmount(state, action) {
      state.value += action.payload;
    },
  },
});

export const {
  increment,
  decrement,
  incrementByAmount,
} = counterSlice.actions;

export default counterSlice.reducer;

只需一次调用即可替代原本需要手写的三部分代码。

Action Types
     +
Action Creators
     +
Reducer

createSlice 会生成什么

以一个最简单的 slice 为例:

createSlice({
  name: "counter",

  initialState,

  reducers: {
    increment(state) {
      state.value += 1;
    },
  },
});

RTK 会将 slice 名称与 reducer 键组合成一个 action 类型

"counter/increment"

并据此创建一个 action 创建函数。

increment()

因此 dispatch(increment()) 等同于发送如下动作:

dispatch({
  type: "counter/increment",
});

将状态存储与 React 相连接

在 main.jsx 中,Provider 使得其下的所有组件都能使用该状态存储。

import ReactDOM from "react-dom/client";
import { Provider } from "react-redux";

import App from "./App";
import { store } from "./app/store";

ReactDOM.createRoot(
  document.getElementById("root")
).render(
  <Provider store={store}>
    <App />
  </Provider>
);

使用 useSelector 读取数据

useSelector() 可以精确获取组件所需的那一部分状态。

import { useSelector } from "react-redux";

function Counter() {
  const count = useSelector(
    state => state.counter.value
  );

  return <h1>{count}</h1>;
}

只有当该值发生变化时,组件才会重新渲染;详情可参阅 React-Redux 如何决定重新渲染。

使用 useDispatch 写入数据

useDispatch() 会返回状态存储的 dispatch 方法。

import { useDispatch } from "react-redux";
import { increment } from "./counterSlice";

function Counter() {
  const dispatch = useDispatch();

  return (
    <button onClick={() => dispatch(increment())}>
      Increment
    </button>
  );
}

点击操作的执行流程如下:

Button Click
     ↓
dispatch(increment())
     ↓
Action
     ↓
Reducer
     ↓
State changes
     ↓
useSelector detects relevant change
     ↓
Component re-renders

通过 payload 传递数据

动作会在 payload 中携带数据,这些数据来自还原器的第二个参数:

incrementByAmount(state, action) {
  state.value += action.payload;
}

调用方会将该值传递进去:

dispatch(incrementByAmount(10));

从而生成这样的动作:

{
  type: "counter/incrementByAmount",
  payload: 10
}

每个操作都有一个type,可选地还有一个payload。

action
│
├── type
└── payload

为何此处允许修改状态

这是一个经典的面试问题:这个 reducer 似乎在修改状态,而 Redux 是禁止这样做的。

increment(state) {
  state.value += 1;
}

RTK 通过 Immer 来执行 reducer,Immer 会传入一个草稿代理对象,记录变更并生成一个新的不可变对象。

Reducer
   ↓
Immer Proxy
   ↓
Track changes
   ↓
Create new immutable state

这样既能写出易于理解的代码,又能保持不可变性。要么修改草稿对象,要么返回新值,绝不能同时进行两者操作;并且在 RTK reducer 之外也不要以这种方式修改状态。

购物车切片

电商购物车初始时是空的。

const initialState = {
  items: [],
};

该切片用于添加、删除和清空商品;removeItem 会赋值一个新的数组,Immer 也能接受这个新数组。

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

const initialState = {
  items: [],
};

const cartSlice = createSlice({
  name: "cart",

  initialState,

  reducers: {
    addItem(state, action) {
      state.items.push(action.payload);
    },

    removeItem(state, action) {
      state.items = state.items.filter(
        item => item.id !== action.payload
      );
    },

    clearCart(state) {
      state.items = [];
    },
  },
});

export const {
  addItem,
  removeItem,
  clearCart,
} = cartSlice.actions;

export default cartSlice.reducer;

添加商品

给定一个产品对象:

const product = {
  id: 101,
  name: "Laptop",
  price: 60000,
};

点击处理程序会将其派发:

const dispatch = useDispatch();

const handleAddToCart = () => {
  dispatch(addItem(product));
};

整个产品数据便成为负载内容。

{
  type: "cart/addItem",

  payload: {
    id: 101,
    name: "Laptop",
    price: 60000
  }
}

随后 reducer 会将其推入:

state.items

移除某个项目

按 id 过滤以移除项目:

removeItem(state, action) {
  state.items = state.items.filter(
    item => item.id !== action.payload
  );
}

组件仅传递 id 值:

dispatch(removeItem(101));

因此在 reducer 内部:

action.payload === 101

计算数值而非直接存储

假设购物车中包含这些条目:

[
  {
    price: 100,
    quantity: 2,
  },
  {
    price: 50,
    quantity: 3,
  }
]

总金额可直接得出:

100 × 2 + 50 × 3
= 200 + 150
= 350

将总金额与各个项目放在一起存储很诱人:

{
  items,
  total
}

但这样一来每个 reducer 都必须保持两者同步。不如直接计算:

const selectCartTotal = state =>
  state.cart.items.reduce(
    (total, item) =>
      total + item.price * item.quantity,
    0
  );

然后再读取该数值:

const total = useSelector(selectCartTotal);

存储真实数据源,所有可计算的内容都从中推导得出。

选择器作为单一访问点

选择器是一种用于读取状态的函数。

const selectCartItems = state =>
  state.cart.items;

组件使用它,而无需了解状态的具体结构。

const items = useSelector(selectCartItems);

否则这条路径会在各处重复出现:

state.cart.items

通过选择器,重构只需修改一个函数即可。

使用 createSelector 进行缓存

createSelector() 会缓存派生结果,直到其输入发生变化。

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

const selectCartItems = state =>
  state.cart.items;

export const selectCartTotal = createSelector(
  [selectCartItems],

  items =>
    items.reduce(
      (total, item) =>
        total + item.price * item.quantity,
      0
    )
);

逻辑很简单:

Input unchanged
      ↓
Return cached result

Input changed
      ↓
Recalculate

这有助于处理耗时的计算,以及那些返回新数组或对象的选择器,否则每次存储更新时都会重新渲染。

使用 createAsyncThunk 加载数据

应用会调用诸如以下的 API:

GET /api/products

请求状态会从“空闲”变为“加载中”,最终变为“成功”

idle
 ↓
loading
 ↓
success

或者变为“错误”。

idle
 ↓
loading
 ↓
error

createAsyncThunk() 可以对这一过程进行建模:为其指定类型前缀和一个异步函数;抛出异常则表示请求失败。

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

export const fetchProducts = createAsyncThunk(
  "products/fetchProducts",

  async () => {
    const response = await fetch("/api/products");

    if (!response.ok) {
      throw new Error("Failed to fetch products");
    }

    return response.json();
  }
);

它会生成三种生命周期操作:

fetchProducts.pending
fetchProducts.fulfilled
fetchProducts.rejected

在 slice 中处理生命周期

products slice 用于跟踪项目、状态及错误信息,并在 extraReducers 中对这些信息进行更新。

const productsSlice = createSlice({
  name: "products",

  initialState: {
    items: [],
    status: "idle",
    error: null,
  },

  reducers: {},

  extraReducers: builder => {
    builder

      .addCase(
        fetchProducts.pending,
        state => {
          state.status = "loading";
        }
      )

      .addCase(
        fetchProducts.fulfilled,
        (state, action) => {
          state.status = "success";
          state.items = action.payload;
        }
      )

      .addCase(
        fetchProducts.rejected,
        (state, action) => {
          state.status = "failed";
          state.error =
            action.error.message;
        }
      );
  },
});

reducers 与 extraReducers 的区别

reducers 定义了 slice 自身的操作及其对应的创建函数。

reducers: {
  addItem() {}
}

extraReducers 用于处理其他地方定义的操作,比如来自 createAsyncThunk() 的操作:

pending
fulfilled
rejected

该 slice 仅负责监听这些操作。

从组件中派发 thunk

在组件挂载时启动请求:

const dispatch = useDispatch();

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

严格模式会在开发环境中重复执行相关操作,因此会看到两次请求。之后选择对应状态

const {
  items,
  status,
  error,
} = useSelector(
  state => state.products
);

并根据状态进行渲染:

if (status === "loading") {
  return <p>Loading...</p>;
}

if (status === "failed") {
  return <p>{error}</p>;
}

return (
  <div>
    {items.map(product => (
      <div key={product.id}>
        {product.name}
      </div>
    ))}
  </div>
);

对于更复杂的数据获取场景,RTK Query 可以替代这种模式。

中间件与 configureStore

中间件位于 dispatch() 与 reducer 之间。

dispatch(action)
      ↓
   Middleware
      ↓
   Reducer
      ↓
 New State

常见用途:

  • 异步逻辑
  • 日志记录
  • 数据分析
  • 身份验证流程
  • 副作用处理
  • 拦截动作

configureStore() 可替代手动配置,它会自动添加默认中间件(包括 thunks)以及 Redux DevTools。

const store = configureStore({
  reducer: {
    auth: authReducer,
    cart: cartReducer,
    products: productsReducer,
  },
});

核心要点

  • slice 用于为某个功能整合状态、reducer 以及对应的动作创建函数。
  • Immer 能让基于变异的 reducer 更安全,但仅限于 RTK reducers 内部。
  • 只存储最少必要的状态;其余状态可通过选择器获取,若计算成本较高则进行缓存。
  • createAsyncThunk() 结合 extraReducers 可用于处理加载、成功和失败状态。
  • configureStore() 的默认配置已能满足大多数应用的需求。