Redux 风格引导
引言
这篇文章为你编写 Redux 代码提供官方的风格指导。列出我们推荐的模式, 最佳实践, 以及编写 Redux 应用的推荐方法。
Redux 核心库和绝大多数的 Redux 文档都是无观点的(unopinionated)。使用 Redux 有很多种方法,而且很多时候根本就没有唯一“正确”的方法。
然而,经过时间和实践的证明,一些方法就是比其他的方法更优越。此外,很多开发者也要求我们提供官方引导,从而减少决策的繁琐。
基于以上背景, 我们将这些推荐写法都列出来,让你避免出现错误、将精力放在非业务代码身上,以及避免不满足规范的代码。我们也知道团队的编码偏好是多种多样的,且不同的项目有不同的需求,根本就没有一种风格指导能满足所有情况。我们推荐你遵循这些推荐,但你也需要评估这些场景是否适用于你的需求。
最后,我们感谢 Vue 文档的作者,因为受到 Vue Style Guide page 的启发,才有了我们这篇文章。
规则分类
我们将规则分为以下三类:
优先级 A:必要
这一级别的规则可以防止错误,所以要不计成本地遵守。例外情况可能存在,但应该非常罕见,并且只能在具备 JavaScript 和 Redux 专业知识的开发者那里出现。
优先级 B:强烈推荐
这些规则在绝大多数的项目中都提高了代码可读性和开发体验。即使违反这些规则,代码仍然能运行,但仅能在极少数情况,有非常充分理由的时候才能去破坏这些规则。只要合理,请尽可能遵守这些规则。
优先级 C:推荐
你可以在一些也很优秀的方案中选择任意一种来保证一致性。在这些规则中,我们介绍了每个每种规则并推荐了一个默认选项。这意味着你可以在代码库中自由地做出不同的选择,只要保持一致性并有充分的理由。但是,请慎重!
A 级优先规则: 必要
不要修改 State
修改 state 是 Redux 应用 bug 的最常见的诱因,包括组件没有正确再渲染,且阻碍了 Redux DevTools 的时间旅行调试。无论是 reducer 中还是任意其他应用代码中,都要始终避免 state 的真实变换。
请使用类似于 redux-immutable-state-invariant
的工具在开发中捕获 mutation,并使用 Immer 库来避免意外的 state 更新.
注意:修改已有值的 副本 是没问题的——这是一种朴素的 immutable 更新方式。 同样的, 如果我们使用了 Immer 库做 immutable 更新, 编写 "mutating" 逻辑也是允许的,因为真实的数据没有被修改—— Immer 在内部进行了安全的变化追踪并且生成了新的 immutably 值。
Reducers 不能产生副作用
Reducer 函数必须只 依赖于 state
和 action
参数,且必须重新计算并返回一个新的 state。其中禁止执行任何异步代码(AJAX 调用, timeouts, promises),生成随机值 (Date.now()
, Math.random()
,修改在 reducer 外面定义的变量,或者执行一些修改 reducer 函数作用域之外变量的代码。
注意:只要符合同样的规则,在 reducer 中调用外部定义的一些方法,比如从库或工具类中 import 的函数等,也是可以的。
不要把不可序列化的值放进 State 或 Action
不要把不可序列化的值放进 Redux store 或者 dispatch 的 actions,比如 Promises、Symbols、Maps/Sets、functions、或类的实例。这一点保证 Redux DevTools 按照预期方式工作。也保证 UI 按照预期方式更新。
例外:有时你也可以将非可序列化数据放进 action 当且仅当 该 action 在传递到 reducer 之前会被 Middleware 拦截住并不继续向下传递。
redux-thunk
和redux-promise
就是个例子。
一个应用只有一个 Redux Store
一个标准的 Redux 应用应有且仅有一个 Store 实例(单例),供整个应用使用。它应该用一个单独的文件比如store.js
定义出来。
理想情况下,不应该有任意一个应用逻辑将其直接引入。他应该使用通过<Provider>
传递给 React 组件树,或通过 thunks 这样的 middlewares 间接引用。在极少数的用例中,你可能需要将其导入其他逻辑文件,但这应该是没有办法的办法。
B 级优先规则: 强烈推荐
在写 Redux 逻辑时使用 Redux Toolkit
Redux Toolkit 是我们推荐的 Redux 工具集。它囊括了一些封装了我们最佳实践的方法,包括配置 store 使其能捕获 mutations 并激活 Redux DevTools 拓展,使用 Immer 简化 immutable 更新等等。
写 Redux 的时候也不是必须要使用 RTK,如果愿意的话你也可以用一些其他的方法,但是使用 RTK 会简化代码逻辑,并确保应用程序遵循好的 Redux 默认行为。
使用 Immer 做 Immutable 更新
手写 immutable 更新逻辑通常比较复杂,却容易出错。Immer库可以让你写“可变”更新逻辑来简化 immutable 更新,即便在应用开发的其他任意地方为了捕捉 mutation 而 freeze 了 state。
我们建议使用 Immer 编写 immutable 更新逻辑,这一点已经作为了 Redux Toolkit 推荐配置的一部分。
将文件结构构造为具有单文件逻辑的功能性文件夹
Redux 本身并不关心应用的目录结构怎么组织。但是,按照一定规则将 Redux 逻辑收归到一处使得代码更可维护。
正因如此,我们建议大多数的应用程序因该按照“功能性文件夹”的方法来组织目录结构(即具有统一功能的文件都在一个文件夹里)。给定一个特定文件夹,有相对应功能的 Redux 逻辑 都应该被写进单独的一个 "slice" 文件,推荐使用 Redux Toolkit 的 createSlice
API。(这也是俗称的 "ducks" 模式)。虽然一些老的 Redux 代码库经常使用一种 "folder-by-type" 方式,将“actions”和“reducers”分别写到不同的文件夹中,但是将相关逻辑都归到一起使得定位代码和修改代码变得更容易。
尽可能的把逻辑放进 Reducers
尽可能试着将计算新的 state 的逻辑代码写进合适的 reducer,而不是准备数据并 dispatch action 的那段代码中(比如 click handler)。这一点有助于确保更多实际应用程序逻辑易于测试,使得时间旅行调试更高效,更帮助避免导致 mutation 和 bug 的一般性错误。
存在一些合理的用例,新的 state 中的某些或全部数据需要被首先计算(例如生成唯一的 ID),但是这种情况应该维持在一个最低的限度。
Reducers 应该持有 State Shape
Redux 根 state 是被唯一的一个的根 reducer 函数持有和计算的。从可维护性的角度,reducer 会被按照键/值对的形式划分为一个个 "slice",每个 "slice reducer" 都负责初始化值且计算和更新 slice state 值。
此外,slice reducers 要实际控制其他作为被计算出的 state 的一部分而返回的值。 尽可能减少“盲目的 spreads/returns 表达式” 的使用,比如 return action.payload
或 return {...state, ...action.payload}
,因为这些表达式依赖于 dispatch action 那段代码才能正确地格式化内容,且 reducer 放弃了 state 数据结构的掌控权。如果 action 的内容不正确,极容易导致 bug。
注意: 一个有着 spread 返回的 reducer 在很多场景下是合理的选择,比如在表单中编辑数据,如果为每个表单项分别写 action type,那将是事倍功半的。
根据存储的数据来命名 State Silce
正如在Reducer 应该持有数据形状提到的那样,基于 state 的“slice”来划分 reducer 逻辑是标准的方法。对应的,combineReducers
是一个将 slice reducer 合并成一个较大 reducer 的标准函数。
传递给 combineReducers
的对象中的键名将定义最终 state 对象中的键名。确保以内部保存的数据后命名这些键,并避免在键名中使用“reducer”这个单词。你的对象应该像这样{users: {}, posts: {}}
,而不是{usersReducer: {}, postsReducer: {}}
。
根据数据的类型而不是组件来组织 state 结构
在应用程序中,根 state silce 应该基于主要数据的类型或者功能领域来定义和命名,而不该基于 UI 中特定的组件。这是因为 Redux store 中的数据和 UI 中的组件并不是一一对应的,且很多组件可能要访问同一份数据。可以把 state 树想象成为一系列的全局数据库,app 的任意部分都可以访问,读取组件中需要的那一些数据。
例如,一个博客 app,想要追踪到登录用户是谁,作者和帖子的信息,抑或是页面激活状态等一些信息。那么一个好的 state 结构也许是这样{auth, posts, users, ui}
。一个不好的 state 结构可能长这样:{loginScreen, usersList, postsList}
。
把 Reducer 看作是 State 机器
有很多“无条件的” Redux reducer。他们只观察 dispatch 的 action 并计算一个新的状态值,而不关心当前状态的逻辑。这可能产生 bug,因为根据 app 其他逻辑,某些 action 在某些时候可能在概念上“无效”。例如,一个“request 成功”的 action 当且仅当 state 已经被“加载了”,或者一个“更新这个项”的 action 在某些项目被标记为“被编辑”状态时被 dispatch 了才会有新的值被计算。
为了解决这个问题,把 reducer 当作是“state 机器”,将现有 state 和 dispatch 的 action 绑定到一起,决定如何计算出一个新的 state,而不是仅让 action 没有状态。
将复杂的嵌套/关联式 State 归一化
很多应用需要在 store 需要缓存复杂数据。数据经常是通过 API 获取的嵌套的表单结构,或者数据之间包含着相关联的实体(比如一条博客数据包含用户数据、帖子数据以及评论数据)。
**在 store 中使用“归一化的”格式**来存储以上数据是更优的。这使得基于项目 ID 查找项目和更新 store 中的单个项目变得更容易,并最终更好的性能模式。
保持 state 的最小化,其他的值派生出来
无论是否可行,请尽可能地保证 store 中实际使用的 data 对象最小化,并且按需从那个 state 派生出 其他的值。这包括计算过滤列表或求和值。例如,todo 应用程序将保留状态中的 todo 对象的原始列表,但在状态更新时,会导出状态外的 todo 过滤列表。类似地,也可以在 store 外计算是否已完成所有 todo 或剩余 todo 的数量。
有以下几点好处:
- 真实的 state 可读性更高
- 计算出派生值并使其与其余数据保持同步所需的逻辑更少
- 原始状态仍然作为引用,不会被替换
派生数据这件事通常使用“selector”函数,该函数可以封装进行派生数据计算的逻辑。为了提高性能,使用 reselect
和 proxy-memoize
这些库可以使 selector 能被缓存,从而缓存前一次的结果。
将 action 建模为事件而不是 setter
Redux 从不关心 action.type
的字段内容是什么——它只需要被定义。写现在时态的("users/update"
)、过去时态的("users/updated"
),描述成一个事件("upload/progress"
)或者看作是“setter”("users/setUserName"
)的 action type 都是合法的。程序中的 action type 是什么含义以及怎么建模这些 action 完全取决于你。
但是,我们建议你将 action 更多地视作 “描述发生的事件”,而不是“setter”。将其视为“事件”总体而言使得 action 名称更有意义,更少的 action 被 dispatch,以及更有意义的 action 日志历史记录。编写“setter”通常导致有很多特别的 action type,更多的 dispatch,且 action 日志会没有意义。
action 的命名要有语义
action.type
字段有两个主要的作用:
- reducer 逻辑通过 action type 来判断如何计算新的 state
- action type 在 redux dev tool 中作为历史日志的显示名称
每次将 action 建模为“事件”,type
字段的实际内容对于 redux 本身来说并不关心。然而,type
的值对于你——一个开发者来说非常重要。action 应该编写语义化,包含关键信息,有描述性的 type 字段。理想情况下,在看 action type 列表的时候就应该知道这段代码在程序中是干什么的,甚至不用进去看代码本身。避免使用过于通用的命名比如 "SET_DATA"
、"UPDATE_STORE"
,因为这种命名无法表述代码具体在做什么。
允许多个 Reducer 响应相同的操作
redux reducer 逻辑能够被分割到很多小的 reducer 当中去,分别独立维护自已的那部分状态树,所有的小 reducer 组合起来构成应用的根 reducer 函数。当一个 action 被 dispatch 了,他可能会被所有的 reducer 执行,也可能是其中一些,也有可能都不执行。
作为一部分,如果可以,建议你弄不同的 reducer 函数来分别处理同一个 action。经验表明,大多数动作通常只由单个 reducer 来处理,这很好。但是,将操作建模为“事件”并允许许多 reducer 响应这些操作通常可以让您的应用程序的代码库更好地扩展,并最大限度地减少需要调度多个操作以完成一次有意义的更新的次数。
避免依次 dispatch 多个 action
避免连续 dispatch 多个 action 来完成一个概念上很大的“事务”。这虽然合法,但是通常会导致多次的 UI 更新,成本较大,且有些中间状态可能会被程序中的其他逻辑置为无效。推荐 dispatch 单个“事件式”的 action,一次性更新所有状态,或者考虑使用 action 的批处理插件来 dispatch 多个 action,从而保持一次 UI 更新。
评估以下每个 state 应该存在哪里
“Redux 三原则”中说明了“整个应用的 state 都存储在一个单一的 state 树中”。这句话被过度解读了。这并不意味着在字面上,整个应用都将每个数据值都必须存储在 Redux store 中。相反的,你能想到的全局的和 app 级的数据值都应该放到一起。“局部”的数据通常只应该保存到最近的 UI 组件中。
正因如此,作为开发者应该自主决定什么数据应该放到 store 中,什么数据应该放到组件状态中。使用这些经验规则来评估每个 state 并确定它们应该放在哪里。
API 使用 React-Redux Hooks API
推荐使用 React-Redux hooks API (useSelector
和 useDispatch
)作为默认方法来使 React 组件和 Redux store 之间交互。虽然传统的 connect
API 仍然可用且未来也将继续支持,但是 hooks API 总体来说使用起来比较简单。这些 hooks 的间接性更少,编写的代码更少,并且与 TypeScript 一起使用比 connect
更简单。
hooks API 在性能和数据流方面确实引入了一些与 connect
不同的权衡,但我们现在推荐它们作为默认。
关联更多组件以从存储中读取数据
推荐以一种更细的粒度,在 UI 组件中从 Redux store 中多次订阅不同的数据。这通常会保持一个更好的 UI 性能,因为给出的这些 state 变化后造成的需要更新的组件更少。
举个例子,应该使 <UserList>
检索出一个具有所有用户的 ID 的列表并通过 <UserListItem userId={userId}>
来渲染列表项,并使 <UserListItem>
关联到它自己关心的那个用户数据。而应该直接关联 <UserList>
并读取整个的用户数组。
以上对于 React-Redux connect()
API 和 useSelector()
hook 都适用。
将 mapDispatch
的对象简写(shorthand)形式和 connect
一起使用
connect
的 mapDispatch
参数可以定义为接收 dispatch
参数的函数,也可以定义为包含 action creator 的对象。我们建议总是使用 mapDispatch
的“对象简写”格式 ,因为这样极大地简化了代码。几乎不需要将 mapDispatch
写为函数。
在函数组件中多次调用 useSelector
当使用 useSelector
hook 检索数据时,尽可能多次调用 useSelector
并使得检索到最小数据量,而不是通过一次 useSelector
调用直接获取一个大的对象。不像 mapState
,useSelector
并不需要返回一个对象,也不需要使 selector 读取更小的值,这意味着给定的状态更改不太可能导致该组件渲染。
尽管如此,也要试着找到一个合适数据粒度作为平衡点。如果单个组件确实需要 state slice 中的所有字段,只需编写一个“useSelector”,它将返回整个片段,而不是为每个单独的字段编写一个的 selector。
使用静态类型
使用静态类型语言系统,如 TypeScript 或 Flow,而不是纯 JavaScript。类型系统能提前发现许多常见错误,改进代码的规范性,并最终获得更好的长期可维护性。虽然 Redux 和 React-Redux 最初设计时考虑的是简单的 JS,但两者都能很好地与 TS 和 Flow 配合使用。Redux Toolkit 是用 TS 专门编写的,旨在通过最少的附加类型声明提供良好的类型安全性。
使用 Redux DevTools 浏览器拓展进行 debug
配置 Redux store 使其支持 Redux DevTools 拓展来调试。它能让你查看:
- action dispatch 的历史记录
- 每个 action 的内容
- 在一个 action 被 dispatch 后的结果
- action 执行前后 state 的差别
- action 被 disptch 处的函数调用栈追踪,显示对应代码
此外,DevTools 支持“时间旅行调试”,在动作历史记录中来回切换,以查看不同时间点的整个应用程序状态和 UI。
Redux 特地设计成支持这种 debug,且 DevTools 成为了为什么使用 Redux 的最强有力的理由之一。
state 使用普通 javascript 对象
推荐使用普通 js 对象和数组来表示 state 树结构,而不是一些特殊的库,比如 Immutable.js。即使使用 Immutable.js 有一些潜在的好处,大多数常见的 state 操作目标(如简单引用比较)通常是不可变更新的属性,那样就不需要特定的库了。这样还可以使 bundle 包更小,并降低数据类型转换的复杂性。
如上所述,如果您想简化不可变的更新逻辑,特别是作为 Redux Toolkit 的一部分,特别推荐使用 Immer。
C 级优先规则: 推荐
把 action type 写成 domain/eventName
的形式
原始的 Redux 文档和例子总体上使用 “SCREAMING_SNAKE_CASE” 的风格来定义 action type,比如 “ADD_TODO”
以及 “INCREMENT”
。这与大多数编程语言中声明常量值的典型约定相匹配。缺点是大写字符串可读性差。
其他社区也采用了一些公约,通常会对行动所涉及的“特征”或“域”以及具体行动类型进行一些说明,并规定 action type。典型的,NgRx 社区使用一种 “[Domain] Action Type”
模式,比如 “[Login Page] Login”
。其他的一些模式比如 “domain:action”
也被广泛使用。
Redux Toolkit's createSlice
函数现在生成的 action type 是类似于这样“domain/action”
,比如 “todos/addTodo”
。从可读性角度出发我们建议使用 “domain/action”
形式。
使用 Flux Standard Action Convention 写 action
最初的 “Flux Architecture” 文档只规定 action 对象应该有一个 “type” 字段,没有对 action 中的字段应该使用什么类型的字段或命名约定给出任何进一步的指导。为了一致性,Andrew Clark 在 Redux 开发早期创建了一种名为 “Flux Standard Actions”规范。总的来说,FSA 这样定义 action:
- 永远把数据存进
payload
字段 - 可能会有
meta
字段来存放一些额外的数据 - 可能会有
error
字段来表示 action 失败的一些错误
很多 Redux 生态中的库采用了 FSA 规范,且 Redux Toolkit 生成的 action creator 也是符合的。
从一致性角度出发推荐使用 FSA 格式的 action。
注意:FSA 规范规定,“error”动作应设置为“error:true”,并使用与动作的“valid”形式相同的动作类型。实际上,大多数开发人员为“成功”和“错误”情况编写单独的操作类型。两者都可以接受。
使用 action creator
”Action creator“ 函数起源于 “Flux 架构“ 方法。结合 Redux,action creator 不是必须的。组件和其他逻辑也能调用 dispatch({type: "some/action"})
来使用 action。
然而,使用 action creator 保持了一致性,尤其是在需要某种准备或附加逻辑来填充 action 内容的情况下(例如生成唯一 ID)。
推荐在 dispatch 任意 action 的时候都使用 action creators。但是,与手写 action creator 不同,我们建议使用来自 Redux Toolkit 的 createSlice
函数,可以自动生成 action creator 和 action types。
使用 Thunk 处理异步逻辑
Redux 从设计上就是可拓展的,且特地设计了一些允许各种形式的异步逻辑植入的 middleware API。那样的话,如果不满足需求,使用者就不需要特地去学习像 RxJS 这样的库。
这导致创建了各种各样的 Redux 异步 middleware 插件,然后反过来引起混乱,也会存在关于应该使用哪种异步 middleware 的问题。
我们建议使用 Redux Thunk middleware 的默认配置,因为对于大多数的典型用例这些都是够用的(例如基本的 AJAX 数据请求)。此外,在 thunk 函数中使用 async/await
语法也使其可读性更高。
如果你有特别复杂的异步工作流包括撤销、防抖、在某个 action 被 dispatch 之后运行一些逻辑,或者“后台线程”行为,那么可以考虑增加一些功能更强大的异步 middleware 比如 Redux-Saga 或者 Redux-Observable。
把复杂的逻辑从组件中移出去
我们一直都建议尽可能将逻辑抽离到组件的外面。有一部分是因为要鼓励“容器/表示”的模式,在这种模式下,许多组件只接受数据作为 props 并相应地显示 UI,但也因为在类组件生命周期方法中处理异步逻辑可能变得难以维护。
我们依然鼓励将复杂的异步逻辑挪到组件外面,通常是放到 thunk 函数里。如果这部分逻辑要从 store state 中读取的话,这一点尤其正确。
但是,React hook 在组件中直接使用,在一定程度上简化了像数据请求这样的逻辑的管理,并且在一些用例中直接替代了 thunk 的作用。
使用 selector 函数从 store state 中读取数据
”selector 函数“是一个用来包装从 Redux store 状态树读取的值并从这些值派生出其他的值的强有力的工具。此外,像 Reselect 这样的库可以创建可缓存的 selector 函数,仅在输入值发生变化时才重新计算结果,这是性能优化的一个重要方面。
我们强烈建议,如果可能的话从 store state 取数的时候都使用带缓存的 selector 函数,并且推荐使用 Reselect。
然而,也不是所有 state 中的字段都必须写 selector 函数。基于哪些属性要经常被访问或更新,以及它能在你的程序中能真正带来多少收益,要找到一个合适的粒度平衡。
将 selector 函数命名成这样:selectThing
我们推荐将 selector 函数的命名前缀为单词 select
,结合要选择的值的描述。例如 selectTodos
,selectVisibleTodos
,和 selectTodoById
。
避免在 Redux 中放表单数据
大多数的表单格式不应该出现在 Redux 中。在大多数的使用案例中,数据并不是全局的,不被缓存的,且同时不会被多组件使用。此外,将表单数据链接到 Redux 通常在每个更改事件使都涉及 dispatch action,造成了性能开销,却没有实际收益。(可能你并不需要进行仅改回一个字母的时间旅行调试比如从 name: "Mark"
改到 name: "Mar"
。)
即使数据最终非要保存到 Redux,也尽可能将表单数据本身保持在本地组件状态中来进行更新,并且只在用户完成表单后 dispatch 一个 action 来更新 Redux store。
一些案例中在 Redux 中维护表单状态确实有意义,例如实时编辑预览(WYSIWYG)。但在大多数情况下是不必要的。