47

我一直在努力寻找解决这个问题的方法......

我正在开发一个带有在线记分牌的游戏。玩家可以随时登录和注销。完成一场比赛后,玩家会看到计分板,看到自己的排名,分数会自动提交。

记分牌显示玩家的排名和排行榜。

截屏

当用户完成游戏(提交分数)和用户只想查看他们的排名时,都会使用记分牌。

这是逻辑变得非常复杂的地方:

  • 如果用户已登录,则将首先提交分数。保存新记录后,将加载记分牌。

  • 否则,计分板将立即加载。玩家将可以选择登录或注册。之后会提交分数,然后再次刷新记分牌。

  • 但是,如果没有要提交的分数(只是查看高分表)。在这种情况下,只需下载播放器的现有记录。但是由于这个动作不会影响记分牌,所以记分牌和球员的记录应该同时下载。

  • 有无限数量的级别。每个级别都有不同的记分牌。当用户查看记分牌时,用户正在“观察”该记分牌。当它关闭时,用户停止观察它。

  • 用户可以随时登录和注销。如果用户退出,用户的排名应该消失,如果用户作为另一个帐户登录,那么应该获取并显示该帐户的排名信息。

    ...但是仅对用户当前正在观察的记分牌进行此信息获取。

  • 对于查看操作,结果应该缓存在内存中,这样如果用户重新订阅同一个计分板,就不会提取。但是,如果提交了分数,则不应使用缓存。

  • 这些网络操作中的任何一个都可能失败,玩家必须能够重试它们。

  • 这些操作应该是原子的。所有状态都应该一次性更新(没有中间状态)。

目前,我可以使用 Bacon.js(一个函数式反应式编程库)来解决这个问题,因为它带有原子更新支持。代码非常简洁,但现在它是一个混乱的不可预知的意大利面条代码。

我开始关注 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操作时,recordreducer 可以简单地删除所有记录。

但是,某些操作确实有副作用。例如,如果我在提交分数之前没有登录,那么我登录成功,该SET_USER操作将用户保存到商店中。

但是因为我有一个分数要提交,所以这个SET_USER动作也必须导致一个 AJAX 请求被触发,同时,record.levelN.statusloading.

问题是:当我以原子方式登录时,如何表示副作用(分数提交)应该发生?

在 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)
    }
  }
}

我觉得这有点奇怪,因为负责这种连锁反应的代码现在存在于两个地方:

  1. 在减速机中。SET_USER调度操作时,reducerrecord还必须将属于观察到的记分板的记录的状态设置为loading
  2. 在动作创建者中,它执行获取/提交分数的实际副作用。

似乎我还必须手动跟踪所有活跃的观察者。而在 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))

实际的培根代码要长得多,因为上面所有复杂的规则,使得培根版本几乎不可读。

但是培根会自动跟踪所有活跃的订阅。这让我开始质疑它可能不值得切换,因为将它重写到 Redux 需要大量的手动处理。任何人都可以提出一些建议吗?

4

2 回答 2

52

当你想要复杂的异步依赖时,只需使用 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 中相当流行的异步控制流解决方案。

于 2015-10-09T11:01:35.243 回答
17

编辑:现在有一个受这些想法启发的redux-saga项目

这里有一些不错的资源


Flux / Redux 的灵感来自后端事件流处理(不管叫什么名字:事件溯源、CQRS、CEP、lambda 架构……)。

我们可以将 Flux 的 ActionCreators/Actions 与 Commands/Events(通常用于后端系统的术语)进行比较。

在这些后端架构中,我们使用一种通常称为Saga或 Process Manager 的模式。基本上它是系统中的一个部分,它接收事件,可以管理自己的状态,然后可以发出新的命令。为了简单起见,它有点像实现 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 渲染的状态应该绝对保持纯净!但并非您的应用程序的所有状态都具有触发渲染的目的,在这种情况下,您需要将应用程序的不同解耦部分与复杂的规则同步。这个“传奇”的状态不应该触发 React 渲染!

我不认为 Redux 提供任何东西来支持这种模式,但你可以很容易地自己实现它。

我已经在我们的启动框架中完成了这项工作,并且这种模式运行良好。它允许我们处理 IFTTT 的事情,例如:

  • 当用户登录处于活动状态并且用户关闭 Popup1 时,然后打开 Popup2,并显示一些提示工具提示。

  • 当用户使用移动网站并打开Menu2,然后关闭Menu1

重要提示:如果您正在使用 Redux 等某些框架的撤消/重做/重放功能,重要的是在重放事件日志期间,所有这些 saga 都是无线的,因为您不希望在重放期间触发新事件!

于 2015-11-03T14:43:32.683 回答