ES6 引入了一个新的关键字 yield
,用它可以实现发生器(generator)和协程(coroutine)。其中,协程有个很有趣的应用是用作事件循环。有了协程,对于事件的处理,除了传统的 “回调函数+状态” 方案外,我们又多了一个选择。
为了演示想法,我们先从 JSON 数字解析(Parsing)的例子开始。JSON 中,数字的语法如下图所示:
转成状态机,如下图:
根据此状态图,可以写出如下代码(注意,这里简化了任务,只判断字符串是不是数字):
function parse(str) {
const DIGITS = "0123456789";
const NON_ZEROS = "123456789";
let s = 0;
let i = 0;
while (s >= 0) {
const ch = i < str.length ? str.charAt(i) : null;
switch (s) {
case 0:
if (ch === '-') s = 1;
else if (ch === '0') s = 7;
else if (NON_ZEROS.includes(ch)) s = 2;
else s = -2;
break;
case 1:
if (NON_ZEROS.includes(ch)) s = 2;
else if (ch === '0') s = 7;
else s = -2;
break;
case 2:
if (DIGITS.includes(ch)) s = 2;
else if (ch === '.') s = 3;
else if (ch === 'e' || ch === 'E') s = 5;
else if (ch === null) s = -1;
else s = -2;
break;
case 3:
if (DIGITS.includes(ch)) s = 4;
else s = -2;
break;
case 4:
if (DIGITS.includes(ch)) s = 4;
else if (ch === 'e' || ch === 'E') s = 5;
else if (ch === null) s = -1;
else s = -2;
break;
case 5:
if (DIGITS.includes(ch)) s = 6;
else if (ch === '+' || ch === '-') s = 8;
else s = -2;
break;
case 6:
if (DIGITS.includes(ch)) s = 6;
else if (ch === null) s = -1;
else s = -2;
break;
case 7:
if (ch === '.') s = 3;
else if (ch === 'e' || ch === 'E') s = 5;
else if (ch === null) s = -1;
else s = -2;
break;
case 8:
if (DIGITS.includes(ch)) s = 6;
else s = -2;
}
if (s >= 0) i++;
}
return s === -1;
}
观察上面的代码可以发现,对于每个状态,实际是在做如下处理:
(currentState, action) => newState
也就是,接收一个动作然后根据一定逻辑修改当前状态。由于业务的状态较多,状态之间迁移逻辑复杂,因此整体代码看起来也很复杂、跳跃。有简洁的方案吗?有,如下:
function parse(str) {
const NON_ZEROS='123456789';
const DIGITS='0123456789';
let i=0;
const bump = () => i<str.length?str.charAt(i++):null;
let ch = bump();
if (ch === '-') ch = bump();
if (ch === '0') ch = bump();
else if (NON_ZEROS.includes(ch)) while (DIGITS.includes(ch = bump()));
else return false;
if (ch === '.') {
if (!DIGITS.includes(ch = bump())) return false;
while (DIGITS.includes(ch = bump()));
}
if (ch === 'e' || ch === 'E') {
if ("+-".includes(ch = bump())) ch = bump();
if (!DIGITS.includes(ch)) return false;
while (DIGITS.includes(ch = bump()));
}
return ch === null;
}
以上代码和之前的代码有相同功能,但不依赖状态 s
,串行化的逻辑读起来更连贯、更符合思维习惯,看起来也更简洁。
好了,这个例子跟文章的主旨有什么联系呢?
虽然 Web GUI 程序业务各不相同,但基本可以抽象为以下结构:
const appState = {/* ... */}; // 程序状态
// 处理事件#1
dom.onxxx = funtion(ev) {
/* 根据 appState 处理事件并修改 appState */
};
// 处理事件#2
dom.onyyyy = funtion(ev) {
/* 根据 appState 处理事件并修改 appState */
};
/* ...处理其它事件 */
如果把之前例子中的数字解析抽象成某个具有很多状态的抽象业务,把 s
抽象为应用程序状态(比如鼠标位置、某个按键是否按下等等),把触发状态改变的动作换做事件,就会发现基于事件驱动的 Web GUI 程序跟数字解析有异曲同工之妙。
数字解析的例子中,字符串是可以迭代的字符序列,因此字符处理转换成串行化的逻辑比较容易。但是事件是随机的、异步的,怎么把事件的处理变成串行化的逻辑呢?事件的特性要求事件处理器是一个可以中断和恢复执行的函数,这正好就是 Generator 函数(发生器函数):
function *gen() {
let v = yield;
console.log(v);
v = yield;
console.log(v);
}
const it = gen();
it.next();
it.next(1); // 打印 "1"
it.next("Hello"); // 打印 "Hello"
我们可以写一个函数对 Generator 函数稍加包装,就能使 Generator 函数成为一个串行化逻辑的事件处理函数:
function co(g) {
const it = g();
it.next();
return function(ev) { it.next(ev); };
}
const h = co(function*(){
let ev = yield;
if (ev.type === 'xxx') {/* ... */}
else if (/* ... */) {/* ... */}
});
dom.onxxx = h;
dom.onyyy = h;
我们可以把经过 co
函数包装过的 Generator 函数叫做协程。协程函数每执行一次就会从之前中断的地方恢复执行,然后又在某个地方中断。
现在我们已经知道如何使用协程串行化的处理事件了,让我们用一个例子实践一下这个新想法:使用协程实现鼠标拖拽。
鼠标拖拽的状态及状态迁移如下图:
协程方案:
const handler = co(function*() {
let evt = null;
while (evt = yield) {
if (evt.type === 'mousedown') {
while (evt = yield) {
if (evt.type === 'mousemove') doDrag(evt);
if (evt.type === 'mouseup') break;
}
}
}
});
dom.onmousedown = dom.onmousemove = dom.onmouseup = handler;
演示效果可以看这里
传统方案:
let dragging = false;
dom.onmousedown = evt => dragging = true;
dom.onmousemove = evt => {
if (dragging) doDrag(evt);
};
dom.onmouseup = evt => dragging = false;
虽然鼠标拖拽示例状态简单,没有显示出协程的明显优势,但随着状态增多,状态迁移变复杂,使用协程的优势应该会逐步体现。
仔细思考协程方案和传统方案,可以发现协程方案有以下优缺点:
- 优点
- 逻辑更集中,不像传统方案那样,完成一个业务的事件处理器分散为不同的函数
- 串行化逻辑,简洁易懂
- 不需要额外状态,可以避免数据不一致导致的 BUG
- 缺点
- 代码量略多
yield
关键字只能在function*
函数里,无法独立出来,可能导致一个超级大函数
工程是权衡的艺术,实际开发过程中肯定要评估、权衡,无论如何,Generator 函数的引入还是给了我们一个新选择。