JavaScript 异步编程(Callback、Promise、Generator、async)

630 阅读12分钟

前言

在面试时往往平时用的最多的东西,一旦被问起,却觉得自己没有办法讲清楚。

其本质原因是,有时候我们只学习了如何使用,并没有去深入了解为什么会有这项技术,这项技术解决了什么问题,以及技术每一步的演变。

一旦我们去总结了之后,那么回答面试官的问题自然可以对答如流。

同步与异步

Javascript语言的执行环境是"单线程"(single thread)。

所谓"单线程",就是指一次只能完成一件任务。如果有多个任务,就必须排队,前面一个任务完成,再执行后面一个任务,以此类推。

这种模式的好处是实现起来比较简单,执行环境相对单纯;坏处是只要有一个任务耗时很长,后面的任务都必须排队等着,会拖延整个程序的执行。常见的浏览器无响应(假死),往往就是因为某一段Javascript代码长时间运行(比如死循环),导致整个页面卡在这个地方,其他任务无法执行。

为了解决这个问题,Javascript语言将任务的执行模式分成两种:同步(Synchronous)和异步(Asynchronous)。

同步

"同步模式"就是后一个任务等待前一个任务结束,然后再执行,程序的执行顺序与任务的排列顺序是一致的、同步的;

异步

"异步模式"则完全不同,每一个任务有一个或多个回调函数(callback),前一个任务结束后,不是执行后一个任务,而是执行回调函数,后一个任务则是不等前一个任务结束就执行,所以程序的执行顺序与任务的排列顺序是不一致的、异步的。

"异步模式"非常重要。在浏览器端,耗时很长的操作都应该异步执行,避免浏览器失去响应,最好的例子就是Ajax操作。

JavaScript 异步模式进化史

  1. callback
  2. promise
  3. Generator
  4. async

这便是异步的发展史,每种方式都有它的优点与缺点,正是没有办法去容忍这些缺点,才会推动技术的不断更新迭代。

Callback

setTimeout(function(){
    console.log("我是回调函数");
},300);

$.ajax(url,function(){
    console.log("我是回调函数");
});

这是最初JavaScript处理异步的方式,随着项目难度增加,尤其是像后台发送请求,会越写越复杂,很容易导致Callback Hell回调地狱

$.ajax(url,function(){
    console.log("我是回调函数1");
    $.ajax(url,function(){
        console.log("我是回调函数2");
        $.ajax(url,function(){
            console.log("我是回调函数3");
        });
    });
});

相信早在jQuery时代,很多同学都写过类似的代码吧。时间一长,突然增加需求或者改需求了,我们就不得不一层一层仔仔细细去修改代码。

与此同时不论是jquery或者社区都已经开始提出自己的想法,例如jquery推出了deferred对象,后来被加入ES6纳入规范,就是现在Promise。

Promise

Promise 是异步编程的一种解决方案,比传统的解决方案——回调函数和事件——更合理和更强大。它由社区最早提出和实现,ES6 将其写进了语言标准,统一了用法,原生提供了Promise对象。

所谓Promise,简单说就是一个容器,里面保存着某个未来才会结束的事件(通常是一个异步操作)的结果。从语法上说,Promise 是一个对象,从它可以获取异步操作的消息。Promise 提供统一的 API,各种异步操作都可以用同样的方法进行处理。

使用promise封装ajax

const ajax = function(url) {
  const promise = new Promise(function(resolve, reject){
    const handler = function() {
      if (this.readyState !== 4) {
        return;
      }
      if (this.status === 200) {
        resolve(this.response);
      } else {
        reject(new Error(this.statusText));
      }
    };
    const client = new XMLHttpRequest();
    client.open("GET", url);
    client.onreadystatechange = handler;
    client.responseType = "json";
    client.setRequestHeader("Accept", "application/json");
    client.send();
  });

  return promise;
};

单层使用:

ajax("/posts.json").then(function(json) {
  console.log(json);
}, function(error) {
  console.error('出错了', error);
});

嵌套使用:

ajax("/posts1.json").then(function(data1) {
    return ajax("/data1.url");
}).then(function(data2) {
    return ajax("/data2.url");    
}).then(function(data3) {
    return ajax("/data3.url");    
}).then(function(data4) {
    return ajax("/data4.url");    
}).catch(function(error) {
  // Promise 对象的错误具有“冒泡”性质,会一直向后传递,直到被捕获为止。也就是说,错误总是会被下一个catch语句捕获。
  console.log('发生错误!', error);
});

不论嵌套多少层请求,结构都非常整齐,不会再出现回调地狱了。

promise的概念理解起来还是非常简单的,由于本文主要是为了阐述异步编程的进化史,因此就不详细展开讲解Promise的基础知识。

Generator

Generator 函数是 ES6 提供的一种异步编程解决方案,语法行为与传统函数完全不同。

Generator 函数有多种理解角度。语法上,首先可以把它理解成,Generator 函数是一个状态机,封装了多个内部状态。

执行 Generator 函数会返回一个遍历器对象,也就是说,Generator 函数除了状态机,还是一个遍历器对象生成函数。返回的遍历器对象,可以依次遍历 Generator 函数内部的每一个状态。

形式上,Generator 函数是一个普通函数,但是有两个特征。

  1. function关键字与函数名之间有一个星号;
  2. 函数体内部使用yield表达式,定义不同的内部状态(yield在英语里的意思就是“产出”)
function* helloWorldGenerator() {
  yield 'hello';
  yield 'world';
  return 'ending';
}

// 调用 Generator 函数后,该函数并不执行,返回的也不是函数运行结果,
// 而是一个指向内部状态的指针对象(Iterator Object)。

var hw = helloWorldGenerator();
hw.next(); // { value: 'hello', done: false }
hw.next(); // { value: 'world', done: false }
hw.next(); // { value: 'ending', done: true }
hw.next(); // { value: undefined, done: true }

执行 Generator 函数会返回一个遍历器对象。我们来看看这个遍历器对象的实现原理

class HelloWorldGenerator {

  constructor(arrVal) {
    this.array = arrVal;
    this.nextIndex = 0;
  }
  
  [Symbol.iterator]() { return this; }

  next() {
    return this.nextIndex < this.array.length ?
        {value: this.array[this.nextIndex++], done: false} :
        {value: undefined, done: true};
  }
  
}

function helloWorldGenerator() {
  return new HelloWorldGenerator(['hello','world','ending']);
}

var hw = helloWorldGenerator();
hw.next(); // { value: 'hello', done: false }
hw.next(); // { value: 'world', done: false }
hw.next(); // { value: 'ending', done: true }
hw.next(); // { value: undefined, done: true }

现在我们可以理解成helloWorldGenerator函数里面的每个yield后面的值,都会加入一个数组。通过调用next()方法去改变指针nextIndex的值,从而实现对数组的依次访问。

yield 表达式的规则

由于 Generator 函数返回的遍历器对象,只有调用next方法才会遍历下一个内部状态,所以其实提供了一种可以暂停执行的函数。yield表达式就是暂停标志。

遍历器对象的next方法的运行逻辑如下。

(1)遇到yield表达式,就暂停执行后面的操作,并将紧跟在yield后面的那个表达式的值,作为返回的对象的value属性值。

(2)下一次调用next方法时,再继续往下执行,直到遇到下一个yield表达式。

(3)如果没有再遇到新的yield表达式,就一直运行到函数结束,直到return语句为止,并将return语句后面的表达式的值,作为返回的对象的value属性值。

(4)如果该函数没有return语句,则返回的对象的value属性值为undefined。

[注意] yield表达式只能用在 Generator 函数里面,用在其他地方都会报错。

next 方法

next 方法的参数

yield表达式本身没有返回值,或者说总是返回undefined。next方法可以带一个参数,该参数就会被当作上一个yield表达式的返回值。

function* foo(x) {
  var y = 2 * (yield (x + 1));
  var z = yield (y / 3);
  return (x + y + z);
}

var a = foo(5);
a.next() // Object{value:6, done:false} 第一个yield没有返回值,因此 y = undefined
a.next() // Object{value:NaN, done:false} 所以第二个value值就是NaN
a.next() // Object{value:NaN, done:true}

var b = foo(5);
b.next() // { value:6, done:false } 
b.next(12) // { value:8, done:false } 12被当做上一个yield的返回值,因此y=12*2,value = 8
b.next(13) // { value:42, done:true } z = 13 ,value = 13 + 24 + 5 = 42

这个功能有很重要的语法意义。Generator 函数从暂停状态到恢复运行,它的上下文状态(context)是不变的。通过next方法的参数,就有办法在 Generator 函数开始运行之后,继续向函数体内部注入值。也就是说,可以在 Generator 函数运行的不同阶段,从外部向内部注入不同的值,从而调整函数行为。

注意,由于next方法的参数表示上一个yield表达式的返回值,所以在第一次使用next方法时,传递参数是无效的。V8 引擎直接忽略第一次使用next方法时的参数,只有从第二次使用next方法开始,参数才是有效的。从语义上讲,第一个next方法用来启动遍历器对象,所以不用带有参数。

协程

传统的编程语言,早有异步编程的解决方案(其实是多任务的解决方案)。其中有一种叫做"协程"(coroutine),意思是多个线程互相协作,完成异步任务。

协程有点像函数,又有点像线程。它的运行流程大致如下。

  • 第一步,协程A开始执行。
  • 第二步,协程A执行到一半,进入暂停,执行权转移到协程B。
  • 第三步,(一段时间后)协程B交还执行权。
  • 第四步,协程A恢复执行。

上面流程的协程A,就是异步任务,因为它分成两段(或多段)执行。

function* asyncJob() {
  // ...其他代码
  var f = yield readFile(fileA);
  // ...其他代码
}

上面代码的函数asyncJob是一个协程,它的奥妙就在其中的yield命令。它表示执行到此处,执行权将交给其他协程。也就是说,yield命令是异步两个阶段的分界线。

协程遇到yield命令就暂停,等到执行权返回,再从暂停的地方继续往后执行。它的最大优点,就是代码的写法非常像同步操作,如果去除yield命令,简直一模一样。

异步任务的封装

下面看看如何使用 Generator 函数,执行一个真实的异步任务。

var fetch = require('node-fetch');

function* gen(){
  var url = 'https://api.github.com/users/github';
  var result = yield fetch(url);
  console.log(result.bio);
}

上面代码中,Generator 函数封装了一个异步操作,该操作先读取一个远程接口,然后从 JSON 格式的数据解析信息。就像前面说过的,这段代码非常像同步操作,除了加上了yield命令。

执行这段代码的方法如下。

var g = gen();
var result = g.next();

result.value.then(function(data){
  return data.json();
}).then(function(data){
  g.next(data);
});

上面代码中,首先执行 Generator 函数,获取遍历器对象,然后使用next方法,执行异步任务的第一阶段。由于Fetch模块返回的是一个 Promise 对象,因此要用then方法调用下一个next方法。

可以看到,虽然 Generator 函数将异步操作表示得很简洁,但是流程管理却不方便(即何时执行第一阶段、何时执行第二阶段)。

Generator 就是一个异步操作的容器。它的自动执行需要一种机制,当异步操作有了结果,能够自动交回执行权。 两种方法可以做到这一点。

(1)回调函数。将异步操作包装成 Thunk 函数,在回调函数里面交回执行权。

(2)Promise 对象。将异步操作包装成 Promise 对象,用then方法交回执行权。

封装基于 Promise 对象的自动执行

var fs = require('fs');

var readFile = function (fileName){
  return new Promise(function (resolve, reject){
    fs.readFile(fileName, function(error, data){
      if (error) return reject(error);
      resolve(data);
    });
  });
};

var gen = function* (){
  var f1 = yield readFile('/etc/fstab');
  var f2 = yield readFile('/etc/shells');
  console.log(f1.toString());
  console.log(f2.toString());
};

然后,手动执行上面的 Generator 函数。

var g = gen();

g.next().value.then(function(data){
  g.next(data).value.then(function(data){
    g.next(data);
  });
});

手动执行其实就是用then方法,层层添加回调函数。理解了这一点,就可以写出一个自动执行器。

function run(gen){
  var g = gen();

  function next(data){
    var result = g.next(data);
    if (result.done) return result.value; // 递归的结束条件:当返回值的done属性为true
    // 否则就一直递归的去调用next方法
    result.value.then(function(data){
      next(data);
    });
  }

  next();
}

run(gen);

说到底Generator函数只是提供了异步函数的同步写法,它本身是不具备异步处理能力的。而 Promise 本质是对回调函数进行包装从而达到处理异步的效果。

co 模块

co 模块是著名程序员 TJ Holowaychuk 于 2013 年 6 月发布的一个小工具,用于 Generator 函数的自动执行。

var gen = function* () {
  var f1 = yield readFile('/etc/fstab');
  var f2 = yield readFile('/etc/shells');
  console.log(f1.toString());
  console.log(f2.toString());
};

co 模块可以让你不用编写 Generator 函数的执行器。

var co = require('co');
co(gen);

async + await

它是 Generator 函数的语法糖

我们把上面的案例改成async + await

const asyncReadFile = async function () {
  const f1 = await readFile('/etc/fstab');
  const f2 = await readFile('/etc/shells');
  console.log(f1.toString());
  console.log(f2.toString());
};

一比较就会发现,async函数就是将 Generator 函数的星号(*)替换成async,将yield替换成await。 但是它不仅如此,async 在yield 函数上还改进了一下四点。

1、内置执行器

Generator 函数的执行必须靠执行器,所以才有了co模块,而async函数自带执行器。也就是说,async函数的执行,与普通函数一模一样,只要一行。

2、更好的语义

async和await,比起星号和yield,语义更清楚了。async表示函数里有异步操作,await表示紧跟在后面的表达式需要等待结果。

3、更广的适用性

co模块约定,yield命令后面只能是 Thunk 函数或 Promise 对象,而async函数的await命令后面,可以是 Promise 对象和原始类型的值(数值、字符串和布尔值,但这时会自动转成立即 resolved 的 Promise 对象)。

4、返回值是 Promise async函数的返回值是 Promise 对象,这比 Generator 函数的返回值是 Iterator 对象方便多了。你可以用then方法指定下一步的操作。

async函数完全可以看作多个异步操作,包装成的一个 Promise 对象,而await命令就是内部then命令的语法糖。

async function getStockPriceByName(name) {
  const symbol = await getStockSymbol(name);
  const stockPrice = await getStockPrice(symbol);
  return stockPrice;
}

getStockPriceByName('goog').then(function (result) {
  console.log(result);
});

async await 注意点

1、await 命令放在 try...catch 代码块中

原因是任何一个await语句后面的 Promise 对象变为reject状态,那么整个async函数都会中断执行。

async function myFunction() {
  try {
    await somethingThatReturnsAPromise();
  } catch (err) {
    console.log(err);
  }
  try {
    await otherthingThatReturnsAPromise();
  } catch (err) {
    console.log(err);
  }
}

// 另一种写法

async function myFunction() {
  await somethingThatReturnsAPromise()
  .catch(function (err) {
    console.log(err);
  });
  await otherthingThatReturnsAPromise()
  .catch(function (err) {
    console.log(err);
  });
}

2、多个await命令后面的异步操作,如果不存在继发关系,最好让它们同时触发。

// 写法一
let [foo, bar] = await Promise.all([getFoo(), getBar()]);

// 写法二
let fooPromise = getFoo();
let barPromise = getBar();
let foo = await fooPromise;
let bar = await barPromise;

3、await命令只能用在async函数之中,如果用在普通函数,就会报错

async function dbFuc(db) {
  let docs = [{}, {}, {}];

  // 报错
  docs.forEach(function (doc) {
    await db.post(doc);
  });
}

4、async 函数可以保留运行堆栈

const a = () => {
  b().then(() => c());
};

上面代码中,函数a内部运行了一个异步任务b()。当b()运行的时候,函数a()不会中断,而是继续执行。等到b()运行结束,可能a()早就运行结束了,b()所在的上下文环境已经消失了。如果b()或c()报错,错误堆栈将不包括a()。

现在将这个例子改成async函数。

const a = async () => {
  await b();
  c();
};

上面代码中,b()运行的时候,a()是暂停执行,上下文环境都保存着。一旦b()或c()报错,错误堆栈将包括a()。这是generator函数的一大特性。

小结

本文内容基本上是参考的《ECMAScript 6 入门》。之所以还要再总结一遍主要是想使用更加简洁的语言,屏蔽了很多使用细节来讲解清楚异步编程的一步一步演变过程,以及最重要最难理解的Generator函数。