前言
最近在写 Node.js 的知识点,之前的内容如下:
之前深入了异步管理中 promise 的高级内容。现在是异步管理的第二部分 生成器(generator) 管理异步任务。
我们将实现一个跟框架无关的 Generator 异步管理器,思想来源于 redux-saga, 它弥补了 promise 本身无法中途取消执行的问题,而且还可以自定义很多异步管理复杂的操作符。
本文主要实现一个可以中断异步请求的 Generator 异步管理器中的操作符, 你可以在此之上实现各种各样的其它需求的操作符,最核心的目的是:
让大家认识到 Generator 也是一种管理异步请求的方式,弥补在这个知识领域,少有探讨的资料的问题
其实在 go 语言里,借助协程(在 Javascript 中 Generator 是协程的一种实现),所以 Javascript 本身是可以写出 go 语言里 goroutine 的那种语法的,有兴趣深入的同学可以查看 js-csp 这个库。
解决场景举例
我们想象一个常见不能再常见的前端场景了,如下图:
再进一步说明问题
按钮 A 按了之后,ajax请求的数据显示在input type=text框里,B按钮也是。
问题就是如果先按 A,此时 ajax 发出去了,但是数据还没返回来, 我们等不及了,马上按 B 按钮,结果此时 A 按钮请求的数据先回来,这就尴尬了,按的 B 按钮,结果先显示 A 按钮返回的数据,怎么解决?
如果 promise 可以中断,我们在切换按钮的时候,能把之前的 promise 取消了的话,这样就完美解决这个问题了。
我试图简化这个过程,我们假设有一个按钮,每次点击请求后端的数据都不一样,然后我只要保证有新的请求,就取消之前所有的请求,就能保证一定最后渲染的,就是最后一个发出请求返回的数据。
实现一个例如:
javascript
const promiseA = new Promise(后端请求函数).then(()=> 得到后端数据,刷新视图)
const promiseB = new Promise(后端请求函数).then(()=> 得到后端数据,刷新视图)
当调用 promiseB 的时候,自动取消 promiseA 正在执行的任务,也就是后端请求可能已经发出了,但是不会刷新视图,因为在执行 promiseA 视图刷新的时候,知道 promiseB 已经调用了,所以取消刷新。
然后推而广之,我们假设有 100个promise,如果同时调用,都只会执行最后一个。
好了,用 promise 的话,还是不太方便的。我们从介绍基本的生成器开始我们今天的内容。
我已经把其封装为一个很简陋的库,在线demo地址(主要代码在 ./saga.js 文件中):
注意: 在线 demo 中有这么一段代码:
js
const onClick = () => {
takeLastChannel.put("1");
takeLastChannel.put("2");
takeLastChannel.put("3");
};
document.getElementById("button").addEventListener("click", onClick);
这里我们连续触发了 3 次,fetchUserIterator,每次传入不同的参数,可我们点击按钮会发现,每次只会显示最后一次调用后的值,也就是 3。其中 1 和 2 都被我们取消渲染了。
有些同学可能学习 es6 以来压根没用过生成器,我借助阮一峰老师的的 es6 课程内容,简单介绍一下生成器,熟悉的同学可以略过
Generator 简介
Generator 函数是 ES6 提供的一种异步编程解决方案,语法行为与传统函数完全不同。本章详细介绍 Generator 函数的语法和 API。
Generator 函数有多种理解角度。语法上,首先可以把它理解成,Generator 函数是一个状态机,封装了多个内部状态。
执行 Generator 函数会返回一个遍历器对象,也就是说,Generator 函数除了状态机,还是一个遍历器对象生成函数。返回的遍历器对象,可以依次遍历 Generator 函数内部的每一个状态。
形式上,Generator 函数是一个普通函数,但是有两个特征。一是,function
关键字与函数名之间有一个星号;二是,函数体内部使用yield
表达式,定义不同的内部状态(yield
在英语里的意思就是"产出")。
javascript
function* helloWorldGenerator() {
yield 'hello';
yield 'world';
return 'ending';
}
var hw = helloWorldGenerator();
上面代码定义了一个 Generator 函数helloWorldGenerator
,它内部有两个yield
表达式(hello
和world
),即该函数有三个状态:hello,world 和 return 语句(结束执行)。
然后,Generator 函数的调用方法与普通函数一样,也是在函数名后面加上一对圆括号。不同的是,调用 Generator 函数后,该函数并不执行,返回的也不是函数运行结果,而是一个指向内部状态的指针对象,也就是上一章介绍的遍历器对象(Iterator Object)。
下一步,必须调用遍历器对象的next
方法,使得指针移向下一个状态。也就是说,每次调用next
方法,内部指针就从函数头部或上一次停下来的地方开始执行,直到遇到下一个yield
表达式(或return
语句)为止。换言之,Generator 函数是分段执行的,yield
表达式是暂停执行的标记,而next
方法可以恢复执行。
yaml
hw.next()
// { value: 'hello', done: false }
hw.next()
// { value: 'world', done: false }
hw.next()
// { value: 'ending', done: true }
hw.next()
// { value: undefined, done: true }
上面代码一共调用了四次next
方法。
第一次调用,Generator 函数开始执行,直到遇到第一个yield
表达式为止。next
方法返回一个对象,它的value
属性就是当前yield
表达式的值hello
,done
属性的值false
,表示遍历还没有结束。
第二次调用,Generator 函数从上次yield
表达式停下的地方,一直执行到下一个yield
表达式。next
方法返回的对象的value
属性就是当前yield
表达式的值world
,done
属性的值false
,表示遍历还没有结束。
第三次调用,Generator 函数从上次yield
表达式停下的地方,一直执行到return
语句(如果没有return
语句,就执行到函数结束)。next
方法返回的对象的value
属性,就是紧跟在return
语句后面的表达式的值(如果没有return
语句,则value
属性的值为undefined
),done
属性的值true
,表示遍历已经结束。
第四次调用,此时 Generator 函数已经运行完毕,next
方法返回对象的value
属性为undefined
,done
属性为true
。以后再调用next
方法,返回的都是这个值。
总结一下,调用 Generator 函数,返回一个遍历器对象,代表 Generator 函数的内部指针。以后,每次调用遍历器对象的next
方法,就会返回一个有着value
和done
两个属性的对象。value
属性表示当前的内部状态的值,是yield
表达式后面那个表达式的值;done
属性是一个布尔值,表示是否遍历结束。
小结:
当在外部调用了 next()
后,会返回类似 {value: 0, done: false}
这样的结果,其中 done
是一个 boolean 值,代表迭代器是否完毕。
既然可以用
done
判斷迭代器是否執行完,那就來點迴圈或是遞迴吧!
Generator Runner 的概念
我们构建一个 runner
函数,用来执行一个 Generator 函数。在 runner
函数里面有一个 next
函数,这个函数便是不断地用 Generator 传入的 done
确认是否已经迭代完,如果还没迭代完,则调用迭代器的下一行指令。
js
import axios from "axios";
function proc(genFn) {
const itr = genFn();
function next(arg) {
let result = itr.next(arg);
if (result.done) {
return arg;
} else {
return Promise.resolve(result.value).then(next);
}
}
return next();
}
function* genFn() {
const USER_URI = "https://reqres.in/api/users";
let res = yield axios.get(USER_URI);
const userId = res.data.data[0].id;
yield axios.get(`${USER_URI}/${userId}`);
}
const result = runner(genFn);
Promise.resolve(result).then(res => console.log(res.data));
channel
首先我们要实现这样一个效果,就是点击按钮,触发调用一个 Generator Runner 来自动执行 Generator
js
$btn.addEventListener('click', () => {
chan.put('传递给 Generator Runner 要执行的参数');
}, false);
这里我们需要引入一个概念------channel
。
上面的 chan 就是 channel 的实例。
channel是对事件源的抽象,作用是先注册一个待执行的 gennerator ,当put触发时,执行之前注册的 gennerator。
简单来说,chan.take,注册 gennerator, chan.put,将之前 注册 gennerator 取出,然后用 proc,也就是Generator Runner 去自动迭代 gennerator(我们后续会写一个比上面稍微复杂一点的 proc)。
channel的简单实现如下:
js
function channel() {
// 当前正在执行的 gennerator
let currentTaker = null;
// cancelCurrentTaker:记录正在执行 gennerator,以方便后续取消
// currentArgs:给 gennerator 传入自定义参数,这样可以在触发 put 操作符的时候,接收这些参数
let info = {
cancelCurrentTaker: undefined,
currentArgs: undefined,
};
function take(taker) {
currentTaker = taker;
}
function put(args) {
// 每次执行之前查看是否有已经执行的任务,有就取消,这样可以保证每次只触发最后一次任务
info.cancelCurrentTaker?.cancel();
// 记录参数
info.currentArgs = args;
// Generator Runner 执行
proc(currentTaker());
}
return { take, put, info };
}
export const chan = channel();
effect 的概念
effect 是一个 JavaScript 对象,用來描述我们的迭代器操作符要如何执行的說明 。举例来说,我们经常会用到 call
:
type
:执行的 effect 类别 。fn
:被执行的函数。args
:被执行函数的参数。
js
export function call(fn, ...args) {
return {
isEffect: true,
type: "call",
fn,
args
};
}
effect Runner
每一种 effect 都有相对应的 effect runner,让每个 effect runner 各司其职,不用把很多概念混杂在一起,分开时会比较好管理。 举例来说,call
的 effect runner 会长这个样子:
fn
: 在 saga function 中我们定义的 callback function。args
: 在 saga function 中我们指定的fn
--- callback function 传入的参数。next
: Generator runner 中的next()
,用于进行下一次迭代
js
export function call(fn, ...args) {
return {
isEffect: true,
type: "call",
fn,
args
};
}
正式开始我们的代码逻辑梳理
有了上面的基础后,我们才能继续讲,这也是为啥 Generator 异步管理器不能流行的原因,就跟 redux-saga 虽然流行,但仍然对很多人来说晦涩难懂。既然这么晦涩了,还不如直接学 rxjs,比这个来的实在。
因为我们的实现跟框架无关,所以我直接在 html 中去实现它:
html 结构如下
html
<h1 id="h1">渲染内容</h1>
<p>点击下方按钮同时发出 3 个请求,但最终只会渲染最后一个</p>
<button id="button">按钮</button>
然后,我们业务逻辑如下:
首先引入我们的库
arduino
import { call, put, takeLastChannel, fork } from "./saga.js";
然后使用 takeLastChannel 来注册我们要调用的 gennerator
js
takeLastChannel.take(fetchUserTakeLatest);
fetchUserTakeLatest 是我们业务中调用后端接口的相关的函数,实现如下:
js
function fetchUser() {
return new Promise((res) => {
setTimeout(() => res("success"), 1000);
});
}
function* fetchUserIterator() {
const user = yield call(fetchUser);
yield put(
(args) => (document.getElementById("h1").innerHTML = args),
takeLastChannel
);
}
export function* fetchUserTakeLatest() {
yield fork(fetchUserIterator, takeLastChannel);
}
最后,我们把触发事件绑定到按钮上,
js
const onClick = () => {
takeLastChannel.put("1");
takeLastChannel.put("2");
takeLastChannel.put("3");
};
document.getElementById("button").addEventListener("click", onClick);
这里我们连续触发了 3 次,fetchUserIterator,每次传入不同的参数,可我们点击按钮会发现,每次只会显示最后一次调用后的值,也就是 3。其中 1 和 2 都被我们取消渲染了。
takeLastChannel.put 就是任务触发器。
接下来 saga.js 的代码如下,经过上面的铺垫,感觉大家会更好理解其内容,如果感觉困难,没有关系,这种模式的晦涩程度已经跟 rxjs 差不多了,对于复杂异步管理,rxjs 毕竟有着成熟的操作符库,但是有了生成器的基础,你就可以用生成器实现 async 函数了,其实 async 本身也可以使用我们的 Generator runner 来实现,我们下期再说:
js
// constant
const TASK_CANCEL = "TASK_CANCEL";
// utils
export const is = {
isPromise(p) {
return typeof p.then === "function" && typeof p.catch === "function";
},
isEffect(effect) {
return !!effect.isEffect;
},
};
const { isPromise, isEffect } = is;
// runner
export function proc(iterator) {
let task = { cancel: () => next(null, TASK_CANCEL) };
next();
function next(err, pre) {
let temp;
if (err) {
temp = iterator.throw(err);
} else if (pre === TASK_CANCEL) {
temp = iterator.return(pre);
} else {
temp = iterator.next(pre);
}
if (temp.done) return;
const value = temp.value;
if (isPromise(value)) {
value
.then((success) => next(null, success))
.catch((error) => next(error));
} else if (isEffect(value)) {
const effectRunner = effectRunnerMap[value.type];
effectRunner(value, next);
} else {
next(null, value);
}
}
return task;
}
function TakeLastChannel() {
let currentTaker = null;
let info = {
cancelCurrentTaker: undefined,
currentArgs: undefined,
};
function take(taker) {
currentTaker = taker;
}
function put(args) {
info.cancelCurrentTaker?.cancel();
info.currentArgs = args;
console.log("currentTaker", currentTaker);
proc(currentTaker());
}
return { take, put, info };
}
export const takeLastChannel = TakeLastChannel();
export function call(fn, ...args) {
return {
isEffect: true,
type: "call",
fn,
args,
};
}
export function put(action, channel) {
return {
isEffect: true,
type: "put",
action,
channel,
};
}
export function fork(saga, channel) {
return {
isEffect: true,
type: "fork",
saga,
channel,
};
}
function runCallEffect({ fn, args }, next) {
fn.call(null, args)
.then((success) => next(null, success))
.catch((error) => next(error));
}
function runPutEffect({ action, channel }, next) {
action(channel.info.currentArgs);
next();
}
function runForkEffect({ saga, channel }, next) {
const task = proc(saga());
if (channel) channel.info.cancelCurrentTaker = task;
next(null);
}
const effectRunnerMap = {
// take: runTakeEffect,
call: runCallEffect,
put: runPutEffect,
fork: runForkEffect,
};