之前在公眾號發(fā)的一篇文章,在這里再發(fā)一次
先來看一道常見的面試題,請給出下面程序的打印順序
console.log('A')
setTimeout(() => console.log('B'), 0)
Promise.resolve().then(() => console.log('C'))
console.log('D')
單純記住答案沒有什么意義思劳,懂得背后的道理才是關鍵,理解JS的事件循環(huán)(Event Loop)便能徹底搞懂其原因炎滞。當然敢艰,除了這題,還有許多其他更重要的問題能從事件循環(huán)的原理中得到解決册赛。
- Event Loop 概覽 -
事件循環(huán)(Event Loop)是JS中相當重要的概念钠导,它描述了JS整體上的運行機制震嫉,理解它對于深層次的開發(fā)工作有很大幫助。下面這張圖大致說明了事件循環(huán)的流程
圖中的幾個概念解釋:
任務 - 需要執(zhí)行的代碼牡属,比如js文件票堵、某個函數(shù)等。
任務隊列 - 存放任務的隊列(Queue)逮栅,用于后續(xù)按先進先出(FIFO)原則逐個執(zhí)行任務悴势。圖中有“宏任務”與“微任務”兩個列隊,后面會細說措伐。
執(zhí)行棧 - Stack特纤,具體執(zhí)行任務的地方。
JS引擎會一直按照該圖所示流程循環(huán)下去侥加,作為切入點我們可以從Process 1開始觀察捧存。頁面中通過script標簽引入的js文件或者是直接寫在script標簽中的代碼,最初都會被瀏覽器放入宏任務隊列担败。整個流程大致分為3個階段
JS引擎會查看宏任務隊列中是否有等待執(zhí)行的任務昔穴,如果有則把第一個任務拿出執(zhí)行;
在每次執(zhí)行完成任務后查看微任務隊列中是否有等待執(zhí)行的任務提前,如果有則拿出第一個去執(zhí)行吗货,如此直至微任務隊列為空。我們可以把Process 3狈网、4宙搬、5理解為是整個循環(huán)中的一個微循環(huán);
之后會判斷是否需要更新頁面孙援,更新頁面后(或者是不需要更新)完成一次循環(huán)害淤,回到階段1。
這里簡單說明一下更新頁面拓售。瀏覽器會努力以60幀每秒的頻率更新頁面窥摄,每更新一次稱作一幀。由于一次事件循環(huán)的用時可能會小于兩幀之間的時間差础淤,所以并不是每一次循環(huán)都會有更新頁面的操作崭放。更新頁面的具體操作包括:輸入事件(如keypress, click)和其它某些事件(如window resize)的發(fā)送(dispatch),requestAnimationFrame回調(diào)的執(zhí)行鸽凶,當然還有重排币砂、重繪等等。
- 異步 -
什么是異步玻侥?就是同時進行著兩件或兩件以上的事决摧,比如你在看這篇文章的同時還在聽歌。眾所周知,JS是單線程程序掌桩,所以它自己是無法做到異步的边锁。JS不能像你一樣,它要么先看完文章再聽歌波岛,要么先聽完歌再看文章茅坛。JS的異步通常都是與它所處的環(huán)境相配合才能做得到。在瀏覽器中而言则拷,自然就是瀏覽器提供的幫助贡蓖。setTimeout、setInterval煌茬、ajax請求等方法實際上都屬于Web APIs斥铺,即它們都是由瀏覽器提供的,你只是通過JS來調(diào)用而已宣旱。
舉個例子
// some codes
// ...
// 500毫秒后執(zhí)行方法funcA
setTimeout(funcA, 500)
// some other codes
// ...
假設這段代碼當前正在JS的執(zhí)行棧中運行仅父,則其在時間上大概是下圖的樣子
當執(zhí)行到setTimeout(funcA, 500)時,就是在告訴瀏覽器等500毫秒后把funcA放到宏任務隊列浑吟。可以看到耗溜,瀏覽器在計時500毫秒的過程與JS執(zhí)行棧中執(zhí)行"some other codes"的過程是并行進行的组力。
同時我們應該注意到,funcA并不是在500毫秒后立即被執(zhí)行抖拴,而只是被放入到了宏任務隊列燎字,至于什么時候會被執(zhí)行就要看事件循環(huán)的具體情況了。如果此刻它是宏任務隊列中的第一個阿宅,則事件循環(huán)再次走到Process 1時funcA便會被放入棧中執(zhí)行候衍,否則它還需要等待前面的任務都執(zhí)行完。至此我們也能得出setTimeout并不精確洒放,它只是保證了一個最少等待時間蛉鹿,setInterval也是同樣的道理。
總結(jié)下JS的異步:它是由JS的事件循環(huán)機制加上瀏覽器的幫助--把相應的任務(通常是一個回調(diào)函數(shù))放入到任務隊列--來實現(xiàn)的往湿。
上面有提到妖异,任務隊列分為宏任務與微任務,它們在事件循環(huán)中的區(qū)別在圖1中可以清楚的分辨:宏任務隊列中的每個任務執(zhí)行后都走一遍完整的循環(huán)领追,但微任務隊列中的所有任務都執(zhí)行完才會進入當前循環(huán)的下一步他膳。除了在事件循環(huán)機制中的區(qū)別,它們的任務來源也不同绒窑。
微任務來源:Promise的then棕孙、catch、finally方法傳入的回調(diào)函數(shù),異步方法(async/await)里await之后的內(nèi)容蟀俊,調(diào)用queueMicrotask方法傳入的回調(diào)函數(shù)
宏任務來源:script標簽引入的JS文件钦铺,script標簽中的代碼,setTimeout與setInterval方法的回調(diào)函數(shù)欧漱,DOM事件的處理函數(shù)(handler或者叫l(wèi)istener)职抡,以及其他需要異步執(zhí)行的內(nèi)容或回調(diào)函數(shù)
我們回頭看看那道面試題,按照事件循環(huán)走一下就很清晰了
// 當前代碼正在執(zhí)行误甚,說明現(xiàn)在處于 Process 3
?
// 直接打印缚甩,所以是第一個輸出
console.log('A')
// 回調(diào)函數(shù)被“立刻”放入宏任務隊列等待執(zhí)行
setTimeout(() => console.log('B'), 0)
// 一個立即完成的Promise,then的回調(diào)函數(shù)被立刻放入微任務隊列等待執(zhí)行
Promise.resolve().then(() => console.log('C'))
// 直接打印窑邦,所以是第二個輸出
console.log('D')
// 代碼執(zhí)行完成擅威,目前輸出結(jié)果是 AD
當這段代碼執(zhí)行完成即Process 3結(jié)束,然后走到Process 4冈钦,發(fā)現(xiàn)微任務隊列中有一個回調(diào)函數(shù)需要執(zhí)行郊丛,則拿出到Process 3執(zhí)行,此時輸出‘C’瞧筛,執(zhí)行完后再次回到Process 4厉熟,微任務隊列已空所以繼續(xù)向下,經(jīng)過(或未經(jīng)過)更新頁面后较幌,最終來到Process 1揍瑟,發(fā)現(xiàn)宏任務隊列有一個回調(diào)函數(shù)需要執(zhí)行,將其拿出到Process 3執(zhí)行乍炉,此時輸出‘B’绢片。所以最終的輸出順序為ADCB
- UI阻塞 -
圖1中可以看到,更新頁面的Process 6會等待前置的所有步驟岛琼,所以如果Process 3 或者說是Process 3底循、4、5整個微循環(huán)耗時過長槐瑞,則更新頁面的頻率可能就達不到60幀每秒的頻率熙涤,頁面會出現(xiàn)卡頓無反應(輸入事件的發(fā)送也是在更新頁面的步驟里)。如果你的代碼里有死循環(huán)随珠,則Process 3將永遠不會結(jié)束灭袁,導致頁面永遠卡死,直到彈出一個頁面無響應的對話框問你是否要結(jié)束該頁面:
實際上不用死循環(huán)窗看,只要你的代碼在Process 3執(zhí)行的時間夠長茸歧,瀏覽器就會彈出這個對話框。
如果你的代碼里有DOM操作显沈,比如插入新元素或者改變元素狀態(tài)软瞎,但是代碼耗時比較久--比如要處理大量數(shù)據(jù)逢唤,則頁面上顯現(xiàn)出結(jié)果也就相應的延遲,給人的感覺就是卡頓涤浇。
通過事件循環(huán)機制我們已經(jīng)明白為什么當代碼耗時過長會阻塞UI鳖藕,解決的方法自然也很明顯:避免Process 3耗時過長。一個方法是另起一個worker只锭,讓耗時的代碼在worker的線程中運行著恩,這樣自然不會占用主線程中Process 3的時間。但worker也有一個缺點--不能操作DOM蜻展。實際上除了worker還有一個辦法喉誊,在worker出現(xiàn)之前經(jīng)常會使用,即拆分代碼纵顾。把耗時過長的代碼拆分到多次事件循環(huán)中執(zhí)行伍茄,即一次事件循環(huán)只執(zhí)行一部分,這樣就有時間更新頁面了施逾。例如
const num = 1e6 // 假設有一百萬個數(shù)據(jù)需要處理
let cur = 0 // 當前處理到第幾條
const deal = () => {
do {
// 處理一條數(shù)據(jù)
// ...
cur += 1
} while(cur < num && cur % 1000 !== 0) // 每次執(zhí)行1000條
?
if (cur < num) { // 如果還有數(shù)據(jù)未處理
setTimeout(deal) // 放入宏任務隊列敷矫,等待執(zhí)行
} else {
console.log('處理完成')
}
// 開始執(zhí)行
deal()
雖然setTimeout沒有指定等待時間,也就是使用默認的0毫秒汉额,但實際上瀏覽器會有一個最小等待時間曹仗,大約是4毫秒。為了節(jié)省一點點時間蠕搜,我們完全可以把setTimeout放到最前面
const num = 1e6 // 假設有一百萬個數(shù)據(jù)需要處理
let cur = 0 // 當前處理到第幾條
const deal = () => {
// setTimeout放在最前面
if (cur + 1000 < num) { // 如果這次處理不完數(shù)據(jù)
setTimeout(deal) // 放入宏任務隊列整葡,等待下次執(zhí)行
}
do {
// 處理一條數(shù)據(jù)
// ...
cur += 1
} while(cur < num && cur % 1000 !== 0) // 每次執(zhí)行1000條
?
if (cur >= num) {
console.log('拆分方式處理完成')
}
// 開始執(zhí)行
deal()
將setTimeout放到前面,則瀏覽器的計時等待與代碼里的數(shù)據(jù)處理同時進行讥脐,從而可以節(jié)省時間。
- 初始動畫 -
來看一段代碼
const div = document.createElement('div')
div.style.padding = '0.5em'
div.style.margin = '10px'
div.style.boxSizing = 'border-box'
div.style.backgroundColor = 'white'
div.style.transition = 'all linear 2s'
?
document.body.appendChild(div)
// 原本設想當元素加添到body后通過背景漸變的方式顯示出來
div.style.backgroundColor = 'red'
這段代碼本意是想利用transition啼器,在新div插入到body后修改div的背景顏色旬渠,以達到動態(tài)漸變顯現(xiàn)的效果,但真實情況是它直接就是紅色端壳。問題在于transition是在更新頁面的步驟中觸發(fā)的告丢。這段代碼執(zhí)行完來到更新頁面步驟時,div樣式的背景顏色已經(jīng)是紅色损谦,而且它是新添加的元素岖免,與上一幀相比這個div的樣式?jīng)]有變化,所以根本不會有過渡動畫照捡。我們本可以嘗試使用setTimout來修改div的背景顏色颅湘,但多試幾次后會發(fā)現(xiàn)過渡效果有時出現(xiàn)有時不出現(xiàn),其原因前面也有提到栗精,因為瀏覽器會嘗試以60幀每秒的頻率更新頁面闯参,所以不是每次事件循環(huán)都會有更新瞻鹏。所以要想確保有過渡動畫,就要確保div被插入body后經(jīng)歷過一次更新頁面鹿寨,然后再修改div的背景顏色新博,再次更新頁面時瀏覽器才會知道其背景顏色有變化
const div = document.createElement('div')
div.style.padding = '0.5em'
div.style.margin = '10px'
div.style.boxSizing = 'border-box'
div.style.backgroundColor = 'white'
div.style.transition = 'all linear 2s'
?
document.body.appendChild(div)
?
// 使用rAF兩次,確定元素狀態(tài)會在頁面更新之后才被修改
requestAnimationFrame(() => {
requestAnimationFrame(() => {
div.style.backgroundColor = 'red'
})
})
requestAnimationFrame(簡稱rAF)的回調(diào)函數(shù)是在更新頁面步驟里執(zhí)行的脚草,若在回調(diào)函數(shù)里再次使用rAF赫悄,新的回調(diào)函數(shù)會在下一次更新時執(zhí)行。上面的代碼中馏慨,外層的rAF確保了新插入的div會更新到頁面上埂淮,內(nèi)層的rAF確保了在div更新到頁面上之后的一幀修改div背景。
這次就說這些熏纯,希望我都講明白了同诫。相關的示例代碼可在瀏覽器中打開下面的地址查看: