简单理解浏览器的event loop 和 JavaScript的同步异步

引用结构化整理:

为什么JavaScript是单线程的?

JavaScript的主要用途是和用户进行交互以及对DOM的操作,为了避免复杂的同步问题(如果多线程,A线程对某DOM添加内容,B线程对它又进行了删除操作,这往往会产生问题),JavaScript在一诞生之际就是单线程,这已经是这门语言的核心特征,现在和将来都不会改变。

HTML5提出Web Worker标准,允许JavaScript创建多个线程(主线程和子线程),但子线程仍然受到了主线程的控制,而且不允许操作DOM,所以JavaScript仍然是单线程。

如何保证单线程内的任务执行起来更合理?

单线程就意味着,所有任务需要排队,前一个任务结束,才会执行后一个任务。如果前一个任务耗时很长,后一个任务就不得不一直等着。于是就有一个概念,任务队列。

如果排队是因为计算量大,CPU忙不过来,倒也算了,但是很多时候CPU是闲着的,因为IO设备(输入输出设备)很慢(比如Ajax操作从网络读取数据),不得不等着结果出来,再往下执行。

JavaScript语言的设计者意识到,这时主线程完全可以不管IO设备,挂起处于等待中的任务,先运行排在后面的任务。等到IO设备返回了结果,再回过头,把挂起的任务继续执行下去。

同步异步任务出现了!

于是,所有任务可以分成两种,一种是同步任务(synchronous),另一种是异步任务(asynchronous)。同步任务指的是,在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务;异步任务指的是,不进入主线程、而进入"任务队列"(task queue)的任务,只有"任务队列"通知主线程,某个异步任务可以执行了,该任务才会进入主线程执行。

具体来说,异步执行的运行机制如下。(同步执行也是如此,因为它可以被视为没有异步任务的异步执行。)

(1)所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。

(2)主线程之外,还存在一个"任务队列"(task queue)。只要异步任务有了运行结果,就在"任务队列"之中放置一个事件。

(3)一旦"执行栈"中的所有同步任务执行完毕,系统就会读取"任务队列",看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。

(4)主线程不断重复上面的第三步。

只要主线程空了,就会去读取"任务队列",这就是JavaScript的运行机制。这个过程会不断重复。  

"任务队列"是一个事件的队列(也可以理解成消息的队列),IO设备完成一项任务,就在"任务队列"中添加一个事件,表示相关的异步任务可以进入"执行栈"了。主线程读取"任务队列",就是读取里面有哪些事件。

"任务队列"中的事件,除了IO设备的事件以外,还包括一些用户产生的事件(比如鼠标点击、页面滚动等等)。只要指定过回调函数,这些事件发生时就会进入"任务队列",等待主线程读取。

所谓"回调函数"(callback),就是那些会被主线程挂起来的代码。异步任务必须指定回调函数,当主线程开始执行异步任务,就是执行对应的回调函数。

"任务队列"是一个先进先出的数据结构,排在前面的事件,优先被主线程读取。主线程的读取过程基本上是自动的,只要执行栈一清空,"任务队列"上第一位的事件就自动进入主线程。但是,由于存在后文提到的"定时器"功能,主线程首先要检查一下执行时间,某些事件只有到了规定的时间,才能进入主线程。

Event Loop

理解到这里就可以引入Event Loop(事件循环)的概念了,正是因为主线程不断的去任务队列(task queue)中读取事件,所以才有了事件的不断循环,也就是说当前主线程中的同步事件执行完毕后,主线程才会去任务队列中读取异步事件,而且这个过程会一直重复下去,这就是事件循环。
eventloop-2_20181012095842572868.jpg
eventloop-20181012095706242392.jpg

图片来源:知乎@小蘑菇小哥

同步和异步

当主线程在运行的时候,会产生堆(heap)和栈(stack),栈中的是同步任务,堆中的则是异步任务,只有栈中的同步任务执行完毕之后,主线程才会去堆中执行异步事件,执行顺序是按照"先入先出"的原则。

栗子:

<script>
    console.log("I am No.1");
      setTimeout(function(){
      console.log("I am NO.2");
      },0)
    setTimeout(function(){
      console.log("I am NO.3");
      },0)
      console.log("I am No.4");
</script>

输出结果是:

I am No.1;
I am No.4;
I am NO.2;
I am NO.3;

执行过程图解:
eventloop-20181012095706242392.jpg

理解了这些,相信你应该对Event Loop可以有一个初步的了解。

注意

同步代码的合并

使用一段同步代码去修改同一个元素的属性,浏览器会直接优化执行最后一个修改。

<script>
    var box = document.querySelector("#box");
      box.style.backgroundColor = "red";
      box.style.backgroundColor = "green";
      box.style.backgroundColor = "blue";
</script>

浏览器会直接给这个div设置背景色为蓝色,相当于只运行了最后一句,这其实是浏览器的一种优化策略。

但是这种优化可能会给我们在设置某些过渡特效时带来困扰,例如:

<script>
    box.onclick = function () {
    box.style.transform = "translateX(1000px)";
    box.style.transition = "transform 2s";
    box.style.transform = "translateX(400px)";
    }
</script>

我们本意是在点击盒子之后让盒子先沿水平轴一下子移动1000像素,然后动画移动到400像素的位置,但是实际代码在执行的过程中是从0位置动画移动到500像素,完全忽视了要让盒子先"闪现"到1000像素的事情。

解决方案:

<script>
    box.onclick = function () {
    box.style.transform = "translateX(1000px)";
    box.offsetWidth;    //伪代码,只是获取一下即可,用于阻断代码的合并,简单有效:推荐
    box.style.transition = "transform 2s";
    box.style.transform = "translateX(400px)";
    }
</script>
常见的异步任务
  • 定时器:setTimeout和setInterval

定时器是js中最为基础和常见的异步任务了,setTimeout和setInterval二者的区别是前者在n毫秒调用延迟后执行回调函数,后者是每隔n毫秒后都会执行回调函数,除非被手动清除。

即使定时器的延迟为0毫秒,也是一个异步任务,因为即使是0ms它们也会改变队列任务的执行顺序

火狐的api文档 有这样一句话  api址 https://developer.mozilla.org/enUS/docs/Web/API/WindowTimers/setTimeout

Because even though setTimeout was called with a delay of zero, it's placed on a queue and scheduled to run at the next opportunity, not immediately. Currently executing code must complete before functions on the queue are executed, the resulting execution order may not be as expected.

  • Ajax

找出之前简单封装的Ajax函数来说明

  <script>
function ajax(option) {
          var method = option.method || 'GET';
          method = method.toUpperCase();
          var url = option.url;
          var datas = option.datas;
          var success = option.success;
          var error = option.error;
          var xhr = window.XMLHttpRequest?new XMLHttpRequest():new ActiveXObject('Microsoft.XMLHTTP');
          if (datas) {
              var tmpDatas =[];
              for(var k in datas){
                  tmpDatas.push(k + '=' + datas[k]);
              }
              datas = tmpDatas.join('&');
          }else{
              datas = '';
          }
          if (method === 'GET') {
              xhr.open(method,url+ '?' +datas);
              xhr.send(null);
          }else{
              xhr.open(method,url);
              xhr.setRequestHeader('Content-Type','application/x-www-form-urlencoded');
              xhr.send(datas);
          }
          xhr.addEventListener('readystatechange', function () {
              if (xhr.readyState === 4) {
                  if (xhr.status === 200) {
                      if (xhr.getResponseHeader('Content-Type').indexOf('json') !== -1) {
                          var backDates = JSON.parse(xhr.responseText);
                          success(backDates);
                      }else{
                          backDates = xhr.responseText;
                          success(backDates);
                      }
                  }else{
                      error();
                  }
              }
          })
      }
</script>

Ajax被称为异步任务比较好理解,因为只有当服务器响应了当次请求,并发送回响应体xhr.responseText之后,回调函数success(backDatas) 才可以执行,如果AJAX是同步的,且响应体体积十分庞大或者网络请求速度十分缓慢,那么之后的主线程所有任务都要等待它完成后才可以继续执行,这肯定是会让用户和开发者crash&crazy的。

总结

在本文中,我们只是很简单的了解了Event Loop机制,如果想要深入了解的话可以查看尾部列出的参考文章进行拓展,另外在ES6中也对异步事件做了很多更新,我会在理清思路之后继续和大家分享在JavaScript这个有趣世界中的各种见识,另外也是在学习的过程中感受到"当你会的更多,不会的也会更多",所以让我们Keep Hacking吧!

参考:

知乎@小蘑菇小哥:https://zhuanlan.zhihu.com/p/45111890

cnblog@c3gen_小胖:https://www.cnblogs.com/c3gen/p/6170504.html

阮一峰:http://www.ruanyifeng.com/blog/2014/10/event-loop.html


1 + 2 =

求知若飢,虛心若愚。