Redux 样式指南
介绍
这是编写 Redux 代码的官方样式指南。它列出了我们推荐的模式、最佳实践以及编写 Redux 应用程序的建议方法。
Redux 核心库和大多数 Redux 文档都是无意见的。有很多方法可以使用 Redux,而且很多时候没有单一的“正确”方法。
然而,时间和经验表明,对于某些主题,某些方法比其他方法更有效。此外,许多开发人员要求我们提供官方指南以减少决策疲劳。
考虑到这一点,我们整理了这份推荐清单,以帮助您避免错误、过度讨论和反模式。我们也理解团队偏好各不相同,不同的项目有不同的需求,因此没有一个样式指南适合所有情况。我们鼓励您遵循这些建议,但请花时间评估自己的情况并决定它们是否适合您的需求。
最后,我们要感谢 Vue 文档作者撰写了Vue 样式指南页面,它启发了本页面的创作。
规则类别
我们将这些规则分为三个类别
优先级 A:必不可少
这些规则有助于防止错误,因此务必学习并遵守它们。可能存在例外情况,但应该非常罕见,并且只能由既精通 JavaScript 又精通 Redux 的专家做出。
优先级 B:强烈推荐
这些规则已被证明可以提高大多数项目的可读性和/或开发人员体验。即使违反这些规则,您的代码仍然可以运行,但违反情况应该很少见且有充分的理由。只要合理,请遵循这些规则。
优先级 C:推荐
如果存在多个同样好的选项,可以做出任意选择以确保一致性。在这些规则中,我们描述了每个可接受的选项并建议一个默认选择。这意味着您可以随意在自己的代码库中做出不同的选择,只要您保持一致并有充分的理由。请务必有充分的理由!
优先级 A 规则:必不可少
不要修改状态
修改状态是 Redux 应用程序中错误的最常见原因,包括组件无法正确重新渲染,并且还会破坏 Redux DevTools 中的时间旅行调试。始终避免实际修改状态值,无论是在 reducer 内部还是在所有其他应用程序代码中。
使用诸如 redux-immutable-state-invariant
之类的工具在开发过程中捕获突变,并使用 Immer 避免在状态更新中意外突变。
注意:修改现有值的副本是可以的 - 这是编写不可变更新逻辑的正常部分。此外,如果您使用 Immer 库进行不可变更新,编写“修改”逻辑是可以接受的,因为实际数据没有被修改 - Immer 安全地跟踪更改并在内部生成不可变更新的值。
Reducer 必须没有副作用
Reducer 函数应该只依赖于它们的 state
和 action
参数,并且应该只根据这些参数计算并返回一个新的状态值。它们不得执行任何类型的异步逻辑(AJAX 调用、超时、Promise)、生成随机值(Date.now()
、Math.random()
)、修改 reducer 外部的变量或运行影响 reducer 函数范围之外的事物的其他代码。
注意: 允许 reducer 调用在自身外部定义的其他函数,例如来自库的导入或实用程序函数,只要它们遵循相同的规则。
详细解释
此规则的目的是保证 reducer 在被调用时会以可预测的方式运行。例如,如果您正在进行时间旅行调试,reducer 函数可能会被多次调用,使用更早的动作来生成“当前”状态值。如果 reducer 存在副作用,这会导致这些副作用在调试过程中被执行,并导致应用程序以意想不到的方式运行。
此规则存在一些灰色区域。严格来说,像 console.log(state)
这样的代码是一种副作用,但在实践中对应用程序的运行方式没有影响。
不要将非序列化值放入状态或动作中
避免将非序列化值(如 Promises、Symbols、Maps/Sets、函数或类实例)放入 Redux 存储状态或分派的 action 中。这确保了诸如通过 Redux DevTools 进行调试之类的功能能够按预期工作。它还确保 UI 会按预期更新。
例外: 您可以将非序列化值放入 action 中,如果该 action 将被中间件拦截并停止,然后再到达 reducer。
redux-thunk
和redux-promise
等中间件就是这种情况的示例。
每个应用程序只有一个 Redux 存储
标准 Redux 应用程序应该只有一个 Redux 存储实例,该实例将被整个应用程序使用。它通常应该在单独的文件中定义,例如 store.js
。
理想情况下,没有应用程序逻辑会直接导入存储。它应该通过 <Provider>
传递给 React 组件树,或者通过中间件(如 thunk)间接引用。在极少数情况下,您可能需要将其导入到其他逻辑文件中,但这应该是最后的手段。
优先级 B 规则:强烈推荐
使用 Redux Toolkit 编写 Redux 逻辑
Redux Toolkit 是我们推荐的用于使用 Redux 的工具集。它具有内置我们建议的最佳实践的函数,包括设置存储以捕获突变并启用 Redux DevTools 扩展,使用 Immer 简化不可变更新逻辑等等。
您不需要在 Redux 中使用 RTK,如果您愿意,也可以自由使用其他方法,但**使用 RTK 将简化您的逻辑并确保您的应用程序以良好的默认值进行设置**。
使用 Immer 编写不可变更新
手动编写不可变更新逻辑通常很困难且容易出错。 Immer 允许您使用“可变”逻辑编写更简单的不可变更新,甚至在开发中冻结您的状态以捕获应用程序中其他地方的变异。**我们建议使用 Immer 编写不可变更新逻辑,最好作为 Redux Toolkit 的一部分**。
将文件结构化为具有单文件逻辑的功能文件夹
Redux 本身并不关心应用程序的文件夹和文件是如何结构化的。但是,将给定功能的逻辑放在一个地方通常可以更容易地维护该代码。
因此,**我们建议大多数应用程序应该使用“功能文件夹”方法来结构化文件**(一个功能的所有文件都在同一个文件夹中)。在给定的功能文件夹内,**该功能的 Redux 逻辑应编写为单个“切片”文件**,最好使用 Redux Toolkit 的 createSlice
API。(这也被称为 "ducks" 模式)。虽然旧的 Redux 代码库通常使用“按类型文件夹”方法,使用单独的文件夹来存放“操作”和“reducer”,但将相关逻辑放在一起可以更容易地查找和更新该代码。
详细说明:示例文件夹结构
示例文件夹结构可能如下所示/src
index.tsx
:渲染 React 组件树的入口点文件/app
store.ts
:存储设置rootReducer.ts
:根 reducer(可选)App.tsx
:根 React 组件
/common
:钩子、通用组件、实用程序等/features
:包含所有“功能文件夹”/todos
:单个功能文件夹todosSlice.ts
:Redux reducer 逻辑和相关操作Todos.tsx
:一个 React 组件
/app
包含依赖于所有其他文件夹的应用程序范围的设置和布局。
/common
包含真正通用且可重用的实用程序和组件。
/features
包含包含与特定功能相关的所有功能的文件夹。在此示例中,todosSlice.ts
是一个“duck”风格的文件,其中包含对 RTK 的 createSlice()
函数的调用,并导出切片 reducer 和操作创建者。
尽可能将逻辑放在 Reducer 中
在可能的情况下,尽量将计算新状态的逻辑放在相应的 Reducer 中,而不是放在准备和分发 Action 的代码中(例如点击处理程序)。这有助于确保更多实际的应用程序逻辑易于测试,能够更有效地使用时间旅行调试,并有助于避免可能导致突变和错误的常见错误。
在某些情况下,可能需要先计算部分或全部新状态(例如生成唯一 ID),但这种情况应该尽量减少。
详细解释
Redux 核心实际上并不关心新状态值是在 Reducer 中计算还是在 Action 创建逻辑中计算。例如,对于一个待办事项应用程序,"切换待办事项"操作的逻辑需要不可变地更新待办事项数组。将 Action 中只包含待办事项 ID,并在 Reducer 中计算新的数组是合法的
// Click handler:
const onTodoClicked = (id) => {
dispatch({type: "todos/toggleTodo", payload: {id}})
}
// Reducer:
case "todos/toggleTodo": {
return state.map(todo => {
if(todo.id !== action.payload.id) return todo;
return {...todo, completed: !todo.completed };
})
}
以及先计算新的数组,并将整个新数组放在 Action 中
// Click handler:
const onTodoClicked = id => {
const newTodos = todos.map(todo => {
if (todo.id !== id) return todo
return { ...todo, completed: !todo.completed }
})
dispatch({ type: 'todos/toggleTodo', payload: { todos: newTodos } })
}
// Reducer:
case "todos/toggleTodo":
return action.payload.todos;
但是,出于以下几个原因,在 Reducer 中进行逻辑处理是更好的选择
- Reducer 总是很容易测试,因为它们是纯函数 - 你只需要调用
const result = reducer(testState, action)
,并断言结果是你期望的。因此,你可以在 Reducer 中放置的逻辑越多,就越容易测试的逻辑就越多。 - Redux 状态更新必须始终遵循 不可变更新规则。大多数 Redux 用户意识到他们必须在 Reducer 内部遵循这些规则,但并不明显的是,如果新状态是在 Reducer 外部计算的,你也必须这样做。这很容易导致错误,例如意外突变,甚至从 Redux 存储中读取一个值并将其直接传递到 Action 中。在 Reducer 中完成所有状态计算可以避免这些错误。
- 如果你使用的是 Redux Toolkit 或 Immer,在 Reducer 中编写不可变更新逻辑会容易得多,并且 Immer 会冻结状态并捕获意外突变。
- 时间旅行调试允许你“撤销”一个已分派的 action,然后要么做一些不同的事情,要么“重做”这个 action。此外,reducer 的热重载通常涉及使用现有的 action 重新运行新的 reducer。如果你有一个正确的 action,但 reducer 有 bug,你可以编辑 reducer 来修复 bug,热重载它,你应该立即获得正确的状态。如果 action 本身是错误的,你将不得不重新运行导致该 action 被分派的步骤。因此,如果 reducer 中有更多逻辑,调试起来会更容易。
- 最后,将逻辑放在 reducer 中意味着你知道在哪里查找更新逻辑,而不是将其分散在应用程序代码的其他随机部分。
Reducer 应该拥有状态形状
Redux 根状态由单个根 reducer 函数拥有和计算。为了可维护性,该 reducer 旨在按键/值“切片”进行拆分,每个“切片 reducer”负责提供初始值并计算该状态切片的更新。
此外,切片 reducer 应该控制作为计算状态的一部分返回的其他值。尽量减少使用“盲目展开/返回”,例如 return action.payload
或 return {...state, ...action.payload}
,因为这些依赖于分派 action 的代码来正确格式化内容,并且 reducer 有效地放弃了对该状态外观的控制权。如果 action 内容不正确,这会导致 bug。
注意:对于像在表单中编辑数据这样的场景,使用“展开返回”reducer 可能是合理的,在这种场景中,为每个单独的字段编写单独的 action 类型将非常耗时且意义不大。
详细解释
想象一个看起来像这样的“当前用户”reducerconst initialState = {
firstName: null,
lastName: null,
age: null,
};
export default usersReducer = (state = initialState, action) {
switch(action.type) {
case "users/userLoggedIn": {
return action.payload;
}
default: return state;
}
}
在这个例子中,reducer 完全假设 action.payload
将是一个格式正确的对象。
但是,想象一下,如果代码的某些部分在 action 中分派了一个“todo”对象,而不是一个“user”对象
dispatch({
type: 'users/userLoggedIn',
payload: {
id: 42,
text: 'Buy milk'
}
})
reducer 会盲目地返回 todo,现在应用程序的其余部分在尝试从存储中读取用户时可能会崩溃。
如果 reducer 有一些验证检查,以确保 action.payload
确实具有正确的字段,或者尝试通过名称读取正确的字段,那么至少可以部分解决这个问题。但是,这确实会增加更多代码,因此这是一个权衡更多代码以换取安全性的问题。
使用静态类型确实使这种代码更安全,也更易于接受。如果 reducer 知道 action
是一个 PayloadAction<User>
,那么执行 return action.payload
应该是安全的。
根据存储的数据命名状态切片
如 Reducer 应该拥有状态形状 中所述,拆分 reducer 逻辑的标准方法是基于状态的“切片”。相应地,combineReducers
是将这些切片 reducer 合并成一个更大的 reducer 函数的标准函数。
传递给 combineReducers
的对象中的键名将定义结果状态对象中的键名。请确保根据内部保存的数据命名这些键,并避免在键名中使用“reducer”一词。您的对象应该看起来像 {users: {}, posts: {}}
,而不是 {usersReducer: {}, postsReducer: {}}
。
详细解释
对象字面量简写使您能够同时定义对象中的键名和值const data = 42
const obj = { data }
// same as: {data: data}
combineReducers
接受一个包含 reducer 函数的对象,并使用它来生成具有相同键名的状态对象。这意味着函数对象中的键名定义了状态对象中的键名。
这会导致一个常见的错误,即使用“reducer”作为变量名导入 reducer,然后使用对象字面量简写将其传递给 combineReducers
import usersReducer from 'features/users/usersSlice'
const rootReducer = combineReducers({
usersReducer
})
在这种情况下,使用对象字面量简写创建了一个类似于 {usersReducer: usersReducer}
的对象。因此,“reducer”现在位于状态键名中。这是多余且无用的。
相反,定义仅与内部数据相关的键名。我们建议使用显式的 key: value
语法以确保清晰度
import usersReducer from 'features/users/usersSlice'
import postsReducer from 'features/posts/postsSlice'
const rootReducer = combineReducers({
users: usersReducer,
posts: postsReducer
})
这需要多打一些字,但它会产生最易于理解的代码和状态定义。
根据数据类型而不是组件组织状态结构
根状态切片应该根据应用程序中的主要数据类型或功能区域来定义和命名,而不是根据 UI 中的特定组件来定义和命名。这是因为 Redux 存储中的数据与 UI 中的组件之间没有严格的一对一对应关系,并且许多组件可能需要访问相同的数据。将状态树视为一种全局数据库,应用程序的任何部分都可以访问它以读取该组件中所需的特定状态部分。
例如,一个博客应用程序可能需要跟踪谁已登录、作者和帖子信息,以及可能有关哪个屏幕处于活动状态的一些信息。一个好的状态结构可能看起来像 {auth, posts, users, ui}
。一个不好的结构可能是 {loginScreen, usersList, postsList}
。
将 Reducer 视为状态机
许多 Redux reducer 是“无条件”编写的。它们只查看分派的 action 并计算新的 state 值,而不会将任何逻辑基于当前 state 可能是什么。这会导致 bug,因为某些 action 在某些时间可能在概念上“无效”,具体取决于应用程序逻辑的其余部分。例如,“请求成功”action 只有在 state 表示它已经“加载”时才应该计算新值,或者“更新此项”action 只有在有标记为“正在编辑”的项时才应该分派。
为了解决这个问题,**将 reducer 视为“状态机”,其中当前 state 和分派的 action 的组合决定是否实际计算新的 state 值**,而不仅仅是 action 本身无条件地决定。
详细解释
一个有限状态机是模拟只能在有限数量的“有限状态”中处于一种状态的有效方法。例如,如果你有一个 fetchUserReducer
,有限状态可以是
"idle"
(尚未开始获取)"loading"
(当前正在获取用户)"success"
(用户获取成功)"failure"
(用户获取失败)
为了使这些有限状态清晰并使不可能的状态不可能,你可以指定一个属性来保存此有限状态
const initialUserState = {
status: 'idle', // explicit finite state
user: null,
error: null
}
使用 TypeScript,这也有助于轻松使用区分联合来表示每个有限状态。例如,如果 state.status === 'success'
,那么你应该期望 state.user
被定义,并且不应期望 state.error
为真值。你可以使用类型来强制执行此操作。
通常,reducer 逻辑是通过首先考虑 action 来编写的。在使用状态机建模逻辑时,重要的是首先考虑 state。为每个状态创建“有限状态 reducer”有助于封装每个状态的行为
import {
FETCH_USER,
// ...
} from './actions'
const IDLE_STATUS = 'idle';
const LOADING_STATUS = 'loading';
const SUCCESS_STATUS = 'success';
const FAILURE_STATUS = 'failure';
const fetchIdleUserReducer = (state, action) => {
// state.status is "idle"
switch (action.type) {
case FETCH_USER:
return {
...state,
status: LOADING_STATUS
}
}
default:
return state;
}
}
// ... other reducers
const fetchUserReducer = (state, action) => {
switch (state.status) {
case IDLE_STATUS:
return fetchIdleUserReducer(state, action);
case LOADING_STATUS:
return fetchLoadingUserReducer(state, action);
case SUCCESS_STATUS:
return fetchSuccessUserReducer(state, action);
case FAILURE_STATUS:
return fetchFailureUserReducer(state, action);
default:
// this should never be reached
return state;
}
}
现在,由于你正在定义每个状态的行为而不是每个 action 的行为,因此你还可以防止不可能的转换。例如,FETCH_USER
action 在 status === LOADING_STATUS
时应该没有效果,你可以强制执行这一点,而不是意外地引入边缘情况。
规范化复杂的嵌套/关系状态
许多应用程序需要在存储中缓存复杂数据。这些数据通常以嵌套形式从 API 中接收,或者数据中不同实体之间存在关系(例如包含用户、帖子和评论的博客)。
建议将这些数据存储在“规范化”形式的存储中。这使得根据 ID 查找项目和更新存储中的单个项目变得更容易,并最终导致更好的性能模式。
保持状态最小化并派生附加值
尽可能地,将 Redux 存储中的实际数据保持在最小限度,并根据需要派生附加值。这包括计算过滤列表或汇总值等操作。例如,一个待办事项应用程序会在状态中保留一个原始的待办事项对象列表,但在状态更新时派生一个过滤后的待办事项列表。类似地,可以计算所有待办事项是否已完成或剩余待办事项数量等检查。
这有几个好处
- 实际状态更容易阅读
- 计算这些附加值并使其与其余数据保持同步所需的逻辑更少
- 原始状态仍然存在作为参考,并没有被替换
派生数据通常在“选择器”函数中完成,这些函数可以封装执行派生数据计算的逻辑。为了提高性能,这些选择器可以使用像reselect
和proxy-memoize
这样的库进行记忆,以缓存以前的结果。
将操作建模为事件,而不是设置器
Redux 不关心 action.type
字段的内容是什么 - 它只需要被定义。在现在时("users/update"
)、过去时("users/updated"
)、描述为事件("upload/progress"
)或被视为“设置器”("users/setUserName"
)中编写操作类型都是合法的。由您来决定在您的应用程序中给定操作的含义以及您如何对这些操作进行建模。
但是,我们建议尝试将操作更多地视为“描述发生的事件”,而不是“设置器”。将操作视为“事件”通常会导致更有意义的操作名称、更少的总操作被分派以及更有意义的操作日志历史记录。编写“设置器”通常会导致太多单独的操作类型、太多分派以及操作日志意义不大。
详细解释
想象一下,您有一个餐厅应用程序,有人点了一份披萨和一瓶可乐。您可以分派一个像这样的操作{ type: "food/orderAdded", payload: {pizza: 1, coke: 1} }
或者您可以分派
{
type: "orders/setPizzasOrdered",
payload: {
amount: getState().orders.pizza + 1,
}
}
{
type: "orders/setCokesOrdered",
payload: {
amount: getState().orders.coke + 1,
}
}
第一个例子将是一个“事件”。“嘿,有人点了一份披萨和一杯汽水,以某种方式处理它”。
第二个例子是一个“设置器”。“我知道有‘已订购披萨’和‘已订购汽水’的字段,我命令你将它们当前的值设置为这些数字”。
“事件”方法只需要分派一个操作,并且它更灵活。无论已经订购了多少披萨都没有关系。也许没有厨师可用,因此订单被忽略了。
使用“设置器”方法,客户端代码需要更多地了解状态的实际结构,“正确”的值应该是什么,并且最终实际上必须分派多个操作才能完成“事务”。
编写有意义的操作名称
action.type
字段有两个主要目的
- Reducer 逻辑检查操作类型以查看是否应该处理此操作以计算新的状态
- 操作类型显示在 Redux DevTools 历史记录日志中,供您阅读
根据 将操作建模为“事件”而不是“设置器”,type
字段的实际内容对 Redux 本身并不重要。但是,type
值确实对您(开发人员)很重要。操作应该使用有意义的、信息丰富的、描述性的类型字段编写。理想情况下,您应该能够阅读已分派操作类型列表,并对应用程序中发生的事情有一个很好的了解,甚至不需要查看每个操作的内容。避免使用非常通用的操作名称,例如 "SET_DATA"
或 "UPDATE_STORE"
,因为它们没有提供有关发生情况的有意义信息。
允许多个 Reducer 响应同一个 Action
Redux Reducer 逻辑旨在拆分成多个更小的 Reducer,每个 Reducer 独立更新状态树的特定部分,最终组合成根 Reducer 函数。当一个 Action 被分发时,它可能被所有、部分或没有 Reducer 处理。
作为其中的一部分,鼓励您 **尽可能让多个 Reducer 函数分别处理同一个 Action**。在实践中,经验表明大多数 Action 通常只由单个 Reducer 函数处理,这很好。但是,将 Action 视为“事件”,并允许多个 Reducer 响应这些 Action 通常可以让您的应用程序代码库更好地扩展,并最大限度地减少完成一次有意义的更新所需的多次分发 Action 的次数。
避免连续分发多个 Action
**避免连续分发多个 Action 来完成一个更大的概念性“事务”**。这是合法的,但通常会导致多次相对昂贵的 UI 更新,并且一些中间状态可能会被应用程序逻辑的其他部分视为无效。建议分发单个“事件”类型的 Action,一次性完成所有适当的状态更新,或者考虑使用 Action 批量处理插件,以仅在最后一次 UI 更新时分发多个 Action。
详细解释
您可以连续分发任意数量的 Action。但是,每个分发的 Action 都会导致执行所有存储订阅回调(通常每个 Redux 连接的 UI 组件一个或多个),并且通常会导致 UI 更新。虽然从 React 事件处理程序排队的 UI 更新通常会批处理到单个 React 渲染传递中,但从这些事件处理程序外部排队的更新则不会。这包括来自大多数 async
函数、超时回调和非 React 代码的分发。在这些情况下,每次分发都会在分发完成之前导致完整的同步 React 渲染传递,这会降低性能。
此外,多个在概念上属于较大“事务”式更新序列的调度会导致中间状态,这些状态可能不被视为有效。例如,如果按顺序调度操作"UPDATE_A"
、"UPDATE_B"
和"UPDATE_C"
,并且某些代码期望a
、b
和c
全部一起更新,则前两个调度后的状态实际上将是不完整的,因为只有其中一个或两个被更新了。
如果确实需要多个调度,请考虑以某种方式对更新进行批处理。根据您的用例,这可能只是对 React 自己的渲染进行批处理(可能使用React-Redux 中的batch()
),对存储通知回调进行去抖动,或者将多个操作分组到一个更大的单次调度中,该调度只产生一个订阅者通知。有关更多示例和相关附加组件的链接,请参阅关于“减少存储更新事件”的常见问题解答条目。
评估每个状态部分应该存在的位置
《"Redux 的三个原则"》指出,“整个应用程序的状态都存储在一个单一的树中”。这种说法被过度解读了。它并不意味着整个应用程序中的每个值都必须保存在 Redux 存储中。相反,应该有一个单一的地方来查找所有您认为是全局的和应用程序范围的值。通常,将“本地”值保存在最接近的 UI 组件中。
因此,您作为开发人员需要决定哪些状态应该实际保存在 Redux 存储中,哪些应该保存在组件状态中。使用这些经验法则来帮助评估每个状态部分并决定它应该存在的位置。
使用 React-Redux Hooks API
优先使用React-Redux Hooks API (useSelector
和 useDispatch
) 作为从 React 组件与 Redux 存储交互的默认方式。虽然经典的connect
API 仍然可以正常工作,并将继续得到支持,但 Hooks API 在多种情况下通常更容易使用。Hooks 的间接性更少,代码更少,并且与 TypeScript 一起使用比connect
更简单。
Hooks API 在性能和数据流方面确实引入了一些与connect
不同的权衡,但我们现在建议将其作为默认选择。
详细解释
经典的connect
API 是一个高阶组件。它生成一个新的包装组件,该组件订阅存储,渲染您自己的组件,并将来自存储和操作创建者的数据作为道具传递下来。
这是一种故意的间接级别,它允许您编写接收所有值作为道具的“演示”风格组件,而无需专门依赖 Redux。
钩子的引入改变了大多数 React 开发人员编写组件的方式。虽然“容器/演示”的概念仍然有效,但钩子促使您编写负责通过调用适当的钩子在内部请求其自身数据的组件。这导致了我们在编写和测试组件和逻辑方面的不同方法。
connect
的间接性一直以来都让一些用户难以跟踪数据流。此外,connect
的复杂性使得使用 TypeScript 正确地进行类型化变得非常困难,因为存在多个重载、可选参数、来自 mapState
/ mapDispatch
/ 父组件的道具合并,以及操作创建者和 thunk 的绑定。
useSelector
和 useDispatch
消除了间接性,因此您的组件如何与 Redux 交互变得更加清晰。由于 useSelector
只接受单个选择器,因此使用 TypeScript 定义它要容易得多,useDispatch
也是如此。
有关更多详细信息,请参阅 Redux 维护者 Mark Erikson 关于钩子和 HOC 之间权衡的帖子和会议演讲。
另请参阅 React-Redux 钩子 API 文档,了解如何正确优化组件和处理罕见的边缘情况。
将更多组件连接到从存储中读取数据
最好让更多 UI 组件订阅 Redux 存储并在更细粒度的级别读取数据。这通常会导致更好的 UI 性能,因为当给定状态发生变化时,需要渲染的组件更少。
例如,与其只连接 <UserList>
组件并读取整个用户数组,不如让 <UserList>
检索所有用户 ID 的列表,将列表项渲染为 <UserListItem userId={userId}>
,并让 <UserListItem>
连接并从存储中提取自己的用户条目。
这适用于 React-Redux connect()
API 和 useSelector()
钩子。
使用 connect
的 mapDispatch
对象简写形式
connect
的 mapDispatch
参数可以定义为接收 dispatch
作为参数的函数,也可以定义为包含动作创建者的对象。我们建议始终使用 mapDispatch
的“对象简写”形式,因为它可以大大简化代码。几乎没有必要将 mapDispatch
写成函数。
在函数组件中多次调用 useSelector
使用 useSelector
钩子检索数据时,建议多次调用 useSelector
并检索少量数据,而不是进行一次返回多个结果的较大 useSelector
调用。与 mapState
不同,useSelector
不需要返回对象,并且让选择器读取较小的值意味着某个状态更改不太可能导致该组件重新渲染。
但是,请尝试找到合适的粒度平衡。如果单个组件确实需要状态切片中的所有字段,只需编写一个返回整个切片的 useSelector
,而不是为每个单独字段编写单独的选择器。
使用静态类型
使用像 TypeScript 或 Flow 这样的静态类型系统,而不是普通的 JavaScript。类型系统将捕获许多常见错误,改进代码文档,并最终提高长期可维护性。虽然 Redux 和 React-Redux 最初是为普通的 JS 设计的,但它们都与 TS 和 Flow 兼容。Redux Toolkit 是专门用 TS 编写的,旨在以最少的额外类型声明提供良好的类型安全性。
使用 Redux DevTools 扩展进行调试
配置您的 Redux 存储以启用 使用 Redux DevTools 扩展进行调试。它允许您查看
- 已分派的动作的历史记录日志
- 每个动作的内容
- 分派动作后的最终状态
- 操作后状态的差异
- 显示操作实际分发代码的函数堆栈跟踪
此外,DevTools 允许您进行“时间旅行调试”,在操作历史记录中来回切换,以查看不同时间点的整个应用程序状态和 UI。
Redux 的设计初衷就是为了实现这种调试,而 DevTools 是使用 Redux 的最强大理由之一。.
使用普通 JavaScript 对象作为状态
建议使用普通 JavaScript 对象和数组作为您的状态树,而不是像 Immutable.js 这样的专门库。虽然使用 Immutable.js 有一些潜在的好处,但大多数常见的目标(例如轻松的引用比较)通常是不可变更新的属性,并不需要特定的库。这也使捆绑包大小更小,并减少了数据类型转换带来的复杂性。
如上所述,如果您想简化不可变更新逻辑,特别是作为 Redux Toolkit 的一部分,我们特别推荐使用 Immer。
详细说明
Immutable.js 从一开始就在 Redux 应用程序中被半频繁地使用。使用 Immutable.js 有几个常见的理由- 通过廉价的引用比较提高性能
- 通过使用专门的数据结构来提高更新性能
- 防止意外变异
- 通过像
setIn()
这样的 API 更轻松地进行嵌套更新
这些理由有一些合理之处,但在实践中,好处并不像宣传的那样好,而且使用它还有很多负面影响。
- 廉价的引用比较是任何不可变更新的属性,而不仅仅是 Immutable.js。
- 可以通过其他机制来防止意外变异,例如使用 Immer(它消除了容易出错的手动复制逻辑,并在开发中默认情况下深度冻结状态)或
redux-immutable-state-invariant
(它检查状态是否存在变异)。 - Immer 允许更简单的更新逻辑,消除了对
setIn()
的需求。 - Immutable.js 的捆绑包大小非常大。
- API 相当复杂。
- API “感染”了您的应用程序代码。所有逻辑都必须知道它是在处理普通 JS 对象还是 Immutable 对象。
- 将不可变对象转换为普通 JS 对象相对来说比较昂贵,并且总是会生成全新的深度对象引用。
- 缺乏对库的持续维护。
使用 Immutable.js 最强有力的理由是快速更新 *非常* 大的对象(数万个键)。大多数应用程序不会处理如此大的对象。
总的来说,Immutable.js 为过小的实际收益增加了过多的开销。Immer 是一个更好的选择。
优先级 C 规则:推荐
将操作类型写成 domain/eventName
最初的 Redux 文档和示例通常使用 "SCREAMING_SNAKE_CASE" 约定来定义操作类型,例如 "ADD_TODO"
和 "INCREMENT"
。这与大多数编程语言中声明常量值的典型约定相匹配。缺点是这些大写字符串可能难以阅读。
其他社区采用了其他约定,通常会用某种方式表明操作相关的 "功能" 或 "领域",以及特定的操作类型。NgRx 社区通常使用类似 "[Domain] Action Type"
的模式,例如 "[Login Page] Login"
。其他模式,例如 "domain:action"
,也被使用过。
Redux Toolkit 的 createSlice
函数目前生成的 action 类型看起来像 "domain/action"
,例如 "todos/addTodo"
。展望未来,**我们建议使用 "domain/action"
约定以提高可读性**。
使用 Flux Standard Action 约定编写操作
最初的 "Flux 架构" 文档只规定操作对象应该有一个 type
字段,并没有对操作字段中应该使用哪些字段或命名约定给出任何进一步的指导。为了提供一致性,Andrew Clark 在 Redux 开发初期创建了一个名为 "Flux Standard Actions" 的约定。简而言之,FSA 约定规定操作
- 应该始终将它们的数据放入
payload
字段中 - 可能有一个
meta
字段用于附加信息 - 可能有一个
error
字段用于指示操作代表某种失败
Redux 生态系统中的许多库都采用了 FSA 约定,Redux Toolkit 生成的 action creator 符合 FSA 格式。
为了保持一致性,建议使用 FSA 格式的操作。.
注意:FSA 规范规定“错误”操作应设置
error: true
,并使用与“有效”操作形式相同的操作类型。在实践中,大多数开发人员会为“成功”和“错误”情况编写单独的操作类型。两种方式都可以接受。
使用操作创建器
“操作创建器”函数起源于最初的“Flux 架构”方法。在 Redux 中,操作创建器并非严格要求。组件和其他逻辑始终可以调用dispatch({type: "some/action"})
,并内联编写操作对象。
但是,使用操作创建器可以提供一致性,尤其是在需要某种准备工作或其他逻辑来填充操作内容(例如生成唯一 ID)的情况下。
建议使用操作创建器来调度任何操作。但是,我们建议使用 Redux Toolkit 中的createSlice
函数来生成操作创建器和操作类型,而不是手动编写操作创建器。
使用 RTK Query 进行数据获取
在实践中,典型的 Redux 应用程序中,副作用最常见的用例是从服务器获取和缓存数据。
因此,我们建议在 Redux 应用程序中使用RTK Query作为数据获取和缓存的默认方法。RTK Query 旨在正确管理从服务器获取数据所需的逻辑,包括缓存数据、对请求进行去重、更新组件等等。在几乎所有情况下,我们建议不要手动编写数据获取逻辑。
使用 Thunks 和监听器进行其他异步逻辑
Redux 的设计是可扩展的,中间件 API 的创建是为了允许将不同形式的异步逻辑插入 Redux 存储。这样,用户就不会被迫学习像 RxJS 这样的特定库,如果它不适合他们的需求。
这导致了各种 Redux 异步中间件插件的创建,反过来也造成了困惑和疑问,即应该使用哪种异步中间件。
我们建议使用Redux thunk 中间件进行命令式逻辑,例如需要访问dispatch
或getState
的复杂同步逻辑,以及中等复杂度的异步逻辑。这包括将逻辑从组件中移出的用例。
我们建议使用 RTK 的 "listener" 中间件 来处理需要响应分派操作或状态变化的 "反应式" 逻辑,例如较长时间运行的异步工作流和 "后台线程" 式的行为。
我们建议不要在大多数情况下使用更复杂的 Redux-Saga 和 Redux-Observable 库,尤其是用于异步数据获取。只有在没有其他工具能够满足您的用例时才使用这些库。
将复杂逻辑移出组件
我们一直建议将尽可能多的逻辑保留在组件之外。这部分是由于鼓励使用 "容器/展示" 模式,在这种模式下,许多组件只是将数据作为 props 接收并相应地显示 UI,但也因为在类组件生命周期方法中处理异步逻辑可能会难以维护。
我们仍然鼓励将复杂的同步或异步逻辑移出组件,通常移入 thunk。如果逻辑需要从 store 状态中读取数据,这一点尤其重要。
但是,使用 React hooks 使得直接在组件内部管理诸如数据获取之类的逻辑变得更容易,这在某些情况下可能会取代 thunk 的需求。
使用选择器函数从 store 状态中读取数据
"选择器函数" 是一个强大的工具,用于封装从 Redux store 状态中读取值并从这些值中推导出更多数据。此外,Reselect 等库可以创建记忆化的选择器函数,这些函数只有在输入发生变化时才会重新计算结果,这是优化性能的重要方面。
我们强烈建议尽可能使用记忆化的选择器函数来读取 store 状态,并建议使用 Reselect 创建这些选择器。
但是,不要觉得你必须为状态中的每个字段编写选择器函数。根据字段访问和更新的频率以及选择器在应用程序中提供的实际益处,找到合理的粒度平衡。
将选择器函数命名为 selectThing
我们建议将选择器函数名称加上 select
前缀,并结合对所选值的描述。例如,selectTodos
、selectVisibleTodos
和 selectTodoById
。
避免将表单状态放入 Redux
大多数表单状态不应该放在 Redux 中。在大多数情况下,数据并非真正全局的,没有被缓存,也没有被多个组件同时使用。此外,将表单连接到 Redux 通常涉及在每个更改事件上分派操作,这会导致性能开销,而且没有实际益处。(您可能不需要将 name: "Mark"
倒退一个字符到 name: "Mar"
。)
即使数据最终会进入 Redux,也更倾向于将表单编辑本身保留在本地组件状态中,并且只在用户完成表单后分派操作来更新 Redux store。
在某些情况下,将表单状态保存在 Redux 中确实有意义,例如 WYSIWYG 编辑项目属性的实时预览。但是,在大多数情况下,这并非必要。