前端基礎進階(十四):深入核心,詳解事件循環機制

Event Loop

JavaScript的學習零散而龐雜,很多時候我們學到了一些東西,但是卻沒辦法感受到進步!甚至過了不久,就把學到的東西給忘了。為了解決自己的這個困擾,在學習的過程中,我一直在試圖尋找一條核心的線索,只要順著這條線索,我就能夠一點一點的進步。

前端基礎進階正是圍繞這條線索慢慢展開,而事件循環機制(Event Loop),則是這條線索的最關鍵的知識點。

所以,我就馬不停蹄的去深入的學習了事件循環機制,并總結出了這篇文章跟大家分享。

1

事件循環機制從整體上告訴了我們JavaScript代碼的執行順序。但是在我學習的過程中,找到的許多國內博客文章對于它的講解淺嘗輒止,不得其法,很多文章在圖中畫個圈就表示循環了,看了之后也沒感覺明白了多少。但是他又如此重要,以致于當我想要面試中高級崗位時,事件循環機制總是繞不開的話題。特別是ES6中正式加入了Promise對象之后,對于新標準中事件循環機制的理解就變得更加重要。這就很尷尬了。

最近有兩篇比較火的文章也表達了這個問題的重要性。

這個前端面試在搞事
80% 應聘者都不及格的 JS 面試題

但很遺憾的是,大神們告訴了大家這個知識點很重要,卻并沒有告訴大家為什么會這樣。所以當我們在面試遇到這樣的問題時,就算你知道了結果,面試官再進一步問一下,我們依然懵逼。

學習事件循環機制之前,我默認你已經懂得了如下概念

  • 執行上下文(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等我們稱之為任務源。而進入任務隊列的是他們指定的具體執行任務。
// setTimeout中的回調函數才是進入任務隊列的任務
setTimeout(function() {
    console.log('xxxx');
})
// 非常多的同學對于setTimeout的理解存在偏差。所以大概說一下誤解:
// setTimeout作為一個任務分發器,這個函數會立即執行,而它所要分發的任務,也就是它的第一個參數,才是延遲執行
  • 來自不同任務源的任務會進入到不同的任務隊列。其中setTimeout與setInterval是同源的。
  • 事件循環的順序,決定了JavaScript代碼的執行順序。它從script(整體代碼)開始第一次循環。之后全局上下文進入函數調用棧。直到調用棧清空(只剩全局),然后執行所有的micro-task。當所有可執行的micro-task執行完畢之后。循環再次從macro-task開始,找到其中一個任務隊列執行完畢,然后再執行所有的micro-task,這樣一直循環下去。
  • 其中每一個任務的執行,無論是macro-task還是micro-task,都是借助函數調用棧來完成。
    純文字表述確實有點干澀,因此,這里我們通過2個例子,來逐步理解事件循環的具體順序。
// 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任務開始執行,全局上下文入棧

第二步:script任務執行時首先遇到了setTimeout,setTimeout為一個宏任務源,那么他的作用就是將任務分發到它對應的隊列中。

setTimeout(function() {
    console.log('timeout1');
})
宏任務timeout1進入setTimeout隊列

第三步:script執行時遇到Promise實例。Promise構造函數中的第一個參數,是在new的時候執行,因此不會進入任何其他的隊列,而是直接在當前任務直接執行了,而后續的.then則會被分發到micro-task的Promise隊列中去。

因此,構造函數執行時,里面的參數進入函數調用棧執行。for循環不會進入任何隊列,因此代碼會依次執行,所以這里的promise1和promise2會依次輸出。

promise1入棧執行,這時promise1被最先輸出
resolve在for循環中入棧執行
構造函數執行完畢的過程中,resolve執行完畢出棧,promise2輸出,promise1頁出棧,then執行時,Promise任務then1進入對應隊列

script任務繼續往下執行,最后只有一句輸出了globa1,然后,全局任務就執行完畢了。

第四步:第一個宏任務script執行完畢之后,就開始執行所有的可執行的微任務。這個時候,微任務中,只有Promise隊列中的一個任務then1,因此直接執行就行了,執行結果輸出then1,當然,他的執行,也是進入函數調用棧中執行的。

執行所有的微任務

第五步:當所有的micro-tast執行完畢之后,表示第一輪的循環就結束了。這個時候就得開始第二輪的循環。第二輪循環仍然從宏任務macro-task開始。

微任務被清空

這個時候,我們發現宏任務中,只有在setTimeout隊列中還要一個timeout1的任務等待執行。因此就直接執行即可。

timeout1入棧執行

這個時候宏任務隊列與微任務隊列中都沒有任務了,所以代碼就不會再輸出其他東西了。

那么上面這個例子的輸出結果就顯而易見。大家可以自行嘗試體會。

這個例子比較簡答,涉及到的隊列任務并不多,因此讀懂了它還不能全面的了解到事件循環機制的全貌。所以我下面弄了一個復雜一點的例子,再給大家解析一番,相信讀懂之后,事件循環這個問題,再面試中再次被問到就難不倒大家了。

// 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輸出。

script首先執行

第二步,執行過程遇到setTimeout。setTimeout作為任務分發器,將任務分發到對應的宏任務隊列中。

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')
    })
})
timeout1進入對應隊列

第三步:執行過程遇到setImmediate。setImmediate也是一個宏任務分發器,將任務分發到對應的任務隊列中。setImmediate的任務隊列會在setTimeout隊列的后面執行。

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')
    })
})
進入setImmediate隊列

第四步:執行遇到nextTick,process.nextTick是一個微任務分發器,它會將任務分發到對應的微任務隊列中去。

process.nextTick(function() {
    console.log('glob1_nextTick');
})
nextTick

第五步:執行遇到Promise。Promise的then方法會將任務分發到對應的微任務隊列中,但是它構造函數中的方法會直接執行。因此,glob1_promise會第二個輸出。

new Promise(function(resolve) {
    console.log('glob1_promise');
    resolve();
}).then(function() {
    console.log('glob1_then')
})

先是函數調用棧的變化
然后glob1_then任務進入隊列

第六步:執行遇到第二個setTimeout。

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')
    })
})
timeout2進入對應隊列

第七步:先后遇到nextTick與Promise

process.nextTick(function() {
    console.log('glob2_nextTick');
})
new Promise(function(resolve) {
    console.log('glob2_promise');
    resolve();
}).then(function() {
    console.log('glob2_then')
})
glob2_nextTick與Promise任務分別進入各自的隊列

第八步:再次遇到setImmediate。

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')
    })
})
nextTick

這個時候,script中的代碼就執行完畢了,執行過程中,遇到不同的任務分發器,就將任務分發到各自對應的隊列中去。接下來,將會執行所有的微任務隊列中的任務。

其中,nextTick隊列會比Promie先執行。nextTick中的可執行任務執行完畢之后,才會開始執行Promise隊列中的任務。

當所有可執行的微任務執行完畢之后,這一輪循環就表示結束了。下一輪循環繼續從宏任務隊列開始執行。

這個時候,script已經執行完畢,所以就從setTimeout隊列開始執行。

第二輪循環初始狀態

setTimeout任務的執行,也依然是借助函數調用棧來完成,并且遇到任務分發器的時候也會將任務分發到對應的隊列中去。

只有當setTimeout中所有的任務執行完畢之后,才會再次開始執行微任務隊列。并且清空所有的可執行微任務。

setTiemout隊列產生的微任務執行完畢之后,循環則回過頭來開始執行setImmediate隊列。仍然是先將setImmediate隊列中的任務執行完畢,再執行所產生的微任務。

當setImmediate隊列執行產生的微任務全部執行之后,第二輪循環也就結束了。

大家需要注意這里的循環結束的時間節點。

當我們在執行setTimeout任務中遇到setTimeout時,它仍然會將對應的任務分發到setTimeout隊列中去,但是該任務就得等到下一輪事件循環執行了。例子中沒有涉及到這么復雜的嵌套,大家可以動手添加或者修改他們的位置來感受一下循環的變化。

OK,到這里,事件循環我想我已經表述得很清楚了,能不能理解就看讀者老爺們有沒有耐心了。我估計很多人會理解不了循環結束的節點。

當然,這些順序都是v8的一些實現。我們也可以根據上面的規則,來嘗試實現一下事件循環的機制。

// 用數組模擬一個隊列
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() {}
}

// 于是,一個訂閱-通知的設計模式就這樣輕松的被實現了

這樣,我們就模擬了一個任務隊列。我們還可以定義另外一個隊列,利用上面的各種方式來規定他們的優先級。

需要注意的是,這里的執行順序,或者執行的優先級在不同的場景里由于實現的不同會導致不同的結果,包括node的不同版本,不同瀏覽器等都有不同的結果。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,748評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,165評論 3 414
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,595評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,633評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,435評論 6 405
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,943評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,035評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,175評論 0 287
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,713評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,599評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,788評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,303評論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,034評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,412評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,664評論 1 280
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,408評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,747評論 2 370

推薦閱讀更多精彩內容