JavaScript的學習零散而龐雜,因此很多時候我們學到了一些東西,但是卻沒辦法感受到自己的進步,甚至過了不久,就把學到的東西給忘了。為了解決自己的這個困擾,在學習的過程中,我一直試圖在尋找一條核心的線索,只要我根據這條線索,我就能夠一點一點的進步。
前端基礎進階正是圍繞這條線索慢慢展開,而事件迴圈機制(Event Loop),則是這條線索的最關鍵的知識點。所以,我就馬不停蹄的去深入的學習了事件迴圈機制,並總結出了這篇文章跟大家分享。
事件迴圈機制從整體上的告訴了我們所寫的JavaScript程式碼的執行順序。但是在我學習的過程中,找到的許多國內部落格文章對於它的講解淺嘗輒止,不得其法,很多文章在圖中畫個圈就表示迴圈了,看了之後也沒感覺明白了多少。但是他又如此重要,以致於當我們想要面試中高階崗位時,事件迴圈機制總是繞不開的話題。特別是ES6中正式加入了Promise物件之後,對於新標準中事件迴圈機制的理解就變得更加重要。這就很尷尬了。
最近有兩篇比較火的文章也表達了這個問題的重要性。
但是很遺憾的是,大神們告訴了大家這個知識點很重要,卻並沒有告訴大家為什麼會這樣。所以當我們在面試時遇到這樣的問題時,就算你知道了結果,面試官再進一步問一下,我們依然懵逼。
在學習事件迴圈機制之前,我預設你已經懂得了如下概念,如果仍然有疑問,可以回過頭去看看我以前的文章。
- 執行上下文(Execution context)
- 函式呼叫棧(call stack)
- 佇列資料結構(queue)
- Promise(我會在下一篇文章專門總結Promise的詳細使用與自定義封裝)
因為chrome瀏覽器中新標準中的事件迴圈機制與nodejs幾乎一樣,因此此處就以整合nodejs一起來理解,其中會介紹到幾個nodejs有,但是瀏覽器中沒有的API,大家只需要瞭解就好,不一定非要知道她是如何使用。比如process.nextTick,setImmediate
OK,那我就先丟擲結論,然後以例子與圖示詳細給大家演示事件迴圈機制。
- 我們知道JavaScript的一大特點就是單執行緒,而這個執行緒中擁有唯一的一個事件迴圈。
當然新標準中的web worker涉及到了多執行緒,我對它瞭解也不多,這裡就不討論了。
- JavaScript程式碼的執行過程中,除了依靠函式呼叫棧來搞定函式的執行順序外,還依靠任務佇列(task queue)來搞定另外一些程式碼的執行。
- 一個執行緒中,事件迴圈是唯一的,但是任務佇列可以擁有多個。
- 任務佇列又分為macro-task(巨集任務)與micro-task(微任務),在最新標準中,它們被分別稱為task與jobs。
- macro-task大概包括:script(整體程式碼), setTimeout, setInterval, setImmediate, I/O, UI rendering。
- micro-task大概包括: process.nextTick, Promise, Object.observe(已廢棄), MutationObserver(html5新特性)
- setTimeout/Promise等我們稱之為任務源。而進入任務佇列的是他們指定的具體執行任務。
1234// setTimeout中的回撥函式才是進入任務佇列的任務setTimeout(function() {console.log('xxxx');}) - 來自不同任務源的任務會進入到不同的任務佇列。其中setTimeout與setInterval是同源的。
- 事件迴圈的順序,決定了JavaScript程式碼的執行順序。它從script(整體程式碼)開始第一次迴圈。之後全域性上下文進入函式呼叫棧。直到呼叫棧清空(只剩全域性),然後執行所有的micro-task。當所有可執行的micro-task執行完畢之後。迴圈再次從macro-task開始,找到其中一個任務佇列執行完畢,然後再執行所有的micro-task,這樣一直迴圈下去。
- 其中每一個任務的執行,無論是macro-task還是micro-task,都是藉助函式呼叫棧來完成。
純文字表述確實有點乾澀,因此,這裡我們通過2個例子,來逐步理解事件迴圈的具體順序。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
// 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'); |
首先,事件迴圈從巨集任務佇列開始,這個時候,巨集任務佇列中,只有一個script(整體程式碼)任務。每一個任務的執行順序,都依靠函式呼叫棧來搞定,而當遇到任務源時,則會先分發任務到對應的佇列中去,所以,上面例子的第一步執行如下圖所示。
第二步:script任務執行時首先遇到了setTimeout,setTimeout為一個巨集任務源,那麼他的作用就是將任務分發到它對應的佇列中。
1 2 3 |
setTimeout(function() { console.log('timeout1'); }) |
第三步:script執行時遇到Promise例項。Promise建構函式中的第一個引數,是在new的時候執行,因此不會進入任何其他的佇列,而是直接在當前任務直接執行了,而後續的.then則會被分發到micro-task的Promise佇列中去。
因此,建構函式執行時,裡面的引數進入函式呼叫棧執行。for迴圈不會進入任何佇列,因此程式碼會依次執行,所以這裡的promise1和promise2會依次輸出。
script任務繼續往下執行,最後只有一句輸出了globa1,然後,全域性任務就執行完畢了。
第四步:第一個巨集任務script執行完畢之後,就開始執行所有的可執行的微任務。這個時候,微任務中,只有Promise佇列中的一個任務then1,因此直接執行就行了,執行結果輸出then1,當然,他的執行,也是進入函式呼叫棧中執行的。
第五步:當所有的micro-tast執行完畢之後,表示第一輪的迴圈就結束了。這個時候就得開始第二輪的迴圈。第二輪迴圈仍然從巨集任務macro-task開始。
這個時候,我們發現巨集任務中,只有在setTimeout佇列中還要一個timeout1的任務等待執行。因此就直接執行即可。
這個時候巨集任務佇列與微任務佇列中都沒有任務了,所以程式碼就不會再輸出其他東西了。
那麼上面這個例子的輸出結果就顯而易見。大家可以自行嘗試體會。
這個例子比較簡答,涉及到的佇列任務並不多,因此讀懂了它還不能全面的瞭解到事件迴圈機制的全貌。所以我下面弄了一個複製一點的例子,再給大家解析一番,相信讀懂之後,事件迴圈這個問題,再面試中再次被問到就難不倒大家了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 |
// 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') }) }) |
這個例子看上去有點複雜,亂七八糟的程式碼一大堆,不過不用擔心,我們一步一步來分析一下。
第一步:巨集任務script首先執行。全域性入棧。glob1輸出。
第二步,執行過程遇到setTimeout。setTimeout作為任務分發器,將任務分發到對應的巨集任務佇列中。
1 2 3 4 5 6 7 8 9 10 11 12 |
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。setImmediate也是一個巨集任務分發器,將任務分發到對應的任務佇列中。setImmediate的任務佇列會在setTimeout佇列的後面執行。
1 2 3 4 5 6 7 8 9 10 11 12 |
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') }) }) |
第四步:執行遇到nextTick,process.nextTick是一個微任務分發器,它會將任務分發到對應的微任務佇列中去。
1 2 3 |
process.nextTick(function() { console.log('glob1_nextTick'); }) |
第五步:執行遇到Promise。Promise的then方法會將任務分發到對應的微任務佇列中,但是它建構函式中的方法會直接執行。因此,glob1_promise會第二個輸出。
1 2 3 4 5 6 |
new Promise(function(resolve) { console.log('glob1_promise'); resolve(); }).then(function() { console.log('glob1_then') }) |
第六步:執行遇到第二個setTimeout。
1 2 3 4 5 6 7 8 9 10 11 12 |
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') }) }) |
第七步:先後遇到nextTick與Promise
1 2 3 4 5 6 7 8 9 |
process.nextTick(function() { console.log('glob2_nextTick'); }) new Promise(function(resolve) { console.log('glob2_promise'); resolve(); }).then(function() { console.log('glob2_then') }) |
第八步:再次遇到setImmediate。
1 2 3 4 5 6 7 8 9 10 11 12 |
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') }) }) |
這個時候,script中的程式碼就執行完畢了,執行過程中,遇到不同的任務分發器,就將任務分發到各自對應的佇列中去。接下來,將會執行所有的微任務佇列中的任務。
其中,nextTick佇列會比Promie先執行。nextTick中的可執行任務執行完畢之後,才會開始執行Promise佇列中的任務。
當所有可執行的微任務執行完畢之後,這一輪迴圈就表示結束了。下一輪迴圈繼續從巨集任務佇列開始執行。
這個時候,script已經執行完畢,所以就從setTimeout佇列開始執行。
setTimeout任務的執行,也依然是藉助函式呼叫棧來完成,並且遇到任務分發器的時候也會將任務分發到對應的佇列中去。
只有當setTimeout中所有的任務執行完畢之後,才會再次開始執行微任務佇列。並且清空所有的可執行微任務。
setTiemout佇列產生的微任務執行完畢之後,迴圈則回過頭來開始執行setImmediate佇列。仍然是先將setImmediate佇列中的任務執行完畢,再執行所產生的微任務。
當setImmediate佇列執行產生的微任務全部執行之後,第二輪迴圈也就結束了。
大家需要注意這裡的迴圈結束的時間節點。
當我們在執行setTimeout任務中遇到setTimeout時,它仍然會將對應的任務分發到setTimeout佇列中去,但是該任務就得等到下一輪事件迴圈執行了。例子中沒有涉及到這麼複雜的巢狀,大家可以動手新增或者修改他們的位置來感受一下迴圈的變化。
OK,到這裡,事件迴圈我想我已經表述得很清楚了,能不能理解就看讀者老爺們有沒有耐心了。我估計很多人會理解不了迴圈結束的節點。
當然,這些順序都是v8的一些實現。我們也可以根據上面的規則,來嘗試實現一下事件迴圈的機制。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
// 用陣列模擬一個佇列 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() {} } // 於是,一個訂閱-通知的設計模式就這樣輕鬆的被實現了 |
這樣,我們就模擬了一個任務佇列。我們還可以定義另外一個佇列,利用上面的各種方式來規定他們的優先順序。
因此,在老的瀏覽器沒有支援Promise的時候,就可以利用setTimeout等方法,來模擬實現Promise,具體如何做到的,下一篇文章我們慢慢分析。