你确定你了解 JavaScript 的事件循环机制吗

SioValenti 4年前
   <p><img src="https://simg.open-open.com/show/958975d2a697984e26a1d97ce3bbf9ec.png"></p>    <p style="text-align:center">Event Loop</p>    <p>JavaScript的学习零散而庞杂,因此很多时候我们学到了一些东西,但是却没办法感受到自己的进步,甚至过了不久,就把学到的东西给忘了。为了解决自己的这个困扰,在学习的过程中,我一直试图在寻找一条核心的线索,只要我根据这条线索,我就能够一点一点的进步。</p>    <p>前端基础进阶正是围绕这条线索慢慢展开,而事件循环机制(Event Loop),则是这条线索的最关键的知识点。所以,我就马不停蹄的去深入的学习了事件循环机制,并总结出了这篇文章跟大家分享。</p>    <p>事件循环机制从整体上的告诉了我们所写的JavaScript代码的执行顺序。但是在我学习的过程中,找到的许多国内博客文章对于它的讲解浅尝辄止,不得其法,很多文章在图中画个圈就表示循环了,看了之后也没感觉明白了多少。但是他又如此重要,以致于当我们想要面试中高级岗位时,事件循环机制总是绕不开的话题。特别是ES6中正式加入了Promise对象之后,对于新标准中事件循环机制的理解就变得更加重要。这就很尴尬了。</p>    <p>最近有两篇比较火的文章也表达了这个问题的重要性。</p>    <p>但是很遗憾的是,大神们告诉了大家这个知识点很重要,却并没有告诉大家为什么会这样。所以当我们在面试时遇到这样的问题时,就算你知道了结果,面试官再进一步问一下,我们依然懵逼。</p>    <p>在学习事件循环机制之前,我默认你已经懂得了如下概念,如果仍然有疑问,可以回过头去看看我以前的文章。</p>    <ul>     <li>执行上下文(Execution context)</li>     <li>函数调用栈(call stack)</li>     <li>队列数据结构(queue)</li>     <li>Promise(我会在下一篇文章专门总结Promise的详细使用与自定义封装)</li>    </ul>    <p>因为chrome浏览器中新标准中的事件循环机制与nodejs几乎一样,因此此处就以整合nodejs一起来理解,其中会介绍到几个nodejs有,但是浏览器中没有的API,大家只需要了解就好,不一定非要知道她是如何使用。比如process.nextTick,setImmediate</p>    <p>OK,那我就先抛出结论,然后以例子与图示详细给大家演示事件循环机制。</p>    <ul>     <li> <p>我们知道JavaScript的一大特点就是单线程,而这个线程中拥有唯一的一个事件循环。</p> <p>当然新标准中的web worker涉及到了多线程,我对它了解也不多,这里就不讨论了。</p> </li>     <li> <p>JavaScript代码的执行过程中,除了依靠函数调用栈来搞定函数的执行顺序外,还依靠任务队列(task queue)来搞定另外一些代码的执行。</p> </li>    </ul>    <p><img src="https://simg.open-open.com/show/cccdecfb63ad485f32923a3528af0c25.png"></p>    <p style="text-align:center">队列数据结构</p>    <ul>     <li> <p>一个线程中,事件循环是唯一的,但是任务队列可以拥有多个。</p> </li>     <li> <p>任务队列又分为macro-task(宏任务)与micro-task(微任务),在最新标准中,它们被分别称为task与jobs。</p> </li>     <li> <p>macro-task大概包括:script(整体代码), setTimeout, setInterval, setImmediate, I/O, UI rendering。</p> </li>     <li> <p>micro-task大概包括: process.nextTick, Promise, Object.observe(已废弃), MutationObserver(html5新特性)</p> </li>     <li> <p>setTimeout/Promise等我们称之为任务源。而进入任务队列的是他们指定的具体执行任务。</p> <pre>  <code class="language-javascript">// setTimeout中的回调函数才是进入任务队列的任务  setTimeout(function() {    console.log('xxxx');  })</code></pre> </li>     <li> <p>来自不同任务源的任务会进入到不同的任务队列。其中setTimeout与setInterval是同源的。</p> </li>     <li> <p>事件循环的顺序,决定了JavaScript代码的执行顺序。它从script(整体代码)开始第一次循环。之后全局上下文进入函数调用栈。直到调用栈清空(只剩全局),然后执行所有的micro-task。当所有可执行的micro-task执行完毕之后。循环再次从macro-task开始,找到其中一个任务队列执行完毕,然后再执行所有的micro-task,这样一直循环下去。</p> </li>     <li> <p>其中每一个任务的执行,无论是macro-task还是micro-task,都是借助函数调用栈来完成。</p> </li>    </ul>    <p>纯文字表述确实有点干涩,因此,这里我们通过2个例子,来逐步理解事件循环的具体顺序。</p>    <pre>  <code class="language-javascript">// demo01  出自于上面我引用文章的一个例子,我们来根据上面的结论,一步一步分析具体的执行过程。  // 为了方便理解,我以打印出来的字符作为当前的任务名称  setTimeout(function() {      console.log('timeout1');  })    new Promise(function(resolve) {      console.log('promise1');      for(var i = 0; i < 1000; i++) {          i == 99 && resolve();      }      console.log('promise2');  }).then(function() {      console.log('then1');  })    console.log('global1');</code></pre>    <p>首先,事件循环从宏任务队列开始,这个时候,宏任务队列中,只有一个script(整体代码)任务。每一个任务的执行顺序,都依靠函数调用栈来搞定,而当遇到任务源时,则会先分发任务到对应的队列中去,所以,上面例子的第一步执行如下图所示。</p>    <p><img src="https://simg.open-open.com/show/812ac20d4cb4fdde83f8b267d2f01375.png"></p>    <p style="text-align:center">首先script任务开始执行,全局上下文入栈</p>    <p>第二步:script任务执行时首先遇到了setTimeout,setTimeout为一个宏任务源,那么他的作用就是将任务分发到它对应的队列中。</p>    <pre>  <code class="language-javascript">setTimeout(function() {      console.log('timeout1');  })</code></pre>    <p><img src="https://simg.open-open.com/show/f53766eed9164799b5e21485f88bef28.png"></p>    <p style="text-align:center">宏任务timeout1进入setTimeout队列</p>    <p>第三步:script执行时遇到Promise实例。Promise构造函数中的第一个参数,是在new的时候执行,因此不会进入任何其他的队列,而是直接在当前任务直接执行了,而后续的.then则会被分发到micro-task的Promise队列中去。</p>    <p>因此,构造函数执行时,里面的参数进入函数调用栈执行。for循环不会进入任何队列,因此代码会依次执行,所以这里的promise1和promise2会依次输出。</p>    <p><img src="https://simg.open-open.com/show/9ed05d6efc0da717dc02ce1406dec453.png"></p>    <p style="text-align:center">promise1入栈执行,这时promise1被最先输出</p>    <p><img src="https://simg.open-open.com/show/57f35d7342cd5f32e2ded7800e5926b5.png"></p>    <p style="text-align:center">resolve在for循环中入栈执行</p>    <p><img src="https://simg.open-open.com/show/5901b775b1eebdf8094ffab2118336a6.png"></p>    <p style="text-align:center">构造函数执行完毕的过程中,resolve执行完毕出栈,promise2输出,promise1页出栈,then执行时,Promise任务then1进入对应队列</p>    <p>script任务继续往下执行,最后只有一句输出了globa1,然后,全局任务就执行完毕了。</p>    <p>第四步:第一个宏任务script执行完毕之后,就开始执行所有的可执行的微任务。这个时候,微任务中,只有Promise队列中的一个任务then1,因此直接执行就行了,执行结果输出then1,当然,他的执行,也是进入函数调用栈中执行的。</p>    <p><img src="https://simg.open-open.com/show/a1d901b9302a0874c42d65a124e257ea.png"></p>    <p style="text-align:center">执行所有的微任务</p>    <p>第五步:当所有的micro-tast执行完毕之后,表示第一轮的循环就结束了。这个时候就得开始第二轮的循环。第二轮循环仍然从宏任务macro-task开始。</p>    <p><img src="https://simg.open-open.com/show/a41fe001cc0be7c377930e934a043130.png"></p>    <p style="text-align:center">微任务被清空</p>    <p>这个时候,我们发现宏任务中,只有在setTimeout队列中还要一个timeout1的任务等待执行。因此就直接执行即可。</p>    <p><img src="https://simg.open-open.com/show/b373dc2fc06380872ba1fb2e224fccec.png"></p>    <p style="text-align:center">timeout1入栈执行</p>    <p>这个时候宏任务队列与微任务队列中都没有任务了,所以代码就不会再输出其他东西了。</p>    <p>那么上面这个例子的输出结果就显而易见。大家可以自行尝试体会。</p>    <p>这个例子比较简答,涉及到的队列任务并不多,因此读懂了它还不能全面的了解到事件循环机制的全貌。所以我下面弄了一个复制一点的例子,再给大家解析一番,相信读懂之后,事件循环这个问题,再面试中再次被问到就难不倒大家了。</p>    <pre>  <code class="language-javascript">// demo02  console.log('golb1');    setTimeout(function() {      console.log('timeout1');      process.nextTick(function() {          console.log('timeout1_nextTick');      })      new Promise(function(resolve) {          console.log('timeout1_promise');          resolve();      }).then(function() {          console.log('timeout1_then')      })  })    setImmediate(function() {      console.log('immediate1');      process.nextTick(function() {          console.log('immediate1_nextTick');      })      new Promise(function(resolve) {          console.log('immediate1_promise');          resolve();      }).then(function() {          console.log('immediate1_then')      })  })    process.nextTick(function() {      console.log('glob1_nextTick');  })  new Promise(function(resolve) {      console.log('glob1_promise');      resolve();  }).then(function() {      console.log('glob1_then')  })    setTimeout(function() {      console.log('timeout2');      process.nextTick(function() {          console.log('timeout2_nextTick');      })      new Promise(function(resolve) {          console.log('timeout2_promise');          resolve();      }).then(function() {          console.log('timeout2_then')      })  })    process.nextTick(function() {      console.log('glob2_nextTick');  })  new Promise(function(resolve) {      console.log('glob2_promise');      resolve();  }).then(function() {      console.log('glob2_then')  })    setImmediate(function() {      console.log('immediate2');      process.nextTick(function() {          console.log('immediate2_nextTick');      })      new Promise(function(resolve) {          console.log('immediate2_promise');          resolve();      }).then(function() {          console.log('immediate2_then')      })  })</code></pre>    <p>这个例子看上去有点复杂,乱七八糟的代码一大堆,不过不用担心,我们一步一步来分析一下。</p>    <p>第一步:宏任务script首先执行。全局入栈。glob1输出。</p>    <p><img src="https://simg.open-open.com/show/4740f51ca049912aa336f20ced045c29.png"></p>    <p style="text-align:center">script首先执行</p>    <p>第二步,执行过程遇到setTimeout。setTimeout作为任务分发器,将任务分发到对应的宏任务队列中。</p>    <pre>  <code class="language-javascript">setTimeout(function() {      console.log('timeout1');      process.nextTick(function() {          console.log('timeout1_nextTick');      })      new Promise(function(resolve) {          console.log('timeout1_promise');          resolve();      }).then(function() {          console.log('timeout1_then')      })  })</code></pre>    <p><img src="https://simg.open-open.com/show/94891bffbfca8ff4fee4bbccf839a4fc.png"></p>    <p style="text-align:center">timeout1进入对应队列</p>    <p>第三步:执行过程遇到setImmediate。setImmediate也是一个宏任务分发器,将任务分发到对应的任务队列中。setImmediate的任务队列会在setTimeout队列的后面执行。</p>    <pre>  <code class="language-javascript">setImmediate(function() {      console.log('immediate1');      process.nextTick(function() {          console.log('immediate1_nextTick');      })      new Promise(function(resolve) {          console.log('immediate1_promise');          resolve();      }).then(function() {          console.log('immediate1_then')      })  })</code></pre>    <p><img src="https://simg.open-open.com/show/ec84ae8bad556d756a850ce7acf71a8f.png"></p>    <p style="text-align:center">进入setImmediate队列</p>    <p>第四步:执行遇到nextTick,process.nextTick是一个微任务分发器,它会将任务分发到对应的微任务队列中去。</p>    <pre>  <code class="language-javascript">process.nextTick(function() {      console.log('glob1_nextTick');  })</code></pre>    <p><img src="https://simg.open-open.com/show/f68f6854c5bd05c6ac9f4fe8bba33371.png"></p>    <p style="text-align:center">nextTick</p>    <p>第五步:执行遇到Promise。Promise的then方法会将任务分发到对应的微任务队列中,但是它构造函数中的方法会直接执行。因此,glob1_promise会第二个输出。</p>    <pre>  <code class="language-javascript">new Promise(function(resolve) {      console.log('glob1_promise');      resolve();  }).then(function() {      console.log('glob1_then')  })</code></pre>    <p><img src="https://simg.open-open.com/show/03e5890b8ddaf992e04ad114d9474c73.png"></p>    <p style="text-align:center">先是函数调用栈的变化</p>    <p><img src="https://simg.open-open.com/show/164783200b59ebd27a1436e7ff3232d9.png"></p>    <p style="text-align:center">然后glob1_then任务进入队列</p>    <p>第六步:执行遇到第二个setTimeout。</p>    <pre>  <code class="language-javascript">setTimeout(function() {      console.log('timeout2');      process.nextTick(function() {          console.log('timeout2_nextTick');      })      new Promise(function(resolve) {          console.log('timeout2_promise');          resolve();      }).then(function() {          console.log('timeout2_then')      })  })</code></pre>    <p><img src="https://simg.open-open.com/show/e1e4269bad1a8eb6eecf886ab1e7913e.png"></p>    <p style="text-align:center">timeout2进入对应队列</p>    <p>第七步:先后遇到nextTick与Promise</p>    <pre>  <code class="language-javascript">process.nextTick(function() {      console.log('glob2_nextTick');  })  new Promise(function(resolve) {      console.log('glob2_promise');      resolve();  }).then(function() {      console.log('glob2_then')  })</code></pre>    <p><img src="https://simg.open-open.com/show/60dfe1f74e715bc46655eb40aaf1c114.png"></p>    <p style="text-align:center">glob2_nextTick与Promise任务分别进入各自的队列</p>    <p>第八步:再次遇到setImmediate。</p>    <pre>  <code class="language-javascript">setImmediate(function() {      console.log('immediate2');      process.nextTick(function() {          console.log('immediate2_nextTick');      })      new Promise(function(resolve) {          console.log('immediate2_promise');          resolve();      }).then(function() {          console.log('immediate2_then')      })  })</code></pre>    <p><img src="https://simg.open-open.com/show/e1c3b7a4767562738cd8dd66db87867c.png"></p>    <p style="text-align:center">nextTick</p>    <p>这个时候,script中的代码就执行完毕了,执行过程中,遇到不同的任务分发器,就将任务分发到各自对应的队列中去。接下来,将会执行所有的微任务队列中的任务。</p>    <p>其中,nextTick队列会比Promie先执行。nextTick中的可执行任务执行完毕之后,才会开始执行Promise队列中的任务。</p>    <p>当所有可执行的微任务执行完毕之后,这一轮循环就表示结束了。下一轮循环继续从宏任务队列开始执行。</p>    <p>这个时候,script已经执行完毕,所以就从setTimeout队列开始执行。</p>    <p><img src="https://simg.open-open.com/show/0fd73b603bf84c95185b38a700caeb74.png"></p>    <p style="text-align:center">第二轮循环初始状态</p>    <p>setTimeout任务的执行,也依然是借助函数调用栈来完成,并且遇到任务分发器的时候也会将任务分发到对应的队列中去。</p>    <p>只有当setTimeout中所有的任务执行完毕之后,才会再次开始执行微任务队列。并且清空所有的可执行微任务。</p>    <p>setTiemout队列产生的微任务执行完毕之后,循环则回过头来开始执行setImmediate队列。仍然是先将setImmediate队列中的任务执行完毕,再执行所产生的微任务。</p>    <p>当setImmediate队列执行产生的微任务全部执行之后,第二轮循环也就结束了。</p>    <p>大家需要注意这里的循环结束的时间节点。</p>    <p>当我们在执行setTimeout任务中遇到setTimeout时,它仍然会将对应的任务分发到setTimeout队列中去,但是该任务就得等到下一轮事件循环执行了。例子中没有涉及到这么复杂的嵌套,大家可以动手添加或者修改他们的位置来感受一下循环的变化。</p>    <p>OK,到这里,事件循环我想我已经表述得很清楚了,能不能理解就看读者老爷们有没有耐心了。我估计很多人会理解不了循环结束的节点。</p>    <p>当然,这些顺序都是v8的一些实现。我们也可以根据上面的规则,来尝试实现一下事件循环的机制。</p>    <pre>  <code class="language-javascript">// 用数组模拟一个队列  var tasks = [];    // 模拟一个事件分发器  var addFn1 = function(task) {      tasks.push(task);  }    // 执行所有的任务  var flush = function() {      tasks.map(function(task) {          task();      })  }    // 最后利用setTimeout/或者其他你认为合适的方式丢入事件循环中  setTimeout(function() {      flush();  })    // 当然,也可以不用丢进事件循环,而是我们自己手动在适当的时机去执行对应的某一个方法    var dispatch = function(name) {      tasks.map(function(item) {          if(item.name == name) {              item.handler();          }      })  }    // 当然,我们把任务丢进去的时候,多保存一个name即可。  // 这时候,task的格式就如下  demoTask =  {      name: 'demo',      handler: function() {}  }    // 于是,一个订阅-通知的设计模式就这样轻松的被实现了</code></pre>    <p>这样,我们就模拟了一个任务队列。我们还可以定义另外一个队列,利用上面的各种方式来规定他们的优先级。</p>    <p>因此,在老的浏览器没有支持Promise的时候,就可以利用setTimeout等方法,来模拟实现Promise,具体如何做到的,下一篇文章我们慢慢分析。</p>    <p> </p>    <p> </p>    <p>来自:http://www.jianshu.com/p/12b9f73c5a4f</p>    <p> </p>