JS异步机制

刚开始使用JS异步的时候,有这样的疑问:JS不是单线程的吗?为什么会有异步机制?但是如果没有异步机制,定时器又是怎样工作的?HTTP请求又是怎样进行的?

要理解JS的异步机制,就要先理解浏览器的事件处理方式,因此我首先去了解了一些浏览器的相关实现,之后整理了异步的几种处理方式。

本文结构:

  • JS的异步机制
    1. 什么是同步,什么是异步
    2. JS的单线程和浏览器的多线程
    3. 事件循环(event loop)
    4. 任务队列(task)
    5. micro task
    6. event loop的处理过程
  • 异步的几种处理方式
    1. 函数嵌套
    2. 回调函数
    3. Promise

一、JS的异步机制

1、什么是同步,什么是异步

一般而言,操作分为发出调用和得到结果两步。发出调用后一直等待,直到拿到结果(这段时间不能做任何事)为同步;发出调用后不等待,继续执行下一个任务,就是异步任务。

为什么要异步?因为在执行一些耗时任务(如ajax请求、事件监听、定时器等)时,如果仍采用同步,浏览器就会停在那里一直等待,造成浏览器假死的现象。所以,浏览器为异步事件开辟了单独的线程来执行。

2、JS的单线程和浏览器的多线程

首先明确两个概念:

  1. JS的确是单线程的。
  2. 浏览器是允许多个线程异步执行的,除了JS引擎线程外还有GUI渲染线程、事件触发线程、HTTP请求线程、定时触发线程、下载线程等;其中,JS引擎线程、事件触发线程、GUI渲染线程属于常驻线程。
浏览器的多线程:
  1. JS引擎是基于事件驱动单线程执行的,JS引擎一直等待着任务队列中任务的到来,然后加以处理(只有当前函数执行栈执行完毕,才会去任务队列中取任务执行)。因此浏览器无论什么时候,始终只有一个JS线程在运行JS程序
  2. 事件触发线程,当一个事件被触发时该线程会把事件添加到待处理队列的队尾,等待JS引擎的处理。这些事件可能来自JS引擎当前执行的代码块如setTimeout,也可来自浏览器内核的其他线程如鼠标点击、AJAX异步请求等,但由于JS的单线程特性,这些事件都需要排队等待JS引擎处理。
  3. GUI渲染线程负责渲染浏览器界面,当界面需要重排、重绘或由于某种操作引发回流时,该线程就会执行。虽说浏览器支持线程异步执行,但是GUI渲染线程和JS引擎是互斥的,当JS引擎执行时GUI线程会被挂起,GUI更新会保存在一个队列中等到JS引擎空闲时立即为执行。这就是JS阻塞页面加载。我们举个例子来看效果:
document.getElementsByTagName('body')[0].style.background='pink';
for(var i=0;i<100000;i++){
  console.log(i)
}

按照代码来看,页面应先变成粉色,再打印出所有i。
现在来进行验证。我们打开任意页面的控制台,输入上述代码。下图为未执行代码时的情况:

执行上述代码中:


我们看到,i已经在打印了,但界面的背景颜色并没有改变。

代码执行完成后,背景颜色变成了粉色,效果如下图:

了解浏览器的线程至关重要。JS之所以有异步机制,正是浏览器的多线程作用的结果。

接下来,我们来看具体是怎样作用的。

3、事件循环(event loop)

事件循环,可以理解为实现异步的一种方式。HTML Standard这样定义——为了协调事件、用户交互、脚本、渲染、网络等,用户代理必须使用本节所述的event loop。

也就是说,其实我们无时无刻不在使用event-loop。触发一个click事件、进行一次ajax请求,背后都有event loop运作。

我们来看MDN的相关文档——并发模型与事件循环

这张图形象地描述了一段代码中栈、堆、队列的存在和调用方式:

  • 栈(Stack)中存储的是同步任务,同步任务是指在主线程上排队执行的任务,如变量和函数的初始化、事件的绑定等可以立即执行、不耗时的任务;
  • 堆(Heap)用来存储对象、函数等;
  • 队列(Queue),即任务队列,用来存储异步任务,在“4、任务队列(task)”中会详细介绍。

4、任务队列(task)

几个基本概念:

  1. 一个event loop有一个或多个task队列;
  2. 当用户代理安排一个任务,必须将该任务增加到相应的event-loop的一个task队列中。
  3. 每一个task都来源于指定的任务源,比如可以为鼠标、键盘事件提供一个task事件,其他事件又是一个单独的队列。

task也被称为macro task(宏任务)(与之相对的是micro task,在“5、micro task”将作介绍),由指定的任务源去提供任务。

task任务源通常分为四种:DOM操作任务源、用户交互任务源、网络任务源、history traversal任务源。task任务源非常宽泛,比如ajax的onload、click事件,基本上我们绑定的各种事件都是task任务源,另外还有setTimeout、setInterval、setImmediate也是task任务源。

总结来说,macro task的任务源有:

  • script(整体代码)
  • setTimeout
  • setInterval
  • setImmediate
  • I/O
  • UI rendering

5、micro task

micro task(微任务)在最新的标准中也被称为“jobs”。每一个event loop都只有一个micro task队列,一个micro task会被push进micro task队列而非task队列。

通常认为micro task任务源有:

  • process.nextTick
  • Promises
  • Object.observe(已废弃)
  • MutationObserver(HTML5新特性)

我们只需知道Promises是属于micro task就可以了。这一点对于Promises的使用至关重要。

6、event loop的处理过程

事件循环的顺序,决定了JS代码的执行顺序。概括起来,event loop的处理过程如下:

  1. 它从script(整体代码)开始第一次循环,之后全局上下文进入函数调用栈,直到调用栈清空(只剩全局)。
  2. 执行所有的micro task。
  3. 执行完micro task队列里的任务,有可能会渲染更新。
  4. 执行一个最老的macro task。
  5. 到第2步,一直这样循环下去。

关于浏览器的相关知识就说到这里,在这里只作简要理解,只要知道它大致的工作流程就可以了。推荐一段视频Loupe(全英文)可以让你更加直观地了解以上内容。

二、异步的几种处理方式

通过以上部分,我们了解了异步任务的执行方式。接下来我们以ajax为例,来看异步的几种处理方式。

1、函数嵌套

我们直接发起一段ajax请求:

 var xhr=new XMLHttpRequest();
xhr.open('GET','xxx');  //网址自己模拟
xhr.send();
xhr.onreadystatechange=function(){
  if(xhr.readyState===4){
    if(xhr.status>=200 && xhr.status<400){
       console.log('成功:');
       console.log(xhr.responseText);
     }else if(xhr.status>=400){
       console.log('失败!');
     }
   }
}

然后我们打印出:

现在想对上面调用ajax得到的结果进行一系列操作:

var xhr=new XMLHttpRequest();
xhr.open('GET','xxx');  //网址自己模拟
xhr.send();
xhr.onreadystatechange=function(){
  if(xhr.readyState===4){
    if(xhr.status>=200 && xhr.status<400){
       console.log('成功:');
       console.log(xhr.responseText);
      //---------------------------------------------
      //第一种方法:在这里写接下来需要用xhr.responseText数据的一些代码
      // ······
      // ······
      //----------------------------------------------
      // 或者:
      //---------------------------------------------
      //第二种方法:把接下来需要进行的操作写在函数里,并把xhr.responseText当作参数传入
      // success.call(null,xhr.responseText);
      //----------------------------------------------
     }else if(xhr.status>=400){
       console.log('失败!');
     }
   }
}

上面的第二种方法就是函数嵌套。虽然它把处理的代码写成了函数,与第一种相比确实好了很多,但是,这段ajax请求实际上是可以封装的,因为除了GET、URL、结果处理代码fn是变化的之外,其他的部分都是可以共用的,所以我们自然而然地用到了回调函数。

2、回调函数

我们把以上ajax请求封装成一个函数:

//options为对象,包含了method、url、success等变量
function ajax(options){
  let {method,url,success}=options;
  var xhr=new XMLHttpRequest();
  xhr.open(method,url);
  xhr.send();
  xhr.onreadystatechange=function(){
    if(xhr.readyState===4){
      if(xhr.status>=200 && xhr.status<400){
        console.log('成功:');
        console.log(xhr.responseText);
        success && success.call(null,xhr.responseText);
      }else if(xhr.status>=400){
        console.log('失败!');
      }
    }
  }
}

调用上面的函数:

ajax({
  method:'GET',
  url:'xxx',
  success:function(data){
    console.log(data);
    //---------------------------------------------
    //在这里写接下来需要用xhr.responseText数据的一些代码
    // ······
    //----------------------------------------------
  }
});

在上面的代码中,success就是回调函数。ajax响应成功后,将调用success函数,并进行一系列的操作,ajax()函数和success函数实际上是分离开的。

那如果在得到第一次ajax的success结果后,我们还要多次进行ajax请求,并继续得到其success结果呢?我们这样做:

ajax({
  method:'GET',
  url:'xxx',
  success:function(data){
    console.log(data);
    //---------------------------------------------
    //在这里写接下来需要用data数据的一些代码
    ajax({
      method:'GET',
      url:'yyy',
      success:function(data){
        console.log(data);
        //---------------------------------------------
        //在这里写接下来需要用data数据的一些代码
        ajax({
          method:'GET',
          url:'zzz',
          success:function(data){
            console.log(data);
            //---------------------------------------------
            //在这里写接下来需要用data数据的一些代码
            // ······
            //----------------------------------------------
          }
        });
        //----------------------------------------------
      }
    });
    //----------------------------------------------
  }
});

这就是多层回调,但是这种方式有一个很明显的缺点,就是会产生“回调地狱”,代码也是相当的不易读,于是,我们开始使用promise。

3、Promise

关于Promise,我在博客Promise整理中整理了关于Promise的状态、Promise.resolve、Promise.reject、Promise.all、Promise.race等相关知识,此处不再赘述。

1)、自己实现一个Promise

只需对上面的ajax函数做简单修改:

//options为对象,包含了method、url、success等变量
function ajax(options){
  return new Promise(function(resolve,reject){
    let {method,url,success}=options;
    var xhr=new XMLHttpRequest();
    xhr.open(method,url);
    xhr.send();
    xhr.onreadystatechange=function(){
      if(xhr.readyState===4){
        if(xhr.status>=200 && xhr.status<400){
          console.log('成功:');
          console.log(xhr.responseText);
          resolve.call(null,xhr.responseText);
        }else if(xhr.status>=400){
          reject.call(bull,xhr);
        }
      }  
   });
}

通过以上代码,我们即可链式调用Promise:

ajax({method:'GET',url:xxx}).then(successFn1,errorFn1).then(successFn2,errorFn2);
2)、Promise的then方法和setTimeout里的方法谁先执行?

举个例子:

Promise.resolve().then(function promise1 () {
       console.log('promise1');
    })
setTimeout(function setTimeout1 (){
    console.log('setTimeout1')
    Promise.resolve().then(function  promise2 () {
       console.log('promise2');
    })
}, 0)

setTimeout(function setTimeout2 (){
   console.log('setTimeout2')
}, 0)

结果:

如果你理解了博客上半部分讲的macro task、micro task和event loop的相关知识,这个答案应该是没有悬念的。

关于JS的异步机制,就先整理到这里。由于个人水平有限,博客错误之处,烦请指正!

参考资料:
1、并发模型与事件循环
2、javascript的单线程事件循环及多线程介绍
3、从event loop规范探究javaScript异步及浏览器更新渲染时机
4、JavaScript单线程和异步机制
5、从Promise来看JavaScript中的Event Loop、Tasks和Microtasks

推荐阅读更多精彩内容