07月08, 2017

为什么我们需要中间件来处理redux的异步流

原文地址: StackOverFlow - Why do we need middleware for async flow in Redux?

官方文档说:如果不用中间件middleware,Redux store只支持同步数据流。 我不太明白为什么,为什么不能在组件中直接调用异步API,然后dispatch action?

例如:一个只有一个输入框和按钮的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函数,它派发action,告诉app要进行更新操作,并进行了异步调用。当调用结束后,结果值通过另外一个action的payload字段进行派发(dispatch)。

这种方式有什么问题呢?为什么我一定要用Redux Thunk或者Redux Promise?感觉我现在这样的实现也可以啊。

我查询了Redux的有关资料,发现Action Creator在以前是要求必须是纯函数的。下面这段话对于异步数据流进行很好的解释

action creator本身是一个纯函数,但是它返回的thunk函数不要求一定是纯函数,而且它可以调用异步操作

Action Creator不再强制要求是纯函数。因此我理解的是thunk/promise中间件已经成为过去时?

答 - by Dan Abramov

这种方式有什么问题呢?为什么我一定要用Redux Thunk或者Redux Promise?

这种方式没有错,但是,在一个大型的应用里面,这种方式就显得不方便了,因为,你有大量的组件会执行相同的操作,这种时候,就需要解决动作竞态问题,保持状态和action creator的密切联系(例如自增长id)。如果将UI视图中的action creator剥离出来,就更容易实现这种效果了。

关于这个部分,你可以参考我在另外一个问题的答复-如何用timeout派发Redux Action

Redux Thunk或Redux Promise等中间件更像派发thunk或promise的语法糖,并不是说你一定要用它。

但是,没有中间件,你的action creator就是这样的:

// 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
}

从代码看,并没有很巨大的差异。但是后面这种方式有个好处就是UI组件并不关心action creator是否是异步的。UI组件就像正常的去调用一个普通的操作,当然也可以使用mapDispatchToProps来简化代码。UI组件也不知道action creator是怎么实现的,因此你可以切换各种异步实现方式(Redux Thunk, Redux Promise, Redux Saga),而且还不需要改组件的代码。回到前面那种处理方式,UI组件首先知道要调用的对象是同步还是异步的,而且还需要根据不同的方式编写不同风格的代码(例如,传递同步参数等)。

接下来我们看看需求变化导致两种方式下的代码变化。 假设我们需要另外一个加载数据的功能,并且和以前的功能联合为一个action creator。

在第一种方式下,我们需要清楚我们到底在调用那种action creator(同步或异步)。

// 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 action creator就不一样了,根本不需要关心是同步还是异步:

// 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!
}

在这种方式下,如果你的action creator需要访问当前Redux state的状态,就可以通过传递给thunk的第二个参数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中间件的好处是UI组件不需要关心action creator是怎么实现的,action creator怎么去访问Redux state,action creator到底是异步还是同步,action creator有没有去调用其他的action creator。虽然使用这种方式有点绕,但是我觉得带来的好处是多多的。

最后要说的是,Redux Thunk和其他方案也只是Redux应用中处理异步的思路之一。另外一个有趣的思路是Redux Saga,它允许你定义一个一直在运行的守护进程(sagas),这个守护进程接受action,在action输出之前处理请求或者调整请求。这种方式相当于把逻辑从action creator移到sagas里面。你有空可以研究一下,看看是否适合你。

我查询了Redux的有关资料,发现Action Creator在以前是要求必须是纯函数的。

这是不对的,文档确实这样说了,那是文档写错了,Action creator没有要求必须是纯函数,我们会修正文档中的说法。

答 - by Sebastien Lorber

当然不,但是你可以用 redux-saga 啊。 :)

关于redux-thunk的回复,Dan Abramov是完全正确的,接下来我会聊聊redux-saga,它们很相似,但是redux-saga要强多了。

命令式 VS 声明式

  • DOM:jQuery是命令式 / React是声明式
  • Monads: IO是命令式/Free是声明式
  • Redux:redux-thunk是命令式/redux-saga是声明式

当你使用thunk的时候,就像你在使用IO monad或者promise,你是很难知道结果的,想要验证一个thunk就是去执行它,去mock dispatcher(或者mock它的整个外部执行环境)

但是,如果你使用mock,意味着你就不是在进行函数编程。

mock意味着你的代码不是纯粹的,在函数编程者的眼中,这是有副作用的。一个铁杆的TDD/Java程序猿曾经问我,你是怎么mock clojure的,我回答是:我们才不mock clojure。我们一旦看到这种代码,就表示我们该重构代码了。

sagas(redux-saga)就像Free monad和React组件,sagas是声明式的 ,不需要mock就可以测试。

请移步这篇文章

在现代FP(函数编程),我们不应该直接写代码,而应该是去描述代码,我们可以通过introspect, transform, 和 interpret 等手段来做到。(实际上,redux-saga就像混编模式,控制流是命令式的,副作用是声明式的)

术语困境:actions/events/commands

Confusion: actions/events/commands...

在前端这个领域,有太多的混乱,对于后端的CQRS/EventSourcing,在前端常常对应到Flux/Redux,因为在Flux中我们使用action这个术语,但是action有时候对应到命令式代码(LOAD_USER),有时候又对应到事件(USER_LOADED)。我个人认为,对于event-sourcing,派发的应该是事件。

实战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 })
  }
}

saga的处理方式是:

每次点击用户名,就获取用户资料,并且将携带了用户资料的事件派发出去。

如代码所示,redux-saga有以下优势:

takeLatest 只需要关注最近一次点击用户名后获得的数据(可以对付用户狂点一堆用户名造成的批量操作问题)。这种场景对于thunks就很麻烦。如果你不想面对这种麻烦,就应该使用takeEvery

action creator依然保持纯粹。注意,我们保留了actionCreator,这样留下了增加action校验(assertions/flow/typescript)的空间。

现在代码更加容易测试,因为代码产生的结果是声明式的。

而且你也不再需要通过actions.loadUser()来触发远程调用。UI组件只需要派发事件。我们也只需要派发事件,不需要actions参与进来了。意味着你已经实现了上下文解耦,saga就是模块化组件解耦的关键点。

这样UI视图也更容易管理了,因为他们不需要关注如何处理什么事情发生了什么事情应该发生之间的转换。

举个例子:视图滚动问题(译者注:主要指下拉到底导致的分页)。CONTAINER_SCROLLED会导致NEXT_PAGE_LOADED,但是这真的是可固定的容器需要负责的部分么?或者不应该是我们去负责加载下一页么?最终用户不得不关注大量复杂的因素,比方说最后一页加载成功了么?或者是否已经有个页面要加载?还有没有更多的数据需要加载?我认为对于可滚动的容器,它只需要关注滚动这个部分,而加载页面只是滚动导致的业务行为。

有些朋友也许会说generator通过局部变量隐藏了redux store外的状态,但是如果你要在thunks里面处理协调复杂的场景的时候,例如使用timer,就会遇到相同的问题,因此后来thunks不得不加上支持从Redux store中获取状态。

saga是支持时间流的,同时也支持复杂的流程日志,开发工具包很快就会推出。下面是一些我们已经实现的简单的异步流程日志。

解耦

sagas不支持用来替代thunks,sagas的思路来自backend / distributed systems / event-sourcing(后端/分布式系统/事件驱动)

有个常见的误解就是sagas是为了替换reudx thunks才出现的,sagas知识提高了可测试性。实际上这只是redux-saga实现的其中一部分特征。使用声明式确实超越了thunk,实现更好的测试新,但是,saga模式实现是可以基于命令式或者声明式的。

首先,saga支持长事务(通过事件的一致性),跨上下文的事务(领域驱动设计)。

为了简化我们的示例(前端),假设有两个控件widget1和widget2。在widget1中点击了某个按钮,会影响widget2。两个widget耦合的做法就是widget1直接派发action到widget2,但是saga的做法是只需要派发buttong被点击的事件,saga会监听到这个事件,然后派发另外一个事件给widget2,这样就实现了widget的解耦。

对于简单的应用来说,这样绕的圈子就太大了,但是对于复杂应用来说,这样反而简单。你可以将widget1和widget2发布到不同的npm库,这样两个widget1和widget2彼此并不知道对方的存在。两个widget的上下文也是完全分离的。他们并不依赖彼此,因此可以独自重用。saga实现了在两个widget之间的解耦和衔接,提供了一种更有价值的解决方案。

下面推荐一些不错的文章,告诉你怎么样使用redux-saga来组织redux应用和解耦:

通知系统

我希望我的组件能够显示应用内通知(最多同时显示3个通知,每个通知出现4秒),但是又不想组件和通知系统耦合到一起。

我也不想我的JSX组件去控制是否要显示或者隐藏通知,我想的是组件可以请求一个通知,然后所有的复杂的处理都在saga里面搞定。如果使用thunks或者promise,就很难搞定这种需求。

关于saga怎么搞定这个事情可以参考这个链接

Why is it called a Saga?

为什么起名Saga?

术语saga来自后端。关于选择这个术语我和Yassine(redux-saga的作者之一)进行长时间的讨论。

最开始,这个术语来自一篇论文,saga模式本来是用来解决分布式事务中的事件一致性问题,但是后端开发者对其进行了扩展并延伸到更宽泛的定义,现在saga已经覆盖了process manager模式(最初saga模式是process manager的一种特殊的形式)

现在,saga能够描述两种不同的事物,事情变复杂了。在redux-saga里面,saga并不是用来处理分布式事务,而是协作app中的action,我觉得redux-saga更适合的名字是redux-process-manager。

其他参考:

替代方案

如果你不喜欢generator,但是你对saga的解耦模式又难以割舍,你可以试试具有相同解决思路的redux-observable ,不过它有一个更牛逼的名字epic。

const loadUserProfileOnNameClick = iterable => iterable
   // Everytime a username gets clicked
  .filter(({ action, state }) => action.type === 'USER_NAME_CLICKED')
   // Fetch the user, but only load data for the last clicked username
  .flatmapLatest(({ action, state }) => fetchUser(action.payload.userId));


function fetchUser(userId) {
  return Observable.fromPromise(fetch('http://data.com/${userId }'))
    .map(userProfile => 
        ({ type: 'USER_PROFILE_LOADED', userProfile })
     )
    .catch(error => 
        Observable.of({ type: 'USER_PROFILE_LOAD_FAILED', error })
    );
}

最后推荐你看看下面几篇帖子

本文链接:http://www.xiaojichao.com/post/why-do-we-need-middleware-for-async-flow-in-redux.html

-- EOF --

Comments