【译】Javascript中的控制流:Callbacks,Promises, async/await

147 阅读10分钟

原文作者:Craig Buckler & James Hibbard

原文地址:www.sitepoint.com/flow-contro…

这篇文章中,我们从一个更高的层次去看Javascript是如何处理异步代码的。我们从回调函数 callbacks 开始,然后是 promises,最后以更流行的 async/await 结束。每个部分都将提供代码实例和要点概述,并链接更深入的资源。

Javascript通常被认为是异步的。这是什么意思呢?它如何影响发展?近年来,这种方法发生了哪些变化呢?

先思考下边的代码:

result1 = doSomething1()
result2 = doSomething2(result1)

大多数编程语言都是同步处理每一行代码。第一行代码执行并返回了一个结果,第二行代码用上一行代码返回的结果当作参数去执行。第一行代码一结束,第二行代码再执行 --- 不论这中间花费了多长时间。

单线程

Javascript是运行在单线程上的。当一个浏览器选项卡运行时,其他的所有都是停止的。这样的设计是必要的,因为改变页面的DOM不能发生在并行线程上,比如当一个线程正在重定向到一个不同的URL上时,另一个线程却在尝试往页面添加子节点,这种操作是很危险的。

我们都知道,线程的处理都是在小块中快速进行的。举个例子,Javascript检测到一个按钮的点击,然后执行一些计算处理,最后更新DOM。一旦完成,浏览器就可以自由的去处理队列中的下一个任务

注:其他编程语言(如PHP),也采用单线程,但有可能被多线程服务管理(如Apache)。一个PHP页面在同一时间的两个请求可以启动两个运行在PHP运行时的隔离实例的线程)

用回调函数实现异步

单线程会肯定会产生问题。比如当Javascript调用一个“慢”进程(如浏览器上的Ajax请求或者服务器上的数据库操作)会发生什么?这个操作可能花费几秒甚至几分钟。浏览器在等待响应的时候是锁定的,在服务器那边,Node.js应用程序也无法处理进一步的用户请求。整个程序就都卡在了这个“慢”的进程上。

上述问题的解决方案就是采用异步处理,当结果出来的时候,让程序调用另一个函数去执行处理,而不是一直等待完成。这个函数就是所谓的回调函数callback,它可以作为参数传递给任何异步函数。

举个例子

doSomethingAsync(callback11);
console.log('finished')
// 当doSomethingAsync函数执行完成时调用
function callback1(error){
  if(!error) console.log('doSomethingAsync complete')
}

doSomethingAsync函数接收一个回调函数作为参数(这里仅是一个函数的引用,因此开销很小)。不管doSomethingAsync函数花费了多长时间,我们知道callback1肯定会在将来的某个时候执行。控制台会输出如下:

finished
doSomethingAsync complete

你可以阅读更多关于callbacks的内容 --> 回到基础: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并等待动画完成的操作,那么它可能会嵌套两到三层,但这通常是可以管理的。但OS或者服务器进程的情况就有所不同。一个Node.js API调用可以接收文件上传,更新多个数据库表,写入日志等很多任务,还可以在响应发送之前进行进一步的API调用,在这种情况下处理一些复杂地业务逻辑就会很容易出现回调函数嵌套很深的情况。

了解更多关于回调地狱内容请移步 --> 从回调地狱中拯救出来

Promises

ES2015(ES6)引入了Promises。回调函数也依然可以使用,但promises提供了更清晰地语法,将异步命令通过调用链实现,以便它们可以串联执行(下一节将详细介绍)

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

  • resolve:当处理完成且状态成功的时候执行的回调函数
  • reject:当发生错误时执行的回调函数,可选的

在下面的例子里,数据库API提供了一个接收一个回调函数的连接方法。外层的 asyncDBconnect方法立即返回了一个新的Promise实例。在连接建立成功或者失败的时候对应执行resolve和reject方法:

const db = require('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.promosify(fs.readFile)
readFileAsync('file.txt')

异步调用链

任何一个返回Promise的函数可以启动在.then()方法中定义的一系列异步函数调用,每一个都传递上一个Promise中resolve函数处理的结果

asyncDBconnect('http://localhost:1234')
	.then(asyncGetSession)   // 传递asyncDBconnect的结果
	.then(asyncLogAccess)    // 传递asyncGetSessiont的结果
	.then(result => {
    console.log('complete')  // 传递 asyncLogAccess的结果
    return result   // 传递给下一个.then()方法
  })
	.catch(err => {   // reject调用
    console.log('error', err)
  })

同步函数也可以放在.then()代码块中执行。返回值传递给下一个.then()语句(如果有的话)。

catch方法定义了一个函数,当之前的任何reject方法被触发时调用该函数。此时,.then()方法就不再执行了。你可以在整个链中定义多个.catch()方法来处理不同的错误。

ES2018引入了.finally()方法,不管promise执行的结果如何,都会执行该方法。这个方法可以执行一些收尾工作,比如清理缓存数据,关闭数据库链接等等。所有现代浏览器都支持这个方法:

function doSomething(){
  doSmething1()
    .then(doSomething2)
    .then(doSomething3)
    .catch(err => {
      console.log(err)
    })
    .finally(() => {
      // 清理这里!
    })
}

Promise就是未来吗?

Promises虽然减少了回调地狱,但也有自己的问题。而大多数教程都没有提及的是:整个promise的调用链是异步的,任何使用了一系列Promise的函数都存在一个现象:要么返回自己的Promise实例,要么就在最后的.then(), .catch()或者.finally()方法中执行回调函数。

坦白说:Promises困扰了我很久。因为它的语法通常看起来比回调函数更复杂,有很多地方很容易会出错,调试也可能会有问题。但是呢,学习基础知识还是很有必要的。

学习更多Promises相关内容请移步 --> Promises概述

async/await

由于Promises的使用有时候可能有些让人望而生畏,所以ES2017引入了async和await。虽然它只是语法糖,但它让Promise变得更好用,而且你可以完全的避免使用.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))
  })
}

(() => {
  connect()
    .then(result => console.log(result))
    .catch(err => console.log(err))
  
})()

可以看出上面的代码封装了一系列的异步命令,通过.then()依次执行。让我们用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;
  }
}

(async () => { await connect(); })();

await让所有调用看起来都是同步的,同时又不占用Javascript的单个处理线程,此外,async函数默认也返回一个Promise,这样他就可以被其他async函数调用。

如上面代码所示,async/await这种形式的代码可能不会很简短,但却有很多优点:

  • 语法清晰,括号很少,出错的概率相对来说也更小
  • 更容易调试。可以在任何await语句上设置断点
  • 更好的错误处理,try/catch语句可以像同步代码一样使用
  • 支持性,兼容性更好,所有现代浏览器和Ndoe 7.6+都实现这种语法。

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

Promises,Promises

async/await依赖于Promise,而Promise在最终又依赖callbacks。这意味你仍然需要知道Promise的工作原理。

另外,在处理多个异步操作时,并没有和 Promise.all 或者 Promise.race 等价的方法。所以在使用一系列不相关的await命令的时候很容易忘记Promise.all这个效果更好的方法。

"丑陋"的 try/catch

在失败的情况下,如果没有用try/catch语句处理await函数的执行,async函数就会默默退出。如果你有一组很长的await命令,你可能就需要多个try/catch函数块,一个替代方法就是使用可以处理错误的高阶函数,这样try/catch语句就不是必要的了(感谢@wesbos的建议)。但是,这种处理在某些情况下可能不适用。比如当应用程序需要用不同于其他错误的方法来响应某些错误的场景。尽管存在一些缺陷,async/await仍然是Javascript的优雅补充。

更多关于async/await的使用请移步 --> Javascript async/await初学者指南,含示例.

总结

当下,异步编程是Javascript中不可避免的。回调函数在大多数应用程序中都是必不可少,但也很容易陷入到那些嵌套很深的函数中。虽然Promises抽象了回调函数,使异步处理更方便,但也存在很多语法陷阱。而且现有函数的转换是一件苦差事,还有.then()的调用链也很混乱。

幸运的是,async/await提供了更清晰地语法,让代码看起来像是同步的,而且也不独占单个处理线程。它将改变你编写Javascript的方式,甚至让你更喜欢Promise --- 如果你之前没有的话。