深入了解js这门语言后,才发现它有着诸多众所周知的难点(例如:闭包、原型链、内存空间等)。有的是因为js的设计缺陷导致的,而有的则是js的优点。不管如何,总需要去学会它们,在学习过程中我觉得只看别人的文章并不能做到深刻理解,所以我决定写这一系列的文章来记录我所学习到的知识点,也方便自己以后回顾,如有写错的地方欢迎指正。
废话不多说,马上进入正题!

一、现在和将来

在我刚开始学习js的时候,并不知道js是单线程的(当然当时连线程是什么都不知道),对异步同步也没有太多的了解,所以很容易写出大部分js新手程序员会写的代码:

var data = ajax("url")
console.log(data)
// data通常并不是你期望的值
现在你可能知道了,标准的ajax请求不是同步完成的,意味着ajax()不会阻塞进程到响应返回data,所以console.log在ajax获得响应前就输出了。这就是我们所说的异步,即现在我们发出了ajax请求,期望在将来能获得结果。

为了等待这个将来的结果,最简单实用的方法不是把ajax请求变成同步(同步会锁定浏览器UI),而是给ajax请求一个回调函数。

ajax("url",function CallbackFunc(data) {
    console.log(data)  // 成功输出data
})

相信上面这段代码是我们最常写的异步编程代码,只要我们把一段代码包装成一个函数(这里是CallbackFunc),并指定它在某个事件响应(ajax响应)时执行,那就是在这段代码中引入异步机制。现在执行的部分是ajax(“url”),将来执行的部分是CallbackFunc函数。

引入了异步编程后我们把代码分成了现在块和将来块,现在这一块的代码按照同步的方式执行,很容易理解。但很多份将来块的代码在未来要以什么样的顺序去执行呢?这时候我们要了解一下js的单线程了。

二、事件循环队列

js的单线程是在同一个时间点上js引擎只能执行一个任务。所以js内部维护了一个被称为事件循环的队列,每个需要被执行的任务都会被放进这个队列中排队,然后js引擎按照先进先出的原则循环地从事件队列中提取任务出来执行。

// 事件循环队列的伪代码
var loop = []
var event

// 不断循环
while(true) {
    if (loop.length > 0) {
        // 先进先出
        event = loop.shift()
    }
    event()
}

换句话说,我们做的事情就是把程序分成很多个小块任务,然后按照我们想要的顺序把任务放进事件队列中。而js引擎做的事情就是不断地从事件队列中提出任务来执行。(严格来说,某些和你编写的代码不相关的事件也会被插入到队列中,比如浏览器被关闭2333)

清楚这一思想后,我们来看一段经典的代码

setTimeout(function callback(){
    console.log("a")
},0)
console.log("b")

这段代码的输出结果是ba。执行setTimeout时,它会把callback回调函数在0秒后插入到事件队列中(而不是在0秒后执行)。
我们从事件队列的角度来思考上面代码的执行过程。首先解析代码时发现有一个事件A(这个事件A包含setTimeout()语句和console.log(“b”)语句),于是把它插入队列中。然后js引擎发现事件循环队列不为空了,就把这个事件提取出来执行,在执行setTimeout语句时,callback回调函数作为事件B在0秒后插入到了事件循环队列中。但这时事件A里的代码还没执行完,而js又是单线程的,所以只能等事件A的代码执行完再去执行事件B的代码。于是就执行事件A的console.log(“b”)后再执行事件B的console.log(“a”)。

而ES6的promise的加入使这个事件循环机制变得复杂了一些:

(function test() {
    setTimeout(function() {console.log(4)}, 0);
    new Promise(function executor(resolve) {
        console.log(1);
        for( var i=0 ; i<10000 ; i++ ) {
            i == 9999 && resolve();
        }
        console.log(2);
    }).then(function() {
        console.log(5);
    });
    console.log(3);
})()

看这段代码前先了解一个定义:

js 只有一个main thread(主进程)和call-stack(一个调用栈),当主进程从事件队列中调取一个任务(task)执行时,该任务会将它执行所需要的资源放入调用栈中,并在使用完毕后将资源弹出调用栈。所以只有调用栈清空时才说明该任务执行完毕,此时主进程又会从事件队列中调取下一个任务。
而任务分为两种类型:

  1. macrotask:也称为task,包含了script(整体代码),setTimeout, setInterval, setImmediate, I/O, UI rendering。
  2. microtask:process.nextTick, Promises, Object.observe, MutationObserver

JS引擎首先从macrotask queue中取出第一个任务,执行完毕后,将microtask queue中的所有任务按顺序执行。然后再从macrotask queue中取下一个,执行完毕后,再次将microtask queue中的全部执行。

这时候我们再看上面这段代码就很清晰了。
首先从macrotask中取出整体代码开始执行,于是setTimeout进入macrotask队列,执行executor函数打印1,等待for循环完毕后调用resolve(),Promise进入microtask队列,打印2,继续执行打印3
然后根据上述调用机制,执行microtask队列中的所有任务,promise的回调函数打印出5,然后调用macrotask队列中的一个任务,setTimeout的回调函数打印出4。
所以结果是1 2 3 5 4

三、回调地狱

回调作为处理js程序异步逻辑的最常用方式,经常会被人滥用,以至于写出传说中的回调地狱代码(callback hell)。

listen("click", function(e) {
    setTimeout(function() {
        ajax("url", function(data) {
            // ....
        })
    }, 500)
})

在《你不知道的JavaScript(中卷)》中提到:

我们的顺序阻塞式的大脑适合思考同步的代码,而无法很好地映射到面向回调的异步代码。这就是回调方式最主要的缺陷。
如果回调函数数量加多,混乱的执行顺序会使我们编写代码出bug的几率大大增加(至少对于我来说)。因此ES6提出一种被称为Promise的范式来解决这一问题。后面新增的异步API大部分都是基于Promise构建的。这里只讲为什么Promise能帮我们更好地控制异步流程。(不了解它的API或者想详细学习它的,可以参考阮一峰老师的ES6入门教程)

Promise的优秀之处就在于它的then链式流能以同步顺序的方式表达异步流,以便于我们的大脑更好地思考。then链式流取决于Promise的两个特性:

  1. 每次对Promise调用then(),它都会创建并返回一个新的Promise,使我们将它们连接起来。
  2. 不管then()调用的完成回调中返回的值是什么,它都会自动设置新创建的Promise为完成。

简单的链式调用如下:

var p = Promise.resolve(1)
p
.then(function(value) {
    // 步骤1
    console.log(value)  // 1
    return value + 1
})
.then(function(value) {
    // 步骤2
    console.log(value)  // 2
})

上面代码中步骤2会等待步骤1的代码执行完毕再开始执行,而且Promise 还允许链式调用的每一步可以有异步能力,即我们可以根据需要给步骤1添加异步代码。

var p = Promise.resolve(1)
p
.then(function(value) {
    // 步骤1
    console.log(value)  // 1
    return new Promise(function(resolve, reject) {
        setTimeout(function() {
            resolve(value + 1)
        }, 1000)
    })
})
.then(function(value) {
    // 步骤2
    // 会在1000毫秒延迟后再输出
    console.log(value)  // 2
})

四、使用async编写异(同)步代码

promise方案的链式then方法使我们的代码看起来很有条理,像是井然有序地顺序执行着,但当后一个then方法的参数包含前一个then方法返回的结果时,整个代码就会变得混乱。

function doIt() {
    const time1 = 300;
    step1(time1)
        .then(time2 => {
            return step2(time1, time2)
                .then(time3 => [time1, time2, time3]);
        })
        .then(times => {
            const [time1, time2, time3] = times;
            return step3(time1, time2, time3);
        })
        .then(result => {
            console.log(`result is ${result}`);
        });
}
doIt();

上面的代码中step2的参数包含step1返回的结果,而step3的参数又包含step1和step2返回的结果。这就是promise的缺点——参数传递太麻烦。如果后一个then方法依赖前一个then方法返回的结果,那用async函数可以完美地解决这一问题:

async function doIt() {
    const time1 = 300;
    const time2 = await step1(time1);
    const time3 = await step2(time1, time2);
    const result = await step3(time1, time2, time3);
    console.log(`result is ${result}`);
}
doIt();

async关键字表示这个函数为异步函数,异步函数永远返回一个promise对象,在异步函数中可以使用await关键字来表示暂停。即上述代码中await step1(time1)表示等待step1执行完毕并将结果赋值给time2,然后才能执行它后面的代码。于是异步代码加上await后就会像同步代码一样执行。

然而async函数的优点也是它的缺点,上面的step1、step2、step3是串行执行的,花的时间会比并行执行三个函数更多。所以如果后面的函数不需要前面函数返回的结果,应该使用promise.all()方法使它们并行执行。