原文: Comparing Redux and Relay, 作者: Mikhail Novikov (reindex CTO & Co-founder)
注: 译文内容根据个人对Redux和Relay的使用经验而翻译, 如发现任何问题, 望指正(可留言或Email: [email protected]).
开发React应用, 有时必须要解决管理客户端state的问题. 现代应用程序不应总是等服务器的响应, 切换页面就要重新请求, 而应该让页面展现尽可能地快. 应用的状态管理层(也可称为缓存层或模型层)正是负责处理这些逻辑.
Redux和Relay在应用中就是负责这一层. 本文会用一些常见的示例代码来比较这两个库。
架构概述
Redux和Relay的灵感都源于Flux(一种架构模式用来设计应用程序, 和MVC属同类). Flux的基本思路是让数据从应用的数据存储中心(Stores)到组件(Components)始终单向流动(单向数据流). 组件调用Action创建函数(Action Creators), 将Actions发送给Store. Flux最初由Facebook提出, 但是他们并没有提供一个已集成好Flux的现成库. 之后, 开源社区很快出现了许多Flux实现, 还有更多自定义实现在闭源代码库中 :). Flux很适合作为React的数据模型, 因为它禁止数据从子组件向上传递到store, 数据的修改必须通过dispatch(action)完成.
Redux
Redux简化了Flux架构. 把Flux中多个Store的概念简化成只有一个Store.
Store的数据可以通过转换方法传给组件(provider把store的数据传给connect, 让它把这些数据传给组件).
Store可以通过reducers处理action. 最大的区别是, reducer是纯函数, 接收2个参数, 现有的state(previousState)和action, 并返回新的state.
Action, 在Flux中通常是改变state的操作, 在Redux中是函数式转换(Flux里的action是函数的形式,但在redux里是普通的js对象). 任何数据都可以储存在Redux store.
Redux是一个很小的库. Action可以通过中间件来拦截或改变, 开源社区中也有很多中间件可用. 通过中间件, 编写功能完善的Redux应用会更容易.
Relay
Relay在很多方面也受到Flux启发. 只有一个store,通过action去改变(在Relay中称为Mutations). 然而, Relay禁止开发人员直接控制Store的内容. 相反, Relay根据GraphQL查询语句去自动处理, 储存或修改服务端数据.
组件通过编写GraphQL查询片段(fragments)来描述依赖的数据, Relay会根据当前组件树中所有组件的依赖数据描述去自动优化查询(把多个组件的请求合并为1次GraphQL请求).
对Store的修改(写操作)可以通过mutation(变更)来实现, mutations在客户端和服务器端都修改数据, 保持数据一致. 不同于Redux, 在Relay中只能变更在服务端声明过的数据, 并且服务器必须有一个GraphQL服务.
Relay提供了许多很赞的功能. Relay负责所有数据的获取, 并确保所需数据无异常. Relay有完善的分页支持, 很适合类瀑布流类的场景(无限滚动). Relay mutations可以做到乐观更新(Optimistic Update), 即: 页面UI先改变, 再以服务器返回结果为准更改页面UI, 如果出错会回滚.
组件集成
Redux
Relay和Redux都可以很好地与与React集成. Redux并不依赖于React, 可以和其他框架搭配使用. Relay目前只能和React/React Native搭配使用. 然而, 为了支持除React外的其他框架, 抽取Relay组件层的工作已经展开.
Redux提倡通过容器组件和展示组件分离
的开发思想实现表现与数据逻辑分离. 即: 只在顶层组件用Redux, 仅用于展示的内部组件的数据都通过props传入.
容器组件通常由Redux创建, 负责dispatch actions以及从Redux store读取state. 展示组件仅仅是普通的React组件. 容器组件通过定义映射store state到props的方法(mapStateToProps)把指定数据传递给展示组件. 复杂的应用,也有存在多个容器组件, 但层次结构应保持使用传递props传递数据.
// 这个例子: VisibleTodoList是容器组件, TodoList是展示组件
import { connect } from 'react-redux'
const getVisibleTodos = (todos, filter) => {
switch (filter) {
case 'SHOW_ALL':
return todos
case 'SHOW_COMPLETED':
return todos.filter(t => t.completed)
case 'SHOW_ACTIVE':
return todos.filter(t => !t.completed)
}
}
const mapStateToProps = (state) => {
return {
todos: getVisibleTodos(state.todos, state.visibilityFilter)
}
}
const VisibleTodoList = connect(
mapStateToProps,
)(TodoList)
export default VisibleTodoList
通过react-redux提供的Provider作为最顶层组件, <Provider> 来包住根组件 <App />,以此来让组件树中所有组件都能访问到store.
import { Provider } from 'react-redux'
const store = createStore(todoApp); // Redux store
// ...
render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root')
)
Relay
Relay把React组件包裹进Relay容器中. Relay容器够自动检索子组件的数据依赖(根据GraphQ查询片段). Relay容器容器互相隔离, 并确保GraphQL查询片段在组件被渲染之前获取到数据, 查询数据作为props传递进UI组件.
// 下面的todo会
class Todo extends React.Component {
render() {
return (
<div>{this.props.todo.id} {this.props.todo.text}</div>
);
}
}
const TodoContainer = Relay.createContainer(Todo, {
fragments: {
todo: () => Relay.QL`
fragment on Todo {
id,
text
}
`,
}
})
组件树里所有的查询片段会合并为1次查询, 已经获取过的数据查询将会自动排除, 如果所有数据都获取过将不会有查询.
const TodoListContainer = Relay.createContainer(Todo, {
fragments: {
todoList: () => Relay.QL`
fragment on TodoConnection {
count,
edges {
node {
${TodoContainer.getFragment('todo')}
}
}
}
`,
},
})
Relay有个顶级的组件RootContainer(相当于entry point). 依赖2个属性: Relay容器和Route.
Route和路由没半点关系, 用于配置数据查询, 未来可能会改名为RelayQueryRoots或RelayQueryConfig.
Relay.Route是必要的, 因为类似的组件可能需要不同的初始数据(比如<TodoList />
组件, 既可以可以用来展示整个团队的任务, 也可以用于展示某个人的任务).
Relay.Route还可使用参数, 用paramDefinitions标记预期的参数.
class TodoRoute extends Relay.Route {
static routeName = 'TodoRoute';
static queries = {
todoById: () => Relay.QL`
query {
todoById(id: $id)
}
`
};
static paramDefinitions = {
id: { required: true }
};
}
render(
<Relay.RootContainer
Component={SingleTodoContainer}
route={new TodoRoute({id: 123})} />,
document.getElementById('root')
)
Mutations(变更)
客户端改变数据是一个常见需求. 我们通常希望交互更快, 做到乐观更新. 之后等待服务器返回结果后, 进行UI改变.
Let’s make a mutation that changes the text of a TODO item, updates it on the server, and rolls back the change, if it fails.
Redux
我们将使用thunk 中间件实现异步操作. 首先触发乐观更新, 然后再回滚或应用变更.
function todoChange(id, text, isOptimistic) {
return {
type: TODO_CHANGE,
todo: { id, text, isOptimistic }
};
}
function editTodo(id, text) {
return (dispatch, getState) => {
const oldTodo = getState().todos[id];
// Perform optimistic update
dispatch(todoChange(id, text, true))
fetch(`/todo/${id}`, {
method: 'POST'
}).then((result) => {
if (result.code === '200') {
// Confirm update
dispatch(todoChange(id, text, false))
} else {
dispatch(todoChange(oldTodo.id, oldTodo.text, false))
}
})
}
}
// In the store handler
case TODO_CHANGE:
return {
...state,
todos: {
...state.todos,
[id]: {
id,
text,
isOptimistic
}
}
}
)
// Now we can dispatch it
store.dispatch(editTodo(todo.id, todo.text));
Relay
Relay中没有自定义动作. 相反, 我们需要定义个Mutation(使用Relay mutation DSL).
GraphQL mutations采用一种有趣的方式 - mutation首先是一个操作, 然后是一个查询. 因此, 我们可以通过mutations使用GraphQL查询, 以类似的方式来获取数据.
这样, Relay根据变更的数据会自动处理UI变动.
class ChangeTodoTextMutation extends Relay.Mutation {
// Get the name of the mutation on server, so that we can call the server
getMutation() {
return Relay.QL`mutation{ updateTodo }`;
}
// Map the props passed to mutation to the server input object
getVariables() {
return {
id: this.props.id,
text: this.props.text,
};
}
// Define a query on the resulting payload, with all the data that changed
getFatQuery() {
return Relay.QL`
fragment on _TodoPayload {
changedTodo {
id
text
}
}
`;
}
// Define what exactly Relay should change in the store. In this case
// we say that it should match the item from `changedTodo` element in the
// result with an item in the store by id and then update the item in the
// store
getConfigs() {
return [{
type: 'FIELDS_CHANGE',
fieldIDs: {
changedTodo: this.props.id,
},
}];
}
// To make Relay make an optimistic update, we need to "fake" the response
// from the server. Here it's pretty easy.
getOptimisticResponse() {
return {
changedTodo: {
id: this.props.id,
text: this.props.text,
},
};
}
}
现在这个mutation可以以类似action的方式被调用.
Relay.Store.commitUpdate(
new ChangeTodoTextMutation({
id: this.props.todo.id,
text: text,
}),
);
我们还可以传递mutation成功或失败的回调函数给commitUpdate
. 当然, 任何情况, 如果请求失败, 回滚的动作都是自动处理的.
Relay.Store.commitUpdate(
new ChangeTodoTextMutation({
id: this.props.todo.id,
text: text,
}), {
onFailure: () => {
console.error('error!');
},
onSuccess: (response) => {
console.log('success!')
}
});
Mutation DSL被认为是Relay的一个弱点.
有时, 不太容易弄清所有参数和Relay所需的精确匹配查询.
应该指出的是Relay可以做一些相当复杂的事情,比如对一些分页数据的条目做变更.
Relay维护人员目前正在开发一种新的底层API, 有望提高编写mutations的体验.
处理分页数据
Redux
在Redux中可以有很多方式实现分页. 在Redux中实现分页, 需要创建一个reducer来跟踪当前获取的数据, 得到下一页的cursor_id或URL或页码参数.
在real-world示例应用中有个用Redux实现分页的例子, 分页数据来自GitHub API.
Redux在这方面处于劣势, 因为缺乏标准的服务端API而不能自动实现分页. 然而, 这也意味着可以更加自由地实现手动分页.
Relay
Relay通过Connection模型来抽象列表数据.
Relay GraphQL的所有connection都支持分页参数, connection的每条元素都有一个cursor属性作为指针标记翻页关系.
此外, connection还提供了pageInfo字段标记是否有下一页.
分页采用标准化API的好处是复杂的工作都交给了Relay.
我们只需传递参数, Relay会去负责获取数据(那些未获取的数据).
可以在Relay容器中传递变量来使用cursor.
变量可以被传递到GraphQL查询片段中.
下面尝试实现一个简单的PaginatedTodoList组件和容器.
class PaginatedTodoList extends React.Component {
nextPage() {
const lastElement = this.props.user.todos.edges.length - 1;
this.setVariables({
after: this.props.user.todos.edges[lastElement].cursor;
});
}
render() {
return (
<div>
<TodoList todos={this.props.user.todos} />
{this.props.user.todos.pageInfo.hasNextPage ?
<button onClick={this.nextPage} :
<div>Last page</div>}
</div>
);
}
}
const PaginatedTodoListContainer = Relay.createContainer(PaginatedTodoList, {
fragments: {
user: () => Relay.QL`
fragment on User {
todos(first: 10, after: $after) {
edges {
cursor
}
# 声明子组件需要的数据,再把数据作为props传给子组件
# `todos`是子组件`<TodoList>` 的 `fragments`里定义的 (https://git.io/voo3i)
${TodoList.getFragment('todos')}
pageInfo {
hasNextPage
}
}
}
`,
},
initialVariables: {
after: null,
},
});
当点击按钮加载更多时, 我们仅仅更新了after
参数,Relay负责将获缺少的数据.
总结
Redux和Relay都是较成熟并经过行业验证的库.
Relay提供了更多实用功能, 但正因为如此, 后端方面会更严格, 需要兼容GraphQL API.
Redux非常灵活, 但是意味着你需要编写更多的代码.
如果你想快速尝试下Relay, 可以尝试下Reindex, Relay兼容的GraphQL实现(Reindex是一个提供GraphQL后端服务的平台, 也是作者的公司).
延伸阅读
- Redux文档 - 最好的Redux文档之一
- Redux入门视频 - Redux作者Dan Abramov制作的课程
- Relay文档
- Reindex教程 - 使用Relay构建todo应用, 包含了mutations
- 深入Relay - 很棒的关于Relay工作原理和背后哲学的演讲
- Redux示例 - Redux官方一些示例项目
- Reindex示例 - Reindex的例子, 全部使用到了Relay