Pros/cons 将 redux-saga 与 ES6 生成器结合使用与将 redux-thunk 与 ES2017 结合使用 async/await

Pros/cons of using redux-saga with ES6 generators vs redux-thunk with ES2017 async/await

现在有很多关于 redux town 的最新小子的讨论,redux-saga/redux-saga。它使用生成器函数来监听 to/dispatching 动作。

在我全神贯注之前,我想知道使用 redux-saga 的 pros/cons 而不是下面我使用 redux-thunk 和 [=29 的方法=].

一个组件可能看起来像这样,像往常一样调度操作。

import { login } from 'redux/auth';

class LoginForm extends Component {

  onClick(e) {
    e.preventDefault();
    const { user, pass } = this.refs;
    this.props.dispatch(login(user.value, pass.value));
  }

  render() {
    return (<div>
        <input type="text" ref="user" />
        <input type="password" ref="pass" />
        <button onClick={::this.onClick}>Sign In</button>
    </div>);
  } 
}

export default connect((state) => ({}))(LoginForm);

然后我的动作看起来像这样:

// auth.js

import request from 'axios';
import { loadUserData } from './user';

// define constants
// define initial state
// export default reducer

export const login = (user, pass) => async (dispatch) => {
    try {
        dispatch({ type: LOGIN_REQUEST });
        let { data } = await request.post('/login', { user, pass });
        await dispatch(loadUserData(data.uid));
        dispatch({ type: LOGIN_SUCCESS, data });
    } catch(error) {
        dispatch({ type: LOGIN_ERROR, error });
    }
}

// more actions...

// user.js

import request from 'axios';

// define constants
// define initial state
// export default reducer

export const loadUserData = (uid) => async (dispatch) => {
    try {
        dispatch({ type: USERDATA_REQUEST });
        let { data } = await request.get(`/users/${uid}`);
        dispatch({ type: USERDATA_SUCCESS, data });
    } catch(error) {
        dispatch({ type: USERDATA_ERROR, error });
    }
}

// more actions...

在redux-saga中,上面例子的等价物是

export function* loginSaga() {
  while(true) {
    const { user, pass } = yield take(LOGIN_REQUEST)
    try {
      let { data } = yield call(request.post, '/login', { user, pass });
      yield fork(loadUserData, data.uid);
      yield put({ type: LOGIN_SUCCESS, data });
    } catch(error) {
      yield put({ type: LOGIN_ERROR, error });
    }  
  }
}

export function* loadUserData(uid) {
  try {
    yield put({ type: USERDATA_REQUEST });
    let { data } = yield call(request.get, `/users/${uid}`);
    yield put({ type: USERDATA_SUCCESS, data });
  } catch(error) {
    yield put({ type: USERDATA_ERROR, error });
  }
}

首先要注意的是我们正在使用 yield call(func, ...args) 形式调用 api 函数。 call 不执行效果,它只是创建一个像 {type: 'CALL', func, args} 这样的普通对象。执行委托给 redux-saga 中间件,中间件负责执行函数并使用结果恢复生成器。

主要优点是您可以使用简单的相等性检查在 Redux 之外测试生成器

const iterator = loginSaga()

assert.deepEqual(iterator.next().value, take(LOGIN_REQUEST))

// resume the generator with some dummy action
const mockAction = {user: '...', pass: '...'}
assert.deepEqual(
  iterator.next(mockAction).value, 
  call(request.post, '/login', mockAction)
)

// simulate an error result
const mockError = 'invalid user/password'
assert.deepEqual(
  iterator.throw(mockError).value, 
  put({ type: LOGIN_ERROR, error: mockError })
)

请注意,我们通过简单地将模拟数据注入迭代器的 next 方法来模拟 api 调用结果。模拟数据比模拟函数简单得多。

要注意的第二件事是对 yield take(ACTION) 的调用。动作创建者在每个新动作上调用 Thunk(例如 LOGIN_REQUEST)。也就是说,动作不断地 推送 给 thunk,而 thunk 无法控制何时停止处理这些动作。

在redux-saga中,生成器拉动下一个动作。也就是说,他们可以控制何时收听某些动作,何时不收听。在上面的例子中,流程指令被放置在一个 while(true) 循环中,所以它会监听每个传入的动作,这在某种程度上模仿了 thunk 推送行为。

拉动方法允许实现复杂的控制流。例如假设我们要添加以下要求

  • 处理注销用户操作

  • 首次成功登录后,服务器 returns 一个令牌,该令牌会在一段时间后过期,存储在 expires_in 字段中。我们必须每 expires_in 毫秒

  • 在后台刷新授权
  • 考虑到在等待 api 调用(初始登录或刷新)的结果时,用户可能会注销 in-between.

你会如何用 thunks 实现它?同时还为整个流程提供完整的测试覆盖率?以下是 Sagas 的外观:

function* authorize(credentials) {
  const token = yield call(api.authorize, credentials)
  yield put( login.success(token) )
  return token
}

function* authAndRefreshTokenOnExpiry(name, password) {
  let token = yield call(authorize, {name, password})
  while(true) {
    yield call(delay, token.expires_in)
    token = yield call(authorize, {token})
  }
}

function* watchAuth() {
  while(true) {
    try {
      const {name, password} = yield take(LOGIN_REQUEST)

      yield race([
        take(LOGOUT),
        call(authAndRefreshTokenOnExpiry, name, password)
      ])

      // user logged out, next while iteration will wait for the
      // next LOGIN_REQUEST action

    } catch(error) {
      yield put( login.error(error) )
    }
  }
}

在上面的示例中,我们使用 race 表达并发要求。如果 take(LOGOUT) 赢得比赛(即用户单击注销按钮)。比赛会自动取消authAndRefreshTokenOnExpiry后台任务。如果 authAndRefreshTokenOnExpirycall(authorize, {token}) 调用中间被阻塞,它也会被取消。取消会自动向下传播。

你可以找到一个runnable demo of the above flow

除了库作者相当详尽的回答之外,我还将添加我在生产系统中使用 saga 的经验。

专业版(使用 saga):

  • 可测试性。将 sagas 测试为 call() returns 一个纯对象非常容易。测试 thunk 通常需要您在测试中包含一个 mockStore。

  • redux-saga 带有许多关于任务的有用辅助函数。在我看来,saga 的概念是为您的应用程序创建某种背景 worker/thread,它充当 react redux 架构中缺失的部分(actionCreators 和 reducers 必须是纯函数。)这导致下一点.

  • Sagas 提供独立的地方来处理所有副作用。根据我的经验,它通常比 thunk 操作更容易修改和管理。

缺点:

  • 生成器语法。

  • 很多概念要学习。

  • API稳定。似乎 redux-saga 仍在添加功能(例如频道?)并且社区没有那么大。如果某天图书馆进行了非向后兼容的更新,则令人担忧。

这是一个结合了 redux-sagaredux-thunk 的最佳部分(优点)的项目:您可以处理 sagas 的所有副作用,同时通过 dispatching对应动作: https://github.com/diegohaz/redux-saga-thunk

class MyComponent extends React.Component {
  componentWillMount() {
    // `doSomething` dispatches an action which is handled by some saga
    this.props.doSomething().then((detail) => {
      console.log('Yaay!', detail)
    }).catch((error) => {
      console.log('Oops!', error)
    })
  }
}

更简单的方法是使用 redux-auto

来自文档

redux-auto fixed this asynchronous problem simply by allowing you to create an "action" function that returns a promise. To accompany your "default" function action logic.

  1. 无需其他 Redux 异步中间件。例如thunk, promise-middleware, saga
  2. 允许您轻松地将承诺传递给 redux 并为您管理它
  3. 允许您将外部服务调用与其转换位置放在一起
  4. 将文件命名为 "init.js" 将在应用启动时调用一次。这有利于在开始时从服务器加载数据

想法是每个 action in a specific file。将服务器调用与"pending"、"fulfilled" 和"rejected" 的reducer 函数放在一起。这使得处理承诺变得非常容易。

它还会自动将 helper object(called "async") 附加到您的状态原型,允许您跟踪 UI 请求的转换。

我只想根据我的个人经历(同时使用 sagas 和 thunk)添加一些评论:

Sagas 非常适合测试:

  • 你不需要模拟用 effects 包装的函数
  • 因此测试干净、可读且易于编写
  • 使用 sagas 时,动作创建者大多 return 普通对象文字。与 thunk 的承诺不同,它也更容易测试和断言。

传奇更强大。您在一个 thunk 的动作创建者中可以做的所有事情,您也可以在一个传奇中做,但反之则不然(或者至少不容易)。例如:

  • 等待 action/actions 发送 (take)
  • 取消现有例程(canceltakeLatestrace
  • 多个例程可以监听同一个动作(taketakeEvery、...)

Sagas 还提供其他有用的功能,这些功能概括了一些常见的应用程序模式:

  • channels 监听外部事件源(例如 websockets)
  • 分叉模型(forkspawn
  • 油门
  • ...

Sagas 是非常强大的工具。然而,权力伴随着责任。当您的应用程序增长时,您很容易迷失方向,因为您无法弄清楚谁在等待分派的动作,或者分派某个动作时发生了什么。另一方面,thunk 更简单,更容易推理。选择一个或另一个取决于许多方面,例如项目的类型和规模、您的项目必须处理的副作用类型或开发团队的偏好。在任何情况下,只要让您的应用程序简单且可预测即可。

根据我的经验,我审查了几个不同的大型 React/Redux 项目,Sagas 为开发人员提供了一种更加结构化的代码编写方式,更容易测试并且更不容易出错。

是的,开始时有点奇怪,但大多数开发人员一天之内就能对它有足够的了解。我总是告诉人们不要担心 yield 开始做什么,一旦你写了几个测试它就会来找你。

我见过几个项目,其中 thunk 被当作来自 MVC 模式的控制器,这很快就变成了无法维护的混乱。

我的建议是在需要与单个事件相关的 A 触发 B 类内容时使用 Sagas。对于任何可以跨越多个动作的东西,我发现编写自定义中间件并使用 FSA 动作的元 属性 来触发它更简单。

一个简短的笔记。生成器是可取消的,async/await — 不是。 因此,对于问题中的一个例子,选择什么并没有真正的意义。 但对于更复杂的流程,有时没有比使用生成器更好的解决方案了。

所以,另一个想法可能是使用带有 redux-thunk 的生成器,但对我来说,这似乎是在尝试发明一辆带方轮的自行车。

当然,生成器更容易测试。

2020 年 7 月更新:

在过去的 16 个月里,React 社区最显着的变化可能是 React hooks.

根据我的观察,为了更好地兼容功能组件和钩子,项目(即使是那些大项目)倾向于使用:

  1. hook + async thunk (hook makes everything very flexible so you could actually place async thunk in where you want and use it as normal functions, for example, still write thunk in action.ts and then useDispatch() to trigger the thunk: ),
  2. useRequest,
  3. GraphQL/Apollo useQuery useMutation
  4. react-fetching-library
  5. 其他流行的数据选择fetching/API调用库、工具、设计模式等

相比之下,目前 redux-saga 与上述方法相比,在大多数 API 调用的正常情况下并没有真正提供显着的好处,同时通过引入许多传奇来增加项目的复杂性 files/generators(也是因为 redux-saga 的最后一个版本 v1.1.1 是在 2019 年 9 月 18 日,那是很久以前的事了)。

但是,redux-saga 仍然提供了一些独特的功能,例如赛车效果和并行请求。因此,如果您需要这些特殊功能,redux-saga仍然是一个不错的选择。


2019 年 3 月原 post:

一些个人经验:

  1. 对于编码风格和可读性,过去使用redux-saga的一个最显着的优点就是避免了redux-thunk中的回调地狱 — 不需要使用很多嵌套then/catch 了。但是现在随着async/awaitthunk的流行,使用redux-thunk的时候也可以写sync风格的async代码,也算是对redux-thunk的一种改进吧。

  2. 使用 redux-saga 时可能需要编写更多样板代码,尤其是在 Typescript 中。例如,如果要实现一个获取异步功能,数据和错误处理可以直接在 action.js 中的一个 thunk 单元中执行,只需一个 FETCH 操作。但是在 redux-saga 中,可能需要定义 FETCH_START、FETCH_SUCCESS 和 FETCH_FAILURE 操作及其所有相关的类型检查,因为 redux-saga 中的一个特性是使用这个一种丰富的“令牌”机制来创建效果并指示 redux 存储以便于测试。当然,不使用这些操作也可以编写传奇,但这会使它类似于 thunk。

  3. 在文件结构上,redux-saga在很多情况下似乎更加明确。在每个 sagas.ts 中很容易找到与异步相关的代码,但在 redux-thunk 中,需要在操作中看到它。

  4. 轻松测试可能是 redux-saga 的另一个重要特性。这真的很方便。但需要澄清的一件事是 redux-saga “调用”测试不会在测试中执行实际的 API 调用,因此需要为 [= 之后可能使用的步骤指定示例结果91=] 呼叫。所以在写redux-saga之前,最好先详细规划一个saga和对应的sagas.spec.ts

  5. Redux-saga 还提供了许多高级功能,例如 运行 并行任务,takeLatest/takeEvery、fork/spawn 等并发助手,这些功能远比砰。

总而言之,我个人想说:在许多正常情况下和中小型应用程序中,请使用 async/await 风格的 redux-thunk。它将为您节省许多样板 codes/actions/typedefs,并且您不需要切换许多不同的 sagas.ts 并维护特定的 sagas 树。但是如果你正在开发一个具有复杂异步逻辑的大型应用程序并且需要像 concurrency/parallel 模式这样的功能,或者对测试和维护有很高的要求(特别是在测试驱动开发中),redux-sagas 可能会节省你的生活。

无论如何,redux-saga 并不比 redux 本身更难、更复杂,也没有所谓陡峭的学习曲线,因为它的核心概念和 API 非常有限。花少量时间学习 redux-saga,说不定有一天对自己有好处。

Thunks vs Sagas

Redux-ThunkRedux-Saga 在几个重要方面有所不同,它们都是 Redux 的中间件库(Redux 中间件是拦截通过 dispatch() 方法进入商店的操作的代码)。

操作实际上可以是任何东西,但如果您遵循最佳实践,操作就是一个普通的 javascript 对象,带有类型字段以及可选的有效负载、元数据和错误字段。例如

const loginRequest = {
    type: 'LOGIN_REQUEST',
    payload: {
        name: 'admin',
        password: '123',
    }, };

Redux-Thunk

除了调度标准操作外,Redux-Thunk 中间件还允许您调度特殊函数,称为 thunks

Thunks(在 Redux 中)通常具有以下结构:

export const thunkName =
   parameters =>
        (dispatch, getState) => {
            // Your application logic goes here
        };

也就是说,thunk 是一个函数,它(可选地)接受一些参数并且 return 是另一个函数。内部函数接受一个 dispatch function 和一个 getState 函数——这两个函数都将由 Redux-Thunk 中间件提供。

Redux-Saga

Redux-Saga 中间件允许您将复杂的应用程序逻辑表达为称为 sagas 的纯函数。从测试的角度来看,纯函数是可取的,因为它们是可预测和可重复的,这使得它们相对容易测试。

Sagas 是通过称为生成器函数的特殊函数实现的。这些是 ES6 JavaScript 的新功能。基本上,在你看到 yield 语句的任何地方,执行都会跳入和跳出生成器。将 yield 语句视为导致生成器暂停并 return 产生的值。稍后,调用者可以在 yield.

之后的语句中恢复生成器

生成器函数就是这样定义的。注意函数关键字后面的星号。

function* mySaga() {
    // ...
}

一旦登录 saga 注册到 Redux-Saga。但是第一行的 yield take 将暂停 saga,直到类型为 'LOGIN_REQUEST' 的动作被分派到商店。一旦发生这种情况,执行将继续。

For more details see this article.