从redux-thunk到redux-saga实践

文章同步于Github Pines-Cheng/blog

简介

本质都是为了解决异步action的问题

Redux Saga可以理解为一个和系统交互的常驻进程,其中,Saga可简单定义如下:

Saga = Worker + Watcher

saga特点:

  • saga的应用场景是复杂异步,如长时事务LLT(long live transcation)等业务场景。
  • 方便测试,可以使用takeEvery打印logger。
  • 提供takeLatest/takeEvery/throttle方法,可以便利的实现对事件的仅关注最近事件、关注每一次、事件限频
  • 提供cancel/delay方法,可以便利的取消、延迟异步请求
  • 提供race(effects),[…effects]方法来支持竞态和并行场景
  • 提供channel机制支持外部事件

Redux Saga适用于对事件操作有细粒度需求的场景,同时他们也提供了更好的可测试性。

thunk VS saga

这里有一个简单的需求,登录页面,使用redux-thunkasync / await。组件可能看起来像这样,像平常一样分派操作。

组件部分二者应该是大同小异:

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 (
); } } export default connect((state) => ({}))(LoginForm);

使用redux-thunk

登录的action文件

// 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) //等待 Store 上指定的 action LOGIN_REQUEST
    try {
      let { data } = yield call(request.post, '/login', { user, pass }); //阻塞,请求后台数据
      yield fork(loadUserData, data.uid); //非阻塞执行loadUserData
      yield put({ type: LOGIN_SUCCESS, data }); //发起一个action,类似于dispatch
    } 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函数。调用不会执行效果,它只是创建一个简单的对象,如{type:’CALL’,func,args}。执行被委托给redux-saga中间件,该中间件负责执行函数并且用其结果恢复generatorr。

优点

相比Redux Thunk,使用Redux Saga有几处明显的变化:

  • 在组件中,不再dispatch(action creator),而是dispatch(pure action)
  • 组件中不再关注由谁来处理当前action,action经由root saga分发
  • 具体业务处理方法中,通过提供的call/put等帮助方法,声明式的进行方法调用
  • 使用ES6 Generator语法,简化异步代码语法

除开上述这些不同点,Redux Saga真正的威力,在于其提供了一系列帮助方法,使得对于各类事件可以进行更细粒度的控制,从而完成更加复杂的操作。

方便测试

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

注意,我们通过简单地将模拟数据注入迭代器的下一个方法来检查api调用结果。模拟数据比模拟函数更简单。

监听过滤action

通过yield take(ACTION)可以方便自由的对action进行拦截和过滤。Thunks由每个新动作的动作创建者调用(例如LOGIN_REQUEST)。即动作被不断地推送到thunk,并且thunk不能控制何时停止处理那些动作。

复杂应用场景

假设例如我们要添加以下要求:

  • 处理LOGOUT用户操作
  • 在第一次成功登录时,服务器返回token,该token在expires_in字段中存储的一些后到期。我们必须在每隔expires_in毫秒时间后的重新向后台刷新授权
  • 考虑到在等待api调用的结果(初始登录或刷新)时,用户可以在其间注销

你将如何实现这一点与thunk?同时还为整个流程提供全面的测试覆盖?

可是如果你使用redux-saga:

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后台任务。
  • 如果authAndRefreshTokenOnExpiry在调用(授权,{token})调用的中间被阻止,它也将被取消。取消自动向下传播。

其他特殊场景

同时执行多个任务

有时候我们需要在几个ajax请求执行完之后,再执行对应的操作。redux-thunk需要借助第三方的库,而redux-saga是直接实现的。

import { call } from 'redux-saga/effects'

// 正确写法, effects 将会同步执行
const [users, repos] = yield [
  call(fetch, '/users'),
  call(fetch, '/repos')
]

当我们需要 yield 一个包含 effects 的数组, generator 会被阻塞直到所有的 effects 都执行完毕,或者当一个 effect 被拒绝 (就像 Promise.all 的行为)。

监听action

在redux-saga中,我们可以使用了辅助函数 takeEvery 在每个 action 来到时派生一个新的任务。 这多少有些模仿 redux-thunk 的行为:举个例子,每次一个组件调用 fetchProducts Action 创建器(Action Creator),Action 创建器就会发起一个 thunk 来执行控制流。

在现实情况中,takeEvery 只是一个在强大的低阶 API 之上构建的辅助函数。 在这一节中我们将看到一个新的 Effect,即 take。take 让我们通过全面控制 action 观察进程来构建复杂的控制流成为可能。

让我们开始一个简单的 Saga 例子,这个 Saga 将监听所有发起到 store 的 action,然后将它们记录到控制台。

使用 takeEvery('')( 代表通配符模式),我们就能捕获发起的所有类型的 action。

import { takeEvery } from 'redux-saga'

function* watchAndLog(getState) {
  yield* takeEvery('*', function* logger(action) {
    console.log('action', action)
    console.log('state after', getState())
  })
}

现在我们知道如何使用 take Effect 来实现和上面相同的功能:

import { take } from 'redux-saga/effects'

function* watchAndLog(getState) {
  while(true) {
    const action = yield take('*')
    console.log('action', action)
    console.log('state after', getState())
  })
}

take 就像我们更早之前看到的 call 和 put。它创建另一个命令对象,告诉 middleware 等待一个特定的 action。 正如在 call Effect 的情况中,middleware 会暂停 Generator,直到返回的 Promise 被 resolve。 在 take 的情况中,它将会暂停 Generator 直到一个匹配的 action 被发起了。 在以上的例子中,watchAndLog 处于暂停状态,直到任意的一个 action 被发起。

注意,我们运行了一个无限循环的 while(true)。记住这是一个 Generator 函数,它不具备 从运行至完成 的行为(run-to-completion behavior)。 Generator 将在每次迭代上阻塞以等待 action 发起。

一个简单的例子,假设在我们的 Todo 应用中,我们希望监听用户的操作,并在用户初次创建完三条 Todo 信息时显示祝贺信息。

import { take, put } from 'redux-saga/effects'

function* watchFirstThreeTodosCreation() {
  for(let i = 0; i < 3; i++) {
    const action = yield take('TODO_CREATED')
  }
  yield put({type: 'SHOW_CONGRATULATION'})
}

与 while(true) 不同,我们运行一个只迭代三次的 for 循环。在 take 初次的 3 个 TODO_CREATED action 之后, watchFirstThreeTodosCreation Saga 将会使应用显示一条祝贺信息然后中止。这意味着 Generator 会被回收并且相应的监听不会再发生。

任务取消

一旦任务被 fork,可以使用 yield cancel(task) 来中止任务执行。取消正在运行的任务,将抛出 SagaCancellationException 错误。

防抖动

为了对 action 队列进行防抖动,可以在被 fork 的任务里放置一个 delay。

const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms))

function* handleInput(input) {
  // 500ms 防抖动
  yield call(delay, 500)
  ...
}

function* watchInput() {
  let task
  while(true) {
    const { input } = yield take('INPUT_CHANGED')
    if(task)
      yield cancel(task)
    task = yield fork(handleInput, input)
  }
}

在上面的示例中,handleInput 在执行之前等待了 500ms。如果用户在此期间输入了更多文字,我们将收到更多的 INPUT_CHANGED action。 并且由于 handleInput 仍然会被 delay 阻塞,所以在执行自己的逻辑之前它会被 watchInput 取消。

常用

Effect

一个 effect 就是一个纯文本 JavaScript 对象,包含一些将被 saga middleware 执行的指令。

使用 redux-saga 提供的工厂函数来创建 effect。 举个例子,你可以使用 call(myfunc, 'arg1', 'arg2') 指示 middleware 调用 myfunc('arg1', 'arg2') 并将结果返回给 yield 了 effect 的那个 Generator。

从 Saga 内触发异步操作(Side Effect)总是由 yield 一些声明式的 Effect 来完成的 (你也可以直接 yield Promise,但是这会让测试变得困难。使用 Effect 诸如 call 和 put,与高阶 API 如 takeEvery 相结合,让我们实现与 redux-thunk 同样的东西, 但又有额外的易于测试的好处。

task

一个 task 就像是一个在后台运行的进程。在基于 redux-saga 的应用程序中,可以同时运行多个 task。通过 fork 函数来创建 task:

function* saga() {
  ...
  const task = yield fork(otherSaga, ...args)
  ...
}

Watcher/Worker

指的是一种使用两个单独的 Saga 来组织控制流的方式。

Watcher: 监听发起的 action 并在每次接收到 action 时 fork 一个 worker。

Worker: 处理 action 并结束它。

示例:

function* watcher() {
  while(true) {
    const action = yield take(ACTION)
    yield fork(worker, action.payload)
  }
}

function* worker(payload) {
  // ... do some stuff
}

take(pattern)

创建一条 Effect 描述信息,指示 middleware 等待 Store 上指定的 action。 Generator 会暂停,直到一个与 pattern 匹配的 action 被发起。

用以下规则来解释 pattern:

  • 如果调用 take 时参数为空,或者传入 '*',那将会匹配所有发起的 action(例如,take() 会匹配所有的 action)。
  • 如果是一个函数,action 会在 pattern(action) 返回为 true 时被匹配(例如,take(action => action.entities) 会匹配那些 entities 字段为真的 action)。
  • 如果是一个字符串,action 会在 action.type === pattern 时被匹配(例如,take(INCREMENT_ASYNC))。
  • 如果参数是一个数组,会针对数组所有项,匹配与 action.type 相等的 action(例如,take([INCREMENT, DECREMENT]) 会匹配 INCREMENT 或 DECREMENT 类型的 action)。

put(action)

用于触发 action,功能上类似于dispatch。

创建一条dispatch Effect 描述信息,指示 middleware 发起一个 action 到 Store。

  • action: Object - 完整信息可查看 Redux 的 dispatch 文档

直接使用dispatch:

//...

function* fetchProducts(dispatch)
  const products = yield call(Api.fetch, '/products')
  dispatch({ type: 'PRODUCTS_RECEIVED', products })
}

该解决方案与我们在上一节中看到的从 Generator 内部直接调用函数,有着相同的缺点。如果我们想要测试 fetchProducts 接收到 AJAX 响应之后执行 dispatch, 我们还需要模拟 dispatch 函数。

相反,我们需要同样的声明式的解决方案。只需创建一个对象来指示 middleware 我们需要发起一些 action,然后让 middleware 执行真实的 dispatch。 这种方式我们就可以同样的方式测试 Generator 的 dispatch:只需检查 yield 后的 Effect,并确保它包含正确的指令。

redux-saga 为此提供了另外一个函数 put,这个函数用于创建 dispatch Effect

import { call, put } from 'redux-saga/effects'
//...

function* fetchProducts() {
  const products = yield call(Api.fetch, '/products')
  // 创建并 yield 一个 dispatch Effect
  yield put({ type: 'PRODUCTS_RECEIVED', products })
}

现在,我们可以像上一节那样轻易地测试 Generator:

import { call, put } from 'redux-saga/effects'
import Api from '...'

const iterator = fetchProducts()

// 期望一个 call 指令
assert.deepEqual(
  iterator.next().value,
  call(Api.fetch, '/products'),
  "fetchProducts should yield an Effect call(Api.fetch, './products')"
)

// 创建一个假的响应对象
const products = {}

// 期望一个 dispatch 指令
assert.deepEqual(
  iterator.next(products).value,
  put({ type: 'PRODUCTS_RECEIVED', products }),
  "fetchProducts should yield an Effect put({ type: 'PRODUCTS_RECEIVED', products })"
)

call(fn, ...args)

用于调用异步逻辑,支持 promise 。

创建一条 Effect 描述信息,指示 middleware 调用 fn 函数并以 args 为参数。fn 既可以是一个普通函数,也可以是一个 Generator 函数。

middleware 调用这个函数并检查它的结果。

如果结果是一个 Generator 对象,middleware 会执行它,就像在启动 Generator (startup Generators,启动时被传给 middleware)时做的。 如果有子级 Generator,那么在子级 Generator 正常结束前,父级 Generator 会暂停,这种情况下,父级 Generator 将会在子级 Generator 返回后继续执行,或者直到子级 Generator 被某些错误中止, 如果是这种情况,将在父级 Generator 中抛出一个错误。

如果结果是一个 Promise,middleware 会暂停直到这个 Promise 被 resolve,resolve 后 Generator 会继续执行。 或者直到 Promise 被 reject 了,如果是这种情况,将在 Generator 中抛出一个错误。
当 Generator 中抛出了一个错误,如果有一个 try/catch 包裹当前的 yield 指令,控制权将被转交给 catch。 否则,Generator 会被错误中止,并且如果这个 Generator 被其他 Generator 调用了,错误将会传到调用的 Generator。

yield fork(fn ...args) 的结果是一个 Task 对象 —— 一个具备某些有用的方法和属性的对象

fork(fn, ...args)

创建一条 Effect 描述信息,指示 middleware 以 无阻塞调用 方式执行 fn。

fork 类似于 call,可以用来调用普通函数和 Generator 函数。但 fork 的调用是无阻塞的,在等待 fn 返回结果时,middleware 不会暂停 Generator。 相反,一旦 fn 被调用,Generator 立即恢复执行。
fork 与 race 类似,是一个中心化的 Effect,管理 Sagas 间的并发。

race(effects)

创建一条 Effect 描述信息,指示 middleware 在多个 Effect 之间执行一个 race(类似 Promise.race([...]) 的行为)。

api

redux-saga的其他详细API列举如下,API详解可以查看API 参考

  • Middleware API

    • createSagaMiddleware(...sagas)
    • middleware.run(saga, ...args)
  • Saga Helpers

    • takeEvery(pattern, saga, ...args)
    • takeLatest(pattern, saga, ..args)
  • Effect creators

    • take(pattern)
    • put(action)
    • call(fn, ...args)
    • call([context, fn], ...args)
    • apply(context, fn, args)
    • cps(fn, ...args)
    • cps([context, fn], ...args)
    • fork(fn, ...args)
    • fork([context, fn], ...args)
    • join(task)
    • cancel(task)
    • select(selector, ...args)
  • Effect combinators

    • race(effects)
    • [...effects] (aka parallel effects)
  • Interfaces

    • Task
  • External API

    • runSaga(iterator, {subscribe, dispatch, getState}, [monitor])

参考

  • 初级教程
  • 探索Redux的最佳实践
  • redux-saga 实践总结
  • Redux Saga实践
  • redux-saga vs redux-thunk的优点/缺点
  • Redux的全家桶与最佳实践

你可能感兴趣的:(redux,react.js,异步)