JavaScript异步流程控制全攻略

字数 2682阅读 352

一.js异步流程的由来

      众所周知,Javascript语言的执行环境是单线程(single thread),作为浏览器脚本语言,JavaScript的主要用途是与用户互动,以及操作DOM。若以多线程的方式操作这些DOM,则可能出现操作的冲突。假设有两个线程同时操作一个DOM元素,线程1要求浏览器删除DOM,而线程2却要求修改DOM样式,这时浏览器就无法决定采用哪个线程的操作。当然,我们可以为浏览器引入“锁”的机制来解决这些冲突,但这会大大提高复杂性,所以JavaScript从诞生开始就选择了单线程执行。
      而单线程就是指一次只能完成一件任务。如果有多个任务,就必须排队,前面一个任务完成,再执行后面一个任务。因为javascript 设计之初是为浏览器设计的GUI编程语言,GUI编程的特性之一是保证UI线程一定不能阻塞,否则性能不好,可能会界面卡死,因为JavaScript是单线程的,有一个致命问题是在某一时刻内只能执行特定的一个任务,并且会阻塞其它任务执行,为了解决这个问题,Javascript语言将任务的执行模式分成同步(Synchronous)和异步(Asynchronous),在遇到类似I/O等耗时的任务时js会采用异步操作,而此时异步操作不进入主线程、而进入"任务队列",只有"任务队列"通知主线程,某个异步任务可以执行了,该任务才会进入主线程执行,这时就不会阻塞其它任务执,而这种模式称为js的事件循环机制(Event Loop)。

  • 同步:调用者发出调用后,在没有得到结果之前,该调用就不返回。后一个任务等待前一个任务结束,然后再执行,程序的执行顺序与任务的排列顺序是一致的、同步的,具有同步关系的一组任务相互发送的信息称为消息或事件。
  • 异步:调用者发出调用后不会立刻得到结果,该调用就返回了。每一个任务有一个或多个回调函数(callback),前一个任务结束后,不是执行后一个任务,而是执行回调函数,后一个任务则是不等前一个任务结束就执行,所以程序的执行顺序与任务的排列顺序是不一致的、异步的,线程就是实现异步的一个方式,异步是让调用方法的主线程不需要同步等待另一线程的完成,从而可以让主线程干其它的事情。
  • 阻塞:指调用结果返回之前,调用者会进入阻塞状态等待。只有在得到结果之后才会返回。
  • 非阻塞:指在不能立刻得到结果之前,该函数不会阻塞当前线程,而会立刻返回。
  • 事件循环机制:
    (1)所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。
    (2)主线程之外,还存在一个"任务队列"(task queue)。只要异步任务有了运行结果,就在"任务队列"之中放置一个事件。
    (3)一旦"执行栈"中的所有同步任务执行完毕,系统就会读取"任务队列",看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。
    (4)主线程不断重复上面的第三步,形成一个事件的循环。
    事件循环机制示意图
  • 阻塞非阻塞和同步异步的主要区别在于前者是相对于调用者来说,后者是相对于被调用者来说。举个栗子,把js比作一个老公的话,有一天上班的时候老公在微信约她老婆今天晚上去吃饭,如果老婆看到消息后马上同意或者拒绝,对老婆来说这就是同步(老公的消息被老婆返回了,同时也得到了结果),如果老婆看到消息后回复说我晚上可能会加班还不确定,过段时间确定了我再来发条消息通知你结果(可以理解为回调函数),对老婆来说这就是异步(老公的消息被老婆返回了,但是还没得到结果,需要等待)。而在老婆还没有给出最终通知结果时(不管是同步回复还是异步回复),如果此时老公打开另一个微信窗口约小三明天晚上去吃饭,此时对老公来说就是非阻塞的,而如果老公在老婆没有最终通知结果之前一直在那等着而没干其他事情,对老公来说这就是阻塞的。显而易见,在这里老公是调用者,老婆是被调用者。
  • 还是上面那个栗子,如果老婆说要过段时间才能通知老公最后结果(也就是异步的时候),此时老公也不能在老婆通知前什么都不干就待在那里,老公没有分身,也就是说老公不是多线程的,他会把这个异步事件先搁置(也就是放到任务队列里) ,作为单线程的他只能亲自去处理其他事情(主线程中处理执行栈),等老婆通知后再来处理这件事情(把这个异步事件从任务队列中取回来在主线程中执行)。所以当js采用异步模式的时候js就是非阻塞了,这也就是为什么说node.js是非阻塞异步I/O了,因为异步和事件循环机制的特性使它是非阻塞的。

二.js为什么要演进异步流程控制

      "异步模式"非常重要。在浏览器端,耗时很长的操作都应该异步执行,避免浏览器失去响应,最好的例子就是Ajax操作。在服务器端,"异步模式"甚至是唯一的模式,因为执行环境是单线程的,如果允许同步执行所有http请求,服务器性能会急剧下降,很快就会失去响应。最早异步模式采用的是回调函数的方法,但是这种方法不利于代码的阅读和维护,各个部分之间高度耦合,流程会很混乱,而且每个任务只能指定一个回调函数,这样就很容易陷入回调地狱,所以异步流程控制模式慢慢衍生出许多方式,下面主要来介绍这些方式有哪些。

三.js异步流程控制的几种主要方式

1.回调函数

有两个任务函数taskFun1和taskFun2,如果按同步方式写

taskFun1();
taskFun2();

taskFun1()如果是一个很耗时的任务,会严重阻塞taskFun2()的执行,用回调函数可以这样写:

 function taskFun1(callbackFun){
    setTimeout(function () {
      // do something
      callbackFun();
    }, 3000);
}
taskFun1(taskFun2);
  • 优点:简单、容易理解和部署,
  • 缺点:不利于代码的阅读和维护,各个部分之间高度耦合,流程会很混乱,而且每个任务只能指定一个回调函数。

2.事件监听

另一种思路是采用事件驱动模式。任务的执行不取决于代码的顺序,而取决于某个事件是否发生。

taskFun1.on("event", taskFun2);

 function taskFun1(){
    setTimeout(function () {
      // taskFun1的任务代码
      taskFun1.trigger('event');
    }, 2000);
  }
/* taskFun1.trigger('event')表示执行完成后,立即触发事件,从而开始执行taskFun2。*/
  • 优点:比较容易理解,可以绑定多个事件,每个事件可以指定多个回调函数,耦合度很低,有利于实现模块化
  • 缺点:整个程序都要变成事件驱动型,事件不能得到流程控制,运行流程会变得很不清晰。

3.发布/订阅

上一节的"事件",完全可以理解成"信号"。假定,存在一个"信号中心",某个任务执行完成,就向信号中心"发布"(publish)一个信号,其他任务可以向信号中心"订阅"(subscribe)这个信号,从而知道什么时候自己可以开始执行。这就叫做"发布/订阅模式",又称"观察者模式"。

element.subscribe("event", taskFun2);
function taskFun1(){
    setTimeout(function () {
      // taskFun1的任务代码
      element.publish("event");
    }, 2000);
}
  • 优点:可以完全掌握事件被订阅的次数,以及订阅者的信息,管理起来特别方便。

4.Promise对象

关于Promises的具体介绍和实现,可以参考用ES6实现一个简单易懂的Promise

比如平时我们常用的axios插件就是采用了promise模式:

axios.get('./demo.txt')
  .then(function(response){
    console.log(response);
  })
  .catch(function(err){
    console.log(err);
  });

而实现的机制就是promise把成功和失败分别代理到resolved 和 rejected .

var promise = new Promise(function(resolve, reject) {
  // 异步操作的代码
  if (/* 异步操作成功 */){
    resolve(value);
  } else {
    reject(error);
  }
});
  • 优点:回调函数变成了链式写法,程序的流程可以看得很清楚,可以实现许多强大的功能,同时还可以捕获到catch异常。
  • 缺点:写法和理解起来都相对费劲

5.Generator与co相结合

与promise不同的是,Generator设计的初衷并不是为了来控制异步流程的,这种写法是express和koa框架的作者拿Generator与co相结合的一种写法,由于generator是一个状态机,所以需要手动调用next 才能执行,node框架的作者开发了co模块,可以自动执行generator,可以理解为一种geek写法。

function readFile(filename) {
  return new Promise(function (resolve, reject) {
    fs.readFile(filename, 'utf8', function (err, data) {
      err ? reject(err) : resolve(data);
    });
  })
}
function *read() {
  console.log('开始');
  let a = yield readFile('1.txt');
  console.log(a);
  let b = yield readFile('2.txt');
  console.log(b);
  let c = yield readFile('3.txt');
  console.log(c);
  return c;
}
co(read).then(function (data) {
  console.log(data);
});
  • 优点:可以用同步的方式编写异步代码
  • 缺点:不够直观,没有语义化

6.await,async

await,async是ES7 引入了的关键字,async函数完全可以看作多个异步操作,包装成的一个Promise对象,实质上是generator+promise的语法糖


*async function read(){
 //await后面必须跟一个promise,
 let a = await readFile('./1.txt');
 console.log(a);
 let b = await readFile('./2.txt');
 console.log(b);
 let c = await readFile('./3.txt');
 console.log(c);
 return 'ok';
 }*/
  • 优点:相比于之前的方式有很好的语义,实现也比较简单,被认为是目前最优的异步流程控制模式。

推荐阅读更多精彩内容