声明式效果
在 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, ...])
call
和 apply
很适合用于返回 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 参考 中找到所有声明式效果的完整列表。