JS之异步发展史

前言

熟悉前端的同学对 JavaScript 的第一印象是什么?不论是弱类型、脚本语言、异步、原型...但用过的同学都对一个特性又爱又恨,那就是异步。本文会首先从异步的原理开始,介绍一些异步编程的方法,从 jQuery 中的异步到 Promise、Generator,再到 async/wait,一步步讲解,内容尽量用最简单的例子来梳理、介绍。

开发中总是会遇到各种异步问题,现粗略的说下 JS 的异步,抛块砖,讲下异步发展,并没有太深入挖掘,否则文章太长,本篇幅稍微有点长,需要有点耐心,比较了解的同学可以直接跳过,内容有错误或不恰当的地方请及时指出,欢迎大家指正、交流。

  1. 异步

    1. 为什么要有异步

      在浏览器中,JS是单线程、异步执行的。单线程,就是同一时刻 JS 引擎只能执行一段代码,浏览器是直接面对用户的,而且往往一个页面会有很多请求,如果所有请求都是同步的,那体验就太糟了,所以请求采用异步,避免用户长时间的等待。

      而 Node 中,“一切皆异步”的思想,更是指出了异步的重要性,目的也是让猿儿们编写高效的程序,不因为请求或 DB 操作而阻塞了服务。

    2. 异步原理

      先来说下同步,同步就是事件1干完,再干事件2,事件1干完之前,事件2只能傻傻的等待。如排队上厕所,前面那个人完事出来你才能进去舒服一下,否则...自己脑补吧,画面太美不敢看。

       
      代码块 同步
       
      // 代码块 同步
      // doSomething1
      const s = Date.now();
      for (let i = 0; i < 100000000; i++) {
          // ...
      }
      console.log(Date.now() - s);    // 约330ms
      // doSomething2

      上面的为同步,在 doSomething2 开始之前,必须等待330ms才能开始 doSomething2,因为浏览器在执行 for 的时候,干不了别的。

      异步,是为了解决“傻傻的等待”的问题,还是上厕所问题,但是这次加了一步,先拿号,然后等待叫号,等轮到你的号通知你之后,直接去厕所就行了,而在这等待期间,你完全可以来两局王者农药,不耽误你干别的。

      // 代码块 异步
      // 1、异步1
      setTimeout(() => {
          // doSomething
      }, 1000)
      ​
      // 2、异步2
      $.ajax({
          url: '/test/data.json',
          success: () => {
              console.log('success');
          }
      });

      像上面这种不立即执行,而是等待有了结果之后,再去执行的函数,称为 callback,即回调函数。

      异步的原理:就是将 callback 作为参数传递给异步执行的函数,等有结果之后再去调用 callback 执行。

    3. 常见异步

      开发中常见的异步操作有:

      • 网络请求,如 ajax,request

      • IO 操作,如 fs.readFile,DB的CRUD

      • 定时函数,如 setTimeout, setInterval

      • 事件监听,如 $btn.on('click', callback)

    4. 结束语

      异步是一开始就有的,但是怎么把异步从回调地狱中解放出来,则是一步一步发展的。jQuery 大家都很熟悉,基本是个前端同学都必用过的,N年前 jQuery 的源码、写法都是标杆,下面先说下它当中的异步解决方案。

  2. jQuery异步方案

    1. v1.5版本之前的异步

      在1.5版本之前,ajax 主要是通过回调函数的写法来实现的:

      const ajax = $.ajax({
          url: '/test/data.json',
          success: () => {
              console.log('success');
          },
          err: () => {
              console.log('err')
          }
      });
      console.log(ajax); // 返回的是一个XHR对象

      这是标准的 callback 的写法,每个人都很熟悉,单着一层还好,如果有三层甚至更多层,那么代码会成“>”状,阅读很糟糕,也不便维护。

    2. v1.5+的异步

      2011年1月31日,jQuery v1.5发布,重写了ajax的API,ajax写法如下:

      const ajax = $.ajax('/test/data.json')
          .done(() => {
              console.log('done');
          })
          .fail(() => {
              console.log('fail');
          })
          .always(() => {
              console.log('finished');
          });
      console.log(ajax); // 返回的是一个deferred对象

      可以看到,最大的区别,是采用了链式的写法,最终返回的是一个 deferred object(延迟对象),实现了Promise接口,至于什么是deferred对象,请移驾此处:deferred

      链式写法的好处,不用把所有请求都丢到callback里,明确了成功就放入 done,失败放入 fail,如果成功后有很多步骤,可以写很多 done,然后链式起来就行了。熟悉 Promise 的同学是不是觉得有一点熟悉,如果看不出来,那么上面的写法还可以像这么写:

      const ajax = $.ajax('/test/data.json')
          .then(() => {
              console.log('success');
          }, () => {
              console.log('err');
          })
          .then(()  => {
              console.log('success');
          }, () => {
              console.log('err');
          });

      就是用 then 来代替 done、fail 等状态,then有两个参数,都是回调函数,第一个是 doneCallback,第二个是 failCallback。此时是不是与 Promise 更像了。其实链式写法,也并没有改变层层回调问题,只是发展路上的一个过渡。

    3. 结束语

      在这章节说下 jQuery 的变化,也是为了说明JS异步发展的一个过程,由 callbac k到链式的写法,jQuery 从一开始的 callback 到之后的 then 链式调用,其实也为之后的 Promise 奠定了基础,下面先讲下async 的处理方式,然后就轮到 Promise。

  3. Async.js

    讲 Promise 之前,先说下 Async.js。Promise,它是需要学习成本的,有人不想用 Promise,但是多层回调嵌套又确实很恶心人,所有就有了async、then 等库的诞生,这些库其实并没有用到 Promise,但是却能以优美的方式去书写异步,算是 callback 的语法糖,但是也可以一定程度逃离“回调地狱”了。

    下面简单介绍下 Async,它可以用在 browser 跟 node 端,它的方法很多,具体可移驾 Async.js 挑出几个常用的 api,看看它的写法。

    • async.series(tasks, [callback]): 顺序执行数组或集合内的函数,执行完一个就执行下一个,错误可在 callback 中获得:

      const async = require('async');
      async.series([
          (callback) => {
              callback(null, 'ok1'); // 为了方便,直接返回字符串
          },
          (callback) => {
              callback(null, 'ok2');
          }
      ], (err, data) => {
          console.log(data); // ['ok1', 'ok2']
      });
    • async.parallel(tasks, [callback]): 并行执行数组、集合内的函数:

      async.parallel([
          (callback) => {
              callback(null, 'ok1');
          },
          (callback) => {
              callback(null, 'ok2');
          }
      ], (err, data) => {
          console.log(data); // ['ok1', 'ok2']
      });

      async.waterfall(tasks, [callback]): 瀑布流方式,任务依次执行,前一个函数的回调,会作为后一个函数的参数:

    • async.waterfall([
          (callback) => {
              callback(null, 'ok1', 'ok2');
          },
          (arg1, arg2, callback) => {
              // 此处 arg1='ok1', arg2='ok2'
              callback(null, 'ok3');
          }
      ], (err, data) => {
          console.log(data); // 'ok3'
      }); 

      从上面几个例子可以看出,它可以很方便的把多个请求放到同一级去处理(数组或集合中),而且功能很多,如顺序执行、并行执行、竞速执行等,非常方便。如果用传统的回调函数形式,则要复杂的多,一层层嵌套,难以书写与维护。所以 Async 这个库很受欢迎,现在 github 上的 stars 将近 25k,也能看出火爆程度,大家是多么想逃离回调地狱。

      但是时代在发展,随着 ES6、ES7、ES8 等的发布,Promise 还是有必要了解、学习一下的。

  4. Promise

    callback一层嵌一层的回调,导致了金字塔问题的出现,也即callhack hell,写个代码都不能愉快的写了。所有新兴事物的快速发展一定是戳中了原来的一些痛点。
    在开发者的千呼万唤中,终于,2015年6月份,ES2015规范正式发布,也是JavaScript的20周年,ES6的发布,也标志着JS开始升级为企业级大型应用的开发语言。Promise也正式加入到ES6,成为一个原生对象,可以直接用。

    1. 什么是Promise

      Promise是一个拥有then方法的函数或对象,一个Promise对象可以理解为一次将要执行的操作,主要是异步操作,之后可以用一种链式调用的方式来组织代码。目前Promise的规范是Promise/A+规范,核心内容如下:

      1. 状态:一个Promise只有3种状态:pending(等待), fulfilled(已完成)或rejected(已拒绝),且必须在其中状态之一。

      2. 状态只能从 pending-->fulfilled,或者 pending-->rejected,不能逆向转换,fulfilled、rejected也不能相互转换。

      3. then方法:一个Promise必须提供一个then方法来获取其值,而且then必须返回一个Promise,以供链式调用。

      4. then方法接收两个可选参数,promise.then(onFulfilled, onRejected) - onFulfilled:pending-->fulfilled时调用,onRejected:pending-->rejected时调用。

    2. 基本用法

      先来看一个fs的异步读取文件的方法:

      const fs = require('fs');
      const read = (fileName) => {
          fs.readFile(fileName, (err, data) => {
              if (err) {
                  console.log(err);
              } else {
                  console.log(data.toString());
              }
          });
      }

      然后用Promise对fs.readFile进行封装:

      const fs = require('fs');
      // readPromise这个方法以后会多次使用
      const readPromise = (fileName) => {
          // 把fs.readFile用Promise包装一层
          const promise = new Promise((resolve, reject) => {
              fs.readFile(fileName, (err, data) => {
                  if (err) {
                      reject(err); // 失败就reject出去
                  } else {
                      resolve(data.toString()); // 成功就resolve出去
                  }
              });
          })
          return promise; // 最后返回一个promise对象
      }

      注意看程序中注释的部分,Promise的callback中有两个非常重要的参数:resolve 和 reject。

      resolve方法:使Promise对象状态变化 pending-->fulfilled,即等待状态变为已完成,表示成功,resolve方法的参数用于成功之后的操作,此处就是获得的文件的内容。
      reject方法:使Promise对象状态变化 pending-->rejected,即等待状态变为已拒绝,表示失败,reject方法的参数用于失败之后的操作,此处就是失败的原因。
      通过上小节的规范可以知道,Promise对象都有then方法,所以readPromise方法可以这么用:

      readPromise('./test.txt')
      .then((data) => {
          console.log(data); // 上面代码中的resolve回的值
      }, (err) => {
          console.log(err); // 上面代码中的reject回的值
      });

      then有两个参数,第一个是成功之后的callback,第二个是失败之后的callback,而参数分别是上步包装的resolve与reject函数的参数。
      上面还有种写法,就是then只接受一个参数,表示成功之后的操作,后续跟上catch方法,捕获reject的异常:

      readPromise('./test.txt')
      .then((data) => {
          console.log(data); // 上面代码中的resolve回的值
      })
      .catch((err) => {
          console.log(err); // 上面代码中的reject回的值
      });

      上面这种写法更清晰点。

    3. 参数传递

      理解Promise的参数传递是很重要的,这样才能得到自己想要的数据。上面已经讲了,resolve的数据会在第一个then接收,reject的数据会在catch接收。因为then返回的还是Promise,所以then可以链式调用,如想对上面的test.txt的数据进行处理,则可以继续then下去:

      readPromise('./test.txt')
      .then((data) => {
          console.log(data); // resolve回的值
          return data; // 此处return的data,将在下个then的参数处获得
      })
      .then((data) => {
          console.log(data + " 数据已经处理了~"); // 此处的data就是上个then里return回的数据
      })
      .catch((err) => {
          console.log(err); // 上面代码中的reject回的值
      });

      then链式操作中返回的值,将会在下个步骤处获得,而如果返回的是一个Promise,那么下个then处获得的就是Promise的第一个then的值。这句话怎么理解,来看个例子,我想读取test1.txt之后,再读取test2.txt,传统callback处理以及Promise处理对比:

      // 普通回调,层层嵌套
      fs.readFile('./test1.txt',(err1, data1) => {
          if (err1) {
              console.log(err1);
          } else {
              console.log(data1);
              // 然后再读取第二个文件
              fs.readFile('./test1.txt', (err2, data2) => {
                  if (err2) {
                      console.log(err2);
                  } else {
                      console.log(data2);
                  }
              });
          }
      });
      ​
      // Promise方式
      const read1 = readPromise('./test1.txt');
      const read2 = readPromise('./test2.txt');
      read1.then((data1) => {
          console.log(data1); // 此处是test1.txt的内容
          return read2; // 此处返回的是read2,一个Promise对象
      })
      .then((data2) => {
          console.log(data2); // 此处是上一步返回的read2的then,所以打印的是test2.txt的内容
      })

      对比可以发现,Promise方式更优雅,也更容易看懂,这只是读取2个文件,如果读取三个甚至更多,那用Promise就更方便了,当然如果不需要读取的有依赖关系,则可用Promise对象的all或race方法。
      如果想读取test1.txt, text2.txt的内容,读完再做其他操作,则可以如下:

      const read1 = readPromise('./test1.txt');
      const read2 = readPromise('./test2.txt');
      Promise.all([read1, read2])
      .then((datas) => {
          console.log(datas[0]); // test1.txt的内容
          console.log(datas[1]); // test2.txt的内容
      });

      如果想读取test1.txt, text2.txt的内容,但是只要有一个返回就可以做其他操作,谁执行的快就用谁,则可以如下:

      const read1 = readPromise('./test1.txt');
      const read2 = readPromise('./test2.txt');
      Promise.race([read1, read2])
      .then((data) => {
          console.log(data); // 先读取完那个文件的内容
      });

      有人说还看到过Promise.resolve,它的作用是把一个thenable对象转换为Promise对象,如下:

      // thenable对象,有then属性,且属性值如下
      const thenable = {
          then: (resolve, reject) => {
              resolve('success');
          }
      }
      // 把thenable对象转换为Promise对象
      const thenToPromise = Promise.resolve(thenable);
      // 然后就可以这么用了
      thenToPromise.then((data) => {
          console.log(data); // 'success'
      });
    4. 相关库

      实际开发中,使用原生的Promise当然可以,不过市面上有现成的第三方库,而且很好用,比较流行的是Q、Bluebird等。他们都可用于浏览器端以及node端,并且可以在不支持Promise的环境中使用,至于用那个,则看个人爱好了,bluebird号称Promise库里最快的,比原生的Promise都快,其实原生的Promise比传统的callback慢不少。
      这里介绍Q.js一些基本的用法,引用官网的一个例子,再次体验下传统回调与Promise库之间的对比:

      // 传统回调
      step1((value1) => {
          step2(value1, (value2) => {
              step3(value2, (value3) => {
                  step4(value3, (value4) => {
                      // Do something with value4
                  });
              });
          });
      });
      ​
      // 用Q
      Q.fcall(promisedStep1)
      .then(promisedStep2)
      .then(promisedStep3)
      .then(promisedStep4)
      .then((value4) => {
          // Do something with value4
      })
      .catch((err) => {
          // Handle any err from all above steps
      })
      .done();

      可以看到,传统回调方式也不错嘛,也有美感,but,这只是简写,如果加上各种异常判断,还有其他操作,那么维护起来很麻烦,也容易出错。而用Q,则清晰了很多,一步完成之后继续下一步,比较符合人的思维,这里看到一个Q的用法:Q.fcall,常用的方法有:Q.fcall, Q.nfcall, Q.nfapply, Q.defer, Q.all, Q.any等。用法都放到一段代码里:

      const Q = require('q');
      const fs = require('fs');
      ​
      // Q.fcall: 接收函数或defer实例,返回一个Promise对象
      const promiseFcall = Q.fcall(() => {
          return 'hello';
      });
      ​
      // Q.nfcall: Node function call, 处理callback是这种形式的:function(err, result),可以直接封装成Promise
      const promiseNfcall = Q.nfcall(fs.readFile, './test.txt', 'utf-8');
      ​
      // Q.nfapply: 与Q.nfcall类似,只是参数不一样,很像js的call与apply用法
      const promiseNfapply = Q.nfapply(fs.readFile, ['./test.txt', 'utf-8']);
      ​
      // Q.defer: 可以定义Promise生成器,如果浏览器不支持Promise,则比较有用,很像原生Promise的写法
      const promiseDefer = (fileName) => {
          const defer = Q.defer();
          fs.readFile(fileName, (err, data) => {
              if (err) {
                  defer.reject(err);
              } else {
                  defer.resolve(data.toString());
              }
          })
      }
      ​
      // Q.all: 与Promise.all类似
      const read1 = Q.nfcall(fs.readFile, './test1.txt', 'utf-8');
      const read2 = Q.nfcall(fs.readFile, './test2.txt', 'utf-8');
      Q.all([read1, read2], (data) => {
          console.log(data[0]);
          console.log(data[1]);
      });
      ​
      // Q.any: 与Promise.race类似
      Q.any([read1, read2])
      .then((data) => {
          console.log(data);
      });

      以上只是简单介绍了最基本的用法,具体可以自行去github上看下。

    5. 结束语

      到此,Promise差不多介绍完了,当然Promise还有很多用法,就不一一列举了,那么Promise有没有改变callback的本质?并没有,Promise只是换了种对异步的写法,优化了对代码的可读性,其实还是依赖callback,获得的数据,还是在then的callback里获取到的。上面看到需要的数据,还是在callback中获得的,还没有真正像同步那样的写法,如果用Generator配合Promise,则写法就完全不同了,接下来进入Generator。

  5. Generator

    1. 协程(非携程)

      介绍Generator前,先讲下协程,协程最初诞生是为了解决低速IO与高速的CPU之间协作问题,协程是指多个线程交互协作,完成异步任务,大概流程如下:

      1. 协程A开始运行

      2. 执行到某处,暂停,然后执行权交给协程B

      3. 一段时间后,协程B交换执行权给协程A

      4. 协程A恢复执行

      还以读取文件为例,代码表示如下:

      function asyncFunction() {
          // doSomething1
          yield readFile('./test.txt');
          // doSomething2
      }

      上面函数asyncFunction就是一个协程,一开始执行doSomething1,当遇到yield后,自身先暂停,执行权移交给readFile,当readFile执行完之后,执行权又交还回asyncFunction,然后接着执行doSomething2。

    2. 什么是Generator

      Generator(生成器)可以说是协程在ES6中的实现,它最大的特点是:可以交出执行权,暂停执行。先看一个简单的Generator写法:

      function* gen() {
          yield 'hello';
          yield 'world';
          return 'ok';
      }
      const g = gen();
      g.next(); // {value: "hello", done: false}
      g.next(); // {value: "world", done: false}
      g.next(); // {value: "ok", done: true}
      g.next(); // {value: undefined, done: true}

      这看上去像是一个函数,所以也可以称为Generator函数,但是要明白,Generator并不是函数,它与普通函数有几点区别:

      • 以function* 开始,注意这个*

      • 内部有一个 yield 关键字,跟return有点像,不同是yield可以有多个

      Generator返回的其实是一个Iterator对象,下面先说下Iterator迭代器。

    3. Iterator迭代器

      在讲Iterator之前,先说下ES6新引入的一个基本类型:Symbol。
      ES6之前JS有6个基本数据类型:string, object, null, boolean, undefined, number。现在增加一个:Symbol,表示独一无二的值。
      Symbol不能用new关键字,因为是一个原始类型的值,不是对象,所以也不能添加属性,可理解为类似字符串数据类型。它可以接收一个字符串参数,主要是为了控制台显示或转换为字符串时容易区分:

      let s1 = Symbol();
      let s2 = Symbol();
      s1 == s2 // false
      ​
      s1 = Symbol('foo');
      s2 = Symbol('foo');
      ​
      s1 // Symbol(foo);
      typeof s1 // 'symbol'
      s1 == s2 // false

      Symbol也可以作为对象的属性key来使用:

      const obj = {
          a: 'foo',
          [Symbol.iterator]: 'foo2'
      }
      console.log(obj); // {a: "foo", Symbol(Symbol.iterator): "foo2"}

      Symbol有个iterator属性,指向该对象的默认遍历器方法。在ES6中有些原生就具有[Symbol.iterator]属性,如数组、Set、Map(也是ES6新引进的)、arguments对象等,这些对象有个特点,就是可以用for...of循环遍历:

      const arr = ['foo1', 'foo2', 'foo3'];
      for (let i of arr) {
          console.log(i); // 'foo1' 'foo2' 'foo3' 这里注意i为value,并不是对应的key
      }

      Iterator对象:具有[Symbol.iterator]属性的数据,都可以生成一个Iterator对象,而怎么使用Iterator对象,有两种方式: next(), for...of。以数组举例:

      const arr = ['foo1', 'foo2', 'foo3'];
      const it = arr[Symbol.iterator](); // 生成arr的iterator对象
      ​
      // next
      it.next(); // {value: "foo1", done: false}
      it.next(); // {value: "foo2", done: false}
      it.next(); // {value: "foo3", done: false}
      it.next(); // {value: undefined, done: true}, done=true表示获取完成
      ​
      // for...of,这种用法不会遍历到return的数据
      for (let i of it) {
          console.log(i); // 'foo1' 'foo2' 'foo3'
      }

      而Generator,就是天生的Iterator对象,所以才有next(),也可以用for...of遍历,针对一开始的例子,现详细解释一下:

      function* gen() {
          yield 'hello';
          yield 'world';
          return 'ok';
      }
      const g = gen();
      g.next(); // {value: "hello", done: false}
      g.next(); // {value: "world", done: false}
      g.next(); // {value: "ok", done: true}
      g.next(); // {value: undefined, done: true} 
      • 首先定义Generator gen,注意声明用function*

      • const g = gen()这步生成Generator对象,但是并没有立即执行代码,处于暂停状态

      • 第一个g.next()会激活状态,开始执行代码,直到遇到第一个yield,此时返回yield之后的数据,再次进入暂停状态

      • 第二个g.next()与之前的类似,最后返回结果,进入暂停

      • 第三个g.next()也是先激活,但是遇到了return,所以就结束了,返回return的数据,已经结束了,此时done=true

      • 第四个g.next(),此时因为已经结束,所以只能返回value=undefined, done=true

      注意,每次next返回的数据,都是{value:xxx, done:xxx}格式。

    4. yield、next

      上面其实已经用到yield、next了,这儿再详细说下。

      • yield* : yield 可以返回一个值或一个表达式,但还可以 yield* 这么用,在Generator里面再套一个Generator:

        function* gen() {
            yield 'a';
            yield 'b';
        }
        function* gen2() {
            yield 'c';
            yield* gen();
            yield 'd';
        }
        const g = gen2();
        g.next(); // {value: "c", done: false}
        g.next(); // {value: "a", done: false}
        g.next(); // {value: "b", done: false}
      • next: next也可以向yield传递参数:

        function* gen() {
            const a = yield 'a';
            console.log(a); // 100
            const b = yield 'b';
            console.log(b); // 200
            yield 'c';
        }
        const g = gen();
        g.next(); // {value: "a", done: false}
        g.next(100); // {value: "b", done: false}
        g.next(200); // {value: "c", done: false}

        g.next(100)是将100传递给上一个已经执行完了的yield的变量,请各位自己先看下是否能准确判断a,b的值以及每个next的返回值。

        • 第一个next返回当然是value='a'

        • 第二个next,传递100给a变量,所以console.log(a)打印100,然后next返回的是'b'

        • 第三个next同上,200传递给b变量,打印200,然后next返回'c'

      讲了这么多,还没到Generator怎么跟异步联系,马上了,下面先说下Thunk函数,已经怎么把Thunk与异步联系起来。

    5. Thunk

      其实Thunk函数并不是Generator的一部分,这节重在介绍Generator,所以放在此处。

      • Thunk函数:将多参数函数替换成单参数函数,只接受一个参数,并且参数是回调函数。任何函数,只要含有回调函数,都可以写成Thunk函数形式。

      看一个例子,以fs.readFile为例:

      // 1、多参数函数
      fs.readFile(fileName, calback);
      ​
      // 2、定义一个fs的thunk转换器
      const thunk = (fileName) => {
          return (callback) => {
              return fs.readFile(fileName, callback);
          }
      }
      // readFileThunk为Thunk函数,用的时候,只传入callback就行
      const readFileThunk = thunk(fileName);
      readFileThunk(callback);

      看着是不是又复杂了...是的,不过现在的复杂是为以后的简单准备的,具体后续会讲。手动写Thunk方法比较麻烦,所以出现了第三方库:thunkify。

      • thunkify: 封装了thunk转换器,可以简化写法:

        const fs = require('fs');
        const thunkify = require('thunkify');
        ​
        const readFile = thunkify(fs.readFile);
        const readFileThunk = readFile(fileName);
        readFileThunk(callback);
      • Thunk配合Generator

        先看个例子:

        const fs = require('fs');
        const thunkify = require('thunkify');
        ​
        const readFile = thunkify(fs.readFile);
        const gen = function* () {
            const data1 = yield readFile('./test1.txt');
            console.log(data1.toString()); // test1.txt的内容
            const data2 = yield readFile('./test2.txt');
            console.log(data2.toString()); // test2.txt的内容
        }

        看上面的代码,获取data1, data2跟同步写法是否基本一样?想读取那个文件,直接按顺序写就行,不用callback里获得结果,或者then中获得结果,只是前面多了一个yield关键字,是不是很爽?
        上面说过,yield会把程序的执行权移出gen函数,但是怎么交换回来呢?这就是Thunk函数的妙用了,它可以用于Generator函数的自动流程管理。下面是一个基于Thunk函数的Generator执行器:

        // 执行器
        function run(generator) {
            const gen = generator();
            // 这个next其实就是Thunk函数的回调函数
            function next(err, data) {
                const res = gen.next(data); // 类似{value: Thunk函数, done: false}
                if (res.done) {
                    return;
                }
                res.value(next); // res.value是一个Thunk函数,而参数next就是一个callback
            }
            next();
        }
        ​
        const gen = function* () {
            const data1 = yield readFile('./test1.txt');
            const data2 = yield readFile('./test2.txt');
        }
        ​
        // run执行Generator函数
        run(gen);

        其实,要用Generator解决回调地狱问题,需要首先处理一下调用的函数,使函数正确执行后能够自动执行next方法,并且传递执行完方法后的结果。

      • Promise配合Generator

        yield 后能跟Thunk函数,也可以跟Promise对象,所以Promise也可以配合Generator解决回调地狱问题。下面是一个基于Promise的Generator执行器:

        // 执行器
        function run(generator) {
            const gen = generator();
            function go(res) {
                // res类似{value: Promise对象, done: false}
                if (res.done) {
                    return res.value;
                }
                // Promise对象有then方法,两个参数为doneCallback,failCallback
                return res.value.then((data) => {
                    return go(gen.next(data));
                }, (err) => {
                    return go(err);
                })
            }
            go(gen.next());
        }
        ​
        const gen = function* () {
            const data1 = yield readPromise('./test1.txt');
            const data2 = yield readPromise('./test2.txt');
        }
        ​
        // run执行Generator函数
        run(gen);

        每次写生成器函数很麻烦,所以TJ大神写了一个co库,下面介绍。

    6. co

      co库可以自动执行Generator函数,其实就是类似上面的run方法,Generator是一个异步操作容器,自动执行需要交换执行权给Generator,有两种方法可以做到:

      • 回调函数,将异步操作包装成Thunk函数,在回调函数里执行交换执行权

      • Promise对象,将异步操作包装成Promise对象,用then里执行交换执行权

      co库就是将两种执行器包装成一个库,所以使用co的时候,yield后面只能是Thunk函数或Promise对象。co现在返回的是一个Promise对象(之前版本返回的是Thunk函数),co非常好用,把刚才的代码重新,将会非常简单:

      const co = require('co');
      ​
      const gen = function* () {
          const data1 = yield readPromise('./test1.txt');
          const data2 = yield readPromise('./test2.txt');
      }
      ​
      // co执行Generator函数
      co(gen);
    7. 结束语

      Generator终于讲完了,配合Thunk函数或Promise对象,确实比之前的callback或then链式调用“顺畅”了很多,很像同步的写法,已经很符合人的顺序执行的思维了。其实Generator的本质是“暂停”,有了这个,才能让程序到一个地方先暂停,执行异步,然后执行完了再继续执行程序,这样就可以把操作连起来了。
      可以看到Generator的异步处理,学习成本比较高,Generator、Thunk、Promise...等,都需要时间去学习,这显然还不够友好,所以在ES7中基于Promise实现了一套异步处理方案:async/await,这个才是最终的方案。

  6. async/await

    async/await是在ES7中实现的,目前好多浏览器不支持,node从7.0.0开始就支持使用--harmony-async-await来支持此功能,另外babel也已经支持async的transform了,使用的时候引入babel就行。

    1. 基本用法

      先介绍下async/await:

      • 基于Promise实现的,不适用于普通的回调函数

      • 非阻塞的

      • 函数声明用async function, 遇到异步用await,且await只能放在async函数中

      先看一段使用async/await的代码:

      // 定义async函数,注意async关键字
      const readAsync = async function() {
          const data1 = await readPromise('./test1.txt'); // 注意await关键字
          const data2 = await readPromise('./test2.txt');
          return 'ok'; // 返回值可以在调用处通过then拿到
      }
      ​
      // 执行
      readAsync();
      // 或者
      readAsync().then((data) => {
          console.log(data); // 'ok'
      });

      是不是非常简单,无需用co,直接执行就行。需要注意一点,await后只能跟Promise对象、字符串,数值等,不能跟Thunk函数,也暗示Promise可能是解决异步的最终方案。同时,async函数也默认返回的是一个Promise对象,函数最后可以return一个值,最后调用时在then里获取。

    2. 与Generator的对比

      上面也看到,async/await与Generator的解决方案很像,区别如下:

      • 声明,async function 代替 function*

      • await 代替 yield

      • 内置执行器,可以直接运行,不需要co这种第三方库

    3. 结束语

      其实async与Generator很像,是因为async相当于把Generator跟执行器进行了包装,是Generator的语法糖,但是也方便了很多,目前也有很多人认为async就是异步的最终方案。

  7. 小结

    讲完了,里面可能有些例子不恰当,也参考了官网或别人的一些例子,总之尽量用简单的例子去铺开。
    由于浏览器的特殊性,JS只能采用异步解决请求,从而性能也比较好,但是也带了了各种麻烦,所以人们从一开始就寻找它的同步写法,试图摆脱恶心的callback-hell,从一开始callback,到Promise对象,到Generator,再到async,异步方案是越来越好,也越来越优雅,随着ES7的普及,其实直接用async就好,不过技术发展总有一些过程,了解这些过程对我们的眼界扩展以及对这门语音会有更好的认识。
    希望讲了这么多能帮助一些同学理解异步,有错误也轻及时指正,最后再总览下法中中的几种写法,体验异步发展过程:

    callback方式:

    fs.readFile('./test1.txt', (err1, data1) => {
        fs.readFile('./test2.txt', (err2, data2) => {
            fs.readFile('./test2.txt', (err2, data2) => {
            });
        });
    });

    Promise方式:

    readPromise('./test1.txt', (data1) => {
        return readPromise('./test2.txt');
    })
    .then((data2) => {
        return readPromise('./test3.txt');
    })
    .then((data3) => {
    });

    Generator方式:

    const gen = function* () {
        const data1 = yield readPromise('./test1.txt');
        const data2 = yield readPromise('./test2.txt');
        const data3 = yield readPromise('./test3.txt');
    }
    co(gen);

    async/await方式:

    const readAsync = async function() {
        const data1 = await readPromise('./test1.txt');
        const data2 = await readPromise('./test2.txt');
        const data3 = await readPromise('./test3.txt');
    }
    readAsync();
    

      

posted @ 2019-06-28 15:56  求索77  阅读(589)  评论(0编辑  收藏  举报