跳至主要内容

声明式效果

redux-saga 中,Sagas 是使用 Generator 函数实现的。为了表达 Saga 的逻辑,我们从 Generator 中 yield 出普通的 JavaScript 对象。我们称这些对象为 Effects。Effect 是一个包含一些信息的 object,用于被 middleware 解释。你可以将 Effects 视为对 middleware 的指令,用于执行某些操作(例如,调用异步函数,向 store 派发 action 等)。

要创建 Effects,可以使用 redux-saga/effects 包中提供的库函数。

在本节和接下来的部分中,我们将介绍一些基本的 Effects。并了解这个概念如何使 Sagas 变得易于测试。

Sagas 可以以多种形式 yield Effects。最简单的方法是 yield 一个 Promise。

例如,假设我们有一个 Saga 监听 PRODUCTS_REQUESTED action。对于每个匹配的 action,它会启动一个任务从服务器获取产品列表。

import { takeEvery } from 'redux-saga/effects'
import Api from './path/to/api'

function* watchFetchProducts() {
yield takeEvery('PRODUCTS_REQUESTED', fetchProducts)
}

function* fetchProducts() {
const products = yield Api.fetch('/products')
console.log(products)
}

在上面的例子中,我们直接在 Generator 内部调用 Api.fetch(在 Generator 函数中,yield 右边的任何表达式都会被求值,然后结果会被 yield 给调用者)。

Api.fetch('/products') 会触发一个 AJAX 请求并返回一个 Promise,该 Promise 将解析为解析后的响应,AJAX 请求将立即执行。简单且符合习惯用法,但是...

假设我们想测试上面的 generator

const iterator = fetchProducts()
assert.deepEqual(iterator.next().value, ??) // what do we expect ?

我们想检查 generator yield 的第一个值的返回值。在我们的例子中,它是运行 Api.fetch('/products') 的结果,这是一个 Promise。在测试期间执行实际的服务既不可行也不实用,因此我们必须模拟 Api.fetch 函数,即我们必须用一个假的函数替换真实的函数,该函数实际上不会运行 AJAX 请求,而只是检查我们是否用正确的参数(在本例中为 '/products')调用了 Api.fetch

模拟使测试变得更加困难和不可靠。另一方面,返回值的函数更容易测试,因为我们可以使用简单的 equal() 来检查结果。这是编写最可靠测试的方法。

不相信?我建议你阅读 Eric Elliott 的文章

(...)equal(),本质上回答了每个单元测试必须回答的两个最重要的问题,但大多数测试都没有回答

  • 实际输出是什么?
  • 预期输出是什么?

如果你完成了一个测试却没有回答这两个问题,那么你并没有真正的单元测试。你只有粗制滥造、半成品的测试。

我们需要做的是确保 fetchProducts 任务使用正确的函数和参数进行调用。

与其直接从 Generator 内部调用异步函数,**我们可以只 yield 函数调用的描述**。也就是说,我们会 yield 一个看起来像这样的对象

// Effect -> call the function Api.fetch with `./products` as argument
{
CALL: {
fn: Api.fetch,
args: ['./products']
}
}

换句话说,Generator 会 yield 包含指令的普通对象,而 redux-saga 中间件会负责执行这些指令并将执行结果返回给 Generator。这样,在测试 Generator 时,我们只需要通过对 yield 的对象进行简单的 deepEqual 检查来确保它 yield 了预期的指令。

出于这个原因,该库提供了一种不同的方式来执行异步调用。

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

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

我们现在使用 call(fn, ...args) 函数。**与前面的示例不同的是,我们现在不会立即执行 fetch 调用,而是 call 会创建一个效果描述**。就像在 Redux 中使用 action creators 来创建一个描述将由 Store 执行的 action 的普通对象一样,call 创建一个描述函数调用的普通对象。redux-saga 中间件负责执行函数调用并使用解析后的响应恢复 generator。

这使我们能够轻松地在 Redux 环境之外测试 Generator。因为 call 只是一个返回普通对象的函数。

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

const iterator = fetchProducts()

// expects a call instruction
assert.deepEqual(
iterator.next().value,
call(Api.fetch, '/products'),
"fetchProducts should yield an Effect call(Api.fetch, './products')"
)

现在我们不需要模拟任何东西,一个基本的相等性测试就足够了。

这些声明式调用的优势在于,我们可以通过迭代 Generator 并对依次 yield 的值进行 deepEqual 测试来测试 Saga 中的所有逻辑。这是一个真正的优势,因为你的复杂异步操作不再是黑盒子,你可以详细地测试它们的运行逻辑,无论它有多复杂。

call 还支持调用对象方法,你可以使用以下形式为调用的函数提供 this 上下文

yield call([obj, obj.method], arg1, arg2, ...) // as if we did obj.method(arg1, arg2 ...)

apply 是方法调用形式的别名

yield apply(obj, obj.method, [arg1, arg2, ...])

callapply 很适合用于返回 Promise 结果的函数。另一个函数 cps 可用于处理 Node 风格的函数(例如 fn(...args, callback),其中 callback 的形式为 (error, result) => ())。cps 代表 Continuation Passing Style(延续传递风格)。

例如

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

const content = yield cps(readFile, '/path/to/file')

当然,你可以像测试 call 一样测试它。

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

const iterator = fetchSaga()
assert.deepEqual(iterator.next().value, cps(readFile, '/path/to/file') )

cps 也支持与 call 相同的方法调用形式。

可以在 API 参考 中找到所有声明式效果的完整列表。