本篇译文的原文:Five Tips for Working with Redux in Large Applicationsjavascript
为何翻译这篇文章,是由于本文中给出的建议和我在实际项目中的实践不谋而合,更完全也更优秀。因此特别想分享给你们。前端
当项目规模逐渐增大以后,入门文档和教程级别的项目代码的局限性会逐渐显现出来,而且你会遇到在小型应用中不会遇到的问题。更致命的地方在于,若是想要解决这些问题,须要对整个应用的代码作出调整。因此最好是在创建项目之处就有意识的融入最佳实践,有助于预防未来问题的发生。java
这篇文章并不适合 redux 的初学者,但愿你已经开发了少量完整应用,或者至少正在开发你的第一个应用的时候来阅读这篇文章,这样你才更有体会。react
本文给出的建议在 Redux 的官方文档或者 React 的官方文档里或多或少确定都有说起。可是文档太庞大,入口太深以致于把这些内容给淹没了。若是你尚未接触过它们,至少这篇文章不会再让你错过它们。git
有些不便的翻译,或者翻译后很别扭,或者你们公认的技术词汇的地方仍然保持原文。下面正式开始github
Redux 是一个用于管理应用状态的出色工具。它的单向数据流和 immmutable state 特点让咱们更容易追踪状态的变动。每个状态的变动都是由被调度的 action 引发 reducer 函数返回新的状态而产生的。咱们站点上许多使用 Redux 构建的用户界面都须要处理大量的数据和复杂的交互,由于用户须要经过这些界面管理他们的广告或者在平台上更新库存信息。在开发这些界面的过程当中,咱们掌握了一些规则和窍门有助于让 Redux 更易于维护。接下来要讨论的几个要点相信对那些使用 Redux 开发大型数据集成类型的应用的同窗们会有所帮助数据库
选择正确的数据结构对应用的组织和性能相当重要。使用索引存储来自接口的可序列化数据会带来不少好处。索引指的是咱们须要进行存储的对象里的对象id,而值则是对象自己。这个模式相似于使用哈希map来存储数据,能够节省查找的时间。对于精通 Redux 的人来讲这可能不足为奇。事实上 Redux 的做者,Dan Abramob 在他的 Redux 教程里也推荐这种数据结构redux
想象你从 REST 接口里请求到了一个列表数据,好比来自/users
服务。咱们决定简单的把这个纯数组数据存储在状态中,和接口返回里的如出一辙。那么当须要从对象里获取某个具体的用户信息时会发生什么?咱们须要遍历状态状态里的全部用户。若是用户数量太多,这会是一个费时的操做。又好比想要追踪用户的子集,好比选中的用户或者非选中的用户又该怎么办?要么把用户存储为两个独立隔离的数组,要么追踪数组里被选中和非选中的用户(数组)索引数组
取而代之的咱们决定重构代码来使用索引存储数据。在 reducer 中应该像这样存储数据:数据结构
{
"usersById": {
123: {
id: 123,
name: "Jane Doe",
email: "jdoe@example.com",
phone: "555-555-5555",
...
},
...
}
}
复制代码
但这样的数据结构又是如何帮助咱们解决这些问题的呢?若是须要查找一个特定的用户对象,只须要简单的像这样访问便可:const user = state.usersById[userId]
. 这个方法不须要遍历整个数组,节省了时间而且简化了检索代码
此时你或许对如何将这种数据结构的数据渲染为一个简单的用户列表感到疑惑。要完成这项工做,咱们须要一个选择器,即一个接受状态传入而后返回数据的函数。一个获取状态中全部用户的简单选择器的例子:
const getUsers = ({ usersById }) => {
return Object.keys(usersById).map((id) => usersById[id]);
}
复制代码
在视图代码中,调用该选择器函数产出用户列表。而后遍历这些用户来渲染视图。咱们还能够编写另外一个函数用于从状态中获取被选中的用户
const getSelectedUsers = ({ selectedUserIds, usersById }) => {
return selectedUserIds.map((id) => usersById[id]);
}
复制代码
选择器模式一样提升了代码的可维护性。想象或许一段时间后咱们须要改变状态的结构(shape)。若是没有选择器的话,须要更新全部的视图代码来响应状态结构的修改。随着视图组件的增长,更改状态结构的负担也会剧烈增加。为了不这个问题,在视图中咱们使用选择器来访问状态,若是底层的状态结构发生了改变,咱们只须要更新选择器来保证访问状态方式的正确性。全部消费方的组件依然会获得它们须要的数据而不用进行更改。基于全部这些缘由,大型的 Redux 应用会从索引和选择器的存储模式中受益
真实的 Redux 应用一般须要从另外一个服务请求一些数据,好比 REST 接口。当获取到数据时,会发起一个 action, 而且附带上刚刚取得的数据。咱们倾向于把来自服务的返回数据称之为“标准状态”(canonical state)。也就是状态中那些来自数据库中的数据。状态也包括其余类型的数据,好比组件的状态,或者应用总体的状态。当首次从API中取得标准数据时,会尝试把它和页面的其余状态都存储在同一个 reducer 中。这个方法虽然会很方便,可是当你须要从不一样的源请求更多类型的数据时,扩展起来会很是困难
另辟蹊径的,咱们把标准状态隔离到它独立的 reducer 文件中去。这种方法鼓励用更好的方式组织和模块化代码。纵向的拓展 reducer 文件(增长单个文件行数)的可维护性比横向拓展 reducer (增长更多的reducer文件供 combineReducers
调用)的可维护性差。将 reducers 拆分为独立的文件会在复用它们方面也会显得更加容易。此外,它不鼓励开发者向数据对象 reducer 添加非标准状态
为何不把其余状态和标准状态存储在一块儿?想象一下咱们有一样一份请求自 REST 接口的用户列表数据。使用上一小节的索引模式进行存储,能够像这样把数据存储在 reducer 中:
{
"usersById": {
123: {
id: 123,
name: "Jane Doe",
email: "jdoe@example.com",
phone: "555-555-5555",
...
},
...
}
}
复制代码
如今想象 UI 容许用户编辑视图。当编辑图标被用户点击时,咱们须要更新视图状态使得视图为用户渲染出编辑控件。咱们决定将视图状态与标准状态合并,在每个索引对象中添加一个新字段 isEditing
,像这样:
{
"usersById": {
123: {
id: 123,
name: "Jane Doe",
email: "jdoe@example.com",
phone: "555-555-5555",
...
isEditing: true,
},
...
}
}
复制代码
编辑以后,点击提交按钮,而后变动便经过 PUT 方法传递回 REST 服务。服务返回对象的新状态。可是如何将新的标准状态合并到 store 中?若是只是根据索引赋值新的对象的话,isEditing
标志便不复存在了。因此如今须要手动指定接口的返回中哪些字段须要合并到 store 中。这让更新逻辑变得复杂了。你或许有多个布尔值、字符串、数组、或者其余 UI 所需的新字段插入到了标准状态中。在这个场景下,添加用于更新标准状态的 action 或许很简单,可是容易忘记重置对象里的 UI 字段而形成无效的状态。因此咱们应该保证标准状态在 store 的独立的 reducer 中,而且保证 action 简单而且易于追踪
另外一个把编辑状态独立出来的好处是,若是用户取消了编辑,咱们能很容易的回滚到标准状态。想象用户点击了编辑图标,而且已经编辑了名称和邮箱字段,如今他不想保留这些更改了,因此他点击了取消按钮。这个操做会引发视图的状态恢复到前一个状态。可是若是已经把标准状态和编辑状态合二为一,咱们便再也不拥有旧的数据,而不得不被迫从新从 REST 接口中再一次请求数据以得到标准状态。因此如今把编辑状态存储到其余的地方。如今总体状态看上去:
{
"usersById": {
123: {
id: 123,
name: "Jane Doe",
email: "jdoe@example.com",
phone: "555-555-5555",
...
},
...
},
"editingUsersById": {
123: {
id: 123,
name: "Jane Smith",
email: "jsmith@example.com",
phone: "555-555-5555",
}
}
}
复制代码
由于如今有了标准状态和(标准状态的副本)编辑状态,用户点击取消编辑以后回滚操做会变的很是简单。只须要使用标准状态替代编辑状态在视图中进行展现便可,而且再也不须要请求 REST 接口。额外的,咱们仍然能在 store 中追踪编辑状态。若是决定继续使用上次的编辑,那么只须要再一次点击编辑按钮,旧的更改随着编辑状态又会呈现出来。总的来讲,保证视图和编辑状态与标准状态的分离,在代码的组织和可维护性上带来更好的开发体验,同时也给使用表单的用户带来了更好的交互体验。
许多应用在发起时只有一个用户界面和单个 store。随着功能的增加应用的也会变得庞大,咱们须要管理不一样视图和 store 之间的状态。为了扩展 Redux 应用,为每个页面建立一个顶级 reducer 或许是一件有益的事情。每一个页面和顶级 reducer 对应于应用中的一个视图。举个例子,用户列表视图会从接口请求全部的用户数据,而后存储在 users
reducer 中,另外一个负责追踪当前用户拥有域名的页面会从域名接口请求数据而后储存下来,状态看起来相似于:
{
"usersPage": {
"usersById": {...},
...
},
"domainsPage": {
"domainsById": {...},
...
}
}
复制代码
像这样组织页面可以使视图和数据解耦且独立(self-contained)。每个页面追踪它本身的状态,reducer 文件甚至也能和视图文件遥相呼应(co-located)。当继续扩展应用时,咱们也许会发现须要在不一样的视图间共享它们共同依赖的状态。当考虑共享状态时请思考如下几点:
举个例子,应用须要在每一个页面展现当前登录用户的信息。咱们须要从接口中获取用户信息而后存储在 store 中。咱们知道每一个页面都依赖这份数据,因此这份数据并不适用于「每一个页面都有独立的 reducer」这个策略。咱们也知道每一个页面不须要依赖这份数据的副本,由于大多数页面不会请求其余的用户也不会修改当前的用户。并且,这份关于当前登录用户的数据不太可能发生更改,除非他们在用户页面修改他们本身。
那么在页面间分享当前用户的状态彷佛是一个好主意,因此咱们把这份数据抽取出来放在处于顶级的它本身的 reducer 中。如今用户第一次访问的页面会检查当前用户的 reducer 是否已经被加载,若是没有的话从接口进行请求。任何链接到 Redux store 的视图都能浏览这份关于当前登录用户的信息。
对于那些共享状态没有意义的场景怎么办?让咱们来考虑另外一个例子。想象每个属于用户的域名下一样也拥有必定数量的子域名。咱们将新增一个子域名列表页面用于展现用户的全部子域名列表,域名列表页面一样提供展现已选域名的子域名。如今就有两个页面同时来展现子域名数据。咱们也知道域名经常被修改,用户可能会在任什么时候候增长、删除或者编辑域名或者子域名。每一个页面可能须要独一无二的数据副本。子域名页面容许经过子域名接口读或者写,并且有可能须要对数据进行翻页操做。相反域名视图只须要一次获取子域名的一部分子集(被选择的域名的子域名)。这样看来结果很是明确了,在这个场景中在不一样页面共享子域名状态彷佛不是一个好的选择。每一个页面应该存储它本身子域名数据的副本
在编写了好几个 reducer 函数以后,咱们决定尝试在状态中的不一样地方复用 reducer 逻辑。举个例子,咱们建立了一个 reducer 从接口请求用户信息。接口每次只返回 100 条用户信息,可是在系统中有成千上万个。为了解决这个问题,reducer 须要记录当前展现的是数据的哪一页。请求逻辑会从 reducer 中读取该信息而后决定下一个请求的翻页参数是什么(好比叫page_number
)。以后在请求域名列表时,最终也编写了相同的逻辑用于请求和存储域名信息,只是接口和对象的结构(schema)不一样而已,翻页的行为仍然保持一致。聪明的开发者会意识到或许可以把 reducer 模块化而且在任何须要翻页的 reducer 中共享这段逻辑。
在 Redux 中共享 reducer 逻辑须要一些小技巧。默认状况下,当一个新的 action 发起时全部的 reducer 函数都会被调用。若是在多个 reducer 函数中共享同一个 reducer 函数,那么当 action 被发起时它会引发全部的 reducer 被触发。这不是重用 reducer 指望的行为。也就是说当请求用户列表而且取得了500条数据时,咱们不但愿域名列表的个数也变成500
咱们推荐是两种方式来实现共享,二者都使用做用域(scope)或者前缀(prefix)对动做类型(types)进行特殊处理。第一种方式须要在 action 携带的信息种传递一个做用域。action 使用动做类型来推断状态中的哪一个字段须要发生更改。为了便于说明,假设有一个拥有多个不一样区域(section)的页面,全部区域都从接口处异步进行加载。追踪加载状况的状态像这个样子:
const initialLoadingState = {
usersLoading: false,
domainsLoading: false,
subDomainsLoading: false,
settingsLoading: false,
};
复制代码
有了这个状态,接下来须要借助 reducer 和 action 来设置每一个区域视图加载状态。咱们能够写拥有不一样 action 的四个 reducer,每个都有独立的动做类型。但那是一大堆的重复代码。取而代之的是,让咱们尝试使用具备做用域的 reducer 和 action,只建立一个动做类型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 creator 函数来调用做用域 reducer。action 看起来像:
const setLoading = (scope, loading) => {
return {
type: SET_LOADING,
payload: {
scope,
loading,
},
};
}
// example dispatch call
store.dispatch(setLoading('users', true));
复制代码
经过使用一个像这样带有做用域的 reducer,解决须要在不一样 reducer 函数和 action 中重复相同逻辑的问题。这显著下降了重复代码的数量以及帮助咱们编写更小的 action 和 reducer 文件。若是须要在页面中添加另外一个区域视图,只须要简单的在初始状态中添加一个新索引,而后使用不一样的做用域调用setLoading
。这个解决办法在拥有几个须要以一样方式更新的类似字段时很是有效
一样想要在状态的不一样处共享 reducer 逻辑,不一样于使用同一个 reducer 和 action 更新状态中的多个字段,咱们但愿在调用combineReducers
时插拔式的重用 reducer 函数。那么须要经过调用 reducer 工厂函数返回一个带有类型前缀的 reducer 函数。
一个复用 reducer 逻辑很好的例子是处理翻页信息时。回到请求用户信息的例子中,接口或许包含上千个用户,接口也将提供将用户分页以后的翻页信息。假设接收到的接口返回长这个样子:
{
"users": ...,
"count": 2500, // the total count of users in the API
"pageSize": 100, // the number of users returned in one page of data
"startElement": 0, // the index of the first user in this response
]
}
复制代码
若是想要下一页的数据,须要发起一个带着startElement=100
参数的 GET 请求。咱们恰好为每个打交道的服务构建了一个 reducer 函数,可是那也意味着在代码中重复了相同的逻辑。相反,能够建立一个独立的翻页 reducer。这个 reducer 来自 reducer 工厂函数,工厂函数接受一个类型前缀参数,而后返回一个新的 reducer 函数
const initialPaginationState = {
startElement: 0,
pageSize: 100,
count: 0,
};
const paginationReducerFor = (prefix) => {
const paginationReducer = (state = initialPaginationState, action) => {
const { type, payload } = action;
switch (type) {
case prefix + types.SET_PAGINATION:
const {
startElement,
pageSize,
count,
} = payload;
return Object.assign({}, state, {
startElement,
pageSize,
count,
});
default:
return state;
}
};
return paginationReducer;
};
// example usages
const usersReducer = combineReducers({
usersData: usersDataReducer,
paginationData: paginationReducerFor('USERS_'),
});
const domainsReducer = combineReducers({
domainsData: domainsDataReducer,
paginationData: paginationReducerFor('DOMAINS_'),
});
复制代码
reducer 工厂函数paginationReducerFor
接收类型前缀参数,该参数将会被添加在该 reducer 函数内全部的类型前。工厂返回一个全部类型都已添加前缀的新的 reducer。如今当发起一个相似于 USERS_SET_PAGINATION
的 action 时,它只会引发用户信息的翻页 reducer 的更新。域名的翻页 reducer 仍然保持不变。这有效的在 store 的多处重用 reducer 函数。为了代码的完整性,这还有一个带有前缀的 action creator 工厂函数:
const setPaginationFor = (prefix) => {
const setPagination = (response) => {
const {
startElement,
pageSize,
count,
} = response;
return {
type: prefix + types.SET_PAGINATION,
payload: {
startElement,
pageSize,
count,
},
};
};
return setPagination;
};
// example usages
const setUsersPagination = setPaginationFor('USERS_');
const setDomainsPagination = setPaginationFor('DOMAINS_');
复制代码
有一些 Redux 应用永远也不须要给用户渲染视图(像接口同样),可是大部分状况下你须要视图将数据渲染出来。目前最受欢迎的与 Redux 配合的渲染 UI 类库是 React,这也是接下来用于展现如何与 Redux 整合的 UI 类库。咱们可使用上面几个小节中学习到的策略来让视图代码更友好。为了实现整合,咱们将使用 react-redux
类库
UI 整合的一个有用模式是在视图中使用访问器访问状态中的数据, 在react-redux
中便于放置访问器的地方是mapStateToProps
函数中。这个函数在connect
函数(用于将 React 组件链接至 Redux store 的函数)被调用时传递进去。在这里你能将状态中的数据映射为组件接收到的属性。这是一个完美的使用选择器从状态获取数据,而后以属性的形式传递给组件的地方。整合的例子以下:
const ConnectedComponent = connect(
(state) => {
return {
users: selectors.getCurrentUsers(state),
editingUser: selectors.getEditingUser(state),
... // other props from state go here
};
}),
mapDispatchToProps // another `connect` function
)(UsersComponent);
复制代码
这种在 React 和 Redux 之间的整合也为咱们提供了使用做用域和类型封装 action 的场所。咱们须要组件的处理函数有能力唤起 store 并调用 action creator。为了完成这项任务,在react-redux
中调用connect
时,咱们传入mapDispatchToProps
函数。函数mapDispatchToProps
是调用 Redux 的 bindActionCreators
函数用于将 action 和 dispatch 方法绑定起来的地方。在其中咱们能够像上一节展现的那样给 action 绑定做用域。举个例子,若是打算在用户列表页面中使用带有做用域模式的 reduer 实现翻页功能,代码以下所示:
const ConnectedComponent = connect(
mapStateToProps,
(dispatch) => {
const actions = {
...actionCreators, // other normal actions
setPagination: actionCreatorFactories.setPaginationFor('USERS_'),
};
return bindActionCreators(actions, dispatch);
}
)(UsersComponent);
复制代码
如今从UsersPage
组件的角度来讲,它接收到了用户列表和其余的状态碎片,以及被绑定的 action creator 做为属性传递给它。组件不须要关心它须要带有什么做用域的 action 又或者如何访问状态;在整合的层面咱们已经对这些问题进行了处理。这种机制让咱们可以建立不须要依赖状态内部工做机制的很是松耦合的组件。但愿借助在这里讨论的各种模式,咱们都能以可伸缩,可维护,以及合理的方式建立 Redux 应用。
更多参考:
本文同时也发布在个人知乎前端专栏,欢迎你们关注