JavaScript异步任务与Promises技术

JavaScript 一般被认为是天生具有异步特性的,这是什么意思?与其它非天生异步的语言相对,异步特性是对「JavaScript应用开发」有什么影响注1?这些异步编程的方式在新版ES6+有没有新改进?

本文摘译自《Flow Control in Modern JS: Callbacks to Promises to Async/Await》和《 An Overview of JavaScript Promises》两篇文章。

何为异步特性?

然,何为异步?我们看看以下的代码:

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

第一条语句运行产了一个结果(result1),第二条语句使用了这个结果。大多数语言同步按顺序执行——先执行第一条,第二条在执行完第一条后才开发执行,_无论第一条执行有多久_。

单线程模型

JavaScript应用是一种丰富的交互应用,这种丰富性决定应用会有多个部分同时“并行”运行,包括处理用户多个交互事件、页面渲染和网络传输等。而JavaScript 是单线程的,意思是说浏览器在执行一项JS代码任务的时候,应用程序的其它部分任务都将被暂停。出于安全的需要,页面的DOM结构不能被多个线程同时修改,如果一个线程正在给DOM增加节点,而另一个线程执行页面跳转( redirecting to a different URL ),这将是一件很危险的事。

「网页是单线程的事实」很少被用户察觉,因为大数交互事件处理只占很少的执行时间。例如,当用户单击了一个按钮,浏览器(替JavaScript应用)捕获这个事件,执行它的处理程序,然后立即返回,浏览器再读取下一个事件队列上的交互事件。时间足够的快,用户是没有感觉的。

P.S. 其它语言其实也是单线程,例如PHP,只不过被执行环境(Apache)管理成多线程。

异步回调处理

单线程模型在交互事件任务很小时,没有问题,相反如果当交互事件任务较大,执行得很慢时,用户就会察觉出来。例如,如果页面执行一个了很慢的Ajax request操作,会出现什么情况呢?这个请求操作有可能需要数秒甚至上分钟,那么浏览器页面就会被锁定没有任务响应,因为它在等服务器返回。

解决方案是引入「异步处理机制」——提供一种特定的API。有了「异步处理功能」,页面线程「不必等待一个耗时的任务」返回结果,返回结果未来某一时刻(异步执行完后)由一个特定的「处理函数」来负责,它就是所谓「异步回调函数」——调用「异步处理API」时通过调用参数指定,例如setTimeout函数的第二个参数。

JavaScript语言本身只提供一些基本构件,例如动态函数对象的构建与值传递;「异步处理机制」,或异步编程任务主要靠运行环境提供,例如浏览器事件捕获,和事件循环与事件队列。

setTimeout 是典型的异步处理API函数

异步处理API的模式

「异步处理API」大概模式:

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

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

doSomethingAsync() 是「异步处理API」的入口,它接受一个回调函数作为参数。异步处理机制约定,无论doSomethingAsync() 执行多久,完成后将执行安装的回调函数。所以上面异步执行的结果大概是这样的:

finished
doSomethingAsync complete

从这个结果可知,doSomethingAsync()调用后立即执行下一条(输出“finished”)。

回调地狱

通常,「异步回调处理函数」都是特制的,为某个特定的异步任务定制,很少用来通用,所以回调处理不必单独定义,可写进异步任务之中——以匿名函数的形式:

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.');
    });
  });
});

async1执行完后调用第二个异步任务async2,接着第三个async3……这就产生了臭名昭著(notorious)回调地狱(callback hell)。这种多层嵌套匿名回调函数的代码异常难读,如果再加异常处理,则情况更加糟糕。

前端开发任务很少出现回调地狱,最多也就二到三层的嵌套,例如发起一个Ajax请求,完了更新DOM,和等待一个交互动画,这些基本在可控的范围之内。

换到服务器端,情况就不一样了。一个Node.js 应用任务常有多步,例如接到一个文件上传请求,完了要更多个数据库数据表,再记录日志,还可能需调用其它API,最后才会返回响应。

Promises

为了整理异步处理流程,规避回调地狱,ES2015 (ES6)引入了 Promises 机制。Promises 的底层其实还是回调API,只是Promises API增加了高层异步逻辑语义,让处理多步异步任务更清晰。Promise可看成一种高级callback(为是简化多步的异步处理的流程),而最新的Generator await只是Promise的语法糖。

那这种Promises 异步编程API是怎样的呢?

Promise 对象与异步计算结果的承诺

Promises 机制的核心是通过「对异步操作包装成一个promise对象」,promise对象“承诺”异步操作返回一个结果,将原来异步流程封装成像同步操作(原来默认就是同步),如此达到整理代码流程的目的。

promise对象创建的代码:

const promise = new Promise((resolve, reject) => {
  //asynchronous code goes here
});

promise对象“承诺”有兑现(resolve)和拒绝(reject)两种可能,分别表征了异步成功返回结果(任意data对象 ),或异步出错(返回error对象)。它们分别由promise对象的then方法(其实是一个内嵌的函数对象)的两个子函数对象[em]处理。

em:“promise对象的then方法”这个概念是一种挑战,原文说then有两个callback表达,对这种内嵌对象定义,这是一种我以为旧的JS代码观;更确切是then本身只是promise的一个内嵌的子对象,而这个对象有两个函数方法。此处认识还有待进一步……

promise对象的创建

任何程序对象(objects)都表征了某个计算任务,「promise 对象」表征了「异步计算任务」,又是怎样的一种对象呢?让我们来看一个实例,对「promise 对象」有一个直观的概念。

下面的代码中,我们的任务是发起一个异步HTTP请求,请求Web服务返回一个随机段子(random joke)的数据,以JSON格式返回;以此为例子,我们看看「promise 对象」是如何被创建(对象的形式)和使用的:

const promise = new Promise((resolve, reject) => {
  const request = new XMLHttpRequest();

  request.open('GET', 'https://api.icndb.com/jokes/random');
  request.onload = () => {
    if (request.status === 200) {
      resolve(request.response); // we got data here, so resolve the Promise
    } else {
      reject(Error(request.statusText)); // status is not 200 OK, so reject
    }
  };

  request.onerror = () => {
    reject(Error('Error fetching data.')); // error occurred, reject the  Promise
  };

  request.send(); // send the request
});

console.log('Asynchronous request made.');

promise.then((data) => {
  console.log('Got data! Promise fulfilled.');
  document.body.textContent = JSON.parse(data).value.joke;
}, (error) => {
  console.log('Promise rejected.');
  console.log(error.message);
});

从代码中我们可看到,Promise对象有一个很大的构造器,构造器的参数是一个包含两个参数(resolve 和 reject)的_匿名函数对象_。此_函数对象_就是异步处理的主体,其中最关键的API,是对「原始的异步操作」(此处的是request.onload)进行包装:

  • 异步成功返回时,将结果传给resolve函数;
  • 异步出错,将错误信息传给reject函数;

promise对象的使用

现在我们创建了一个具体的 promise 对象(包括构造定义,和New它),此对象[em]的计算含义是该promise对象“承诺”在未来某一时刻包含了可用的JSON格子的段子数据。那么我们怎么样去使用它呢?

EM:其实可以具体命名为JokeJSONpromise之类的对象名

promise 对象表征的承诺的处理,由它的「内嵌子对象then」来负责的。then对象的构造器由「两个轻便函数对象」定义,分别负责处理“承诺兑现”和“承诺拒绝”。例如,当异步成功时,第一个兑现函数被执行(参数是resolve传的data 对象);反之,第二个拒绝函数被执行(参数是reject传的error 对象)。

Chaining Promises

现在我们算是看懂了一个完整的promise异步实例了,如果我们有两个或以上的promise异步任务,前一个任务的结果要用于后一个任务,那么我们可将其链起来(Chaining)。

function getPromise(url) {
  // return a Promise here
  // send an async request to the url as a part of promise
  // after getting the result, resolve the promise with it
}

const promise = getPromise('some url here');

promise.then((result) => {
  //we have our result here
  return getPromise(result); //return a promise here again
}).then((result) => {
  //handle the final result
});

错误处理

前面我们已经知道then函数对象有两个构造参数,第二个是用来处于异步出错的。Promises机制还为我们提供了异步异常处理API——catch() 函数对象,这也可以用来异步出错的。


  1. 更确切的是JS应用的交互任务天性具有异步特性,影响主要表现是,有一组异步编程的API。
裸男
Nakeman.cn 2023 Build by Gatsby and Tailwind, Deploy on Netlify.