1. 异步任务
我从具体的项目中分离出了一个有趣的问题,可以描述如下:
页面上有一个按钮,每次点击它,都会发送一个ajax请求,
并且,用户可以在ajax返回之前点击它。
现在我们要实现一个功能,
以按钮的点击顺序展示ajax的响应结果。
2. 准备活动
为了以后编码的方便,先将ajax请求mock一下,
let count = 0;
// 模拟ajax请求,以随机时间返回一个比之前大一的自然数
const mockAjax = async () => {
console.warn('send ajax');
await new Promise((res, rej) => setTimeout(() => res(++count), Math.random() * 3000));
console.warn('ajax return');
return count;
};
然后,假设按钮的id为sendAjax
,
<input id="sendAjax" type="button" value="Click" />
3. 冷静再冷静
document.querySelector('#sendAjax').addEventListener('click', async () => {
const result = await mockAjax();
console.log(result);
});
一开始,我们可能会想到这样的办法。
可惜,这是有问题的。
因为click
事件,可能会在后面async函数还未返回之前,再次触发。
导致前一个请求还未返回,后面又发起了新请求。
其次,我们可能还会想到,记录每一个请求的时间戳,将结果排序,
这也是有问题的,因为我们不知道未来还有多少次点击(<- 下文的关键信息),
如果无法拿到所有的结果,那么排序就有困难了。
那怎么办呢?
如果请求还未返回之前,能进行控制就好了。
4. 让我们Lazy一点
于是我想到了把新请求lazy化,放到一个队列中,
如果当前有其他任务在执行,就暂不处理。
否则,如果当前是空闲的,那就把队列中的任务都取出来,依次执行。
const PromiseExecutor = class {
constructor() {
// lazy promise队列
this._queue = [];
// 一个变量锁,如果当前有正在执行的lazy promise,就等待
this._isBusy = false;
}
each(callback) {
this._callback = callback;
}
// 通过isBusy实现加锁
// 如果当前有任务正在执行,就返回,否则就按队列中任务的顺序来执行
add(lazyPromise) {
this._queue.push(lazyPromise);
if (this._isBusy) {
return;
}
this._isBusy = true;
// execute是一个async函数,执行后立即返回,返回一个promise
// 因此,add可以在execute内的promise resolved之前再次执行
this.execute();
};
async execute() {
// 按队列中的任务顺序来依次执行
while (this._queue.length !== 0) {
const head = this._queue.shift();
const value = await head();
this._callback && this._callback(value);
}
// 执行完之后,解锁
this._isBusy = false;
};
};
以上代码,我用了一个队列和变量锁,对新请求进行了管控。
其中的关键点是execute
的异步性,
我们看到add
函数在尾部调用了this.execute();
,会立即返回。
这样就不会阻塞JavaScript线程,可以多次调用add
函数了。
下面我们来看下它的使用方法吧,
const executor = new PromiseExecutor;
document.querySelector('#sendAjax').addEventListener('click', () => {
// 添加一个lazy promise
executor.add(() => mockAjax());
});
// 注册回调,该回调会按lazy promise的加入顺序,逐个获取它们resolved的值
executor.each(v => {
console.log(v);
});
5. 更远一些
上文中有一句话,启发了我,
迫使我从不同的角度重新考虑了这个问题。
我们提到,由于“我们不知道未来还有多少次点击”,所以是无法进行排序的。
因此,我发现这是一个和“无穷流”相关的问题。
即,我们不应该把事件看成回调,而是应该看成流(stream)。
所以,我们可以寻找响应式的方式来解决它。
以下两篇文章可以帮你快速回顾一下响应式编程(Reactive Programming)。
——也称反应式编程 (:зゝ∠)
好了,下面我们要开始进行响应式编程了。
首先,click
事件可以形成一个“点击流”,
const clickStream = cont => document.querySelector('#sendAjax').addEventListener('click', cont);
这里的cont指的是Continuation,可以参考上面提到的第二篇文章。
其次,我们需要将这个“点击流”,变换成最终的“ajax结果流”,
并且保证“ajax结果流”的顺序,与“点击流”的顺序相同。
因此,问题在概念上就被简化了,
事实上,所有的stream
连同operator
一起,构成了一个Monad。
下面我们来编写operator
吧,用来对流进行变换,我们只要记着,
“什么时候调用cont就什么时候把东西放到结果流中”,即可。
const streamWait = function (mapValueToPromise) {
const stream = this;
// 使用一个队列和一个变量锁来进行调度
// 如果当前正在处理,就入队,否则就一次性清空队列
// 并且在清空的过程中,有了新的任务还可以入队
const queue = [];
let isBusy = false;
return cont => stream(async v => {
queue.push(() => mapValueToPromise(v));
// 如果当前正在处理,就返回,不改变结果stream中的值
if (isBusy) {
return;
}
// 否则就按顺序处理,将每一个任务的返回值放到结果流中
isBusy = true;
while (queue.length !== 0) {
const head = queue.shift();
const r = await head();
cont(r);
}
// 处理完了以后,恢复空闲状态
isBusy = false;
});
};
我们再来看下怎么使用它,是不是更加通俗易懂了呀。
// 点击流
const clickStream = cont => document.querySelector('#sendAjax').addEventListener('click', cont);
// ajax结果流
const responseStream = streamWait.call(clickStream, e => mockAjax());
// 订阅结果流
responseStream(v => console.log(v));
Your mouse is a database. —— Erik Meijer