JS 使用协程处理事件

ES6 引入了一个新的关键字 yield,用它可以实现发生器(generator)和协程(coroutine)。其中,协程有个很有趣的应用是用作事件循环。有了协程,对于事件的处理,除了传统的 “回调函数+状态” 方案外,我们又多了一个选择。

为了演示想法,我们先从 JSON 数字解析(Parsing)的例子开始。JSON 中,数字的语法如下图所示:
数字的状态

转成状态机,如下图:
number-dfa

根据此状态图,可以写出如下代码(注意,这里简化了任务,只判断字符串是不是数字):

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 函数叫做协程。协程函数每执行一次就会从之前中断的地方恢复执行,然后又在某个地方中断。

现在我们已经知道如何使用协程串行化的处理事件了,让我们用一个例子实践一下这个新想法:使用协程实现鼠标拖拽。

鼠标拖拽的状态及状态迁移如下图:

mousedown
mousemove
mouseup
未拖拽
拖拽中

协程方案:

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 函数的引入还是给了我们一个新选择。

猜你喜欢

转载自blog.csdn.net/ZML086/article/details/122130375