用一个计数器案例,深入搞懂 redux 原理

用一个计数器案例,深入搞懂 redux 原理

三葉Leaves Author

在本文中,我会用我觉得最适宜上手的学习顺序组织这篇入门 redux 的笔记。

  • 在开头我会先介绍不依赖于框架的 redux 写法,这使得仅需要 JS 基础的小伙伴也能上手;
  • 之后,我会详细总结现代 redux 的实现,通过这个能看懂现代的大多数使用 redux 的项目;
  • 文末,我还会给出最新的 redux 特性,也就是 RTK v2 的一些区别和思考,以此紧跟社区最新进展和方向。

经典 Redux

Redux 并不一定要和 React 绑定,其本身就可以独立存在或者适配其他的框架。

我个人认为学习经典 Redux 实现是学习 Redux 的必经之路,所以下面就来拆解一个简单案例吧。

完整代码(下面会拆分解释):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import {createStore} from 'redux';  

const initialState = {
counter: 0
};

function counterReducer(state = initialState, action) {
switch (action.type) {
case 'amountAdded':
return {
...state, counter: state.counter + action.payload
};
// more cases...
default:
// 这个很重要,是实现 state 更新的关键
return state;
}
}

const store = createStore(counterReducer);
store.subscribe(() => {
console.log('state changed, now state :', store.getState());
})

store.dispatch({type: 'amountAdded', payload: 5});

console.log(store.getState()); // 应该输出:{ counter: 5 }

架构图
架构图

1. store 的创建和订阅

counterReducer 函数

1
2
3
4
5
6
7
8
9
10
11
12
13
function counterReducer(state = initialState, action) {  
switch (action.type) {
case 'amountAdded':
return {
...state,
counter: state.counter + action.payload
};
// more cases...
default:
// 这个很重要,是实现 state 更新的关键
return state;
}
}

counterReducer 就像一本操作手册,它内部定义了一系列操作,根据它接受的操作指令类型(也就是 action type),用字符串匹配这种平平无奇的方式找到对应的操作,然后执行它。

这其中有三个点需要多加关注:

1. 初始值

如果没人给它 state,它就用 initialState 开始工作 (state = initialState) 。
在这之后有了前一个 state,那么传入的就变成了 prevState,之后的 action 操作就会针对前一个值进行。

如此一来,再结合 action 机制,就可以进行"时间旅行“之类的操作来追踪 state 的变化。

2. state 的不可变性

我们一定不能直接修改 state 的值

而是通过创建其副本然后再覆盖更新的方式,来修改它:

1
2
3
4
return {  
...state,
counter: state.counter + action.payload
};

我觉得几个重要的原因如下:

  1. 为了实现响应式更新
    这是因为后续 store 需要比较 prevState !== nextState 才知道状态变了,如果直接修改其值,那它的引用地址没变,store 无法检测到它的更新,后续更是没法更新 UI 了。

  2. 提供可预测性
    每一次更新,我们都是创建出来一个新东西,旧状态不会丢失,如此你就可以知道变了哪里。之后,你甚至还可以记录、回滚、时间旅行。

在 React 中,总是应该避免修改原数据,避免副作用,这在很多地方都有体现。

3. fallback 策略中必须原封不动返回传入的 state
1
2
3
default:  
// 这个很重要,是实现 state 更新的关键
return state;

这是在所有 action type 都匹配不上的时候做的最后的兜底工作。

为什么要返回 state ,而不是 null 或者直接不管它变成 undefined ?

这还是因为 store 会比较最后 RootState 有没有发生变化,你返回传入的 state,那就证明没匹配到操作,那自然也不需要发生变化,很合理。
如果返回 undefined ,会报错。如果返回 null,state 变成了 null,这就是发生了变化,但是实际上并没有进行操作,这不合理。

createStore 函数

1
const store = createStore(counterReducer);

这一步就是把”操作手册“注入 store,生成了专属于你的 store 实例。

到这里,store 内部的 currentState 就是 { counter: 0 }

subscribe 函数

1
2
3
store.subscribe(() => {  
console.log('state changed, now state :', store.getState());
})

只要 state 被更新,subscribe 内部的回调函数就会执行。

这个内部的回调函数,相当于下文中会讲的 useSelector 函数中的 seletor 函数。

2. 使用 dispatch 修改 state

1
2
3
store.dispatch(
{type: 'amountAdded', payload: 5}
);

我们往 dispatch 函数里传入了一个 action。所谓 action ,其实就是一个对象,这个对象简单定义了一个用于操作 state 的指令。

[!question] 为什么必须通过 dispatch(action),不能直接修改 store.state?
这保证了 单向数据流 (One-Way Data Flow)。所有的 state 变化来源都是可追溯的(都是由某个 action 引起的),这让应用的状态变得可预测。
如果任何地方都可以随意修改 state,那么调试将成为一场噩梦。dispatch 就是那个唯一的、可控的入口。

3. 跳过安检,直达 Reducers

在这个简单的例子中,被 dispatch 后,我们没有经过中间件处理,action 指令会直奔 Reducers 。这就像一个没有安检的快速通道,在下文中会讲到 React + Redux 实现中,这个过程会经过复杂的处理。

4. 执行 Reducers

dispatch 之后,store 直接调用了上文中的 counterReducer 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
function counterReducer(state = initialState, action) {  
switch (action.type) {
case 'amountAdded':
return {
...state,
counter: state.counter + action.payload
};
// more cases...
default:
// 这个很重要,是实现 state 更新的关键
return state;
}
}

这个函数接受两个参数:

  1. previousState: 当前 store 里的状态,也就是 { counter: 0 }
    1. action: 刚刚 dispatch 的 action 对象 {type: 'amountAdded', payload: 5}

之后,经过 switch 的匹配,进入了 amountAdded 那个 case ,内部真正的计算逻辑这时候才开始执行。

最后,Reducer 返回一个全新的对象:{ counter: 5 }。

5. Store 更新与订阅者通知

先保存

store 接收到 Reducer 返回的全新对象 { counter: 5 },并用它替换掉了内部旧的 { counter: 0 }。现在,store.getState() 的返回值就是 { counter: 5 } 了

再通知

store 立即查看它的订阅者列表,发现了我们之前用 store.subscribe() 注册的那个回调函数。于是,它执行了这个函数:

1
2
3
() => {
console.log('state changed, now state :', store.getState());
}

所以,控制台打印出了:state changed, now state : { counter: 5 }。

最后,你的主线程代码继续执行 console.log(store.getState());,它当然也打印出 { counter: 5 }。

这就是经典案例在 Redux 内部走过的完整旅程。清晰地展示了 Action -> Reducer -> New State -> Subscription 的核心循环。

React + Redux 的现代实现

在这个章节中,我使用现代化的 RTK方式,redux 风格采用社区推荐的 Ducks 模式。

[!note]- 关于 Ducks: Redux Reducer Bundles
很多老教程组织 redux 的方式和 ducks 大相径庭,关于这件事可参考以下资源:

一、逻辑层 (counterSlice.js)

我创建这样的文件:

src/features/counter/counterSlice.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import {createAsyncThunk, createSlice, type PayloadAction} from "@reduxjs/toolkit";  

// 简单定义一下 state 的类型
interface CounterState {
value: number
}

// 定义初始值,这和经典实现没什么两样
const initialState: CounterState = {
value: 10,
}

const counterSlice = createSlice({
// 这样命名以后,生成的 action 的 type 都自动有正确的前缀
name: 'counter',
initialState,
reducers: {
// 一个不需要传参给 action payload 的简单自增操作
incremented(state) {
// immer library 保证了 immutable
// 所以可以直接这样修改 state
state.value++;
},
amountAdded(state, action: PayloadAction<number>) {
state.value = state.value + action.payload;
}
}
})

// RTK 做的,等同于生成
// {
// incremented: () => ({ type: 'counter/incremented', payload: undefined })
// }
export const {incremented, amountAdded} = counterSlice.actions;

export default counterSlice.reducer;

关于 slice

1
2
3
const counterSlice = createSlice({  
// ...
})

这个 createSlice 很强大,具体的功能看看上面的代码注释应该能懂。

我还是想重点解释一下什么是 Slice。理解了这个词,就相当于理解了现代是怎么组织 redux 的。

Slice 是切片的意思,我从两个角度理解这个词:

1. 切片虽小,但是也是整体的快照。所以它包含完整的功能

createSlice 集成了 action 的命名、创建、state 更新、Reducers 定义等诸多功能。

现代开发讲求低耦合、高内聚。再没什么设计比这个更内聚的了。

2. 切片是整体的一部分

在过去,所有的逻辑都集中在一起,十分的混乱。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 一个臃肿的、未拆分的 reducer
function monolithicReducer(state = {novels: [], techBooks: []}, action) {
switch(action.type) {
case 'novels/addBook':
// ... 小说区的逻辑
case 'novels/removeBook':
// ... 小说区的逻辑
case 'tech/addBook':
// ... 科技区的逻辑
case 'tech/borrowBook':
// ... 科技区的逻辑
// ... 无尽的 case
default:
return state;
}
}

成百上千的 case,中间还有很多重复的命名前缀。如果两名开发者同时修改这个 switch ,还会导致 git 冲突。

我们使用 slice 切片,不同功能放在单独的文件里,各自负责各自的事情,管理一下子变得容易了。

关于导出语句

我们来看看这个文件都导出了什么东西:

导出 action creator
1
export const {incremented, amountAdded} = counterSlice.actions;  

在搞清楚这个的作用之前,我一直有一个疑问:

在定义 amountAdded 方法时,有这样的代码:

1
2
3
amountAdded(state, action: PayloadAction<number>) {
state.value = state.value + action.payload;
}

但是之后用的时候,却是这样:

1
dispatch(amountAdded(3))
  • 明明 amountAdded 定义有两个参数 state 和 action,但用的时候只传了一个参数 3 ?

  • 明明 amountAdded 函数内部的效果是操作 state 值,为什么在 dispatch 里的效果却变成创建了一个 action ?

这就要牵扯到现代 redux 的一个不同了。

你在 createSlice 中定义的 amountAdded 不是最终的 amountAdded。它实际上智能的生成了两个东西,一个是 action creator,另一个才是你真正写的 reducer 逻辑。完整的逻辑可能如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 第一步:定义
const counterSlice = createSlice({
reducers: {
// 这个函数被用来生成 reducer
amountAdded(state, action: PayloadAction<number>) {
state.value = state.value + action.payload;
}
}
})

// 第二步:createSlice 的内部处理
// ✅ 生成 Action Creator
const amountAddedActionCreator = (payload) => ({
type: 'counter/amountAdded',
payload
})

// ✅ 生成 Reducer 函数
const amountAddedReducer = (state, action) => {
state.value = state.value + action.payload;
}

// ✅ 组合成最终的 slice
counterSlice = {
actions: { amountAdded: amountAddedActionCreator },
reducer: function(state, action) {
switch(action.type) {
case 'counter/amountAdded':
return amountAddedReducer(state, action)
// ...
}
}
}

这也是为什么上面可以用 counterSlice.actions 的原因,其实就是从里面解构出需要的 actions creator。

导出 reducers
1
export default counterSlice.reducer;

这是为将来的 combineReducers 准备的。还记得上面的说的 slice 里的 [[#2. 切片是整体的一部分|第二点:切片是整体的一部分]] 吗?我们把这个切片导出去,将来可以用来构成那个所谓的整体。

也就是无数个 slice 里的 reducers ,构成了最后那个很多个 case 的 switch 。

二、 Store 配置 (store.js)

接着,我创建了这样的文件:

src/app/store.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import {configureStore} from "@reduxjs/toolkit";  

// 把刚才导出的切片 reducers 导入进来
import counterReducer from '../features/counter/counterSlice'

export const store = configureStore({
reducer: {
counter: counterReducer,
// 之后还会有更多不同的切片产出的 reducers... },
})

export type AppDispatch = typeof store.dispatch;

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

使用 RTK 的 configureStore,默认集成了很多好东西。它不仅仅是 createStore 的一个简单别名。它是一个高级的、带有“默认最佳实践”的工厂函数。

它有下面几个核心的功能:

1. 智能合并 Reducer

如果 reducer 是一个函数:

configureStore 会直接将其作为 root reducer。这和你使用 createStore(rootReducer) 的行为一致。

如果 reducer 是一个对象

这是 configureStore 的一个便捷特性。如果你传入一个像下面这样的对象:

1
2
3
4
5
// 这个对象里面包含了一个个像 counterReducer 那样包含 switch 的函数
reducer: {
todos: todosReducer,
user: userReducer
}

configureStore 会自动为你调用 combineReducers,将这个对象转换成一个 Root Reducer。这省去了你手动导入和调用 combineReducers 的步骤。

最终,store 只需要比较这个 Root State 有没有变化即可决定要不要广播、更新 UI。

2. 自动配置 Redux DevTools Extension

这使得你可以直接使用 redux 浏览器插件,在这之前你需要写一堆恶心的样板代码来做。

3. 强大的默认 Middleware

它自动配置好了一组经过精心挑选、被社区广泛认为是“必备”的中间件,其中比较有用的像:

  • redux-thunk:将来用于处理异步逻辑,简直必备。
  • Immutability Check Middleware:用来检查你有没有违反 immutate 原则直接修改 state
  • Serializability Check Middleware:检查你放入 action 和 state 中的所有值是否是“可序列化的”(即可以被 JSON.stringify 的),这和 支持持久化和水合 (Hydration) 有关。

4. 整合 createStore

完成了上述所有牛逼操作以后,最后才是进行了之前的 createStore 操作。

三、 typescript 支持(hooks.ts)

这里我先做一些有关于 typescript 的事情。我定义了两个 hook,这在之后组件中会用到,而不是使用原生的 useSelector 等。

src/app/hooks.ts

1
2
3
4
5
6
7
8
// 导入必要的类型和原始 hooks
import { type TypedUseSelectorHook, useDispatch, useSelector } from "react-redux";
import type { RootState, AppDispatch } from "./store";

// RootState: 描述了整个 Redux state 树的结构
// AppDispatch: 描述了我们 store 的 dispatch 方法能接受的所有类型(包括 thunks)
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

创建类型增强的 selector hook

关于什么是 selector hook,可见 React 响应式原理

1
2
//    We create a new hook named `useAppSelector`
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
  • 发生了什么? 
    我们创建了一个新的常量(也是一个 Hook)叫做 useAppSelector。

  • 它的类型是? 
    TypedUseSelectorHook<RootState>。这是一个由 react-redux 提供的特殊类型,它的作用就是“创建一个预设了 state 类型的 useSelector”。我们把我们的 RootState 类型作为参数传给了它。

  • 它的值是? 
    原始的 useSelector 函数。

  • 结果是?
    从今以后,在你的任何组件里,当你使用 useAppSelector 时,它回调函数中的 state 参数会被自动推断为 RootState 类型。你再也不用手动写 (state: RootState) => … 了,VSCode 的自动补全也会变得无比智能!

如果不做这些…

如果直接在组件使用 useSelector,会有这样的问题:

  1. useSelector 的 state 是 unknown 类型,TypeScript 会报错,因为它不知道 state 的结构,你必须每次都手动为 state 添加类型:
1
const count = useSelector((state: RootState) => state.counter.value);
  1. 你的 IDE 不会有任何智能的提示,因为它不知道你的 Root State 树结构。

创建类型增强的 dispatch hook

1
2
//    We create a new hook named `useAppDispatch`
export const useAppDispatch = () => useDispatch<AppDispatch>();
  • 发生了什么? 我们创建了一个新的函数(Hook)叫做 useAppDispatch。

  • 它的内部实现? 它仅仅是调用了原始的 useDispatch,但关键在于,它在调用时,已经把 AppDispatch 这个类型参数 <AppDispatch> “焊”上去了。

  • 结果是? 从今以后,在你的任何组件里,只要你调用 useAppDispatch(),拿到的 dispatch 函数就已经被 TypeScript 完全理解了。它知道这个 dispatch 不仅能派发普通 action,还能派发你在 createSlice 中定义的异步 thunks。你再也不用在组件里写 <AppDispatch> 了

如果不做这些…

dispatch 也是一个泛型,它不知道你有哪些 thunk action。当你 dispatch 一个 thunk 时,类型推断可能会失效,所以你每次都需要手动指定类型:

1
const dispatch = useDispatch<AppDispatch>();

四、UI 层(App.tsx)

首先要把 store 引进来,全是样板代码,主要就是需要用 <Provider> 包裹:

src/main.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { StrictMode } from "react"
import { createRoot } from "react-dom/client"
import { Provider } from "react-redux"
import { App } from "./App"
import { store } from "./app/store"
import "./index.css"

const container = document.getElementById("root")

if (container) {
const root = createRoot(container)

root.render(
<StrictMode>
<Provider store={store}>
<App />
</Provider>
</StrictMode>,
)
} else {
throw new Error(
"Root element with ID 'root' was not found in the document. Ensure there is a corresponding HTML element with the ID 'root' in your HTML file.",
)
}

在 React 组件中,我们使用刚才定义的 useAppSelector 和 useAppDispatch Hooks 来与 Redux 交互。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// 导入刚才定义的 hooks
import {useAppDispatch, useAppSelector} from "./app/hooks";

// 导入 slice 里定义的 action creator
import {incremented, amountAdded} from "./features/counter/counterSlice";

// 无关紧要的一些玩意
import reactLogo from './assets/react.svg'
import viteLogo from '/vite.svg'
import './App.css'

function App() {
// 用我们刚才定义的两个 hooks
const count = useAppSelector(state => state.counter.value)
const dispatch = useAppDispatch();

// 按钮点击逻辑。点这个按钮,最终会触发对应的 reducers 来执行数据更新
const handleClick = () => {
// dispatch(incremented())
dispatch(amountAdded(3))
}

return (
<>
<div>
<a href="https://vite.dev" target="_blank">
<img src={viteLogo} className="logo" alt="Vite logo"/>
</a>
<a href="https://react.dev" target="_blank">
<img src={reactLogo} className="logo react" alt="React logo"/>
</a>
</div>
<h1>Leif, happy coding</h1>
<div className="card">
{/* 按钮在这 */}
<button onClick={handleClick}>
count is {count}
</button>
<p>
Edit <code>src/App.tsx</code> and save to test HMR
</p>
</div>
<p className="read-the-docs">
Click on the Vite and React logos to learn more
</p>
</>
)
}

export default App

1. 使用 useAppSelector 创建 UI 用到的动态数据

请见之前我写的文章: React 响应式原理,这里不多赘述

2. 使用 dispatch 发送更新数据指令

[!note]- dispatch 这个词的含义?
dispatch 源自拉丁语 dis-(分开)+ pactare(安排、协商),意思是“安排开去、分派> 出去”。

一个贴合 dispatch 函数的例句:

The general dispatched troops to the front line.
将军把部队派往前线

dispatch 是 Redux store 实例上的一个核心方法 (store.dispatch)。

[!note]- store 实例上还有哪些方法?
扒了一下 redux 的源码:

1
2
3
4
5
6
7
8
9
10
11
12
interface Store<S = any, A extends Action = UnknownAction, StateExt extends > unknown = unknown> {

dispatch: Dispatch`<A>`;

getState(): S `&` StateExt;

subscribe(listener: ListenerCallback): Unsubscribe;

replaceReducer(nextReducer: Reducer<S, A>): void;

[Symbol.observable](): Observable<S & StateExt>;
}

你可以把它想象成一个信使派遣中心”。它是 唯一能够触发 state 变更的合法途径。你不能直接修改 store.getState() 返回的 state 对象,所有变更都必须通过派遣 action 来启动。

先创建实例:

1
const dispatch = useAppDispatch();

在组件中使用:

1
2
3
4
// 按钮点击逻辑。点这个按钮,最终会触发对应的 reducers 来执行数据更新
const handleClick = () => {
dispatch(amountAdded(3))
}

amountAdded(3) 实际上是一个传了 3 为 payload 的 action creator 函数,最终创建出这样的 action 给 dispatch 用:

1
2
3
4
{
type: 'counter/amountAdded',
payload: 5
}

那么,dispatch 在收到 action 后,到底会发生什么?

在之前经典的 redux 实现中,action 会直接被送给 Reducers 用。但是现在不一样了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
┌─────────────┐    dispatch(action)    ┌─────────────────────┐
│ Action │ ────────────────────► │ Store │
│ Creators │ │ │
└─────────────┘ │ ┌───────────────┐ │
│ │ Dispatch │ │
│ │ Manager │ │
│ └───────┬───────┘ │
│ │ │
│ ▼ │
│ ┌───────────────┐ │
│ │ Middleware │ │
│ │ Chain │ │
│ └───────┬───────┘ │
│ │ │
│ ▼ │
│ ┌───────────────┐ │
┌─────────────────────│──┤ Reducer │ │
│ │ │ Coordinator │ │
│ │ └───────┬───────┘ │
│ │ │ │
▼ │ ▼ │
┌───────────────┐ │ ┌───────────────┐ │
│ Reducers │◄───────────│──┤ State │ │
│ │ newState │ │ Manager │ │
│ compute new │────────────│──┤ │ │
│ state │ │ └───────┬───────┘ │
└───────────────┘ │ │ │
│ ▼ │
│ ┌───────────────┐ │
│ │ Notification │ │
│ │ System │ │
│ └───────┬───────┘ │
└─────────────────────┘


┌─────────────────┐
│ React Components │
│ (re-render) │
└─────────────────┘
Middleware 链式处理

在 action 被真正送到 Reducer 手上之前,它会先经过一个叫做 Middleware (中间件) 的特殊关卡。可以把 Middleware 想象成 dispatch 和 reducer 之间的“管道”。Action 在这个管道里流动,每个 Middleware 都有机会观察、修改、延迟、甚至阻止这个 action。

在管道里,有一个很重要的检查操作:

1
2
3
4
5
6
7
8
9
10
// redux-thunk 中间件:
const thunkMiddleware = ({ dispatch, getState }) => next => action => {
// 如果是函数,执行它
if (typeof action === 'function') {
return action(dispatch, getState)
}

// 否则传给下一个中间件
return next(action)
}

这是因为有时候,你 dispatch 的可能不是 action 对象,而是一个 Thunk Action Creator 函数,这个函数和异步操作有关,在这种情况下,中间件需要进行一些特殊处理,之后我会详细说明。

普通 action 对象情况
  • 中间件会进行一次 action 合法性校验,大体就是检查你 action type 是不是字符串之类的,以及防止并发修改等等提升鲁棒性的操作。

  • 在这之后,就可以传给 rootReducers 做真正的处理了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 3. 所有中间件处理完后,到达核心 dispatch
coreDispatch(action) {
console.log('执行核心 dispatch,当前状态:', this.currentState)

// 1️⃣ 保存前一个状态(用于比较)
const previousState = this.currentState

// 2️⃣ 调用 rootReducer,计算新状态
const newState = this.reducer(previousState, action)

console.log('新状态计算完成:', newState)

// 3️⃣ 更新状态
this.currentState = newState

// 4️⃣ 通知所有监听者
this.notifyListeners()
}

其中的第2️⃣步的 RootReducers 执行逻辑大概是这样:

  1. Redux 会调用每个 slice 的 reducer,内含 switch 逻辑
  2. 只有 counterReducer 匹配到了 action type,于是执行真正的 state 操作
  3. 到第 3️⃣ 步,完成 store 中的 state 替换

之后的响应式 UI 更新的细节,可见 React 响应式原理

异步情况

如果需要异步的更新 state,那又怎么办?

你可能会想:
直接在 counterSlice 的 Reducers 部分里加一个函数,内部执行异步操作不就行了吗?

但是实际上, Redux 要求 Reducer 必须是纯函数 —— 给定相同的 state 和 action,永远返回相同的 newState,并且不能有任何副作用(比如 API 请求)。

[!note]- 为什么我们需要纯函数?
React 官方 docs 专门针对这个问题有大篇幅说明,请见:
保持组件纯粹 – React 中文文档

下面,我们就一步步更改之前的程序,来实现一个异步的增加功能。

为了不依赖真实的后端,我们在 counterSlice.ts 文件中创建一个模拟函数:

1
2
3
4
5
6
7
8
// src/features/counter/counterSlice.ts

// 在文件顶部,假装我们有一个 API 服务
function fetchCount(amount = 1): Promise<{ data: number }> {
return new Promise((resolve) =>
setTimeout(() => resolve({ data: amount }), 500) // 模拟 500ms 的网络延迟
);
}
用 createAsyncThunk 创建 thunk action creator

在 counterSlice.ts 中,紧接着 initialState 定义之后,添加以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 创建一个异步 thunk,这个 incrementAsync 是一个函数,用于创建 action ,也就是 thunk action creator
// 用到了 RTK 里的 createAsyncThunk
const incrementAsync = createAsyncThunk(
// 第一个参数是一个前缀字符串
'counter/fetchCount',
// 第二个参数是一个名为 payloadCreator 的函数,用于创建 payload
// 真正的异步函数就放在这里面
async (amount: number) => {
const response = await fetchCount(amount);
// 返回的这玩意,会作为 fulfilled action 的 payload
return response.data;
}
)
  • 第一个参数的用途

incrementAsync 作为一个 action creator ,会根据你传入的第一个参数(也就是 counter/fetchCount 自动创建三个 action:

1
2
3
4
// createAsyncThunk 会自动生成 3 个 action types:
'counter/fetchCount/pending' // 开始请求时
'counter/fetchCount/fulfilled' // 请求成功时
'counter/fetchCount/rejected' // 请求失败时
  • 第二个参数的用途

第二个参数是一个称作 payloadCreator 的异步函数,这是我们真正的异步逻辑所在。

payloadCreator 异步的获取 action.payload,以便之后给 reducers 使用。

[!question]- 明明 payload creator 是一个 async 函数,为什么返回的东西最后能直接变成 action 的 payload?
async 无论如何都会返回一个 promise ,redux 内部对其进行解包过程。

[!question] 这个异步函数做什么?为什么叫 payload creator?
我们想想看执行异步操作的目的是什么:

我们花时间等待一个结果,这个结果的值我们接下来会用到,实际上,就是异步的等待 action 对象里的 payload 值敲定,在确定了 payload 值以后,我们才可以真实的操作 state。

实际上,虽然刚才的案例只是展示了一个参数,但是 payloadCreator 其实有两个参数 (arg, thunkAPI)

  1. arg: 为本次异步操作提供独特的、必要的输入数据,传递给真正的异步函数用。每次 dispatch 都可以是不同的值。如果某个 thunk 不需要输入,可以忽略它(用 _)。
  2. thunkAPI:可以理解为一个工具箱,其中有几件有用的工具:
  • 工具1:getState

假设当前 slice 有一个异步操作: API 请求。请求需要附带一个认证 Token,这个 Token 又存在另一个 slice 里: auth slice 里。

这时候,我们就需要用这个工具获得信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
async (arg, thunkAPI) => {
// 打开工具箱,拿出 getState 工具
const state = thunkAPI.getState();
// 从全局情报中,找到 auth 分区的情报
const token = state.auth.token;

if (!token) {
// 如果没有 token,任务无法继续
console.error('没有认证信息,任务取消!');
// 稍后会讲到如何优雅地中止任务
return;
}

const response = await fetch('/api/private-data', {
headers: { 'Authorization': `Bearer ${token}` }
});
return response.json();
}
  • 工具2:dispatch

想象在开始一个耗时很长的异步任务时,我们想先弹出一个通知。这时候,需要 dispatch 别的 slice 里的 reducer :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { showNotification } from '../notifications/notificationSlice';

async (arg, thunkAPI) => {
// 任务开始,先用 dispatch 工具发个通知
thunkAPI.dispatch(showNotification('正在上传大文件,请稍候...'));

// ... 执行漫长的上传逻辑 ...
await uploadLargeFile(arg);

// 任务完成,再发个通知
thunkAPI.dispatch(showNotification('上传成功!'));

return { success: true };
}
  • 工具3:rejectWithValue (非常重要!)

用于当任务可预见地失败时,用一种标准化的方式中止任务,并附带清晰的失败报告

想象用户提交的表单数据验证失败,或者 API 返回了明确的错误信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
async (formData, thunkAPI) => {
try {
const response = await fetch('/api/submit', {
method: 'POST',
body: JSON.stringify(formData)
});

if (!response.ok) {
// 服务器返回了错误,比如 400 Bad Request
const errorData = await response.json(); //{"message": "邮箱格式不正确"}
// 这不是网络问题,是业务逻辑失败。我们主动中止任务。
// 用 rejectWithValue 工具,把清晰的错误信息作为失败报告。
return thunkAPI.rejectWithValue(errorData.message);
}

return await response.json();

} catch (error) {
// 这是意外情况,比如网络断了。
// createAsyncThunk 会自动捕获这个异常,并派发 rejected action。
// 但我们也可以用 rejectWithValue 来提供更友好的信息。
return thunkAPI.rejectWithValue('网络连接失败,请检查网络后重试');
}
}

thunkAPI.rejectWithValue('网络连接失败,请检查网络后重试') 括号里的东西最后会被用作 action 里的 payload 值。

相比直接throw new Error(),我们把错误信息放在 payload 里,非常干净,可以直接用于 UI 显示错误提示。

修改一下 state 的结构
1
2
3
4
5
6
7
8
9
10
// 为了处理异步加载状态,我们需要在 state 中添加一个 status 字段
interface CounterState {
value: number;
status: 'idle' | 'loading' | 'failed'; // idle:空闲, loading:加载中, failed:失败
}

const initialState: CounterState = {
value: 0,
status: 'idle',
};

其实就是多加了一个 status 字段,用于标记状态。这在以后 UI 中可以用到,来显示加载状态之类的。

在 extraReducers 里添加方法

前文说过,不能直接在 Reducers 部分添加异步方法,所以我们需要额外添加一个 extraReducers:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const counterSlice = createSlice({
name: 'counter',
initialState,
reducers: {
// ...
},
// 下面开始异步操作
extraReducers: builder => {
builder
.addCase(incrementAsync.pending, state => {
state.status = 'loading'
})
.addCase(incrementAsync.fulfilled, (state, action) => {
state.value = state.value + action.payload;
})
.addCase(incrementAsync.rejected, state => {
state.status = 'failed'
})
}
})

builder 的核心就是它有一个 addCase 方法,这个方法有两个参数。

在解释第一个参数之前,先明白 JavaScript 的一个特性 :

  • 函数本身也是对象,可以给函数添加属性

所以我们可以看到 incrementAsync.pending 这种东西,incrementAsync 是一个 action creator ,所以它实际上对应着就是 pending 状态的 action,如此一来 action type 就敲定了。

所以,incrementAsync.pending 实际上等效于 'counter/fetchCount/pending'

事实上,addCase 本身会对传入的第一个参数做一个校验,如果直接传了字符串,比如 'counter/fetchCount/pending',那就会直接使用,此时和 switch 里的字符串 action type 没什么区别。

如果发现传入的是 action creator(incrementAsync.fulfilled 也是一个 action creator ),那就调用 action creator 的 type 属性。没错,每一个 action creator 都有 type 属性,用于直接读取它要创建的 action 的 type。

这不只是存在于 thunk action creator,普通的也一样:

1
console.log(amountAdded.type) // 会输出:"counter/amountAdded"

.addCase 的第二个参数就是我们需要修改的 state,这个和同步的部分没什么区别。

在 addCase 内部,我们开始写真实的操作 state 的代码,就和之前同步的部分一样。

在组件中使用

其他的一样,还是用 dispatch :

1
dispatch(incrementAsync(5))

dispatch 之后,中间件检测到这是一个函数,而不是简单的 action 对象。于是,会触发以下流程:

  1. 第一步:立即 dispatch 一个 pending 状态的 action
1
2
3
4
5
// thunk 内部自动 dispatch pending action
dispatch({
type: 'counter/fetchCount/pending',
meta: { requestId: 'abc123', arg: 5 }
})

你应该注意到了异步的 dispatch 结构和同步的不一样。

在里面并没有 payload,这是因为 pending 状态下 payload 还没有得到,需要在前文提到的 payload creator 执行之后才有。

meta 里的 requestID 是 createAsyncThunk 根据时间戳和随机数自动生成的,真实情况是 36 位,这主要为了追踪和调试。

其中的 arg 前文提过了,是给异步函数的参数。

  1. 第二步:执行 Pending Action 通过 Middleware 和 Reducer

我们在前文中定义过:

1
2
3
.addCase(incrementAsync.pending, state => {
state.status = 'loading'
})

这个是 Reducer,所以刚才发出的 Pending Action 直接通过中间件来找他了。

  1. 第三步:异步操作执行

也就是执行 payloadCreator 函数,这个函数是 createAsyncThunk 里用到的第二个参数。

执行成功后,我们就有 payload 的真实值了。

  1. 第四步:dispatch 第二个 action

这个 action 有真实的 payload ,之后的流程就和同步的部分没两样了:

1
2
3
4
5
6
// 500ms 后,thunk 继续执行,dispatch fulfilled action
dispatch({
type: 'counter/fetchCount/fulfilled',
payload: 5, // response.data
meta: { requestId: 'abc123', arg: 5 }
})

dispatch 之后,同样经过中间件,会被最终送到之前定义的这个 reducer 执行:

1
2
3
.addCase(incrementAsync.fulfilled, (state, action) => {  
state.value = state.value + action.payload;
})

之后 UI 更新部分就和之前一样了,详细可见 React 响应式原理

RTK v2

上文所有部分都是 RTK v1 的实现,RTK v2 在部分情况下会显得更加简洁、直观。

下面是一些新的特性和示例代码,对之前的例子进行升级。

自定义我们的 slice

简单来说,之前的 Slice 的 Reducers 部分不能放异步函数,需要 extraReducers 这种不优雅的玩意。但是现在可以,不过需要我们做一件事:

自定义一个有异步能力的 slice

新建文件 src/app/createAppSlice.ts

1
2
3
4
5
6
7
8
9
// src/app/createAppSlice.ts
import { asyncThunkCreator, buildCreateSlice } from "@reduxjs/toolkit";

// `buildCreateSlice` 允许我们创建一个带有预配置 "creators" 的 slice 创建器。
// creators 就像是用于定义 reducer case 的特殊工具。
export const createAppSlice = buildCreateSlice({
// 我们在这里注入 asyncThunkCreator,以便在 slice 中直接创建异步 thunks。
creators: { asyncThunk: asyncThunkCreator },
});

这个 buildCreateSlice 函数也是从 RTK 里导过来的,RTK 不再提供一个功能固化的 createSlice,而是提供了一个 “createSlice 的工厂”,这个工厂就是 buildCreateSlice。

buildCreateSlice 的核心配置项就是 creators。

这有点像插件的感觉,asyncThunkCreator 就是其中一个插件,开发者当然也可以写更多自己的“插件”,来扩展 slice 的能力。

这种模式下,Redux 更加开放,充分体现了为什么很多人说 React 可以应用于大型项目。

自定义 slice 的 Reducers 特性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
// src/features/counter/counterSlice.ts

// 1. 导入我们定制的 createAppSlice,而不是原始的 createSlice
import { createAppSlice } from "../../app/createAppSlice";
import type { PayloadAction } from "@reduxjs/toolkit";

// 模拟 API 的函数保持不变
function fetchCount(amount = 1): Promise<{ data: number }> {
return new Promise((resolve) =>
setTimeout(() => resolve({ data: amount }), 500)
);
}

// State 接口定义也保持不变
export interface CounterSliceState {
value: number;
status: "idle" | "loading" | "failed";
}

const initialState: CounterSliceState = {
value: 0,
status: "idle",
};

export const counterSlice = createAppSlice({
name: "counter",
initialState,

// 2. "reducers" 字段的变化:接收一个 "create" 对象
reducers: (create) => ({
// 3. 定义同步 Reducer
increment: create.reducer((state) => {
state.value += 1;
}),
decrement: create.reducer((state) => {
state.value -= 1;
}),
incrementByAmount: create.reducer(
(state, action: PayloadAction<number>) => {
state.value += action.payload;
}
),

// 4. 定义异步 Thunk
incrementAsync: create.asyncThunk(
// a. Payload Creator: 异步逻辑
async (amount: number) => {
const response = await fetchCount(amount);
return response.data;
},
// b. Thunk 的生命周期 Reducers
{
pending: (state) => {
state.status = "loading";
},
fulfilled: (state, action) => {
state.status = "idle";
state.value += action.payload; // payload 类型被完美推断
},
rejected: (state) => {
state.status = "failed";
},
}
),
}),

// 5. 定义 Selectors
selectors: {
selectCount: (counter) => counter.value,
selectStatus: (counter) => counter.status,
},
});

// 6. 导出 Action Creators
export const { increment, decrement, incrementByAmount, incrementAsync } =
counterSlice.actions;

// 7. 导出 Selectors
export const { selectCount, selectStatus } = counterSlice.selectors;

重点关注 Reducers 部分即可。

有两个不同:

  1. 之前的 reducers 就是一个对象,现在是一个有 create 作为参数的函数:
    • 对于常规 reducer,直接使用 create.reducer()
    • 对于异步 reducer,使用 create.asyncThunk(payloadCreator, reducers)

虽然看起来只是多了一层 create.reducer() 的包裹,但这样做是有好处的。这个包裹允许 RTK 在未来添加更多元数据或增强功能,同时保持了 API 的一致性。它向阅读代码的人明确表示:“这是一个标准的、同步的 reducer”。

  1. 之前 incrementAsync 是单独用 createAsyncThunk 创建的,现在直接写在了 reducers 里,很舒服。

自定义 slice 的 Selector 特性

我们还能在上面看到这样的代码:

1
2
3
4
5
// 5. 定义 Selectors
selectors: {
selectCount: (counter) => counter.value,
selectStatus: (counter) => counter.status,
},
1
2
// 7. 导出 Selectors
export const { selectCount, selectStatus } = counterSlice.selectors;

之后在组件中用的时候,可以直接这样写:

1
const count = useAppSelector(selectCount)

之前是这样:

1
2
// 之前
const count = useAppSelector(state => state.counter.value)

没什么可以多说的,一看就知道代码更加内聚了。

更直观易懂的 hooks 类型配置

src/app/hooks.ts

1
2
3
4
5
6
// 新版 hooks.ts
import { useDispatch, useSelector } from "react-redux";
import type { AppDispatch, RootState } from "./store";

export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
export const useAppSelector = useSelector.withTypes<RootState>()

这是因为 v2 版本的 RTK , useSelector 和 useDispatch 都有 withTypes 方法了,确实比之前看起来更直观易懂。

更支持 RTK Query 的 store 配置

src/app/store.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import type { Action, ThunkAction } from "@reduxjs/toolkit"
import { combineSlices, configureStore } from "@reduxjs/toolkit"
import { setupListeners } from "@reduxjs/toolkit/query"
import { counterSlice } from "../features/counter/counterSlice"
import { quotesApiSlice } from "../features/quotes/quotesApiSlice"

// `combineSlices` automatically combines the reducers using
// their `reducerPath`s, therefore we no longer need to call `combineReducers`.
const rootReducer = combineSlices(counterSlice, quotesApiSlice)
// Infer the `RootState` type from the root reducer
export type RootState = ReturnType<typeof rootReducer>

// The store setup is wrapped in `makeStore` to allow reuse
// when setting up tests that need the same store config
export const makeStore = (preloadedState?: Partial<RootState>) => {
const store = configureStore({
reducer: rootReducer,
// Adding the api middleware enables caching, invalidation, polling,
// and other useful features of `rtk-query`.
middleware: getDefaultMiddleware => {
return getDefaultMiddleware().concat(quotesApiSlice.middleware)
},
preloadedState,
})
// configure listeners using the provided defaults
// optional, but required for `refetchOnFocus`/`refetchOnReconnect` behaviors
setupListeners(store.dispatch)
return store
}

export const store = makeStore()

// Infer the type of `store`
export type AppStore = typeof store
// Infer the `AppDispatch` type from the store itself
export type AppDispatch = AppStore["dispatch"]
export type AppThunk<ThunkReturnType = void> = ThunkAction<
ThunkReturnType,
RootState,
unknown,
Action
>

这部分比之前的麻烦很多,但是对 RTK Query 有更好的支持。

TODO:补充新版支持 RTK Query 的 store 解读。

  • 标题: 用一个计数器案例,深入搞懂 redux 原理
  • 作者: 三葉Leaves
  • 创建于 : 2025-08-07 00:00:00
  • 更新于 : 2025-08-15 12:08:24
  • 链接: https://blog.oksanye.com/6233daf4fc5d/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。
评论