JavaScript中的流控制:回调、promises、异步/等待 在本文中,我们将深入了解如何在 JavaScript 中使用异步代码。我们将从回调开始,继续到promises,然后以更现代的async/await. 每个部分都将提供示例代码,概述要注意的要点,并链接到更深入的资源。

JavaScript 经常声称是异步的。那是什么意思?它如何影响发展?近年来,这种方法有何变化?
考虑以下代码:

result1 = doSomething1();
result2 = doSomething2(result1);

大多数语言同步处理每一行。第一行运行并返回结果。第二行在第一行完成后运行——不管需要多长时间

单线程处理

JavaScript 在单个处理线程上运行。在浏览器选项卡中执行时,其他一切都会停止。这是必要的,因为对页面 DOM 的更改不能发生在并行线程上;让一个线程重定向到不同的 URL 而另一个线程尝试附加子节点是很危险的。

这对用户来说很少是显而易见的,因为处理会以小块的形式快速发生。例如,JavaScript 检测按钮点击、运行计算并更新 DOM。一旦完成,浏览器就可以自由处理队列中的下一个项目。

(旁注:PHP 等其他语言也使用单线程,但可能由 Apache 等多线程服务器管理。同时对同一 PHP 页面的两个请求可以启动两个运行 PHP 运行时的独立实例的线程.)

通过回调实现异步

单线程引发了一个问题。当 JavaScript 调用浏览器中的 Ajax 请求或服务器上的数据库操作等“慢速”进程时会发生什么?该操作可能需要几秒钟——甚至几分钟。浏览器在等待响应时会被锁定。在服务器上,Node.js 应用程序将无法处理进一步的用户请求。

解决方案是异步处理。不是等待完成,而是告诉进程在结果准备好时调用另一个函数。这称为回调,它作为参数传递给任何异步函数。

例如:

doSomethingAsync(callback1);
console.log('finished');

// call when doSomethingAsync completes
function callback1(error) {
  if (!error) console.log('doSomethingAsync complete');
}

doSomethingAsync函数接受回调作为参数(仅传递对该函数的引用,因此开销很小)。花多长时间并不重要doSomethingAsync;我们所知道的是,callback1它将在未来的某个时间被执行。控制台将显示:

finished
doSomethingAsync complete

您可以在回归基础中阅读有关回调的更多信息:什么是 JavaScript 中的回调?

回调地狱

通常,回调只会被一个异步函数调用。因此可以使用简洁的匿名内联函数:

doSomethingAsync(error => {
  if (!error) console.log('doSomethingAsync complete');
});

可以通过嵌套回调函数串联完成一系列两个或多个异步调用。例如:

async1((err, res) => {
  if (!err) async2(res, (err, res) => {
    if (!err) async3(res, (err, res) => {
      console.log('async1, async2, async3 complete.');
    });
  });
});

不幸的是,这引入了回调地狱——一个臭名昭著的概念,甚至有自己的网页!代码难以阅读,并且在添加错误处理逻辑时会变得更糟。

回调地狱在客户端编码中相对罕见。如果您正在进行 Ajax 调用、更新 DOM 并等待动画完成,它可以深入两到三级,但它通常仍可管理。

操作系统或服务器进程的情况有所不同。Node.js API 调用可以接收文件上传、更新多个数据库表、写入日志,并在发送响应之前进行进一步的 API 调用。

您可以在从回调地狱中保存中阅读有关回调地狱的更多信息。

Promises

ES2015 (ES6) 引入了promises。回调仍在表面之下使用,但 promises 提供了一种更清晰的语法,可以链接异步命令,以便它们连续运行

要启用基于 promise 的执行,必须更改基于异步回调的函数,以便它们立即返回 promise 对象。该对象promises在将来的某个时候运行两个函数之一(作为参数传递):

  • resolve: 处理成功完成时运行的回调函数
  • reject:发生故障时运行的可选回调函数

在下面的示例中,数据库 API 提供了一个connect接受回调函数的方法。外部asyncDBconnect函数立即返回一个新的promises,并在连接建立或失败时resolve运行:reject

const db = require('database');

// Connect to database
function asyncDBconnect(param) {
  return new Promise((resolve, reject) => {
    db.connect(param, (err, connection) => {
      if (err) reject(err);
      else resolve(connection);
    });
  });
}

Node.js 8.0+ 提供了一个util.promisify() 实用程序来将基于回调的函数转换为基于promises的替代方法。有几个条件:

  • 回调必须作为最后一个参数传递给异步函数
  • 回调函数必须期待一个错误后跟一个值参数

例子:

// Node.js: promisify fs.readFile
const
  util = require('util'),
  fs = require('fs'),
  readFileAsync = util.promisify(fs.readFile);

readFileAsync('file.txt');

异步链接

任何返回promises的东西都可以启动一系列在.then()方法中定义的异步函数调用。每个都传递了前一个的结果resolve

asyncDBconnect('http://localhost:1234')
  .then(asyncGetSession)      // passed result of asyncDBconnect
  .then(asyncGetUser)         // passed result of asyncGetSession
  .then(asyncLogAccess)       // passed result of asyncGetUser
  .then(result => {           // non-asynchronous function
    console.log('complete');  //   (passed result of asyncLogAccess)
    return result;            //   (result passed to next .then())
  })
  .catch(err => {             // called on any reject
    console.log('error', err);
  });

同步功能也可以在.then()块中执行。返回值传递给下一个.then()(如果有的话)。

该方法定义了一个函数,该函数在触发.catch()任何前一个函数时调用。reject届时,将不再运行其他.then()方法。您可以.catch()在整个链中使用多种方法来捕获不同的错误。

ES2018引入了一种.finally()方法,无论结果如何,它都会运行任何最终逻辑——例如,清理、关闭数据库连接等。所有现代浏览器都支持它:

function doSomething() {
  doSomething1()
  .then(doSomething2)
  .then(doSomething3)
  .catch(err => {
    console.log(err);
  })
  .finally(() => {
    // tidy-up here!
  });
}

流控制推荐阅读

JavaScript中的三元运算符-极速阅读

如何避免JavaScirpt回调地狱