
本文共 3735 字,大约阅读时间需要 12 分钟。
關於 JavaScript 事件循環
前言
會寫這篇是因為關於 js 的事件這一大板塊,實在是一個非常重要的知識點,無論是在面試,考試,或是實際代碼端中都無法避開,是一個非常重要卻又龐大的知識點。為了更清楚的搞明白,也為了日後方便自己查閱資料所需,就自己動手寫了這篇。在這篇中,著重於事件循環來講,其中又會再對不同知識點做更多講解。之後會對事件流再寫一篇。
正文
單線程的 js
首先我們知道,js 是單線程語言,所謂單線程就是說 js 引擎負責解釋和運行代碼的線程只有一個。那為什麼 js 要是單線程的呢?其實也很好理解,因為其實 js 最開始就是為了使網頁動起來的腳本語言,主要就是希望可以將讓靜態的 html 增添交互性,因此也就是操作 html 元素。那假設 js 是多線程,那如果對於同一個 div
,同時有多個 js 線程在改變它,那最後這個 div
會是怎樣的誰都無法知道,而這顯然是我們不希望發生的。
同步任務 vs 異步任務
這邊先給出一個個人認為
那上面知道了,js 是單線程的,所以所有任務都只能在主線程上排隊等待執行。但是萬一有些任務,比如 Ajax 請求,就是特別耗時,且存在很多不確定因素(網絡,設備等等),無法預測到底甚麼時候該任務才會完成。如果也把這些任務放到等待隊列中,那主線程肯定是會阻塞,這樣會大大降低瀏覽器的效率,很糟糕!
於是瀏覽器就把這些任務分派到異步任務隊列中,並跟他們說:「你們隨便要執行多久吧,等你們執行好了再跟我說喔!」。js 中最常見的異步請求是 setTimeout
,相信大家都聽過,先來看一個簡單的例子:
console.log('start')setTimeout(() => { console.log('setTimeout is asynchronous')}, 0);console.log('end')
輸出如下:
可以看到雖然 setTimeout
任務被延時 0 秒,相當於根本沒有延時,但輸出卻是在最後。原因就是 js 引擎會先執行完同步隊列中的任務,再去執行異步隊列中的任務,在此例中也就是 setTimeout
任務。
再多補充點,如果同步任務好死不死也有特別耗時,那麼異步任務也不會按時執行。來看看如下例子:
console.log('start')setTimeout(function() { console.log('setTimeout is asynchronous')}, 1000)console.log('now')let list = []for(let i = 0;i<9999999;i++){ let now = new Date() list.push(i)}
這個例子,輸出仍然如上面一樣。
但是值得注意的地方在於,我們預期 setTimeout is asynchronous 應該在 1s 後輸出,但實際上我們會發現大約在 2s 後才輸出。原因就是因為同步任務的 for 循環執行太多次,延遲了異步任務被執行的時間。
到這邊先簡單整理下,事件循環的基本流程如下:
- 所有任務都在主線程上執行,形成一個執行棧
- 首先 js引擎 會把所有同步任務依序裝進執行棧,並一一執行,如果碰到異步任務,就會把該任務放進異步隊列中,也就是所謂的「任務隊列」(FIFO原則)。
- 等到所有同步任務都執行完並離開執行棧,js引擎 才會去檢查任務隊列,並將異步任務放到執行棧中執行。
- 重複第 3 步直到任務隊列為空
整個這樣的過程就稱為事件循環。
宏任務 vs 微任務
上面提到的任務隊列放的就是所有的異步任務,但其實還分得更細。js 還將異步任務分為宏任務(macrotask)與微任務(microtask),不同的 API 註冊的任次會依序進入自身所對應的隊列中,然後等待事件循環機制將他們一次壓入棧中執行。下面給出一個整理:
宏任務包括:
- plain script(整體 js 代碼)
- setTimeout, setInterval, setImmediate
- I/O
- UI 渲染
微任務包括:
- process.nextTick
- Promise
- MutationObserver(H5 新特性)
可以把整體的 js 代碼也看成是一個根宏任務,主線程也是從這個宏任務開始執行。於是上面的事件循環機制中的異步任務執行緒可以改變一下:
這邊要在多做點補充,因為當時也是到這邊以為自己都懂了,沒想到還只是一知半解而已。
一個掘金的老哥(ssssyoki)的文章摘要: js異步有一個機制,就是遇到宏任務,先執行宏任務,將宏任務放入eventqueue,然後再執行微任務,將微任務放入eventqueue。最騷的是,這兩個queue還不是一個queue。當你往外拿(也就是要放到執行棧中時)的時候先從微任務裡拿回掉函數,然後再從宏任務的queue上拿宏任務的回掉函數。 我當時看到這我就服了還有這種騷操作。
所以看到這邊,在結合上圖就知道,其實所謂的「執行」異步任務只是將宏任務或是微任務放入各自 eventqueue 中,真正的執行其實是先從微任務隊列中拿到執行棧,待微任務隊列為空後才接著去拿宏任務隊列上的任務。所謂異步隊列可以說是包含了宏任務隊列以及微任務兩個隊列。其中,兩個隊列都分別按照 FIFO 的原則。
現在就讓我們用 Promise 來理解以上概念,如果對於 js 的 Promise 還不是特別熟悉的,歡迎先去看看 。那麼例子如下:
console.log('start')setTimeout(function() { console.log('timeout');}, 0)new Promise(function(resolve) { console.log('promise'); // 注意这边调用resolve // 不然then方法不会执行 resolve()}).then(function() { console.log('then');})console.log('end');
分析一下上面代碼的執行過程:
- 剛開始打印
start
- 遇到宏任務 setTimeout,放入宏任務隊列中,等待執行
- 遇到 Promise 回調函數,同步執行,打印
promise
- 當 promise 物件 resolve 後 then 方法屬於微任務,被放入微任務隊列等待執行
- 打印
end
- 同步任務都執行完,先從微任務隊列拿,執行 then 方法的回調函數,打印
then
- 再從宏任務隊列拿,執行 setTimeout 回掉函數,打印
timeout
。
實際的打印應該如下:
把 Promise 變一下,再看看一個例子:
async function async1() { console.log('async1 start') await async2() console.log('async1 end')}async function async2() { console.log('async2')}async1()console.log('script end')
直觀的看代碼想當然我們會認為輸出順序應該是 async1 start
->async2
->async1 end
->script end
。但當然沒那麼簡單啦!真正理解了 async 後,我們會知道其實 async 是對 Promise 的進一步封裝,具體我會再寫一篇詳細說明,但這邊先接受,async 其實會把 await 後(包括 await 語句本身)的所有東西都當作 Promise 一樣放到 then 函數中。因此,對上面的代碼改寫,就會變如下:
async function async1() { console.log('async1 start') new Promise(function(resolve) { console.log('async2') resolve() }).then(function() { console.log('async1 end') })}async1()console.log('script end')
這時,根據我們上面對事件循環的理解再分析一下:
- 輸出
async1 start
- 遇到 Promise,先同步執行輸出
async2
,then 屬於微任務,要等到 Promise 物件 resolve 後才會被放到微任務隊列中 - 輸出
script end
- 同步任務都執行完,執行棧空,檢查微任務隊列,執行 then 中的回調函數,輸出
async1 end
實際輸出如下:
結語
這篇大致上涵蓋了關於 js 事件循環的概念,個人認為是一個還算重要的知識點,應該也還算詳細。希望看完這篇對大家有所幫助,若有誤,也歡迎多多指教!
发表评论
最新留言
关于作者
