William's Blog

JavaScript的异步编程

JavaScript异步编程的背景知识

  javascript引擎是基于单线程事件循环的概念构建的,同一时刻只能执行一个代码块。为了实现异步,javascript引擎引入事件循环和任务队列机制。当某段代码需要被执行时,它会被添加到任务队列中,当任务队列前面的任务被执行完成后,事件循环会开始执行该代码。当一个异步操作结束并且需要执行响应程序时,javascript引擎会将响应程序添加到任务队列中,在下一次事件循环时执行。

侦听事件

  在Web应用中,浏览器会通过触发一些事件来响应用户交互,例如当用户点击页面按钮时会触发浏览器的click事件。开发者可以侦听这些事件并注册处理函数来作出一些响应。当某个事件被触发时,对应的事件处理函数会被添加到javascript引擎的任务队列中,然后被执行。一个常见的应用侦听事件的方式实现异步响应用户交互的示例如下:

const button = document.querySelector('button');

button.addEventListener('click', () => console.log('You clicked button'));

  当用户点击页面button元素时,javascript引擎会执行console.log(‘You clicked button’)语句,打印出 ‘You clicked button’。
  侦听事件实现异步的方式只适合处理简单的交互,当处理复杂的逻辑时,使用侦听事件的方式往往需要追踪每个事件的事件目标,代码逻辑因此可能会变得复杂并且难以调试,此外,事件处理函数必须在事件被触发前注册,否则事件处理函数不会被执行。

回调函数

  回调函数一般是以函数参数的形式进行传递,给要执行异步操作的函数传入一个回调函数作为参数,当异步操作结束时,调用回调函数来响应异步操作。以Web应用中常见的ajax请求为例来说明回调函数的用法:

function fetchData(api, method, successCallback) {
  const xhr = new XMLHttpRequest();
  xhr.open(method, api);
  xhr.onreadystatechange = () => {
    if(xhr.readyState === 4 && xhr.status === 200) {
      successCallback('success');    }
  };
  xhr.send();
}

// 调用fetchData
fetchData(
  '/api/',
  'get',
  console.log
);

  函数fetchData封装了异步请求并接受一个回调函数作为参数,当异步请求成功时,回调函数被调用并进行相应的处理。上述示例中,当异步请求成功时,javascript引擎将回调函数添加到任务队列中,执行后打印出 ‘success’。
  与侦听事件的方式相比较,回调函数的方式更灵活,可以很方便地将多个独立的异步操作链接起来:

// 调用fetchData
fetchData(
  '/api1/',
  'get',
  () => fetchData(
     '/api2/',
      'get',
      console.log
  )
);

  上述示例中,通过回调函数将两个异步请求链接起来,当/api1/请求成功后,会发起/api2/的请求,只有两个请求都成功时,javascript引擎才会打印出 ‘success’。
  虽然回调函数的链式调用很方便,但是其缺点也显而易见。当函数嵌套层次过多时,很容易形成 “回调地狱” ,严重影响代码的可读性,一个 “回调地狱” 示例如下:

method1((err, res) => {
  
  if (err) {
    throw err;
  }
  
  method2((err, res) => {
    
     if (err) {
        throw err;
     }
     
     method3((err, res) => {
       
       if (err) {
         throw err;
       }
       
       method4((err, res) => {
         
         if (err) {
           throw err;
         }
         
         method5(res);
       })
     })
  })
});

除了 “回调地狱” 外,回调函数还有以下局限性:

  1. 对于嵌套的函数调用,需要针对每一层级的调用进行错误处理,使得代码变得繁琐。
  2. 当并行执行多个异步操作时,如同时发送2个异步请求,单纯使用回调函数无法很好管理它们的状态,需要额外编写代码追踪多个回调函数来实现对异步操作的管理。

setTimeout/setInterval/requestAnimationFrame

  当需要在特定时间之后执行某个任务时,可以使用setTimeout/setInterval/requestAnimationFrame来实现。setTimeout用来实现特定延时之后执行某个任务,例如:

setTimeout(
  () => console.log('elapsed'),
  1000
);

  当javascript引擎执行到setTimeout时,javascript引擎并不会立即将回调函数添加到任务队列中,而是在1000ms之后才会将其添加到任务队列中。当新的事件循环开始时会执行setTimeout回调函数打印出 ‘elapsed’。
  setInterval和requestAnimationFrame都用来实现定期执行某一任务,他们的不同之处在于,setInterval可以让开发者自定义任务执行间隔,而requestAnimationFrame的回调函数会在浏览器渲染下一帧前执行,其执行间隔依赖于当前屏幕的帧率。一般而言,当需要使用javascript实现动画时,优先使用requestAniamtionFrame,这样实现的动画更加流畅。

Promise

  Promise对象的作用是管理异步操作状态,在任一时刻,它只可能处于以下三种状态中的一种:

  1. Pending,进行态。该状态表示异步操作尚未完成。
  2. Fulfilled,完成态。该状态表示异步操作成功完成。
  3. Rejected,拒绝态。该状态表示程序出错或者其他原因导致异步操作未能成功完成。

  Promise对象的状态变换是单向和一次性的,只能由Pending状态变换为Fulfilled或者Rejected状态。Promise对象有一个then方法,通过then方法可以在Promise对象的状态发生变换时执行相应的动作。then方法接受两个可选参数,第一个是当Promise对象的状态变为Fulfilled时要调用的函数,与异步操作成成功时相关的数据都可以传递给这个函数;第二个是当Promise对象的状态变为Rejected时要调用的函数,与异步操作失败相关的数据都可以传递给这个函数。
  使用Promise构造函数可以创建Promise对象,Promise构造函数接受一个参数:包含初始化Promise代码的执行器(executor)函数。执行器函数接受两个函数作为参数:resolve和reject。执行器函数会开启异步任务,当异步任务成功完成时,调用resolve函数并传入异步任务返回的数据;当异步任务失败时,调用reject函数并传入失败原因。传入执行器函数的resolve和reject函数是由Promise内部实现的,通过这两个函数,Promise对象可以知道异步任务当前的状态,从而变换自身状态并执行使用then方法注册的处理函数。调用resolve或reject函数时,使用then方法注册的处理函数并不会被立即执行,而是被添加到javascript引擎的任务队列中等待下一次事件循环再执行。
  使用Promise对象对上文中的fetchData函数进行重构:

function fetchData(api, method) {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
      xhr.open(method, api);
      xhr.onreadystatechange = () => {
        if(xhr.readyState === 4 && xhr.status === 200) {
          resolve('success');        }
        reject('failure');      };
      xhr.send();
  });
}

// 调用fetchData返回对应的Promise对象
const p = fetchData('/api/', 'get');

// 调用Promise对象的then方法,在异步任务成功/失败时执行打印动作
p.then(console.log, console.log);

  除了在then方法中处理异步任务的异常之外,还可以调用Promise对象的catch方法来统一捕获所有异常。catch方法接受一个回调函数来处理异步任务的异常,回调函数参数为异常信息。then方法和catch方法均会返回新的Promise对象使其可以被链式调用。串联的Promise对象只有当前一个Promise对象的状态变成Fulfilled或者Rejected时,其才会被处理。使用then方法的链式调用实现请求/api/成功后再打印新信息的功能:

fetchData('/api/', 'get')
  .then(console.log, console.log)
  .then(() => console.log('new Promise'));

  链式调用的then方法和catch方法可以通过回调函数的return语句从前一级向后一级传递值,对于不同类型的返回值,javascript引擎对then方法或catch方法新返回的Promise对象的处理如下:

  1. 返回值为非Promise对象。新创建的Promise对象状态为Fulfilled,并且存储的值为返回值。
  2. 返回值为Promise对象。新创建的Promise对象会接管返回值的状态和值。
  3. 抛出异常。新创建的Promise对象的状态为Rejected,并且存储的值为抛出的异常信息。

  一个简单的Promise链式调用传递值的示例如下:

const p1 = new Promise((resolve, reject) => {
  resolve(1);
});

const p2 = new Promise((resolve, reject) => {
  reject(-1);
});

p1.then((value) => {
  console.log(value);
  return value + 1;
}).then((value) => {
  console.log(value);
  throw new Error('操作失败');
}).catch((err) => {
  console.log(err.message);
  return '操作完成';
}).then((value) => {
  console.log(value);
  return p2;
}).catch(console.log);

上述示例最终打印出的结果和顺序如下:
1
2
操作失败
操作完成
-1
  Promise对象可以让开发者非常方便地管理多个异步任务的状态和执行流程,与侦听事件和回调函数这两种方式相比,Promise可以确保当异步操作完成之后对应的处理函数一定会被执行,而且使用Promise对象编写的代码结构也清晰易读。除此之外,Promise对象还提供了all和race两种方法,解决了同时监听多个异步任务的问题。all和race方法均接受一个包含多个Promise对象的可迭代对象作为参数并且返回一个Promise对象,但是两个方法表现不一样:
  对于all方法而言,只有当可迭代对象中的所有Promise对象的状态均变成Fulfilled时,all方法返回的Promise对象的状态才会变成Fulfilled并且其值是一个按可迭代对象中的迭代顺序存放所有被监听的Promise对象返回值的数组;当可迭代对象有一个Promise对象状态变为Rejected时,all方法返回的Promise对象的状态会变成Rejected并且其值为可迭代对象中达到Rejected状态的Promise对象的返回值。
  对于race方法而言,只要可迭代对象中的某个Promise对象达到Fulfilled状态或者Rejected状态,race方法返回的Promise对象的状态就会转变成Fulfilled或者Rejected状态并且保存迭代对象中达到Fulfilled状态或者Rejected状态的Promise对象的值。
  Promise对象是现代javascript组织异步编程的主要方式,但是当then的链式回调很长或者then回调中再次嵌套新Promise链式调用时,整个代码逻辑和可读性就会变的很差,和 “回调地狱” 产生一样的效果。有没有一种类似于编写同步任务代码的方式来编写异步任务代码?

生成器

  生成器是一种特殊的函数,返回迭代器。什么是迭代器?迭代器是一种特殊的对象,它拥有一个next方法,每次调用会返回一个结果对象,结果对象有两个属性:1 、value,表示下一个将要返回的值;2、done,是一个bool值,用来标志是否还有更多数据待返回。每个迭代器内部还保存一个内部指针,用来指向当前集合中值的位置,每次调用next方法都会返回下一个可用的值。当迭代器返回最后一个值后再次调用next方法,那么返回的结果对象中属性done的值为true,属性value则包含迭代器最终返回的值,这个返回值不是数据集合的一部分,它与函数的返回值类似,是函数调用过程中最后一次给调用者传递信息的方法,如果没有相关数据则返回undefined。
  使用function关键字定义函数时,在function关键字后面添加星号(*)可以定义一个生成器函数,星号(*)可以紧挨着function关键字,也可以在中间添加一个空格。在生成器函数中使用关键字yield可以指定生成的迭代器的next方法被调用时返回的结果对象的值。一个简单的用生成器生成迭代器的示例如下:

function *createIterator() {
  yield 1;
  yield 2;
  yield 3;
}

const iterator = createIterator();

console.log(iterator.next());
console.log(iterator.next());
console.log(iterator.next());
console.log(iterator.next());

  上述代码打印出的结果及其顺序如下:

{ value: 1, done: false }

{ value: 2, done: false }

{ value: 3, done: false }

{ value: undefined, done: true }

  生成器有一个很有趣的特点:每当执行完一条yield语句之后,生成器函数就会自动停止执行,当生成的迭代器的next方法再次被调用时,生成器会执行下一个yield语句。试想一下,如果使用yield语句可以返回异步调用的结果,那么使用生成器就可以像编写同步任务代码那样来编写异步任务代码。

使用Promise和生成器组织异步编程

  以上文中使用Promise重构的fetchData函数为例说明如何使用生成器以”同步”编程的方式组织异步代码。
  首先,使用生成器定义一个任务函数,执行所有异步操作:

function *taskDef() {
  const value = yield fetchData('/api/', 'get');
  console.log(value);
}

  然后,创建一个执行任务的函数:

function run(taskDef) {
  const task = taskDef();
  let result = task.next();
  
  // 递归执行所有异步操作
  (function step() {
    if (!result.done) {
      // 使用Promise封装返回的结果
      Promise.resolve(result.value).then((value) => {
        // 将异步操作返回的数据注入到生成器中替换yield语句的返回值,后续操作可以使用该值
        result = task.next(value);
        step();
      }).catch((err) => {
        result = task.throw(new Error(err));
        step();
      });
    }
  })();
}

  最后使用run方法执行任务函数:

run(taskDef);

  当fecthData这个异步函数执行成功之后,run函数会打印出 ‘success’,当fetchData执行失败之后,run函数抛出错误,此时程序退出。
  考察上面的示例,如果我们将run函数封装成一个通用的函数,那每次实现异步编程时,我们只需要创建一个生成器,在生成器中使用yield关键字返回一个Promise对象然后调用run方法传入生成器就可以了。开发者只需要关注生成器的创建,使用yield关键字以”同步”的方式编写异步代码。

async/await

  async/await是ES 7中引入的语法糖,其引入的目的是更加方便开发者以”同步”的方式编写异步代码,其本质上是在内部使用生成器和Promise对象实现了上述run方法的功能从而让异步编程变得简单。
  本文不再对async/await做过多的介绍,详情可以参考mdn官方文档

总结

  本文总结了javascript常见的异步编程的方式,从原始的侦听事件和回调函数模式到现代基于Promise和生成器实现的采用”同步”的方式编写异步代码的模式。对于开发者而言,没有哪一种方法是完美的,开发者需要根据使用场景灵活选取实现异步编程的方式。

参考文献

  1. Understanding ECMAScript 6
  2. mdn: Asynchronous Java​Script
  3. Promises/A+
  4. Promise
  5. mdn: Making asynchronous programming easier with async and await

William

本博客作者 William 现任职于北京贝壳找房,从事web前端开发相关工作。
您可以通过Email与他取得联系