likes
comments
collection
share

JavaScript 中的流程控制:Callback、Promise、async/await

作者站长头像
站长
· 阅读数 11

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

JavaScript 通常被认为是异步的。这意味着什么?它如何影响发展?近年来这种方法有何变化?

考虑以下代码:

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

大多数语言同步处理每一行。第一行运行并返回结果。第一行完成后,第二行就会运行——无论需要多长时间

单线程处理

JavaScript是单线程运行的,浏览器一次只能执行一项任务。因为对页面 DOM 的更改不能在并行线程上发生;如果一个线程重定向到不同的 URL,而另一个线程尝试附加子节点,这将是危险的。

这对于用户来说基本是无感的,因为处理以小块的形式快速发生。例如,JavaScript 检测按钮单击、运行计算并更新 DOM。一旦完成,浏览器就可以自由地处理队列中的下一个项目。

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

通过Callback异步

单线程会产生问题。当 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 调用。

Promise

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

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

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

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

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() 实用程序,将基于回调的函数转换为基于 Promise 的替代函数。有几个条件:

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

例子:

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

readFileAsync('file.txt');

异步.then()

任何返回 Promise 的东西都可以启动方法中定义的一系列异步函数调用.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!
  });
}

充满希望的未来?

Promise 减少了回调地狱,但也带来了自己的问题。

教程经常没有提及整个 Promise 链式调用是异步的。任何使用一系列 Promise 的函数都应该返回自己的 Promise 或在 Final 或 方法中运行回调.then()函数。.catch()``.finally()

async/await

Promise可能令人望而生畏,因此ES2017引入了asyncawait。虽然它可能只是语法糖,但它使承诺更加好用,并且可以避免then的链式调用。例如下面Promise例子:

function connect() {
  return new Promise((resolve, reject) => {
    asyncDBconnect('http://localhost:1234')
      .then(asyncGetSession)
      .then(asyncGetUser)
      .then(asyncLogAccess)
      .then(result => resolve(result))
      .catch(err => reject(err))
  });
}

// run connect (self-executing function)
(() => {
  connect();
    .then(result => console.log(result))
    .catch(err => console.log(err))
})();

要重写此使用async/await

  • 外部函数前面必须有一条async语句
  • 对异步、基于 Promise 的函数的调用必须先于 ,await以确保在执行下一个命令之前完成处理
async function connect() {
  try {
    const
      connection = await asyncDBconnect('http://localhost:1234'),
      session = await asyncGetSession(connection),
      user = await asyncGetUser(session),
      log = await asyncLogAccess(user);

    return log;
  }
  catch (e) {
    console.log('error', err);
    return null;
  }
}

// run connect (self-executing async function)
(async () => { await connect(); })();

await有效地使每个调用看起来像是同步的,同时不会阻碍 JavaScript 的单个处理线程。此外,async函数总是返回一个 Promise,因此它们又可以被其他async函数调用。

async/await代码可能不会更短,但有相当大的好处:

  • 语法更清晰。括号更少,出错的机会也更少。
  • 调试更容易。可以在任何语句上设置断点await
  • 错误处理更好。try/catch块的使用方式与同步代码相同。
  • 支持很好。它在所有现代浏览器和 Node 7.6+ 中实现。

也就是说,并非一切都是完美的……

Promise还是async/await

async/await依赖于 Promise,而 Promise 最终又依赖于回调。这意味着您仍然需要了解 Promise 的工作原理。

此外,在处理多个异步操作时,没有Promise.allPromise.race的直接等效项。很容易忘记Promise.all,这比使用一系列不相关的await命令更有效率。

try/catch

async如果您省略了try/catch任何await失败的函数,函数将默默退出。如果您有一组很长的异步await命令,则可能需要多个try/catch块。

但是,在应用程序必须以与其他错误不同的方式对某些错误作出反应的情况下,此选项可能不实用。

尽管存在一些缺陷,但async/await它是对 JavaScript 的优雅补充。

JavaScript 之旅

异步编程是 JavaScript 中无法避免的挑战。回调在大多数应用程序中都是必不可少的,但很容易陷入深度嵌套的函数中。

Promise 抽象回调,但存在许多语法陷阱。转换现有函数可能是一件苦差事,而且.then()链条看起来仍然很混乱。

幸运的是,async/await它提供了清晰度。代码看起来是同步的,但它不能独占单个处理线程。它将改变您编写 JavaScript 的方式,甚至可以让你欣赏 Promise——如果您以前不喜欢的话!