译者:is_january
Redux 是一款用来管理应用状态 (state) 的优秀工具,这种数据的单向流和其对不可变状态的专注,使得关于状态改变的推测变得简单。每次对于我们状态的更新都是由一个分发的动作 (dispatched action) 所引起的,这会导致我们的 reducer 函数返回一个带有我们需要的变化的新状态。当我们的客户在我们的平台上管理他们的广告或者发布产品的时候,UI 中的很多部分,这部分 UI 是我们在 AppNexus 中用 Redux 创建的,它们 会处理大量的数据和非常复杂的用户交互。在开发他们的接口的过程中,我们已经形成了一套有益的规则和注意点以保证 Redux 的可管理性。以下的几点讨论应该能帮助任何正在使用 Redux 开发大型、强数据应用的人员:
-
第一部分: 使用索引和选择器来储存和访问状态 (state)
-
第二部分 : 将数据对象、对于那些数据对象的编辑、以及其他 UI 状态分离开
-
第三部分 : 在单页应用的多屏之间共享状态,以及什么时候不该这么做
-
第四部分 : 在状态的不同位置之间复用公共 reducer
-
第五部分 : 将 React 组件连接到 Redux 状态的最佳实践
1. 以索引 (index) 储存数据。以选择器 (selector) 访问
选择合适的数据结构能让我们应用的组织结构和性能有很大不同。从 API 中存储序列化的数据会很大程度上受益于基于索引的存储。索引 (index) 是一个 javascript 对象,它里面的键是我们储存的数据对象的 id,其值是实际的数据对象本身。这种模板很像是用 hashmap 来存数据,而且我们在查询的时候也能获得同样的好处。这也许对那些精通 Redux 的人员来说没什么了不起的,的确,Redux 的创造者 Dan Abramov 在他的 Redux 教程 中推荐这种数据结构。
想像你有一列来自 REST API 的数据对象,比如说,来自
/users
服务的数据。假设我们决定简单地把平凡数组存到我们的状态里去,就好像它是在 response 里一样。当我们需要读取一个特定 user 对象的时候会发生什么?我们也许得去遍历状态中的所有 users。如果有很多 user,这也许会是一次开销很大的操作。如果我们想要跟踪 user 的一个子集,可能是被选中的和未被选中的,又会发生什么呢?我们可以把数据存在两个独立的数组里面,或者选择跟踪在选中和未选中 user 主数组中的索引。
相反,我们决定重构我们的代码来把数据存在一个索引里,我们可能像下面这样在 reducer 里存储数据:
{
""usersById"": {
123: {
id: 123,
name: ""Jane Doe"",
email: ""[email protected]"",
phone: ""555-555-5555"",
...
},
...
}
}
复制代码
所以,这种数据结构是怎样帮助我们处理这些问题的呢?如果我们想要查询一个特定的 user 对象,我们可以像这样来简单地访问状态:
const user = state.usersById[userId]
复制代码
这个读取的方法不需要遍历整张列表,为我们节省了时间并且简化了读取的代码
现在你也许想弄明白我们实际上是怎么用这些数据结构来实现渲染一张简易 user 列表的。为了达成这个目标,我们将会使用选择器,选择器是一个读取状态(作为参数),然后返回你要的数据的函数,一个显而易见的例子也许会是在我们的状态里获取所有 user 信息:To do so, we will use a selector, which is a function that takes the state and returns your data. A simple example would be a function to get all the users in our state:
const getUsers = ({ usersById }) => {
return Object.keys(usersById).map((id) => usersById[id]);
}
复制代码
在我们 view 层的代码里,我们用状态 (作为参数) 去调用那个函数,以生成 user 列表。然后我们就能迭代那些 user,并生成我们的 view。我们可以另外写一个函数只是为了从我们的状态里拿到被选中的 user,像这样:
const getSelectedUsers = ({ selectedUserIds, usersById }) => {
return selectedUserIds.map((id) => usersById[id]);
}
复制代码
选择器模板同样提升了代码的可维护性。想像一下接下来,我们希望改变我们状态 (state) 的结构,如果没有选择器,我们可能得修改所有的 view 层代码,以匹配我们新的状态结构。随着视图层组件 (view components) 的数目不断增长,改变状态结构的负荷会急剧增长。为了避免这个问题,我们会使用选择器来访问视图 (view) 中的状态。如果下层的 state 结构改变了,我们只需要正确地更新选择器来读取数据就行了。所有使用服务的组 件仍旧会获取他们的数据,而且我们不用去更新它们。基于所有这些原因,大型 Redux 应用会从索引和选择器数据存储模板中受益。
2. 从视图和编辑状态中分离出标准状态 (canonical state)
真实情况下,Redux 应用通常需要从其他服务上,比如 REST API 上,抓取某种数据,当我们接收到那部分数据的时候,我们会 dispatch 一个带有所有返回数据 payload 的 action。我们把从某个服务返回的数据叫做“标准数据(canonical state)”,举个栗子,数据的当前正确状态,这种正确状态就好像是它被存在我们数据库里一样。我们的状态同样包含着一些其他的数据,像是 UI 组件的状态啊、或者是整个应用的状态,并且把他们包含在一起,看作是一个整体。我们第一次从 API 读取标准状态(canonical state) 的时候,我们也许会忍不住把这个状态存在同一个的 reducer 文件里,就好像该页面状态的其他部分一样。虽然这个方法可能比较方便,但当你需要从不同数据源拿到多种数据的时候,你很难去控制(状态的)规模。
替代的方法是,我们会把标准状态分离成他们各自的 reducer 文件,这种方法鼓励更加优雅的代码组织和模块化系统。垂直管理(在同一个文件里不断增加更多行的代码) reducer 文件比水平管理(往
combineReducers
这个调用点添加更多的 reducer 文件) reducer 文件更难维护,把 reducer 分解成他们各自的文件使得重用那些 reducer (更多内容请移步
Section 3
)变得更简单了。而且,它会阻止开发者们往数据对象 reducer 里添加非标准化的状态(non-canonical state)。
为什么不把其他类型的状态也用标准化状态来存储呢?想象一下我们有一张从 REST API 获得的相同的 user 列表,用索引存储模板的话,我们会把数据像这样放到 reducer 里面:
{
""usersById"": {
123: {
id: 123,
name: ""Jane Doe"",
email: ""[email protected]"",
phone: ""555-555-5555"",
...
},
...
}
}
复制代码
现在假设我们的 UI 允许用户能在视图中被编辑,当编辑图标被某位用户点击的时候,我们需要更新状态以使得视图渲染那个用户的编辑控制区。相较于把我们的视图状态存放在标准状态之外,我们决定把它放在
users/by-id
索引中对象的新字段里。现在我们的状态看上去是这样的:
{
""usersById"": {
123: {
id: 123,
name: ""Jane Doe"",
email: ""[email protected]"",
phone: ""555-555-5555"",
...
isEditing: true,
},
...
}
}
复制代码
我们做了一些编辑,点击了提交按钮,而且数据的改变也被以 PUT 请求返回给了 REST 服务,REST 服务返回了
usersById
对象的新状态,但是我们怎么把新的标准状态合并回我们 store 里去呢?如果我们只是把新 user 对象按他们的键 id 来存放在
users/by-id
索引里的话,那么我们的
isEditing
标识位就不在那里面了。现在我们需要手动指定 API payload 上的哪些字段是我们需要放回到 store 里去的,这使得我们更新的逻辑复杂化了。你也许会有多个布尔值、字符串、数组,或是其他对于 UI 状态来说必要的新字段,这些字段将会被追加到标准状态中去。在这种情形下,你很容易添加了一个新的 action 来修改标准状态,却忘了把其他 UI 字段重设到数据对象中去,这会导致一个无效状态。我们应该把标准数据存放在它自己在 reducer 里的独立数据 store 中,并且保证我们的 action 更简单和更容易推演。
另一个保持编辑状态独立的好处是如果用户取消了编辑,我们能够很容易将标准状态复位。如果我们已经点了某个用户的编辑图标,并且已经编辑了用户的 name 和 email address,现在我们不需要保留这些修改,所以点击了取消键,这应该使得我们在视图里做的变更回退到它们之前的状态,但是,因为我们用编辑状态覆盖了我们的标准状态,我们不再拥有旧的数据状态了,我们会被迫重新从 REST API 上抓取数据以再次获得我们的标准状态。现在我们的状态也许看上去长这样:
{
""usersById"": {
123: {
id: 123,
name: ""Jane Doe"",
email: ""[email protected]"",
phone: ""555-555-5555"",
...
},
...
},
""editingUsersById"": {
123: {
id: 123,
name: ""Jane Smith"",
email: ""[email protected]"",
phone: ""555-555-5555"",
}
}
}
复制代码
因为我们对于对象的编辑状态和标准状态各有一份拷贝,因此很容易在点击取消以后重置。我们只需要在视图里展示标准状态而非编辑状态,并且不需要对 REST API 的更进一步调用。作为奖励,我们仍然在 store 中跟踪编辑状态。如果决定我们真的需要保留这些编辑,我们只需要再次点击编辑按钮,并且现在编辑状态就会以我们之前旧的改变显示。总而言之,把编辑状态和视图状态从标准状态中分离出来,并且从代码组织和可维护性,以及一种和表单交互的更佳的用户体验出发,这种做法给这两者都提供了更好的开发者体验。
3. 合理地在视图间共享状态
很多应用也许是从一个单一的 store 和用户界面开始的,当我们不断开发我们的应用以扩展其特性的时候,我们需要在多个不同视图和 store 间管理状态。为了扩展我们的 Redux 应用,在每个页面都创建一个顶级的 reducer 也许会有所帮助。每个页面和顶级 reducer 都对应一个我们应用中的视图(view),比如,用户页面会从 API 抓取 user 数据并存放在
users
reducer 里,另一个追踪当前用户域的页面会从我们的 domain API 抓取和储存数据。现在状态长这样:
{
""usersPage"": {
""usersById"": {...},
...
},
""domainsPage"": {
""domainsById"": {...},
...
}
}
复制代码
像这样组织页面能保证视图背后的数据解耦而且独立。每个页面都会跟踪它自己的状态,而且我们的 reducer 文件能和我们的视图文件放在一起。随着我们不断扩张我们的应用,也许会发现需要在两个视图中共享某个状态,这两个视图同时依赖那个数据(状态)。在共享状态时考虑以下情形:
-
有多少视图(view)或其他 reducer 会依赖这个数据?
-
是否每个页面都需要这份数据的拷贝?
-
这个数据的变化到底有多频繁?
举个栗子,我们的应用需要在每个页面都显示当前登录用户的某些信息,我们需要从 API 获取用户信息,然后存在我们的 reducer 里。我们知道每个页面都需要依赖这个数据,所以它并不适合我们的每个页面都有顶级 reducer 的策略。我们知道每个页面并不一定需要数据的唯一拷贝,因为大多数据页面不会抓取其他用户或者修改当前用户,而且,当前登录用户的数据是不太可能改变的除非他们正在用户页面做一些编辑。
在页面之间共享当前 user 的状态看起来是个不错的方案,所以我们会把它抽出来,放到它自己的文件的顶级 reducer 中去。现在第一个用户访问的页面会检查当前的 user reducer 是否被载入,并且在未被加载的情况下从 API 请求数据。任何连接到 Redux store 的视图都能访问当前登录用户的信息。
那么哪些情形是没理由使用共享离状态的呢?我们另外想一个例子。假设每个属于用户的域同样还有几个子域,我们向应用加入一个子域页面,它会显示一张所有用户子域的列表,(主)域页面同样有一个选项可以显示某个给定域的子域。现在我们有了两个依赖子域数据的页面。我们也知道域会在某种频繁基础上进行改变—————用户可能会在任何时候添加、删除、编辑域或子域,每个页面也有可能需要他自己的数据拷贝。子域页面会允许子域 API 的读写,也可能需要根据数据的不同页数做分页,相比之下,(主)域页面将只需要一次(某个给定域的子域)抓取子域的子集。在这些视图间分享子域状态也许不是一个合适的用法,这点很清楚,每个页面应该存档一份它自己的子域数据拷贝。
4. 重用公共的跨状态 reducer 函数
在写过一些 reducer 函数以后,我们可能决定去尝试重用我们在状态不同位置的 reducer 逻辑。例如,我们可以创建一个 reducer 以从 API 上抓取 user 数据,这个 API 每次只返回100条 user 数据,但我们可能在系统里有上千条甚至更多的 user 数据,为了做到这一点,我们的 reducer 也需要跟踪当前显示的是哪一页的数据。我们的抓取逻辑会从 reducer 里读取(数据)来决定发送给下一个 API 请求的分页参数(如
page_number
),之后当我们需要抓取域列表的时候,我们会以完全相同的抓取和储存域的逻辑来结束,只是使用一个不同的 API endpoint 和不同的对象 schema,分页行为是一样的。资深的开发者会意识到我们也许可以将这个 reducer 模块化,并且在需要做分页的所有 reducer 中将这部分逻辑共享。
共享 reducer 逻辑在 Redux 里可能会一些小技巧。默认情况下,所有 reducer 函数会在 dispatch 一个新 action 的时候被调用,如果我们在多个其他 reducer 中共享某个 reducer 函数(应该指的是共享 reducer 有同一个 type,译者注),那么当我们 dispatch action 的时候,它会导致 __所有__这些 reducer 都被触发,但这不是我们想要的重用 reducer 的行为。当我们抓取 user 并且得到一个总数 500 的时候,我们不希望域的
count
同样也变成 500。
我们推荐两种不同的实现共享 reducer 的方式,他们都使用特殊的限制域(scope)或是 type(reducer 的参数之一,译者注) 前缀。第一种方式往某个 action 的 payload 里传递一个限制域,这个 action 使用 type 来推断要更新的状态中的键。为了更清楚地解释,我们想象一下有一个包含几个不同部分的网页,这些部分都要从不同 API 端点异步加载。我们用来跟踪加载的状态长这样:
const initialLoadingState = {
usersLoading: false,
domainsLoading: false,
subDomainsLoading: false,
settingsLoading: false,
};
复制代码
有了这样一个状态,我们需要 reducer 和 action 为每个视图部分设置加载状态。我们可以为四个不同的 action 写四个不同 reducer 函数————每一个 reducer 都会使用他们自己的 action type。这样会产生很多重复代码!代替的做法是,让我们尝试用一个有限制域的(scoped) reducer 和 action。我们只创建了一个 action type
SET_LOADING
,以及一个长这样的 reducer:
const loadingReducer = (state = initialLoadingState, action) => {
const { type, payload } = action;
if (type === SET_LOADING) {
return Object.assign({}, state, {
// sets the loading boolean at this scope
[`${payload.scope}Loading`]: payload.loading,
});
} else {
return state;
}
}
复制代码
我们也需要提供一个有限制域的 action 创建器函数以调用我们这个有限制域的 reducer。这个 action 应该像这样:
const setLoading = (scope, loading) => {
return {
type: SET_LOADING,
payload: {
scope,
loading,
},
};
}
// example dispatch call
store.dispatch(setLoading('users', true));
复制代码
通过使用这样一个有限制域的 reducer,我们不再需要在多个 action 和 reducer 函数中重复 reducer 逻辑。这极大地减少了代码重复量,并且能够帮助我们编写更小的 action 和 reducer 文件。如果你需要生视图里添加其他部分,我们只需要往我们的 initial state 里提供一个新的键,另外做一次 dispatch 调用,并且在调用这个 dispatch 的时候往
setLoading
里传入一个不同的限制域(scope)。这种方案在我们有几个需要以相同方式更新的、相似且并列的字段的时候非常有效。
但是有些时候我们需要在状态的不同位置间共享 reducer 逻辑。我们想要一个可重用的 reducer 函数,我们可以用这个 reducer 调用
combineReducers
来嵌入状态的不同位置,而不是用一个 reducer 和 action 向状态中的某个位置写入多个字段。这个 reducer 将会通过对一个 reducer 工厂函数(reducer factory function)的调用而返回,这个工厂函数会返回一个新加前缀 type (action 的 type,译者注) 的新 reducer。