/

Redux Essentials 学习笔记

最近在研究 Redux。看了中文文档感觉翻不太行,所以我在读英文文档,但由于我英文水平不太行,本文将会以中文为主展现,主要作为个人记忆用。

感觉 Redux 这个入门文档有点杂,掺入了官方推荐工具Redux-ToolkitRedux-React的内容。本篇介绍的 Redux 是借用这些工具使用的。

基本配置

创建store

Redux 的 store 由 RTK(Redux Toolkit)的 configureStore api 创建。

文档建议此段代码位置在 /src/app/store.js

1
2
3
4
5
6
7
8
import { configureStore } from "@reduxjs/toolkit";
import counterReducer from "../features/counter/counterSlice";

export default configureStore({
reducer: {
counter: counterReducer,
},
});

从文档中的代码可以看出configureStore用法:

  • 输入 一个对象
    • reducer(object)
      • 切片名:(createSlice()创建的切片.reducer)
  • 输出 一个 store 对象

其中 store 对象会被export default,并且 store 对象只能用于Provider中,即index.js中的<Provider store={store}>

一个 React App 建议(或者说 应当)只有一个 store

Provider覆盖 React 根结点

将 React 原本的<App />修改为:

1
2
3
<Provider store={store}>
<App />
</Provider>

在 React App 的入口文件index.js中使用由Redux-React提供的Provider将根节点覆盖住,并传入 store

创建 Redux 切片

创建一个 Redux 切片即为在 Redux store 树 🌲 创建一个分支

文档将其写在/src/feature/counter/counterSlice.js

createSlice()

首先使用 React Toolkit 提供的createSlice()创建一个 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
export const counterSlice = createSlice({
name: "counter",
initialState: {
value: 0,
},
reducers: {
increment: (state) => {
// Redux Toolkit allows us to write "mutating" logic in reducers. It
// doesn't actually mutate the state because it uses the immer library,
// which detects changes to a "draft state" and produces a brand new
// immutable state based off those changes
state.value += 1;
},
decrement: (state) => {
state.value -= 1;
},
incrementByAmount: (state, action) => {
state.value += action.payload;
},
postAdded: {
reducer(state, action) {
state.push(action.payload);
},
prepare(title, content) {
return {
payload: {
id: nanoid(),
title,
content,
},
};
},
},
},
});

由这段代码可以看出createSlice用法:

  • 输入 一个对象
    • name:切片名称
    • initialState 初始值,文档建议为对象形式
    • reducers 一个对象,包含处理更改的 reducer
      • reducer 名称:reducer 函数
        • 函数输入
          • state:当前切片值
          • action:包含 Redux dispatch 传入的原始 Action 对象,当使用 RTK 自动生成的Action Creator进行 dispatch 时,Action Creator函数的参数位于action.payload位置(同时这也是官方推荐的Action 对象的标准写法)
        • 函数特性
          • 在 Redux 中,reducer 函数应当为纯函数,因为我注意到英文文档中并没有说过他是纯函数,只是在每次提到这个问题是列举一遍其特点,这里做一下摘抄

            reducers must always follow some special rules:

            • They should only calculate the new state value based on the state and action arguments
            • They are not allowed to modify the existing state. Instead, they must make immutable updates, by copying the existing state and making changes to the copied values.
            • They must not do any asynchronous logic or other “side effects”

            why are these rules important?

            • One of the goals of Redux is to make your code predictable. When a function’s output is only calculated from the input arguments, it’s easier to understand how that code works, and to test it.
            • On the other hand, if a function depends on variables outside itself, or behaves randomly, you never know what will happen when you run it.
            • If a function modifies other values, including its arguments, that can change the way the application works unexpectedly. This can be a common source of bugs, such as “I updated my state, but now my UI isn’t updating when it should!”
            • Some of the Redux DevTools capabilities depend on having your reducers follow these rules correctly.
          • 尽管文档反复强调immutable,即不更改行参,但是由于 RTK 使用了immerjs辅助,你也可以在 RTK 的 reducer 中使用 mutating 的代码,但是请注意只能在 RTK 的 reducer 中这么写!!!

            引用原文
            🔥WARNING
            Remember: reducer functions must always create new state values immutably, by making copies! It’s safe to call mutating functions like Array.push() or modify object fields like state.someField = someValue inside of createSlice(), because it converts those mutations into safe immutable updates internally using the Immer library, but don’t try to mutate any data outside of createSlice!

      • 也可以在 reducer 名后传入一个对象{reducer, prepare}
        • prepare函数,参数可以自定义,但与 reducer 不同的是我们可以将 reducer 禁止的代码(异步、随机、副作用等)写在prepare函数里。使用了prepare函数后,在其生成的Action Creator中可以直接使用我们自定义的prepare函数的参数调用。
          • 参数:自定义
          • 返回值:一个无需type字段的 Action 对象,即{payload:{}},也允许添加metaerror字段
            • payload:Action 对象的payload
            • meta:可用于向 action 添加额外的描述性值
            • error:该字段应该是一个布尔值,指示此 action 是否表示某种错误
        • reducer函数本身,与之前说的要求一样
    • extraReducers:一个函数,参数为一个builder对象,用于为外部定义的 actions 定义 reducers,build 对象的操作可以链式调用,下面是一些用法介绍:(其中 reducer 函数与之前定义相同(state, action) => {}
      • builder.addCase(actionCreator, reducer):为一个 Action Creator 或者一个 Action 字符串添加 reducer
      • builder.addMatcher(matcher, reducer):为 matter function 返回为 true 的所有 action 添加 reducer
      • builder.addDefaultCase(reducer):当没有其他项匹配时的默认 reducer

        样例:

        1
        2
        3
        4
        5
        extraReducers: (builder) => {
        builder
        .addCase("counter/decrement", (state, action) => {})
        .addCase(increment, (state, action) => {});
        };
  • 返回值 返回一个初始化好的 Redux 切片对象,我们可以从这个对象中获得我们有用的东西(见下文)

获取Action Creator

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

此段代码用于从 Redux 切片中获取Action Creator函数

导出切片的Selectors

如果我们可以直接获取到 Redux store 对象,可以直接使用store.getState()获取整个 store 树的存储,Selectors就是负责从整个 store 数中拿到我们切片数据的

或许我们可以直接把它(Selector)写成这样:

1
export const selectCounterValue = (state) => state.counter;

就是一个非常简单的把 store 树上一部分取出来的工具,可以在这里写好然后导出,也可以用的时候现写。

刚说到“如果我们可以直接获取到 Redux store 对象”,这是一种理想情况,而 RTK 不允许我们随意引用和使用 store 对象,而且这么做也不优雅,这时候会需要用到 Redux-React 提供的useSelector工具,将会在后文解释

导出 reducer

我们之前初始化 Redux 切片时传入的 Reducer 是一个一个独立的,还要经过 RTK 的合并和处理(其中有一项可能和之前说的 immerjs 有关),才能用于 Redux store,可以通过以下代码将 RTK 处理过的 reducer 导出

1
export default counterSlice.reducer;

在 React Component 中应用 Redux

这部分内容主要由Redux-React提供,毕竟 Redux 并不是局限于某个 js 框架的状态管理工具,他是一个非常开放的工具。

获取 Redux store 数据

Redux-React提供了useSelector钩子用来获取 Redux store 中的数据,使用方法如下

引入

1
import { useSelector } from "react-redux";

在 React Component 中调用

1
const count = useSelector(selectCount);

其中selectCount即为上文中导出的 Selector

当然也可以写为行内的 Selector:

1
const count = useSelector((state) => state.count);

这样可以得到 Selector 筛选后的 store 数据,并且得益于Redux-React对 React 的单独适配,当筛选后的 store 数据变化时,对应组件也会进行重新渲染。

但是需要注意的一点是,每次调用 dispatch 时,不管在哪里调用 dispatch,都会重新计算一遍Selector函数,如果计算结果发生变化,则会使对应组件重新渲染。这里发生的变化如果是对象,则比较的对象的引用是否发生变化,即{}!=={}

dispatch 更改

由于 Redux 禁止直接获取 store,Redux-React提供了useDispatch钩子用来获取 dispatch 函数

引入

1
import { useSelector } from "react-redux";

在 React Component 中调用

1
const dispatch = useDispatch();

然后便可以直接使用dispatch函数去触发更改了,与store.dispatch用法相同,可以传入Action Creator(包括 RTK 生成的 Action Creator),也可以传入一个简单的 action 对象,下面这两种方法都是可以的,传入 payload 的类型也没有限制。

1
2
dispatch({ type: "counter/incrementByAmount", payload: 2 });
dispatch(incrementByAmount(2));

⚠️ 警告
Redux actions and state should only contain plain JS values like objects, arrays, and primitives. Don’t put class instances, functions, or other non-serializable (不可序列化) values into Redux!.

使用 Thunk 函数

thunk 函数通常写在 slice 文件内,(与createSlice同文件),这样可以便于寻找代码。

使用简单的 Thunk

thunk 是一种特定类型的 Redux 函数,可以包含异步逻辑。Thunk 是使用两个函数编写的:

  • 内部 thunk 函数,它以 dispatchgetState 作为参数
  • 外部 thunk creator 函数,它创建并返回 thunk 函数

例如在Redux Demo中有一段异步 dispatch 的例子,这个例子写在/src/feature/counter/counterSlice.js

1
2
3
4
5
export const incrementAsync = (amount) => (dispatch, getState) => {
setTimeout(() => {
dispatch(incrementByAmount(amount));
}, 1000);
};

thunk 函数可以这样 dispatch

1
dispatch(incrementAsync(5));

我们可以将这个函数提高一下代码可读性来看

1
2
3
4
5
6
7
export const incrementAsync = (amount) => {
return (dispatch, getState) => {
setTimeout(() => {
dispatch(incrementByAmount(amount));
}, 1000);
};
};

代入至 dispatch

1
2
3
4
5
dispatch((dispatch, getState) => {
setTimeout(() => {
dispatch(incrementByAmount(5));
}, 1000);
});

也就是相当于向dispatch传入一个函数 A,参数为dispatchgetState函数,在函数 A 内实现异步逻辑之后再去dispatch

使用 thunk 需要在创建时将 redux-thunk middleware(一种 Redux 插件)添加到 Redux store 中。幸运的是,RTK 的 configureStore 函数已经自动为我们配置好了,所以我们可以继续在这里使用 thunk。

另外再提供一个改自文档的网络请求的例子

1
2
3
4
5
6
7
8
9
10
const fetchUserById = (userId) => {
return async (dispatch, getState) => {
try {
const user = await fetchUser(userId);
dispatch(userLoaded(user));
} catch (err) {
// do sth
}
};
};

可以看到外部 thunk creator 函数的作用是为了将我们的参数传入 thunk 内部函数,并且还能与 Action Creator 形式保持一致

使用createAsyncThunk创建异步 Thunk

RTK 提供了一个createAsyncThunk方法来创建异步的 Thunk,方便我们进行网络请求等操作

直接看一个文档样例

1
2
3
4
export const fetchPosts = createAsyncThunk("posts/fetchPosts", async () => {
const response = await client.get("/fakeApi/posts");
return response.data;
});

在此例中,刚开始 fetch 数据时会 dispatch posts/fetchPosts/pending Action,fetch 完成后会 dispatch posts/fetchPosts/fulfilled Action,出现问题时则会 dispatch posts/fetchPosts/rejected Action

参数

  • Action 字符串
  • payloadCreator函数,其中包含我们的异步代码,参数如下:
    • arg:dispatch 这个 Action Creator 时候传入的第一个参数,如果有多个参数,则应放在一个对象中传入
    • thunkAPI:一个对象
      • dispatch:the Redux store dispatch method
      • getState:the Redux store getState method
      • extra:the “extra argument” that can be passed into the thunk middleware when creating the store. This is typically some kind of API wrapper, such as a set of functions that know how to make API calls to your application’s server and return data, so that your thunks don’t have to have all the URLs and query logic directly inside.
      • requestId:a unique string ID value that was automatically generated to identify this request sequence
      • signal:一个AbortController.signal对象,可用于查看执行时是否已将此请求取消
      • rejectWithValue(value, [meta]):rejectWithValue is a utility function that you can return (or throw) in your action creator to return a rejected response with a defined payload and meta. It will pass whatever value you give it and return it in the payload of the rejected action. If you also pass in a meta, it will be merged with the existing rejectedAction.meta.
      • fulfillWithValue(value, meta):fulfillWithValue is a utility function that you can return in your action creator to fulfill with a value while having the ability of adding to fulfilledAction.meta.
  • options对象
    • condition(arg, { getState, extra } ): boolean | Promise<boolean>:用于跳过执行payloadCreator,详细信息Canceling Before Execution
    • dispatchConditionRejection:当上一个condition参数返回值为false时,不会 dispatch 任何 Action,如果想要 dispatch 一个 rejection 可以将这个参数置为true
    • idGenerator(arg): string:用于生成随机requestId的函数。默认使用nanoid
    • serializeError(error: unknown) => any:to replace the internal miniSerializeError method with your own serialization logic.
    • getPendingMeta({ arg, requestId }, { getState, extra }): any:一个函数,用于创建一个对象最终会合入pendingAction.meta

返回值

  • 一个 thunk action creator,用于 dispatch 这个异步 thunk action,eg:'users/fetchByIdStatus'
  • 还可以调用它其中包含的 action creator
    • pending:待完成,eg:'users/fetchByIdStatus/pending'
    • fulfilled:已完成,eg:'users/fetchByIdStatus/fulfilled'
    • rejected:被拒绝,eg:'users/fetchByIdStatus/rejected'

thunk action creator 的 dispatch 过程

  • dispatch the pending action
  • call the payloadCreator callback and wait for the returned promise to settle
  • when the promise settles:
    • if the promise resolved successfully, dispatch the fulfilled action with the promise value as action.payload
    • if the promise resolved with a rejectWithValue(value) return value, dispatch the rejected action with the value passed into action.payload and ‘Rejected’ as action.error.message
    • if the promise failed and was not handled with rejectWithValue, dispatch the rejected action with a serialized version of the error value as action.error
  • Return a fulfilled promise containing the final dispatched action (either the fulfilled or rejected action object)

关于这个createAsyncThunk方法的具体细节可以查看https://redux-toolkit.js.org/api/createAsyncThunk

性能优化注意点

useSelector重复渲染

问题描述

每次调用 dispatch 时,不管在哪里调用 dispatch,都会重新计算一遍Selector函数,如果计算结果发生变化,则会使useSelector对应组件重新渲染。这里发生的变化如果是对象,则比较的对象的引用是否发生变化,即{}!=={}

观察下列代码

1
2
3
4
const postsForUser = useSelector((state) => {
const allPosts = selectAllPosts(state);
return allPosts.filter((post) => post.user === userId);
});

每次 dispatch 时计算得出的数组对象的引用是不相同的,所以会导致不管在哪里 dispatch,都会使这个组件重新渲染

解决方法

可以使用Redux Reselect创建 memoized Selector 函数。

RTK 已经帮我们加好了这个库,所以只需要在 RTK 中引出createSelector函数即可使用它创建 Selector 函数。

1
2
3
4
5
6
7
8
9
import { createSelector } from "@reduxjs/toolkit";

export const selectPostsByUser = createSelector(
[selectAllPosts, (state, userId) => userId],
(posts, userId) => posts.filter((post) => post.user === userId)
);

// 用法
const postsForUser = useSelector((state) => selectPostsByUser(state, userId));

这里createSelector第二个参数函数仅在第一个列表中 Selector 返回值发生变化时执行并返回新值

数据范式化

为了便于查找数据,我们可以将数据范式化(Normalized),即:

  • 我们 state 中的每个特定数据只有一个副本,不存在重复。
  • 已范式化的数据保存在查找表中,其中项目 ID 是键,项本身是值。
  • 也可能有一个特定项用于保存所有 ID 的数组。

JavaScript 对象可以用作查找表,类似于其他语言中的 “maps” 或 “dictionaries”。 以下是一组“用户”对象的范式化 state 可能如下所示:

1
2
3
4
5
6
7
8
9
10
{
users: {
ids: ["user1", "user2", "user3"],
entities: {
"user1": {id: "user1", firstName, lastName},
"user2": {id: "user2", firstName, lastName},
"user3": {id: "user3", firstName, lastName},
}
}
}

For more details on why normalizing state is useful, see Normalizing State Shape and the Redux Toolkit Usage Guide section on Managing Normalized Data.

使用createEntityAdapter管理范式化数据

RTK 提供了createEntityAdapterAPI 来管理范式化数据,可以将数据以{ ids: [], entities: {} }形式存储,并且生成一系列 reducer 和 selector ,它有以下优点:

  • 不用自己手动维护序列化数据
  • 预先生成一系列 reducer:添加所有项、更新一个项、删除多项 等
  • 可以将项 ID 按一定顺序排列,并且仅当项增删改或者排序规则改变时会重新排列

createEntityAdapter
接收参数(一个配置对象):

  • sortComparer函数:ID 排序函数(排序函数写法类似Array.sort()

返回值(一个adapter对象):

  • 包含一系列用于增删改的 reducer,具体 reducer 参见createEntityAdapter#crud-functions
  • getSelectors函数
    • 接收一个 Selector 函数,用于从 store 中筛选出序列化数据所在位置
      eg:state => state.posts
    • 返回一个对象,包含一系列序列化数据选择器:
      • selectAll
      • selectById
      • selectIds
  • getInitialState函数:将会返回这组序列化数据的 InitialState,用于填入 RTKcreateSlice中的initialState,除了序列化数据原有的值{ ids: [], entities: {} }外,如果想向里面加入其他键值,可以以对象形式传入这个函数:const initialState = postsAdapter.getInitialState({ status: 'idle', error: null })

经过createEntityAdapter生成的对应 state 形式为{ ids: [], entities: {} }ids用于存键名列表,entities对象用于键值对应数据

其他功能

@reduxjs/toolkit

nanoid

基于nanoid/nonsecure,用于生成一个随机 id 字符串,在 Redux 内部主要用于管理createAsyncThunk的异步请求 id,也可以用于其他用途。

1
2
3
4
import { nanoid } from "@reduxjs/toolkit";

console.log(nanoid());
// 'dgPXxUz_6fWIQBD8XmiSy'

未完待续