异步编程的背景

JS 引擎建立在单线程事件循环的概念上。单线程(Single-threaded)意味着同一时刻只能执行一段代码,与 Java 或 C++ 这种允许同时执行多段不同代码的多线程语言形成了反差。多段代码可以同时访问或修改状态,维护并保护这些状态就变成了难题,这也是基于多线程的软件中出现 bug 的常见根源之一。

JS 引擎在同一时刻只能执行一段代码,所以引擎无须留意那些 “可能” 运行的代码。代码会被放置在作业队列(job queue)中,每当一段代码准备被执行,它就会被添加到作业队列。当 JS 引擎结束当前代码的执行后,事件循环就会执行队列中的下一个作业。事件循环(event loop)是 JS 引擎的一个内部处理线程,能监视代码的执行并管理作业队列。要记住既然是一个队列,作业就会从队列中的第一个开始,依次运行到最后一个。

事件模型

当用户点击一个按钮或按下键盘上的一个键时,一个事件(event)——例如 onclick —— 就被触发了。该事件可能会对此交互进行响应,从而将一个新的作业添加到作业队列的尾部。这就是 JS 关于异步编程的最基本形式。事件处理程序代码直到事件发生后才会被执行,此时它会拥有合适的上下文。例如:

let button = document.getElementById("my-btn");
button.onclick = function(event) {
    console.log("Clicked");
};

在此代码中,console.log("Clicked") 直到 button 被点击后才会被执行。当 button 被点击,赋值给 onclick 的函数就被添加到作业队列的尾部,并在队列前部所有任务结束之后再执行。

事件可以很好地工作于简单的交互,但将多个分离的异步调用串联在一起却会很麻烦, 因为必须追踪每个事件的事件对象(例如上例中的 button)。此外,你还需确保所有的事件处理程序都能在事件第一次触发之前被绑定完毕。例如,若 button 在 onclick 被绑定之前就被点击,那就不会有任何事发生。因此虽然在响应用户交互或类似的低频功能时,事件很有用,但它在面对更复杂的需求时仍然不够灵活。

回调模式

当 Node.js 被创建时,它通过普及回调函数编程模式提升了异步编程模型。回调函数模式类似于事件模型,因为异步代码也会在后面的一个时间点才执行。不同之处在于需要调用的函数(即回调函数) 是作为参数传入的,如下所示:

readFile("example.txt", function(err, contents) {
    if (err) {
        throw err;
    }

    console.log(contents);
});
console.log("Hi!");

此例使用了 Node.js 惯例,即错误优先(error-first)的回调函数风格。readFile() 函数用于读取磁盘中的文件(由第一个参数指定),并在读取完毕后执行回调函数(即第二个参数)。如果存在错误,回调函数的 err 参数会是一个错误对象;否则 contents 参数就会以字符串形式包含文件内容。

使用回调函数模式,readFile() 会立即开始执行, 并在开始读取磁盘时暂停。这意味着 console.log("Hi!") 会在 readFile() 被调用后立即进行输出,要早于 console.log(contents) 的打印操作。当 readFile() 结束操作后,它会将回调函数以及相关参数作为一个新的作业添加到作业队列的尾部。在之前的作业全部结束后,该作业才会执行。

回调函数模式要比事件模型灵活得多,因为使用回调函数串联多个调用会相对容易。例如:

readFile("example.txt", function(err, contents) {
    if (err) {
        throw err;
    }

    writeFile("example.txt", function(err) {
        if (err) {
            throw err;
        }

        console.log("File was written!");
    });
});

在此代码中,对于 readFile() 的一次成功调用引出了另一个异步调用,即调用 writeFile() 函数。注意这两个函数都使用了检查 err 的同一基本模式。当 readFile() 执行结束后,它添加一个作业到作业队列,从而导致 writeFile() 在之后被调用(假设没有出现错误)。接下来,writeFile() 也会在执行结束后向队列添加一个作业。

这种模式运作得相当好,但你可能会迅速察觉陷入了回调地狱(callback hell), 这会在嵌套过多回调函数时发生,就像这样:

method1(function(err, result) {

    if (err) {
        throw err;
    }

    method2(function(err, result) {

        if (err) {
            throw err;
        }

        method3(function(err, result) {

            if (err) {
                throw err;
            }

            method4(function(err, result) {

                if (err) {
                    throw err;
                }

                method5(result);
            });

        });

    });

});

像本例一样嵌套多个方法调用会创建错综复杂的代码,会难以理解与调试。当想要实现更复杂的功能时,回调函数也会存在问题。要是你想让两个异步操作并行运行,并且在它们都结束后提醒你,那该怎么做?要是你想同时启动两个异步操作,但只采用首个结束的结果,那又该怎么做?

在这些情况下,你需要追踪多个回调函数并做清理操作,Promise 能大幅度改善这种情况。