协慌网

登录 贡献 社区

为什么在 Redux 中我们需要中间件来实现异步流?

根据文档, “没有中间件,Redux 存储仅支持同步数据流” 。我不明白为什么会这样。为什么容器组件不能调用异步 API,然后dispatch操作?

例如,想象一个简单的 UI:一个字段和一个按钮。当用户按下按钮时,该字段将填充来自远程服务器的数据。

字段和按钮

import * as React from 'react';
import * as Redux from 'redux';
import { Provider, connect } from 'react-redux';

const ActionTypes = {
    STARTED_UPDATING: 'STARTED_UPDATING',
    UPDATED: 'UPDATED'
};

class AsyncApi {
    static getFieldValue() {
        const promise = new Promise((resolve) => {
            setTimeout(() => {
                resolve(Math.floor(Math.random() * 100));
            }, 1000);
        });
        return promise;
    }
}

class App extends React.Component {
    render() {
        return (
            <div>
                <input value={this.props.field}/>
                <button disabled={this.props.isWaiting} onClick={this.props.update}>Fetch</button>
                {this.props.isWaiting && <div>Waiting...</div>}
            </div>
        );
    }
}
App.propTypes = {
    dispatch: React.PropTypes.func,
    field: React.PropTypes.any,
    isWaiting: React.PropTypes.bool
};

const reducer = (state = { field: 'No data', isWaiting: false }, action) => {
    switch (action.type) {
        case ActionTypes.STARTED_UPDATING:
            return { ...state, isWaiting: true };
        case ActionTypes.UPDATED:
            return { ...state, isWaiting: false, field: action.payload };
        default:
            return state;
    }
};
const store = Redux.createStore(reducer);
const ConnectedApp = connect(
    (state) => {
        return { ...state };
    },
    (dispatch) => {
        return {
            update: () => {
                dispatch({
                    type: ActionTypes.STARTED_UPDATING
                });
                AsyncApi.getFieldValue()
                    .then(result => dispatch({
                        type: ActionTypes.UPDATED,
                        payload: result
                    }));
            }
        };
    })(App);
export default class extends React.Component {
    render() {
        return <Provider store={store}><ConnectedApp/></Provider>;
    }
}

渲染导出的组件后,我可以单击该按钮,并且输入已正确更新。

注意connect调用中update功能。它调度一个动作,告诉应用程序正在更新,然后执行异步调用。调用完成后,将提供的值作为另一个操作的有效负载进行分派。

这种方法有什么问题?如文档所示,我为什么要使用 Redux Thunk 或 Redux Promise?

编辑:我在 Redux 仓库中搜索了线索,发现过去需要 Action Creators 是纯函数。例如,以下用户试图为异步数据流提供更好的解释:

动作创建者本身仍然是一个纯函数,但是它返回的 thunk 函数不必是纯函数,并且它可以执行我们的异步调用

动作创建者不再需要是纯粹的。因此,过去肯定需要使用 thunk / promise 中间件,但是似乎不再是这种情况了?

答案

这种方法有什么问题?如文档所示,我为什么要使用 Redux Thunk 或 Redux Promise?

这种方法没有错。这在大型应用程序中很不方便,因为您将有不同的组件执行相同的操作,您可能希望对某些操作进行反跳操作,或者将某些本地状态(如自动递增 ID)保持在操作创建者附近,等等。从维护角度将动作创建者提取到单独的功能中。

您可以阅读我对 “如何在超时时分派 Redux 动作” 的回答,以获取更详细的演练。

中间件像终极版咚或终极版无极只是给你 “语法糖” 派遣的 thunk 或承诺,但你不必使用它。

因此,没有任何中间件,您的动作创建者可能看起来像

// action creator
function loadData(dispatch, userId) { // needs to dispatch, so it is first argument
  return fetch(`http://data.com/${userId}`)
    .then(res => res.json())
    .then(
      data => dispatch({ type: 'LOAD_DATA_SUCCESS', data }),
      err => dispatch({ type: 'LOAD_DATA_FAILURE', err })
    );
}

// component
componentWillMount() {
  loadData(this.props.dispatch, this.props.userId); // don't forget to pass dispatch
}

但是,使用 Thunk 中间件,您可以这样编写:

// action creator
function loadData(userId) {
  return dispatch => fetch(`http://data.com/${userId}`) // Redux Thunk handles these
    .then(res => res.json())
    .then(
      data => dispatch({ type: 'LOAD_DATA_SUCCESS', data }),
      err => dispatch({ type: 'LOAD_DATA_FAILURE', err })
    );
}

// component
componentWillMount() {
  this.props.dispatch(loadData(this.props.userId)); // dispatch like you usually do
}

因此没有太大的区别。我喜欢后一种方法的一件事是,该组件无需关心动作创建者是否是异步的。它只是dispatch ,也可以使用mapDispatchToProps用短语法绑定此类操作创建者,等等。组件不知道操作创建者的实现方式,因此您可以在不同的异步方法之间进行切换(Redux Thunk,Redux Promise, Redux Saga),而无需更改组件。另一方面,使用前一种显式方法,您的组件可以确切地知道特定的调用是异步的,并且需要dispatch (例如,作为 sync 参数)。

还考虑一下此代码将如何更改。假设我们要具有第二个数据加载功能,并将它们组合在一个动作创建器中。

使用第一种方法时,我们需要注意所调用的是哪种动作创建者:

// action creators
function loadSomeData(dispatch, userId) {
  return fetch(`http://data.com/${userId}`)
    .then(res => res.json())
    .then(
      data => dispatch({ type: 'LOAD_SOME_DATA_SUCCESS', data }),
      err => dispatch({ type: 'LOAD_SOME_DATA_FAILURE', err })
    );
}
function loadOtherData(dispatch, userId) {
  return fetch(`http://data.com/${userId}`)
    .then(res => res.json())
    .then(
      data => dispatch({ type: 'LOAD_OTHER_DATA_SUCCESS', data }),
      err => dispatch({ type: 'LOAD_OTHER_DATA_FAILURE', err })
    );
}
function loadAllData(dispatch, userId) {
  return Promise.all(
    loadSomeData(dispatch, userId), // pass dispatch first: it's async
    loadOtherData(dispatch, userId) // pass dispatch first: it's async
  );
}


// component
componentWillMount() {
  loadAllData(this.props.dispatch, this.props.userId); // pass dispatch first
}

使用 Redux Thunk 动作创建者可以dispatch其他动作创建者的结果,甚至不考虑它们是同步的还是异步的:

// action creators
function loadSomeData(userId) {
  return dispatch => fetch(`http://data.com/${userId}`)
    .then(res => res.json())
    .then(
      data => dispatch({ type: 'LOAD_SOME_DATA_SUCCESS', data }),
      err => dispatch({ type: 'LOAD_SOME_DATA_FAILURE', err })
    );
}
function loadOtherData(userId) {
  return dispatch => fetch(`http://data.com/${userId}`)
    .then(res => res.json())
    .then(
      data => dispatch({ type: 'LOAD_OTHER_DATA_SUCCESS', data }),
      err => dispatch({ type: 'LOAD_OTHER_DATA_FAILURE', err })
    );
}
function loadAllData(userId) {
  return dispatch => Promise.all(
    dispatch(loadSomeData(userId)), // just dispatch normally!
    dispatch(loadOtherData(userId)) // just dispatch normally!
  );
}


// component
componentWillMount() {
  this.props.dispatch(loadAllData(this.props.userId)); // just dispatch normally!
}

使用这种方法,如果以后您希望动作创建者查看当前的 Redux 状态,则可以只使用getState参数,而无需完全修改调用代码:

function loadSomeData(userId) {
  // Thanks to Redux Thunk I can use getState() here without changing callers
  return (dispatch, getState) => {
    if (getState().data[userId].isLoaded) {
      return Promise.resolve();
    }

    fetch(`http://data.com/${userId}`)
      .then(res => res.json())
      .then(
        data => dispatch({ type: 'LOAD_SOME_DATA_SUCCESS', data }),
        err => dispatch({ type: 'LOAD_SOME_DATA_FAILURE', err })
      );
  }
}

如果需要将其更改为同步,则也可以在不更改任何调用代码的情况下执行此操作:

// I can change it to be a regular action creator without touching callers
function loadSomeData(userId) {
  return {
    type: 'LOAD_SOME_DATA_SUCCESS',
    data: localStorage.getItem('my-data')
  }
}

因此,使用像 Redux Thunk 或 Redux Promise 这样的中间件的好处是组件不知道动作创建者的实现方式,它们是否关心 Redux 状态,它们是同步还是异步以及是否调用其他动作创建者。缺点是间接的,但我们认为在实际应用中值得这么做。

最后,Redux Thunk 和朋友只是 Redux 应用程序中异步请求的一种可能方法。另一个有趣的方法是Redux Saga ,它使您可以定义长时间运行的守护程序(“sagas”),这些守护程序会在操作到来时进行操作,并在输出操作之前转换或执行请求。这将逻辑从动作创建者转移到了传奇。您可能想要检查一下,然后选择最适合您的东西。

我在 Redux 存储库中搜索了一些线索,发现过去需要 Action Creators 是纯函数。

这是不正确的。文档说了这一点,但是文档是错误的。
从未要求动作创建者是纯粹的功能。
我们修复了文档以反映这一点。

你不知道

但是... 您应该使用 redux-saga :)

丹 · 阿布拉莫夫(Dan Abramov)的答案是关于redux-thunk但是我将再讲一些关于 redux-saga 的内容,它非常相似,但功能更强大。

命令式 VS 声明式

  • DOM :jQuery 是命令性的 / React 是声明性的
  • Monads :IO 势在必行 / Free 是声明式
  • Redux 效果redux-thunk是必需的 / redux-saga是声明性的

当您手头有重击时,例如 IO monad 或诺言,执行后就不容易知道它会做什么。测试一个 thunk 的唯一方法是执行它,并模拟调度程序(如果它与更多的东西交互,则模拟整个外部世界……)。

如果您使用的是模拟,那么您就没有在进行函数式编程。

从副作用的角度来看,模拟标志着您的代码是不纯的,并且在功能程序员的眼中,它证明了某些错误。与其下载库来帮助我们检查冰山是否完好,不如我们绕着它航行。一位 TDD / Java 顽固的家伙曾经问我如何在 Clojure 中进行模拟。答案是,我们通常不这样做。我们通常将其视为我们需要重构代码的标志。

来源

sagas(因为它们在redux-saga )是声明性的,并且像 Free monad 或 React 组件一样,它们无需任何模拟就易于测试。

另请参阅本文

在现代 FP 中,我们不应该编写程序,而应该编写程序的描述,然后可以对其进行自省,转换和解释。

(实际上,Redux-saga 就像是一个混合体:流程是必须的,但效果是声明性的)

混乱:动作 / 事件 / 命令...

前端世界对如何将某些后端概念(如 CQRS / EventSourcing 和 Flux / Redux)相关联感到困惑,主要是因为在 Flux 中,我们使用术语 “操作” 来表示命令性代码( LOAD_USER )和事件( USER_LOADED )。我相信,像事件来源一样,您应该只调度事件。

在实践中使用 sagas

想象一个带有指向用户个人资料链接的应用程序。每个中间件处理此问题的惯用方式是:

redux-thunk

<div onClick={e => dispatch(actions.loadUserProfile(123)}>Robert</div>

function loadUserProfile(userId) {
  return dispatch => fetch(`http://data.com/${userId}`)
    .then(res => res.json())
    .then(
      data => dispatch({ type: 'USER_PROFILE_LOADED', data }),
      err => dispatch({ type: 'USER_PROFILE_LOAD_FAILED', err })
    );
}

redux-saga

<div onClick={e => dispatch({ type: 'USER_NAME_CLICKED', payload: 123 })}>Robert</div>


function* loadUserProfileOnNameClick() {
  yield* takeLatest("USER_NAME_CLICKED", fetchUser);
}

function* fetchUser(action) {
  try {
    const userProfile = yield fetch(`http://data.com/${action.payload.userId }`)
    yield put({ type: 'USER_PROFILE_LOADED', userProfile })
  } 
  catch(err) {
    yield put({ type: 'USER_PROFILE_LOAD_FAILED', err })
  }
}

这个传奇转化为:

每次单击用户名时,获取用户配置文件,然后使用加载的配置文件调度事件。

如您所见, redux-saga有一些优点。

使用takeLatest可以表示您只想获取最后一次单击的用户名的数据(处理并发问题,以防用户非常快地单击许多用户名)。这类东西很难用 th 子来打。如果您不希望这种行为,则可以使用takeEvery

您可以使动作创作者保持纯正。请注意,保留 actionCreators(在 sagas put和 component dispatch )仍然很有用,因为将来可能会帮助您添加动作验证(断言 / 流程 / 打字稿)。

由于效果是声明性的,因此您的代码变得更具可测试性

您不再需要触发类似 rpc 的调用,例如actions.loadUser() 。您的 UI 只需调度已发生的事件。我们只会触发事件(总是使用过去时!),不再执行任何操作。这意味着您可以创建解耦的“鸭子”有界上下文,并且传奇可以充当这些模块化组件之间的耦合点。

这意味着您的视图更易于管理,因为它们不再需要在已发生的事情和应该发生的事情之间包含转换层

例如,想象一个无限滚动视图。 CONTAINER_SCROLLED可以导致NEXT_PAGE_LOADED ,但可滚动容器是否真的有责任决定是否应加载另一页呢?然后,他必须知道更复杂的内容,例如是否成功加载了最后一页,或者是否已经有一个试图加载的页面,或者是否还有其他要加载的项目?我不这么认为:为了获得最大的可重用性,可滚动容器应该只描述它已经被滚动。页面的加载是该滚动的 “业务影响”

有人可能会争辩说,生成器可以使用本地变量将状态固有地隐藏在 redux 存储之外,但是如果您通过启动计时器等开始在 thunk 中编排复杂的东西,则无论如何都会遇到同样的问题。现在有一个select效果,可以从您的 Redux 存储中获取一些状态。

Sagas 可以进行时间旅行,还可以启用当前正在使用的复杂流日志记录和开发工具。这是一些已经实现的简单异步流日志记录:

传奇流记录

去耦

Sagas 不仅在替换 redux 修改器。它们来自后端 / 分布式系统 / 事件源。

一个普遍的误解是,sagas 即将到来,以更好的可测试性取代您的 redux thunk。实际上,这只是 redux-saga 的实现细节。使用声明性效果比使用 thunk 更好,因为可测试性更好,但是 saga 模式可以在命令性或声明性代码之上实现。

首先,传奇是一款软件,它可以协调长时间运行的事务(最终的一致性)以及跨不同有界上下文的事务(域驱动的设计术语)。

为了简化前端世界的工作,假设有 widget1 和 widget2。单击 widget1 上的某个按钮时,它将对 widget2 产生影响。与其将 2 个小部件耦合在一起(即,小部件 1 调度以小部件 2 为目标的动作),小部件 1 仅调度其按钮被单击。然后,传奇侦听此按钮的单击,然后通过分发 widget2 知道的新事件来更新 widget2。

这增加了简单应用程序不必要的间接级别,但使扩展复杂应用程序更加容易。现在,您可以将 widget1 和 widget2 发布到不同的 npm 存储库,这样它们就不必彼此了解,而不必共享全局的动作注册表。现在这 2 个小部件是可以单独使用的有界上下文。他们不需要彼此保持一致,也可以在其他应用程序中重复使用。传奇是两个小部件之间的耦合点,以对您的业务有意义的方式协调它们。

关于如何构建 Redux 应用程序的一些不错的文章,出于解耦的原因,您可以在这些文章上使用 Redux-saga:

一个具体的用例:通知系统

我希望我的组件能够触发应用内通知的显示。但是我不希望我的组件与具有自己的业务规则的通知系统高度耦合(最多同时显示 3 条通知,通知队列,4 秒显示时间等)。

我不希望我的 JSX 组件决定何时显示 / 隐藏通知。我只是给它请求通知的功能,而将复杂的规则留在了传奇中。这种东西很难用大块的东西或承诺实现。

通知

我在这里描述了传奇如何做到这一点

为什么叫佐贺?

术语 “传奇” 来自后端世界。 在最初的长时间讨论中,我最初将 Yassine(Redux-saga 的作者)介绍给该术语。

最初,该术语是在论文中引入的,该传奇模式本来应该用于处理分布式事务中的最终一致性,但是后端开发人员已将其用法扩展到更广泛的定义,因此现在也涵盖了 “流程管理器” 模式(某种程度上,原始的传奇模式是流程管理器的一种特殊形式)。

如今,“传奇” 一词令人困惑,因为它可以描述两种不同的事物。由于它在 redux-saga 中使用,因此它并不描述处理分布式事务的方法,而是协调应用程序中的操作的方法。 redux-saga也可以称为redux-process-manager

也可以看看:

备择方案

如果您不喜欢使用生成器的想法,但是您对 saga 模式及其解耦特性感兴趣,那么您也可以使用redux-observable实现相同的功能,它使用名称epic来描述完全相同的模式,但使用 RxJS。如果您已经熟悉 Rx,就会感到宾至如归。

const loadUserProfileOnNameClickEpic = action$ =>
  action$.ofType('USER_NAME_CLICKED')
    .switchMap(action =>
      Observable.ajax(`http://data.com/${action.payload.userId}`)
        .map(userProfile => ({
          type: 'USER_PROFILE_LOADED',
          userProfile
        }))
        .catch(err => Observable.of({
          type: 'USER_PROFILE_LOAD_FAILED',
          err
        }))
    );

一些 redux-saga 有用的资源

2017 年建议

  • 不要仅仅为了使用它而过度使用 Redux-saga。仅可测试的 API 调用是不值得的。
  • 在大多数情况下,请勿从您的项目中删除垃圾。
  • 如果有意义的话,请不要犹豫,在yield put(someActionThunk)

如果您对使用 Redux-saga(或 Redux-observable)感到害怕,但只需要使用解耦模式,请检查redux-dispatch-subscribe :它允许侦听调度并在侦听器中触发新的调度。

const unsubscribe = store.addDispatchListener(action => {
  if (action.type === 'ping') {
    store.dispatch({ type: 'pong' });
  }
});

简短的回答:对我来说,这似乎是一种完全合理的解决异步问题的方法。有几个警告。

在刚开始工作的新项目中,我有非常相似的思路。我是 Vanilla Redux 优雅的系统的忠实拥护者,该系统用于更新商店和重新渲染组件,而这种方式不影响 React 组件树的胆量。我似乎迷上了那种优雅的dispatch机制来处理异步问题。

最后,我采用了一种非常相似的方法,该方法与我从项目中剔除出来的库中的库类似,我们称之为react-redux-controller

由于以下几个原因,我最终没有采用您所拥有的确切方法:

  1. 按照您编写的方式,这些分派功能无法访问商店。您可以通过让 UI 组件传递分派功能所需的所有信息来解决该问题。但是我认为这不必要地将这些 UI 组件耦合到调度逻辑。更成问题的是,没有明显的方法可以使分派函数以异步连续的方式访问更新后的状态。
  2. 调度功能可以通过词法范围dispatch connect语句失控,这将限制重构的选项 - 仅使用一种update方法就显得相当笨拙。因此,如果需要将这些调度程序功能分解为单独的模块,则需要一些系统来组成这些调度程序功能。

综合起来,您必须装配一些系统以允许dispatch以及将商店以及事件的参数注入到您的调度功能中。我知道这种依赖注入的三种合理方法:

  • redux-thunk通过将它们传递到您的 thunk 中(通过圆顶定义使其完全不完全是 thunk)以一种功能性的方式来做到这一点。我没有使用其他dispatch中间件方法,但是我认为它们基本上是相同的。
  • react-redux-controller 用协程执行此操作。另外,它还使您可以访问 “选择器”,这些选择器是您作为connect的第一个参数而传递的功能,而不必直接与原始的规范化商店一起使用。
  • 您还可以通过多种可能的机制this上下文中,从而以面向对象的方式进行操作。

更新

在我看来,这个难题的一部分是react-redux的局限性。 connect的第一个参数获取状态快照,但不获取调度。第二个参数获取调度,但不获取状态。由于能够在继续 / 回调时看到更新的状态,所以两个参数都不会关闭当前状态。