JavaScript基础专题之异步(十三)

386 阅读9分钟

什么是异步?

简单说就是一个任务分成两段,先执行第一段,然后转而执行其他任务,等做好了准备,再回过头执行第二段。

我们以JqueryAJAX 发送请求为例。

console.log('1')
var ajax = $.ajax({
    url: '/data/data1.json',
    success: function (res) {
        console.log('success')
    }
})
console.log('2')
//1 2 success

上面代码中 $.ajax() 需要传入两个参数进去,urlsuccess,其中url是请求的路由,success是一个函数。这个函数传递过去不会立即执行,而是等着请求成功之后才能执行。

上面的代码中,任务的第一段是向 url 发送请求。然后,程序执行其他任务,等到数据返回,我们再执行后续的操作。

这种不连续的执行,就叫做异步。相应地,连续的执行,就叫做 ``同步

为何会有异步?

一句话, JS 是单线程的语言。所谓“单线程”就是一根筋,会对程序一行一行的进行执行,上一行代码的执行未完成,就会一直等着上一行执行结束后执行。例如

var i, 
	t = Date.now()
for (i = 0; i < 100000000; i++) {
}
console.log(Date.now() - t)  // 230 (chrome浏览器)

上面的程序花费 230ms 的时间执行完成,执行过程中必须要执行完循环,之后才会下面的代码。

对于 JS 的浏览器环境下,可能会有大量的网络请求,而一个网络资源什么时候返回,这个时间是不可预估的。那么这种情况也要傻傻的等着,啥都不做吗?

那肯定不行。

因此,JS 对于这种场景就设计了异步。即发起一个网络请求,就先不管这边了,先干其他事儿,网络请求什么时候返回结果,到时候再说。这样就能保证一个网页的流畅的运行了。

异步场景与解决方案

我们常用的异步场景有大致几点:

  • 网络请求,如ajax
  • IO 操作,如 node 中的readFile writeFile `
  • 定时函数,如setTimeout setInterval
  • 事件监听,如 addEventListener

在 ES6 一直前,我们异步编程的解决方案最多的是运用 回调函数,自 ES6 出现之后,将 JavaScript 异步编程带入了一个全新的阶段。

解决方案大概分为以下几种:

  • 回调函数
  • Promise 对象
  • Generator对象
  • Async/Await语法

下面先说说回调函数。

回调函数

JavaScript 语言最初对异步编程的实现,就是运用 回调函数。所谓回调函数,就是把任务的第二段单独写在一个函数里面,等到重新执行这个任务的时候,就直接调用这个函数。**它的英语名字 callback,直译过来就是"重新调用"。

Node.js 中读取文件为例

fs.readFile('/etc/passwd', function (err, data) {
  if (err) throw err;
  console.log(data);
});

上面代码中,readFile 函数的第二个参数,就是回调函数,也就是任务的第二段。等到操作系统返回了 /etc/passwd 这个文件以后,回调函数才会执行。

一个有趣的问题是,为什么 Node.js 约定,回调函数的第一个参数,必须是错误对象err(如果没有错误,该参数就是 null)?原因是执行分成两段,在这两段之间抛出的错误,程序无法捕捉,只能当作参数,传入第二段。

Promise

回调函数本身可以很好的解决异步问题,但是又出现新的问题,就是多层嵌套。假定读取A文件之后,再读取B文件,代码如下。

fs.readFile(fileA, function (err, data) {
  fs.readFile(fileB, function (err, data) {
    // ...
  });
})

可想而知,如果依次读取多个文件,就会出现多重嵌套。这样我们的代码可读性和可维护性就会降低。这种情况就称为"回调地狱"(callback hell)。

Promise 就是为了解决这个问题而提出的。它不是新的语法功能,而是一种新的写法,允许使用链式调用,逻辑更加清晰。

采用Promise,连续读取多个文件,写法如下。

var readFile = require('fs-readfile-promise');

readFile(fileA)
.then(function(data){
  console.log(data.toString());
})
.then(function(){
  return readFile(fileB);
})
.then(function(data){
  console.log(data.toString());
})
.catch(function(err) {
  console.log(err);
});

上面代码中,使用了 fs-readfile-promise 模块,它的作用就是返回一个 Promise 版本的 readFile 函数。Promise 提供 then 方法加载回调函数,catch方法捕捉执行过程中抛出的错误。

可以看到,Promise 的写法只是回调函数的改进,使用then方法以后,异步任务的两段执行看得更清楚了。

Promise 的最大问题是代码冗余严重,原来的任务被Promise 包装了一下,不管什么操作,一眼看去都是一堆 then,可读性也变的很差。

那么,有没有更好的写法呢?

Generator

在 ES6 出现之前,基本都是各式各样类似Promise的解决方案来处理异步操作的代码逻辑,但是 ES6 的Generator却给异步操作又提供了新的思路,马上就有人给出了如何用Generator来更加优雅的处理异步操作。

先来一段最基础的Generator代码

function* Hello() {
    yield 100
    yield (function () {return 200})()
    return 300
}

var h = Hello()
console.log(typeof h)  // object

console.log(h.next())  // { value: 100, done: false }
console.log(h.next())  // { value: 200, done: false }
console.log(h.next())  // { value: 300, done: true }
console.log(h.next())  // { value: undefined, done: true }

nodejs 环境执行这段代码,打印出来的数据都在代码注释中了,也可以自己去试试。将这段代码简单分析一下吧

  • 定义Generator时,需要使用function*,其他的和定义函数一样。内部使用yield,至于yield的用处以后再说
  • 执行var h = Hello()生成一个Generator对象,经验验证typeof h发现不是普通的函数
  • 执行Hello()之后,Hello内部的代码不会立即执行,而是出于一个暂停状态
  • 执行第一个 h.next() 时,会激活刚才的暂停状态,开始执行Hello内部的语句,但是,直到遇到 yield 语句。一旦遇到 yield 语句时,它就会将 yield 后面的表达式执行,并返回执行的结果,然后又立即进入暂停状态。
  • 因此第一个 console.log(h.next()) 打印出来的是 { value: 100, done: false }value 是第一个yield返回的值, done: false 表示目前处于暂停状态,尚未执行结束,还可以再继续往下执行。
  • 执行第二个 h.next() 和第一个一样,不在赘述。此时会执行完第二个 yield 后面的表达式并返回结果,然后再次进入暂停状态
  • 执行第三个 h.next() 时,程序会打破暂停状态,继续往下执行,但是遇到的不是yield而是return。这就预示着,即将执行结束了。因此最后返回的是 { value: 300, done: true }done: true 表示执行结束,无法再继续往下执行了。
  • 再去执行第四次 h.next() 时,就只能得到 { value: undefined, done: true } ,因为已经结束,没有返回值了。

一口气分析下来,发现并不是那么简单,可见Generator的学习成本多高,但是一旦学会,那将受用无穷!别着急,跟着我的节奏慢慢来,一行一行代码看,你会很快深入了解Genarator

但是,你要详细看一下上面的所有步骤,争取把我写的每一步都搞明白。如果搞不明白细节,至少要明白以下几个要点:

  • Generator不是函数
  • Hello()不会立即出发执行,而是一上来就暂停
  • 每次h.next()都会打破暂停状态去执行,直到遇到下一个yield或者return
  • 遇到yield时,会执行yeild后面的表达式,并返回执行之后的值,然后再次进入暂停状态,此时done: false
  • 遇到return时,会返回值,执行结束,即done: true
  • 每次h.next()的返回值永远都是{value: ... , done: ...}的形式

之前讲解Promise时候,主要是使用then做链式操作。

举个例子:

readFilePromise('some1.json').then(data => {
    console.log(data)  // 打印第 1 个文件内容
    return readFilePromise('some2.json')
}).then(data => {
    console.log(data)  // 打印第 2 个文件内容
    return readFilePromise('some3.json')
}).then(data => {
    console.log(data)  // 打印第 3 个文件内容
    return readFilePromise('some4.json')
}).then(data=> {
    console.log(data)  // 打印第 4 个文件内容
})

而如果学会Generator那么读取多个文件就是如下这样写。先不要管如何实现的,光看一看代码,你就能比较出哪个更加简洁、更加易读、更加所谓的优雅!

//借助 co 函数库
co(function* () {
    const r1 = yield readFilePromise('some1.json')
    console.log(r1)  // 打印第 1 个文件内容
    const r2 = yield readFilePromise('some2.json')
    console.log(r2)  // 打印第 2 个文件内容
    const r3 = yield readFilePromise('some3.json')
    console.log(r3)  // 打印第 3 个文件内容
    const r4 = yield readFilePromise('some4.json')
    console.log(r4)  // 打印第 4 个文件内容
})

是不是更像是同步了?

我们看到了 Generator 已经实现了异步代码同步书写,那我们看看 Async/Await是什么?

Async/Await

Async/Await 作为异步编程的终极解决方案,根本不用关心它是不是异步。

Async/Await 又是怎么实现的呢?

一句话概括,Async/Await 就是 Generator 的语法糖。

举个例子:

var fs = require('fs');

var readFile = function (fileName){
  return new Promise(function (resolve, reject){
    fs.readFile(fileName, function(error, data){
      if (error) 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());
};

写成 Async/Await,就是下面这样。

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

一比较就会发现,Async/Await 就是将 Generator 函数的星号(*)替换成 async,将 yield 替换成 await,仅此而已。

JavaScript基础专题系列

JavaScript基础专题之原型与原型链(一)

JavaScript基础专题之执行上下文和执行栈(二)

JavaScript基础专题之深入执行上下文(三)

JavaScript基础专题之闭包(四)

JavaScript基础专题之参数传递(五)

JavaScript基础专题之手动实现call、apply、bind(六)

JavaScript基础专题之类数组对象(七)

JavaScript基础专题之实现自己的new Object(八)

JavaScript基础专题之创建对象几种方式及优缺点(九

JavaScript基础专题之继承的实现及其优缺点(十)

JavaScript基础专题之类型检测(十一)

JavaScript基础专题之类型转换(十二)

如果有错误或者不严谨的地方,还请大伙给予指正。如果这片文章对你有所帮助或者有所启发,还请给一个赞,鼓励一下作者。