瘦人说

在JavaScript异步和同步之间穿梭

异步和同步

如果你正在使用JavaScript,那么你对异步和同步的代码并不会陌生。经常接触到的异步的用法很多,比如一个DOM事件的调用,一个Ajax请求或是NodeJS中读文件方法的调用等等都是在使用异步调用。当你遍历取到的数据并加上处理逻辑的时候,你就会使用上同步的代码。整个编码的过程里,其实你就是在同步和异步的方式之间穿梭。

无需多说同步代码是怎么运行的,程序顺序执行的方式是学习编程的第一课,但它却先入为主地给我们的脑子里烙了个印,以至于很多开发者想学JS的时候,觉得JS是个调用方式特别的语言,需要时间去适应同步到异步的思维转换。所以我期望更多地介绍异步方式的调用,帮助大家打破那些受禁锢的异步编程思维

异步调用并不会阻止代码的顺序执行,而是在将来的某一个时刻触发设置好的逻辑,所以我们

  1. 并不知道逻辑什么时候会被调用
  2. 只能定义当触发的时候逻辑是什么
  3. 只能等待,同时可以去处理其他的逻辑

setTimeout就是这样的一个异步调用。

1
2
3
4
5
6
7
setTimeout(function () {
console.log(1);
}, 0);
console.log(2);
// output
// 2
// 1

冒泡排序动画的例子

冒泡算法人人都会,但是如果需要加上动画效果,算法就会变得有趣得多。先看看冒泡逻辑是怎么写的。

1
2
3
4
5
6
7
8
9
function bubbleSort(arr) {
for(var i = 0, len = arr.length ; i < len - 1 ; i++){
for(var ii = 0; ii < len - i - 1; ii++){
if(arr[ii] > arr[ii + 1]){
swap(arr, ii, ii + 1);
}
}
}
}

需要改变的点就是swap方法需要是异步的,当两两交换的动画结束后,继续下一步比较,请参考Comparison Sorting Algorithms里的动画。这个例子虽然简单,但其实在Web应用和页游中,需要在计算逻辑(同步)和可视化(异步)之间频繁切换的场景还很多。

为达目的,于是把代码写成了这样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function asyncBubbleSort(arr, i){
i = i || 0;
if(i == arr.length - 1) return;
(function bubble(arr, ii){
if(ii != arr.length - i - 1){
if(arr[ii] > arr[ii + 1]){
/* 异步的swap */
asyncSwap(arr, ii, ii + 1, function(){
bubble(arr, ++ii);
});
}else bubble(arr, ++ii);
}else{
bubble(arr, ++i);
}
})(arr, 0);
}

这个冒泡的算法逻辑已经面目全非,和脑海中的冒泡排序天差地别,试想如果是遇到更加复杂的场景,那这个逻辑将会多么的复杂。带着问题,我们先看看目前在JS异步调用方面,我们都有些什么尝试。

异步调用方式的进化

其实我们没有停止过对更好的异步调用方式的追求,目的就是让代码更易读,更接近我们最初认为的那个样子。多层嵌套是异步调用的一个特点,你的代码可能会是这样的。

1
2
3
4
5
6
7
step1(function (value1) {
step2(value1, function(value2) {
step3(value2, function(value3) {
doSomething(value1, value2, value3);
});
});
});

为了让代码可读,传统方式一般会定义一个新的方法让主体代码更整洁些,如下所示。但step1step2step3三个方法的组合方式会有很多种的,代码的结构会变得复杂,变得不好维护。

1
2
3
4
5
6
7
8
9
10
11
12
13
function steps(callback) {
step1(function (value1) {
step2(function(value2) {
step3(function(value3) {
callback && callback(value1, value2, value3);
});
});
});
}
//
steps(function (value1, value2, value3) {
doSomething(value1, value2, value3);
});

CommonJS Promise

当CommonJS的Promise出来之后,在异步调用的控制,可读性上有了很大的进步。代码变成了这个样子,并不用抽新的方法,三个方法的组合方式也可任意组织。

1
2
3
4
5
6
7
8
9
$.when(step1(), step2(), step3(), function (value1, value2, value3) {
doSomething(value1, value2, value3);
});
//
// 和
//
$.when(step1(), step3(), function (value1, value3) {
doAnotherThing(value1, value3);
});

再如支持Promise的$.ajaxAPI比之前配置的方式优雅了很多。

1
2
3
4
5
6
7
8
9
10
$.ajax( "example.php" )
.done(function() {
alert( "success" );
})
.fail(function() {
alert( "error" );
})
.always(function() {
alert( "complete" );
});

但是稍微改一下,就会发现Promise也不是完美的。如果遇到多个函数之间是相互依赖的情况,还是会利用到多层callback的方式,相比起来并没有多大的改进。

1
2
3
4
5
6
7
step1().done(function (value1) {
step2(value1).done(function (value2) {
step3(value2).done(function (value3) {
doSomething(value1, value2, value3);
});
});
});

如果你想要使用Promise,提供几个库给大家按照需求选择使用。jQuery的Deferred对象, When和强力推荐的Q

控制异步运行队列

最易读的方式当然是像同步一样去编写异步代码,让现有逻辑能够复用,降低异步的编程复杂度。虽然是异步方法,但期望依然可以串行调用。刚接触到这种想法的时候,我其实是很惊讶的,因为从没这么颠覆性的去思考解决这个问题。刚以为这完全是不可能的,但实际上已经接触到了很多这样的例子,比如karma-ng-scenario, karma-e2e-dslselenium-webdriver实现的那样。

这是一段ng-scenario的API调用,你会发现在输入框中填值,点击按钮,断言等操作都是异步调用,但是代码确是同步方式编写的。

1
2
3
4
5
6
7
8
9
describe('Buzz Client', function() {
it('should filter results', function() {
input('user').enter('jacksparrow');
element(':button').click();
expect(repeater('ul li').count()).toEqual(10);
input('filterText').enter('Bees');
expect(repeater('ul li').count()).toEqual(1);
});
});

非常神奇,不是么!你可以参考karma-e2e-dsl的代码是怎么利用promise对象做到的。

这其实都是烟雾弹,这种顺序的写法在代码运行的时候,每一个语句其实并没有去执行相应的逻辑,而是把它真正的逻辑入队列delay运行了,等到某一个时刻,把队列里面的逻辑挨个串起来运行一遍。所以你会发现,这种设计它是不可能出现返回值的,如下的例子就是无法实现的,因为赋值表达式立即执行,不受运行队列控制。

1
2
3
4
5
6
7
var val = step1();
// step1() 让step1真正的逻辑进入队列
// step1() 的返回值赋给val变量 <= 赋值逻辑未受队列控制
step2();
// step2() 让step2的真正逻辑进入队列
console.log(val);
// 立即输出val的值,此时step1的逻辑在控制中还没有执行

所谓强扭的瓜不甜,硬要在现有JS调用方式下把异步写成同步方式肯定会有些不足,像之前提到的Promise和利用控制延迟的逻辑队列的方式,都是利用技巧和聪明才智的小步改进,并不能彻底达到目的。

把异步调用推向极致优雅

早在2008年,JavaScript 1.7中,Firefox和Opera就开始支持一种良好的,可用于代码控制的功能,它叫迭代器IteratorGenerator,详细的说明可以参考MDN的例子。(混乱的JS版本和功能衍进可以参考Wiki

我在这里要介绍的是Generator对异步调用方式的改变。先回到文章开始时的冒泡排序的例子,如果使用了Generator去控制代码运行队列的方式来编写冒泡动画的话,代码可以写成这样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
function swapAsync(arr, i, ii) {
return function (done) {
var tmp = arr[i];
arr[i] = arr[ii];
arr[ii] = tmp;
// 等待动画运行完
swapAnimation(i, ii, done);
};
}
//
function run(generator) {
var g = generator();
(function move(val) {
var next = g.next(val);
if (!next.done) {
next.value(function () {
move(next.value)
});
}
})();
}
//
function* bubbleSort(arr) {
for(var i = 0, len = arr.length ; i < len - 1 ; i++){
for(var ii = 0; ii < len - i - 1; ii++){
if(arr[ii] > arr[ii + 1]){
yield swapAsync(arr, ii, ii + 1);
}
}
}
}
//
run(bubbleSort([3, 2, 1]));

其中的run就是一个简单的利用Generator控制代码队列调用的实现,每次取到yield的对象时,得到的时一个异步的可运行的方法(swapAsync()调用的返回值是方法)。立即运行此方法,等待异步调用结束后又让代码继续运行(利用move()方法)。

整个过程就时每当运行到yield的时候,bubbleSort代码就会停止运行,收到通知之后代码继续运行,完全模拟了同步的节奏。因为这种代码控制的方式时JS原生支持的,所以赋值运算也是可以被控制的。

1
var val = yield randomNumAsync();

变得可行。同时,可以把yield语法后面的对象称为yieldable对象。这里就有一个异步随机数赋值运算实现的例子,里面可以看到如何定义赋值运算和不同种类的yieldable对象的。

Iterator和Generator并不是新鲜的事物,JS1.7的这个功能因为当时浏览器的兼容性问题,没有大范围被使用,在.NET Framework 4.5里也出现了await关键字的语法,支持了这种异步方式。那么为什么现在要“新瓶装老酒”呢,是因为EcmaScript 6将要登上舞台,在NodeJS和浏览器端都会有更多强大的功能和优雅的语法出现,之前阻止其发展的屏障也会被慢慢打破。目前github上很多library都可以开始使用上了ES6的特性,TJ大神的Koaco就是其中的典范。

从最基础的callback调用方式到generator的比较完美的异步调用方式的过程里,我们这些在异步和同步之间游走的开发者不断得到新的开发体验,提升开发效率,更重要的是,我们也作为这门语言的使用者和贡献者,见证了一个语言的进步。希望会有更多的好的异步调用方式出现和应用,相信最近这几年里这些编程方式和思维会在游戏开发和数据可视化等领域被大量使用。

最后还是忍不住放出2012年在我自己的Mr.Async里面实现的冒泡排序动画,纪念下我就是从那个时候开始思考怎么让异步变得更优雅的,当时受到很多老赵的影响。

Comments

Proudly published with Hexo