引子
當我在看節流函式的時候,碰到了setTimtout,於是從js執行機制挖到了event-loop。那麼我們們就先從這個簡單的節流函式看起。
// 節流:如果短時間內大量觸發同一事件,那麼在函式執行一次之後,該函式在指定的時間期限內不再工作,直至過了這段時間才重新生效。
function throttle (fn, delay) {
let sign = true;
return function () {
if (sign) {
sign = false;
setTimeout (() => {
fn();
sign = true;
}, delay);
} else {
return false;
}
}
}
window.onscroll = throttle(foo, 1000);
複製程式碼
那麼這個節流函式是怎麼實現的節流呢?
讓我們來看一下它的執行步驟(假設我們一直不停的在滾動):
- 當我們開啟頁面,程式碼執行到
window.onscroll = throttle(foo, 1000)
就會直接執行 throttle函式,定義了一個變數sign
為 true,然後碰到了 return 跳出 throttle函式,並返回另一個匿名函式。 - 然後我們滾動頁面,那麼就會觸發 onscroll 事件,執行 throttle函式。而此時我們的 throttle函式,實際就是執行 return 的那個匿名函式。因為閉包的緣故,儲存了 sign的值(感覺還要填個閉包的坑...),此時的sign 是 true。就執行 if判斷,把sign 改為 false。然後碰到了定時器,我們現在不用管定時器的回撥函式的內容。
- 我們還一直在滾動,那麼又觸發了 onscroll事件,於是繼續進行 if else 判斷。此時 sign 已經是false了,什麼都沒有發生。
- 繼續,我們一直不停的在滾動,還是觸發了 onscroll事件,因為 sign 還是false,所以還是什麼都沒有發生。
- 一直重複步驟4,直到1s以後的那個 onscroll事件執行完成後,我們的setTimeout被執行了,首先執行了我們的需要被執行的fn()函式,然後把 sign置為 true。又開始跟前面一樣,執行 if判斷了。
那麼為什麼在執行了 if判斷的過程中,碰到了setTimeout,我們的sign並沒有被改為true,從而一直的執行 if判斷呢?那麼就需要聊一聊js的執行機制了。終於要進正題了,真不容易...
js執行機制
先看一下阮一峰大佬的
(1)所有同步任務都在主執行緒上執行,形成一個執行棧(execution context stack)。
(2)主執行緒之外,還存在一個"任務佇列"(task queue)。只要非同步任務有了執行結果,就在"任務佇列"之中放置一個事件。
(3)一旦"執行棧"中的所有同步任務執行完畢,系統就會讀取"任務佇列",看看裡面有哪些事件。那些對應的非同步任務,於是結束等待狀態,進入執行棧,開始執行。
(4)主執行緒不斷重複上面的第三步。
我自己歸類就是js中有:
-
同步任務和非同步任務
-
巨集任務(macrotask)和微任務(microtask)
-
主執行緒(同步任務) - 所有同步任務都在主執行緒上執行,形成一個執行棧。
-
任務佇列(非同步任務):當非同步任務有了結果,就在任務佇列中放一個事件。
-
JS執行機制:當"執行棧"中的所有同步任務執行完畢,系統就會讀取"任務佇列"
其中巨集任務包括:script(主程式碼), setTimeout, setInterval, setImmediate, I/O, UI rendering
微任務包括:process.nextTick(Nodejs), Promises, Object.observe, MutationObserver
這裡我們注意到,巨集任務裡有 script,也就是我們的正常執行的主程式碼。
事件迴圈 event-loop
主執行緒從"任務佇列"中讀取事件,這個過程是迴圈不斷的,所以整個的這種執行機制又稱為Event Loop(事件迴圈)。此機制具體如下:主執行緒會不斷從任務佇列中按順序取任務執行,每執行完一個任務都會檢查microtask佇列是否為空(執行完一個任務的具體標誌是函式執行棧為空),如果不為空則會一次性執行完所有microtask。然後再進入下一個迴圈去任務佇列中取下一個任務執行。
我又給總結了一下籠統的過程:script(巨集任務) - 清空微任務佇列 - 執行一個巨集任務 - 清空微任務佇列 - 執行一個巨集任務, 如此往復。
- 先執行script裡的同步程式碼(此時是巨集任務)。碰到非同步任務,放到任務佇列。
- 查詢任務佇列有沒有微任務,有就把此時的微任務全部按順序執行 (這就是為什麼promise會比setTimeout先執行,因為先執行的巨集任務是同步程式碼,setTimeout被放進任務佇列了,setTimeout又是巨集任務,在它之前先得執行微任務(就比如promise))。
- 執行一個巨集任務(先進到佇列中的那個巨集任務),再把這次巨集任務裡的巨集任務和微任務放到任務佇列。
- ...一直重複2、3步驟
要做到心中有佇列,有先進先出的概念
借用前端小姐姐的一張圖來解釋:
現在再看開頭的節流函式,就明白為什麼碰到了setTimeout,我們的sign並沒有被改為true了把。
那我們繼續,看一下最近看到的爆款題。
開始闖關
第一關
看這段程式碼
console.log('script start');
setTimeout(() => {
console.log('setTimeout1');
}, 0);
new Promise((resolve) => {
resolve('Promise1');
}).then((data) => {
console.log(data);
});
new Promise((resolve) => {
resolve('Promise2');
}).then((data) => {
console.log(data);
});
console.log('script end');
複製程式碼
對照這上面的執行過程不難得出結論,script start -> script end -> Promise1 -> Promise2 -> setTimeout1
就算 setTimeout 不延時執行,它也會在 Promise之後執行,誰讓js就是先執行同步程式碼,然後去找微任務再去找巨集任務了呢。
懂了這裡,那我們繼續咯。
第二關
setTimeout(() => {
console.log('setTimeout1');
setTimeout(() => {
console.log('setTimeout3');
}, 0);
Promise.resolve().then(data=>{
console.log('setTimeout 裡的 Promise');
});
}, 0);
setTimeout(() => {
console.log('setTimeout2');
}, 0);
Promise.resolve().then(() => {
console.log('Promise1');
});
複製程式碼
根據前面的流程
- 執行script,看到了第一個 setTimeout 放入任務佇列,看到了第二個 setTimeout 放到任務佇列。看到了Promise.then() 放到任務佇列,並沒有同步程式碼。
- 檢查微任務,發現了 Promise.then() 列印
Promise1
。 - 檢查發現沒有別的微任務了,檢查巨集任務,此時有兩個巨集任務(兩個setTimeout),但是規則告訴我們,只執行一個巨集任務,因為佇列是先進先出的原則,執行先進入佇列的那個 setTimeout,列印
setTimeout1
。又發現了 一個 setTimeout,放進任務佇列。看見了 Promise.then() ,列印setTimeout 裡的 Promise
。 - 檢查巨集任務,發現了巨集任務,執行先進的那個,所以列印
setTimeout2
。 - 檢查微任務,沒有。
- 檢查巨集任務,列印
setTimeout3
。
搞清楚了這個,那我們再繼續玩兒玩兒?
第三關
console.log('script start');
setTimeout(() => {
console.log('setTimeout1');
}, 0);
new Promise((resolve) => {
console.log('Promise3');
resolve();
}).then(() => {
console.log('Promise1');
});
new Promise((resolve) => {
resolve();
}).then(() => {
console.log('Promise2');
});
console.log('script end');
複製程式碼
再來看看這個程式碼的執行結果呢。
script start -> Promise3 -> script end -> Promise1 -> Promise2 -> setTimeout1
有些朋友可能會說,不是說好了 Promise 是微任務,要在主程式碼執行以後才執行嘛,你個 Promise3 咋叛變了。
其實 Promise3 沒有叛變,之前說的 Promise微任務是.then()執行的程式碼。而在new Promise的回撥函式裡的程式碼是同步任務。
第四關
我們繼續看關於promise的
setTimeout(()=>{
console.log(1)
},0);
let a=new Promise((resolve)=>{
console.log(2)
resolve()
}).then(()=>{
console.log(3)
}).then(()=>{
console.log(4)
});
console.log(5);
複製程式碼
這個輸出 2 -> 5 -> 3 -> 4 -> 1。你想對了嘛?
這個要從Promise的實現來說,Promise的executor是一個同步函式,即非非同步,立即執行的一個函式,因此他應該是和當前的任務一起執行的。而Promise的鏈式呼叫then,每次都會在內部生成一個新的Promise,然後執行then,在執行的過程中不斷向微任務(microtask)推入新的函式,因此直至微任務(microtask)的佇列清空後才會執行下一波的macrotask。
第五關
promise繼續進化
new Promise((resolve,reject)=>{
console.log("promise1")
resolve()
}).then(()=>{
console.log("then11")
new Promise((resolve,reject)=>{
console.log("promise2")
resolve()
}).then(()=>{
console.log("then21")
}).then(()=>{
console.log("then23")
})
}).then(()=>{
console.log("then12")
})
複製程式碼
直接上解釋吧。
遇到這種巢狀式的Promise不要慌,首先要心中有一個佇列,能夠將這些函式放到相對應的佇列之中。
Ready GO
第一輪
- current task: promise1是當之無愧的立即執行的一個函式,參考上一章節的executor,立即執行輸出
[promise1]
- micro task queue: [promise1的第一個then]
第二輪
- current task: then1執行中,立即輸出了
then11
以及新promise2的promise2
- micro task queue: [新promise2的then函式,以及promise1的第二個then函式]
第三輪
- current task: 新promise2的then函式輸出
then21
和promise1的第二個then函式輸出then12
。- micro task queue: [新promise2的第二then函式]
第四輪
- current task: 新promise2的第二then函式輸出
then23
- micro task queue: []
END
可能有人會對第二輪的佇列表示疑問,為什麼是 ”新promise2的then函式“ 先進了佇列,然後才是 ”promise1的第二個then函式“ 進入佇列?”新promise2的第二then函式“ 為什麼有沒有在這一輪中進入到佇列中來呢?
看不懂沒關係,我們來除錯一下程式碼:
在列印完 promise2
以後,19行先執行到了 })
這裡,然後到了then這裡。
再下一步,到了 promise1的第二個})
這裡了。並沒有執行20行的console.log。
由此看出:promise2的第一個then進入任務佇列中了。並沒有被執行.then()。
繼續執行,列印 then21
。
由此得出:promise1的第二個then放入非同步佇列中,並沒有被執行。程式執行到這裡,巨集任務算是執行完了。檢查微任務,此時佇列中放著 [ '新promise2的then函式', 'promise1的第二個then函式'] ,也就是第二輪所寫的佇列。
這一步,到了promise2的二個then前面的})
。
往下執行到了這裡,又碰到了非同步,放入佇列中去。
此時佇列: [ 'promise1的第二個then函式' ,'promise2的第二個then函式' ]
列印 promise1 的 then12
。
先進先出,所以先執行了 'promise1的第二個then函式' 。
此時佇列: [ 'promise2的第二個then函式' ]
最後才輸出了 then23
。
第六關 async/await
截至到上一關,我本以為我已經完全掌握了event-loop。後來我看到了 async/await , async await是generator
和 Promise
的語法糖這個大家應該都知道,但是列印之後跟我預期的不太一樣,頓時有點兒蒙圈,後來一分析,原來如此。
async function async1() {
console.log("async1 start");
await async2();
console.log("async1 end");
}
async function async2() {
console.log( 'async2');
}
console.log("script start");
setTimeout(function () {
console.log("settimeout");
},0);
async1();
new Promise(function (resolve) {
console.log("promise1");
resolve();
}).then(function () {
console.log("promise2");
});
console.log('script end');
複製程式碼
這段程式碼也算是網紅程式碼了,我已經不下三個地方見過了...
先仔細想一想應該輸出什麼,然後列印一下看看。(chrome 73版本列印結果)
script start
async1 start
async2
promise1
script end
async1 end
promise2
settimeout
複製程式碼
直接從async開始看起吧。
當程式執行到了async1();
的時候
-
首先輸出
async1 start
-
執行到
await async2();
,會從右向左執行,先執行async2()
,列印async2
,看見await
,會阻塞程式碼去執行同步任務。
async/await僅僅影響的是函式內的執行,而不會影響到函式體外的執行順序。也就是說async1()並不會阻塞後續程式的執行,
await async2()
相當於一個Promise,console.log("async1 end");
相當於前方Promise的then之後執行的函式。
如此一來,就可以得出上面的結果了。
但是,你也許列印出來會是下面這樣的結果:
這個就跟V8有關係了(在chrome 71版本中,我列印出的是圖片中的結果)。至於async/await和promise到底誰會先執行,這裡偷個懶,大家看 小美娜娜:Eventloop不可怕,可怕的是遇上Promise裡的版本5有非常詳細的解讀。
參考文章:
阮一峰:JavaScript 執行機制詳解:再談Event Loop