由節流函式引發出我對event-loop的思考,順便刷刷爆款題

躺著吃肉都會胖發表於2019-04-01

引子

當我在看節流函式的時候,碰到了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);
複製程式碼

那麼這個節流函式是怎麼實現的節流呢?

讓我們來看一下它的執行步驟(假設我們一直不停的在滾動):

  1. 當我們開啟頁面,程式碼執行到window.onscroll = throttle(foo, 1000)就會直接執行 throttle函式,定義了一個變數 sign 為 true,然後碰到了 return 跳出 throttle函式,並返回另一個匿名函式。
  2. 然後我們滾動頁面,那麼就會觸發 onscroll 事件,執行 throttle函式。而此時我們的 throttle函式,實際就是執行 return 的那個匿名函式。因為閉包的緣故,儲存了 sign的值(感覺還要填個閉包的坑...),此時的sign 是 true。就執行 if判斷,把sign 改為 false。然後碰到了定時器,我們現在不用管定時器的回撥函式的內容。
  3. 我們還一直在滾動,那麼又觸發了 onscroll事件,於是繼續進行 if else 判斷。此時 sign 已經是false了,什麼都沒有發生。
  4. 繼續,我們一直不停的在滾動,還是觸發了 onscroll事件,因為 sign 還是false,所以還是什麼都沒有發生。
  5. 一直重複步驟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步驟

要做到心中有佇列,有先進先出的概念

借用前端小姐姐的一張圖來解釋:

event-loop2

現在再看開頭的節流函式,就明白為什麼碰到了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');
});
複製程式碼

根據前面的流程

  1. 執行script,看到了第一個 setTimeout 放入任務佇列,看到了第二個 setTimeout 放到任務佇列。看到了Promise.then() 放到任務佇列,並沒有同步程式碼。
  2. 檢查微任務,發現了 Promise.then() 列印Promise1
  3. 檢查發現沒有別的微任務了,檢查巨集任務,此時有兩個巨集任務(兩個setTimeout),但是規則告訴我們,只執行一個巨集任務,因為佇列是先進先出的原則,執行先進入佇列的那個 setTimeout,列印 setTimeout1。又發現了 一個 setTimeout,放進任務佇列。看見了 Promise.then() ,列印setTimeout 裡的 Promise
  4. 檢查巨集任務,發現了巨集任務,執行先進的那個,所以列印setTimeout2
  5. 檢查微任務,沒有。
  6. 檢查巨集任務,列印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函式“ 為什麼有沒有在這一輪中進入到佇列中來呢?

看不懂沒關係,我們來除錯一下程式碼:

由節流函式引發出我對event-loop的思考,順便刷刷爆款題

由節流函式引發出我對event-loop的思考,順便刷刷爆款題

在列印完 promise2 以後,19行先執行到了 })這裡,然後到了then這裡。

由節流函式引發出我對event-loop的思考,順便刷刷爆款題

再下一步,到了 promise1的第二個})這裡了。並沒有執行20行的console.log。

由此看出:promise2的第一個then進入任務佇列中了。並沒有被執行.then()。

由節流函式引發出我對event-loop的思考,順便刷刷爆款題

繼續執行,列印 then21

由此得出:promise1的第二個then放入非同步佇列中,並沒有被執行。程式執行到這裡,巨集任務算是執行完了。檢查微任務,此時佇列中放著 [ '新promise2的then函式', 'promise1的第二個then函式'] ,也就是第二輪所寫的佇列。

由節流函式引發出我對event-loop的思考,順便刷刷爆款題

這一步,到了promise2的二個then前面的})

往下執行到了這裡,又碰到了非同步,放入佇列中去。

此時佇列: [ 'promise1的第二個then函式' ,'promise2的第二個then函式' ]

由節流函式引發出我對event-loop的思考,順便刷刷爆款題

列印 promise1 的 then12

先進先出,所以先執行了 'promise1的第二個then函式' 。

此時佇列: [ 'promise2的第二個then函式' ]

由節流函式引發出我對event-loop的思考,順便刷刷爆款題

最後才輸出了 then23


第六關 async/await

截至到上一關,我本以為我已經完全掌握了event-loop。後來我看到了 async/await , async await是generatorPromise 的語法糖這個大家應該都知道,但是列印之後跟我預期的不太一樣,頓時有點兒蒙圈,後來一分析,原來如此。

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之後執行的函式。

如此一來,就可以得出上面的結果了。

但是,你也許列印出來會是下面這樣的結果:

clipboard.png

這個就跟V8有關係了(在chrome 71版本中,我列印出的是圖片中的結果)。至於async/await和promise到底誰會先執行,這裡偷個懶,大家看 小美娜娜:Eventloop不可怕,可怕的是遇上Promise裡的版本5有非常詳細的解讀。

參考文章:

安歌:淺談js防抖和節流

阮一峰:JavaScript 執行機制詳解:再談Event Loop

前端小姐姐:徹底搞懂瀏覽器Event-loop

小美娜娜:Eventloop不可怕,可怕的是遇上Promise

隆金岑:js事件迴圈機制(瀏覽器端Event Loop) 以及async/await的理解

相關文章