如何处理 Redux 中复杂的副作用?
How to handle complex side-effects in Redux?
我已经苦苦挣扎了几个小时才找到解决这个问题的方法...
我正在开发一款带有在线记分牌的游戏。玩家可以随时登录和注销。完成一局游戏后,玩家会看到计分板,看到自己的排名,分数会自动提交。
计分板显示玩家的排名,以及排行榜。
记分板在用户完成游戏(提交分数)和用户只想查看排名时使用。
这就是逻辑变得非常复杂的地方:
如果用户已登录,则将首先提交分数。保存新记录后,将加载记分板。
否则,计分板将立即加载。玩家将可以选择登录或注册。之后会提交分数,然后会再次刷新计分板。
但是,如果没有提交分数(只是查看高分table)。在这种情况下,只需下载播放器的现有记录。但是由于这个动作不影响计分板,所以计分板和玩家的记录应该同时下载。
关卡数量不限。每个级别都有不同的记分牌。当用户查看记分牌时,用户正在“观察”该记分牌。当它关闭时,用户停止观察它。
用户可以随时登录和注销。如果用户注销,用户的排名应该消失,如果用户以另一个帐户登录,则应该获取并显示该帐户的排名信息。
...但是这种获取此信息的操作应该只发生在用户当前正在观察的记分牌上。
对于查看操作,结果应该缓存在内存中,这样如果用户重新订阅同一个记分板,就不会进行提取。但是,如果有正在提交的分数,则不应使用缓存。
任何这些网络操作都可能失败,玩家必须能够重试。
这些操作应该是原子的。所有状态都应该一次性更新(没有中间状态)。
目前,我可以使用 Bacon.js(一个函数式反应式编程库)来解决这个问题,因为它带有原子更新支持。代码相当简洁,但现在是乱七八糟的unpredictable意大利面条代码。
我开始研究 Redux。所以我试着构建商店,并想出了这样的东西(在 YAMLish 语法中):
user: (user information)
record:
level1:
status: (loading / completed / error)
data: (record data)
error: (error / null)
scoreboard:
level1:
status: (loading / completed / error)
data:
- (record data)
- (record data)
- (record data)
error: (error / null)
问题变成:我应该把副作用放在哪里。
对于无副作用的操作,这变得非常容易。例如,在 LOGOUT
操作中,record
reducer 可以简单地关闭所有记录。
但是,有些动作确实有副作用。例如,如果我在提交分数之前没有登录,那么我登录成功,SET_USER
操作将用户保存到商店。
但是因为我有一个分数要提交,这个SET_USER
动作也必须引起一个AJAX请求被触发,同时,设置record.levelN.status
到loading
.
问题是:当我以原子方式登录时,如何表示副作用(分数提交)应该发生?
在 Elm 架构中,更新程序在使用 Action -> Model -> (Model, Effects Action)
形式时也会产生副作用,但在 Redux 中,它只是 (State, Action) -> State
.
根据 Async Actions 文档,他们推荐的方法是将它们放入动作创建器中。这是否意味着提交分数的逻辑也必须放在操作创建者中才能成功登录操作?
function login (options) {
return (dispatch) => {
service.login(options).then(user => dispatch(setUser(user)))
}
}
function setUser (user) {
return (dispatch, getState) => {
dispatch({ type: 'SET_USER', user })
let scoreboards = getObservedScoreboards(getState())
for (let scoreboard of scoreboards) {
service.loadUserRanking(scoreboard.level)
}
}
}
我觉得这有点奇怪,因为负责这种连锁反应的代码现在存在于 2 个地方:
- 在减速器中。当
SET_USER
动作被调度时,record
reducer 还必须将属于观察记分板的记录的状态设置为 loading
.
- 在动作创建器中,执行 fetching/submitting 分数的实际副作用。
看来我还必须手动跟踪所有活跃的观察者。而在 Bacon.js 版本中,我做了这样的事情:
Bacon.once() // When first observing the scoreboard
.merge(resubmit口) // When resubmitting because of network error
.merge(user川.changes().filter(user => !!user).first()) // When user logs in (but only once)
.flatMapLatest(submitOrGetRanking(data))
实际的 Bacon 代码要长得多,因为上面所有的复杂规则,使得 Bacon 版本几乎不可读。
但 Bacon 会自动跟踪所有活跃订阅。这让我开始质疑它可能不值得切换,因为将其重写为 Redux 需要大量手动处理。任何人都可以提出一些建议吗?
当您需要复杂的异步依赖项时,只需使用 Bacon、Rx、channels、sagas 或其他异步抽象。您可以在有或没有 Redux 的情况下使用它们。 Redux 示例:
observeSomething()
.flatMap(someTransformation)
.filter(someFilter)
.map(createActionSomehow)
.subscribe(store.dispatch);
您可以随心所欲地编写异步操作 — 唯一重要的部分是它们最终会变成 store.dispatch(action)
调用。
Redux Thunk 对于简单的应用程序来说已经足够了,但是随着你的异步需求变得更加复杂,你需要使用真正的异步组合抽象,而 Redux 并不关心你使用哪个。
更新:一段时间过去了,出现了一些新的解决方案。我建议您查看 Redux Saga,它已成为 Redux 中相当流行的异步控制流解决方案。
编辑:现在有一个受这些想法启发的redux-saga项目
这里有一些不错的资源
- Managing processes in Redux Saga
- From actionsCreators to Sagas
- Snake game implemented with Redux-saga
Flux / Redux 的灵感来自后端事件流处理(无论名称是什么:eventsourcing、CQRS、CEP、lambda 架构...)。
我们可以将 Flux 的 ActionCreators/Actions 与 Commands/Events(后端系统中通常使用的术语)进行比较。
在这些后端架构中,我们使用一种通常称为 Saga 或流程管理器的模式。基本上它是系统中接收事件的一部分,可以管理自己的状态,然后可以发出新命令。为了简单起见,它有点像实现 IFTTT (If-This-Then-That)。
您可以使用 FRP 和 BaconJS 实现它,但您也可以在 Redux reducer 之上实现它。
function submitScoreAfterLoginSaga(action, state = {}) {
switch (action.type) {
case SCORE_RECORDED:
// We record the score for later use if USER_LOGGED_IN is fired
state = Object.assign({}, state, {score: action.score}
case USER_LOGGED_IN:
if ( state.score ) {
// Trigger ActionCreator here to submit that score
dispatch(sendScore(state.score))
}
break;
}
return state;
}
明确一点:从 reducer 计算出来的用于驱动 React 渲染的状态应该绝对保持纯净!但并非您的应用程序的所有状态都具有触发渲染的目的,在这种情况下,需要将应用程序的不同解耦部分与复杂的规则同步。此 "saga" 的状态不应触发 React 渲染!
我不认为 Redux 提供任何支持这种模式的东西,但你可以很容易地自己实现它。
我已经在我们的 startup framework 中完成了这个并且这个模式工作正常。它允许我们处理 IFTTT 事情,例如:
当用户引导处于活动状态并且用户关闭 Popup1,然后打开 Popup2,并显示一些提示工具提示。
当用户使用手机网站时,打开Menu2,然后关闭Menu1
重要:如果您正在使用某些框架(如 Redux)的 undo/redo/replay 功能,重要的是在事件日志重放期间,所有这些传奇都是未连接的,因为您不希望在重放期间触发新事件!
我已经苦苦挣扎了几个小时才找到解决这个问题的方法...
我正在开发一款带有在线记分牌的游戏。玩家可以随时登录和注销。完成一局游戏后,玩家会看到计分板,看到自己的排名,分数会自动提交。
计分板显示玩家的排名,以及排行榜。
记分板在用户完成游戏(提交分数)和用户只想查看排名时使用。
这就是逻辑变得非常复杂的地方:
如果用户已登录,则将首先提交分数。保存新记录后,将加载记分板。
否则,计分板将立即加载。玩家将可以选择登录或注册。之后会提交分数,然后会再次刷新计分板。
但是,如果没有提交分数(只是查看高分table)。在这种情况下,只需下载播放器的现有记录。但是由于这个动作不影响计分板,所以计分板和玩家的记录应该同时下载。
关卡数量不限。每个级别都有不同的记分牌。当用户查看记分牌时,用户正在“观察”该记分牌。当它关闭时,用户停止观察它。
用户可以随时登录和注销。如果用户注销,用户的排名应该消失,如果用户以另一个帐户登录,则应该获取并显示该帐户的排名信息。
...但是这种获取此信息的操作应该只发生在用户当前正在观察的记分牌上。
对于查看操作,结果应该缓存在内存中,这样如果用户重新订阅同一个记分板,就不会进行提取。但是,如果有正在提交的分数,则不应使用缓存。
任何这些网络操作都可能失败,玩家必须能够重试。
这些操作应该是原子的。所有状态都应该一次性更新(没有中间状态)。
目前,我可以使用 Bacon.js(一个函数式反应式编程库)来解决这个问题,因为它带有原子更新支持。代码相当简洁,但现在是乱七八糟的unpredictable意大利面条代码。
我开始研究 Redux。所以我试着构建商店,并想出了这样的东西(在 YAMLish 语法中):
user: (user information)
record:
level1:
status: (loading / completed / error)
data: (record data)
error: (error / null)
scoreboard:
level1:
status: (loading / completed / error)
data:
- (record data)
- (record data)
- (record data)
error: (error / null)
问题变成:我应该把副作用放在哪里。
对于无副作用的操作,这变得非常容易。例如,在 LOGOUT
操作中,record
reducer 可以简单地关闭所有记录。
但是,有些动作确实有副作用。例如,如果我在提交分数之前没有登录,那么我登录成功,SET_USER
操作将用户保存到商店。
但是因为我有一个分数要提交,这个SET_USER
动作也必须引起一个AJAX请求被触发,同时,设置record.levelN.status
到loading
.
问题是:当我以原子方式登录时,如何表示副作用(分数提交)应该发生?
在 Elm 架构中,更新程序在使用 Action -> Model -> (Model, Effects Action)
形式时也会产生副作用,但在 Redux 中,它只是 (State, Action) -> State
.
根据 Async Actions 文档,他们推荐的方法是将它们放入动作创建器中。这是否意味着提交分数的逻辑也必须放在操作创建者中才能成功登录操作?
function login (options) {
return (dispatch) => {
service.login(options).then(user => dispatch(setUser(user)))
}
}
function setUser (user) {
return (dispatch, getState) => {
dispatch({ type: 'SET_USER', user })
let scoreboards = getObservedScoreboards(getState())
for (let scoreboard of scoreboards) {
service.loadUserRanking(scoreboard.level)
}
}
}
我觉得这有点奇怪,因为负责这种连锁反应的代码现在存在于 2 个地方:
- 在减速器中。当
SET_USER
动作被调度时,record
reducer 还必须将属于观察记分板的记录的状态设置为loading
. - 在动作创建器中,执行 fetching/submitting 分数的实际副作用。
看来我还必须手动跟踪所有活跃的观察者。而在 Bacon.js 版本中,我做了这样的事情:
Bacon.once() // When first observing the scoreboard
.merge(resubmit口) // When resubmitting because of network error
.merge(user川.changes().filter(user => !!user).first()) // When user logs in (but only once)
.flatMapLatest(submitOrGetRanking(data))
实际的 Bacon 代码要长得多,因为上面所有的复杂规则,使得 Bacon 版本几乎不可读。
但 Bacon 会自动跟踪所有活跃订阅。这让我开始质疑它可能不值得切换,因为将其重写为 Redux 需要大量手动处理。任何人都可以提出一些建议吗?
当您需要复杂的异步依赖项时,只需使用 Bacon、Rx、channels、sagas 或其他异步抽象。您可以在有或没有 Redux 的情况下使用它们。 Redux 示例:
observeSomething()
.flatMap(someTransformation)
.filter(someFilter)
.map(createActionSomehow)
.subscribe(store.dispatch);
您可以随心所欲地编写异步操作 — 唯一重要的部分是它们最终会变成 store.dispatch(action)
调用。
Redux Thunk 对于简单的应用程序来说已经足够了,但是随着你的异步需求变得更加复杂,你需要使用真正的异步组合抽象,而 Redux 并不关心你使用哪个。
更新:一段时间过去了,出现了一些新的解决方案。我建议您查看 Redux Saga,它已成为 Redux 中相当流行的异步控制流解决方案。
编辑:现在有一个受这些想法启发的redux-saga项目
这里有一些不错的资源
- Managing processes in Redux Saga
- From actionsCreators to Sagas
- Snake game implemented with Redux-saga
Flux / Redux 的灵感来自后端事件流处理(无论名称是什么:eventsourcing、CQRS、CEP、lambda 架构...)。
我们可以将 Flux 的 ActionCreators/Actions 与 Commands/Events(后端系统中通常使用的术语)进行比较。
在这些后端架构中,我们使用一种通常称为 Saga 或流程管理器的模式。基本上它是系统中接收事件的一部分,可以管理自己的状态,然后可以发出新命令。为了简单起见,它有点像实现 IFTTT (If-This-Then-That)。
您可以使用 FRP 和 BaconJS 实现它,但您也可以在 Redux reducer 之上实现它。
function submitScoreAfterLoginSaga(action, state = {}) {
switch (action.type) {
case SCORE_RECORDED:
// We record the score for later use if USER_LOGGED_IN is fired
state = Object.assign({}, state, {score: action.score}
case USER_LOGGED_IN:
if ( state.score ) {
// Trigger ActionCreator here to submit that score
dispatch(sendScore(state.score))
}
break;
}
return state;
}
明确一点:从 reducer 计算出来的用于驱动 React 渲染的状态应该绝对保持纯净!但并非您的应用程序的所有状态都具有触发渲染的目的,在这种情况下,需要将应用程序的不同解耦部分与复杂的规则同步。此 "saga" 的状态不应触发 React 渲染!
我不认为 Redux 提供任何支持这种模式的东西,但你可以很容易地自己实现它。
我已经在我们的 startup framework 中完成了这个并且这个模式工作正常。它允许我们处理 IFTTT 事情,例如:
当用户引导处于活动状态并且用户关闭 Popup1,然后打开 Popup2,并显示一些提示工具提示。
当用户使用手机网站时,打开Menu2,然后关闭Menu1
重要:如果您正在使用某些框架(如 Redux)的 undo/redo/replay 功能,重要的是在事件日志重放期间,所有这些传奇都是未连接的,因为您不希望在重放期间触发新事件!