1.异步
程序中现在运行的部分和将来运行的部分之间的关系是异步编程的核心。
多数JavaScript开发者从来没有认真思考过自己程序中的异步到底是如何出现的,以及为什么会出现,也没有探索过处理异步的其他方法。一直以来,低调的回调函数就算足够好的方法了。目前为止,还有很多人坚持认为回调函数完全够用。
但是,作为在浏览器、服务器以及其他能够想到的任何设备上运行的一流编程语言,JavaScript面临的需求日益扩大。为了满足这些需求,JavaScript的规模和复杂性也在持续增长,对异步的管理也越来越令人痛苦,这一切都迫切需要更强大、更合理的异步方法。
1.1 分块的程序
现在我们发出一个异步Ajax请求,然后在将来才能得到返回的结果(通过使用回调函数)。
//ajax(...)是某个库中提供的某个Ajax函数。
ajax("http://some.url.1",function myCallbackFunction(data){
console.log(data);//得到一些数据
});
function now(){
return 21;
}
function later(){
answer = answer * 2;
console.log("Meaning of life: ", answer);
}
var answer = now();
setTimeout(later, 1000);//Meaning of life: 42
setTimeout(...)
设置了一个事件(定时)在将来执行,所以函数later()
的内容会在之后的某个时间(从现在起1000毫秒之后)执行。
任何时候,只要把一段代码包装成一个函数,并指定它在响应某个事件(定时器、鼠标点击、Ajax响应等)时执行,你就是在代码中创建了一个将来执行的块,也由此在这个程序中引入了异步机制。
1.2 事件循环
现在我们来澄清一件事情(可能令人震惊):尽管你显然能够编写异步JavaScript代码,但直到最近(ES6),JavaScript才真正内建有直接的异步概念。
JavaScript引擎并不是独立运行的,它运行在宿主环境中,对多数开发者来说通常就是Web浏览器。经过最近几年的发展,JavaScript已经超过了浏览器的范围,进入了其他环境,比如通过像Node.js这样的工具进入服务器领域。实际上,JavaScript现如今已经嵌入到了从机器人到电灯泡等各种各样的设备中。
所有这些环境都提供了一种机制来处理程序中多个块的执行,且执行每个块时调用JavaScript引擎,这种机制被称为事件循环。
ES6中Promise对事件循环队列的调度运行能够直接进行精细控制。
1.3 并行
异步是关于现在和将来的时间间隙,而并行是关于能够同时发生的事情。
var a = 20;
function foo(){
a = a + 1;
}
function bar(){
a = a * 2;
}
ajax("...",foo);
ajax("...",bar);
由于JavaScript的单线程特性,foo()
和bar()
中的代码具有原子性。也就是说,一旦foo()
开始运行,它的所有代码都会在bar()
中的任意代码运行之前完成,或者相反。这称为完整运行特性。
1.4 并发
两个或多个“进程”同时执行就出现了并发。这里的“进程”之所以打上引号,是因为这并不是计算机科学意义上的真正操作系统级进程。这是虚拟进程,或者任务,表示一个逻辑上相关的运算序列。
1.5 任务
在ES6中,有一个新的概念建立在事件循环队列之上,叫作任务队列。这个概念给大家带来的最大影响可能是Promise的异步特性。
事件循环队列类似于一个游乐园游戏:玩过了一个游戏之后,你需要重新到队尾排队才能再玩一次。而任务队列类似于玩过了游戏之后,插队接着继续玩。
2.回调
回调是编写和处理JavaScript程序异步逻辑的最常用方式。
回调函数是JavaScript的异步主力军,并且它们不辱使命地完成了自己的任务。
2.1 continuation
//A
ajax("...",function(data){
//C
});
//B
//A
和//B
表示程序的前半部分,而//C
标识了程序的后半部分。前半部分立刻执行,然后是一段时间不确定的停顿。在未来的某个时刻,如果Ajax调用完成,程序就会从停下的位置继续执行后半部分。
信任的问题
//C
会延迟到将来发生,并且在第三方的控制下。我们把这称为控制反转,也就是把自己程序一部分的执行控制交给某个第三方。在你的代码和第三方工具之间有一份并没有明确表达的契约。
//过分信任输入
function addNumbers(x,y){
return x + y;
}
addNumbers(21,21);//42
addNumbers(21,"21");//"2121"
//针对不信任输入的防御性代码
function addNumbers(x,y){
if(typeof x != "number" || y != "number"){
throw Error("Bad parameters");
}
return x + y;
}
addNumbers(21,21);//42
addNumbers(21,"21");//Error: "Bad parameters"
//依旧安全但更好一些
function addNumbers(x,y){
x = Number(x);
y = Number(y);
return x + y;
}
addNumbers(21,21);//42
addNumbers(21,"21");//42
3.Promise
通过回调表达程序异步和管理并发的两个主要缺陷:缺乏顺序性和可信任性。
我们用回调函数来封装程序中的continuation,然后把回调交给第三方,期待其能够调用回调,实现正确的功能。通过这种形式,我们要表达的意思是:“这是将来要做的事情,要在当前的步骤完成之后发生”。
如果我们不把自己程序的continuation传给第三方,而是希望第三方给我们提供了解其任务何时结束的能力,然后我们自己的代码来决定下一步做什么。这种范式就称为Promise。
绝大多数JavaScript/DOM平台新增的异步API都是基于Promise构建的。
4.生成器
我们把注意力转移到一种顺序、看似同步的异步流程控制表达风格。使这种风格成为可能的“魔法”就是ES6生成器(generator)。
4.1 打破完整运行
var x = 1;
//下面是生成器函数
function *foo(){
x++;
yield;//暂停点
console.log("x: ",x);
}
function bar(){
x++;
}
var it = foo();//构造迭代器
it.next();//启动foo()
x;//2
bar();
x;//3
it.next();//x: 3
注意:function* foo(){...}
、function *foo(){...}
是一样的,唯一区别是*
位置的风格不同。function*foo(){...}
(没有空格)也一样,这只是风格偏好问题。
上述代码的运行过程:
it = foo()
运算并没有执行生成器*foo()
,而只是构造了一个迭代器(iterator),这个迭代器会控制它的执行。- 第一个
it.next()
启动了生成器*foo()
,并运行了*foo()
第一行的x++
。 *foo()
在yield
语句处暂停,在这一点上第一个it.next()
调用结束。- 我们查看x的值,此时为2。
- 我们调用
bar()
,它通过x++
再次递增x。 - 我们再次查看x的值,此时为3.
- 最后的
it.next()
调用从暂停处恢复了生成器*foo()
的执行,并运行console.log(...)
语句,这条语句使用当前x的值3。
相关阅读:知乎上关于生成器的解释
4.2 生成器+Promise
ES6中最完美的世界就是生成器(看似同步的异步代码)和Promise(可信任可组合)的组合。
获得Promise和生成器最大效用的最自然的方法就是yield出来一个Promise,然后通过这个Promise来控制生成器的迭代器。
推荐阅读:ECMAScript 6 入门
参考资料:《你不知道的JavaScript》(中卷) 第二部分 异步和性能