以下为笔者读《你不知道JS–中卷》的读书笔记
作为单线程的JavaScript虽然速度不算慢吧,但跟多线程处理事务的速度以及方便程度还是有所差距吧,异步算是对此有所弥补以及增强吧,而异步编程的核心就是程序中现在运行的部分和将来运行的部分之间的联系。
首先安利一个小知识开篇吧
控制台I/O延迟
并没有什么规范或一组需求指定 console.* 方法族如何工作——它们并不是 JavaScript 正式的一部分,而是由宿主环境添加到 JavaScript 中的。
因此,不同的浏览器和 JavaScript 环境可以按照自己的意愿来实现,有时候这会引起混淆。
下面这种情景不是很常见,但也可能发生,从中(不是从代码本身而是从外部)可以观察到这种情况:
var a = {
index: 1
};
// 然后
console.log( a ); // ??
// 再然后
a.index++;
我们通常认为恰好在执行到 console.log(..) 语句的时候会看到 a 对象的快照,打印出类 似于{ index: 1 }这样的内容,然后在下一条语句a.index++执行时将其修改,这句的执行会严格在 a 的输出之后。
多数情况下,前述代码在开发者工具的控制台中输出的对象表示与期望是一致的。但是, 这段代码运行的时候,浏览器可能会认为需要把控制台 I/O 延迟到后台,在这种情况下, 等到浏览器控制台输出对象内容时,a.index++ 可能已经执行,因此会显示 { index: 2 }。
到底什么时候控制台 I/O 会延迟,甚至是否能够被观察到,这都是游移不定的。如果在调试的过程中遇到对象在 console.log(..) 语句之后被修改,可你却看到了意料之外的结果, 要意识到这可能是这种 I/O 的异步化造成的。
如果遇到这种少见的情况,最好的选择是在 JavaScript 调试器中使用断点, 而不要依赖控制台输出。次优的方案是把对象序列化到一个字符串中,以强 制执行一次“快照”,比如通过 JSON.stringify(..)。
完整运行和竞态条件
由于 JavaScript 的单线程特性,所以任意函数foo()以及 bar()中的代码具有原子性。也就是说,一旦 foo() 开始运行,它的所有代码都会在 bar() 中的任意代码运行之前完成,或者相反。这称为完整运行(run-to-completion)特性。
var a = 1;
var b = 2;
function foo() {
a++;
b = b * a;
a = b + 3;
}
function bar() {
b--;
a = 8 + b;
b = a * 2;
}
// ajax(..)是某个库中提供的某个Ajax函数
ajax( "http://some.url.1", foo );
ajax( "http://some.url.2", bar );
在上面代码中,由于不知道异步请求的成功时间,所以foo() 和 bar() 的运行顺序是未知的。
在JavaScript的特性中,这种函数顺序的不确定性就是通常所说的竞态条件(race condition),foo() 和 bar() 相互竞争,看谁先运行。具体来说,因为无法可靠预测 a 和 b 的最终结果,所以才是竞态条件。
代码实际执行顺序
代码中语句的顺序和 JavaScript 引擎执行语句的顺序并不一定要一致。
如果你观察到了类似于我们将要展示的编译器对语句的重排序,那么这很明显违反了规范,而这一定是由所使用的 JavaScript 引擎中的 bug 引起的——该 bug 应该被报告和修正!但是更可能的情况是,当你怀疑 JavaScript 引擎 做了什么疯狂的事情时,实际上却是你自己代码中的 bug(可能是竞态条件) 引起的。所以首先要检查自己的代码,并且要反复检查。通过使用断点和单步执行一行一行地遍历代码,JavaScript 调试器就是用来发现这样 bug 的最强大工具。
var a, b;
a = 10; b = 30;
a = a + 1;
b = b + 1;
console.log( a + b ); // 42
这段代码中没有显式的异步(除了前面介绍过的很少见的异步 I/O !),所以很可能它的执 行过程是从上到下一行行进行的。
但是,JavaScript 引擎在编译这段代码之后,可能会发现通过(安全 地)重新安排这些语句的顺序有可能提高执行速度。重点是,只要这个重新排序是不可见的,一切都没问题。
比如,引擎可能会发现,其实这样执行会更快:
var a, b;
a = 10; a++;
b = 30; b++;
console.log( a + b ); // 42
或者这样:
var a, b;
a = 11; b = 31;
console.log( a + b ); // 42
或者甚至这样:
// 因为a和b不会被再次使用
console.log( 42 ); // 42
前面的所有情况中,JavaScript 引擎在编译期间执行的都是安全的优化,最后可见的结果都是一样的。
但是这里有一种场景,其中特定的优化是不安全的,因此也是不允许的(当然,不用说这 其实也根本不能称为优化):
var a, b;
a = 10; b = 30;
// 我们需要a和b处于递增之前的状态!
console.log( a * b ); // 300
a = a + 1;
b = b + 1;
console.log( a + b ); // 42
还有其他一些例子,其中编译器重新排序会产生可见的副作用(因此必须禁止),比如会产生副作用的函数调用(特别是 getter 函数),或 ES6 代理对象
尽管 JavaScript 语义让我们不会见到编译器语句重排序可能导致的噩梦,这是一种幸运, 但是代码编写的方式(从上到下的模式)和编译后执行的方式之间的联系非常脆弱,理解这一点也非常重要。