高頻面試題:JavaScript事件循環機制解析

文章首次發表在 我的博客html

前言

最近面試了不少家公司,這道題幾乎是必被問到的一道題。以前總以爲本身瞭解得差很少,可是當第一次被問到的時候,殊不知道該從哪裏開始提及,涉及到的知識點不少。因而花時間整理了一下。並不只僅是由於面試遇到了,而是理解JavaScript事件循環機制會讓咱們日常遇到的疑惑也獲得解答。html5

通常面試官會這麼問,出道題,讓你說出打印結果。而後會問分別說說瀏覽器的node的事件循環,區別是什麼,什麼是宏任務和微任務,爲何要有這兩種任務...node

本篇文章參考了不少文章,同時加上本身的理解,若是有問題但願你們指出。git

事件循環

  1. JavaScript是單線程,非阻塞的
  2. 瀏覽器的事件循環
    • 執行棧和事件隊列
    • 宏任務和微任務
  3. node環境下的事件循環
    • 和瀏覽器環境有何不一樣
    • 事件循環模型
    • 宏任務和微任務
  4. 經典題目分析

1. JavaScript是單線程,非阻塞的

單線程:github

JavaScript的主要用途是與用戶互動,以及操做DOM。若是它是多線程的會有不少複雜的問題要處理,好比有兩個線程同時操做DOM,一個線程刪除了當前的DOM節點,一個線程是要操做當前的DOM階段,最後以哪一個線程的操做爲準?爲了不這種,因此JS是單線程的。即便H5提出了web worker標準,它有不少限制,受主線程控制,是主線程的子線程。web

非阻塞:經過 event loop 實現。面試

2. 瀏覽器的事件循環

執行棧和事件隊列

爲了更好地理解Event Loop,請看下圖(轉引自Philip Roberts的演講 《Help, I'm stuck in an event-loop》vim

Help, I'm stuck in an event-loop

執行棧: 同步代碼的執行,按照順序添加到執行棧中promise

function a() {
    b();
    console.log('a');
}
function b() {
    console.log('b')
}
a();
複製代碼

咱們能夠經過使用 Loupe(Loupe是一種可視化工具,能夠幫助您瞭解JavaScript的調用堆棧/事件循環/回調隊列如何相互影響)工具來了解上面代碼的執行狀況。瀏覽器

調用狀況

  1. 執行函數 a()先入棧
  2. a()中先執行函數 b() 函數b() 入棧
  3. 執行函數b(), console.log('b') 入棧
  4. 輸出 bconsole.log('b')出棧
  5. 函數b() 執行完成,出棧
  6. console.log('a') 入棧,執行,輸出 a, 出棧
  7. 函數a 執行完成,出棧。

事件隊列: 異步代碼的執行,遇到異步事件不會等待它返回結果,而是將這個事件掛起,繼續執行執行棧中的其餘任務。當異步事件返回結果,將它放到事件隊列中,被放入事件隊列不會馬上執行起回調,而是等待當前執行棧中全部任務都執行完畢,主線程空閒狀態,主線程會去查找事件隊列中是否有任務,若是有,則取出排在第一位的事件,並把這個事件對應的回調放到執行棧中,而後執行其中的同步代碼。

咱們再上面代碼的基礎上添加異步事件,

function a() {
    b();
    console.log('a');
}
function b() {
    console.log('b')
    setTimeout(function() {
        console.log('c');
    }, 2000)
}
a();
複製代碼

此時的執行過程以下

img

咱們同時再加上點擊事件看一下運行的過程

$.on('button', 'click', function onClick() {
    setTimeout(function timer() {
        console.log('You clicked the button!');    
    }, 2000);
});

console.log("Hi!");

setTimeout(function timeout() {
    console.log("Click the button!");
}, 5000);

console.log("Welcome to loupe.");
複製代碼

img

簡單用下面的圖進行一下總結

執行棧和事件隊列

宏任務和微任務

爲何要引入微任務,只有一種類型的任務不行麼?

頁面渲染事件,各類IO的完成事件等隨時被添加到任務隊列中,一直會保持先進先出的原則執行,咱們不能準確地控制這些事件被添加到任務隊列中的位置。可是這個時候忽然有高優先級的任務須要儘快執行,那麼一種類型的任務就不合適了,因此引入了微任務隊列。

不一樣的異步任務被分爲:宏任務和微任務 宏任務:

  • script(總體代碼)
  • setTimeout()
  • setInterval()
  • postMessage
  • I/O
  • UI交互事件

微任務:

  • new Promise().then(回調)
  • MutationObserver(html5 新特性)

運行機制

異步任務的返回結果會被放到一個任務隊列中,根據異步事件的類型,這個事件實際上會被放到對應的宏任務和微任務隊列中去。

在當前執行棧爲空時,主線程會查看微任務隊列是否有事件存在

  • 存在,依次執行隊列中的事件對應的回調,直到微任務隊列爲空,而後去宏任務隊列中取出最前面的事件,把當前的回調加到當前指向棧。
  • 若是不存在,那麼再去宏任務隊列中取出一個事件並把對應的回到加入當前執行棧;

當前執行棧執行完畢後時會馬上處理全部微任務隊列中的事件,而後再去宏任務隊列中取出一個事件。同一次事件循環中,微任務永遠在宏任務以前執行。

在事件循環中,每進行一次循環操做稱爲 tick,每一次 tick 的任務處理模型是比較複雜的,但關鍵步驟以下:

  • 執行一個宏任務(棧中沒有就從事件隊列中獲取)
  • 執行過程當中若是遇到微任務,就將它添加到微任務的任務隊列中
  • 宏任務執行完畢後,當即執行當前微任務隊列中的全部微任務(依次執行)
  • 當前宏任務執行完畢,開始檢查渲染,而後GUI線程接管渲染
  • 渲染完畢後,JS線程繼續接管,開始下一個宏任務(從事件隊列中獲取)

簡單總結一下執行的順序: 執行宏任務,而後執行該宏任務產生的微任務,若微任務在執行過程當中產生了新的微任務,則繼續執行微任務,微任務執行完畢後,再回到宏任務中進行下一輪循環。

宏任務和微任務

深刻理解js事件循環機制(瀏覽器篇) 這邊文章中有個特別形象的動畫,你們能夠看着理解一下。

console.log('start')

setTimeout(function() {
  console.log('setTimeout')
}, 0)

Promise.resolve().then(function() {
  console.log('promise1')
}).then(function() {
  console.log('promise2')
})

console.log('end')
複製代碼

瀏覽器事件循環

  1. 全局代碼壓入執行棧執行,輸出 start
  2. setTimeout壓入 macrotask隊列,promise.then 回調放入 microtask隊列,最後執行 console.log('end'),輸出 end
  3. 調用棧中的代碼執行完成(全局代碼屬於宏任務),接下來開始執行微任務隊列中的代碼,執行promise回調,輸出 promise1, promise回調函數默認返回 undefined, promise狀態變成 fulfilled ,觸發接下來的 then回調,繼續壓入 microtask隊列,此時產生了新的微任務,會接着把當前的微任務隊列執行完,此時執行第二個 promise.then回調,輸出 promise2
  4. 此時,microtask隊列 已清空,接下來會會執行 UI渲染工做(若是有的話),而後開始下一輪 event loop, 執行 setTimeout的回調,輸出 setTimeout

最後的執行結果以下

  • start
  • end
  • promise1
  • promise2
  • setTimeout

node環境下的事件循環

和瀏覽器環境有何不一樣

表現出的狀態與瀏覽器大體相同。不一樣的是 node 中有一套本身的模型。node 中事件循環的實現依賴 libuv 引擎。Node的事件循環存在幾個階段。

若是是node10及其以前版本,microtask會在事件循環的各個階段之間執行,也就是一個階段執行完畢,就會去執行 microtask隊列中的任務。

node版本更新到11以後,Event Loop運行原理髮生了變化,一旦執行一個階段裏的一個宏任務(setTimeout,setInterval和setImmediate)就馬上執行微任務隊列,跟瀏覽器趨於一致。下面例子中的代碼是按照最新的去進行分析的。

事件循環模型

┌───────────────────────┐
┌─>│        timers         │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     I/O callbacks     │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     idle, prepare     │
│  └──────────┬────────────┘      ┌───────────────┐
│  ┌──────────┴────────────┐      │   incoming:   │
│  │         poll          │<──connections───     │
│  └──────────┬────────────┘      │   data, etc.  │
│  ┌──────────┴────────────┐      └───────────────┘
│  │        check          │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
└──┤    close callbacks    │
   └───────────────────────┘
複製代碼

事件循環各階段詳解

node中事件循環的順序

外部輸入數據 --> 輪詢階段(poll) --> 檢查階段(check) --> 關閉事件回調階段(close callback) --> 定時器檢查階段(timer) --> I/O 事件回調階段(I/O callbacks) --> 閒置階段(idle, prepare) --> 輪詢階段...

這些階段大體的功能以下:

  • 定時器檢測階段(timers): 這個階段執行定時器隊列中的回調如 setTimeout() 和 setInterval()。
  • I/O事件回調階段(I/O callbacks): 這個階段執行幾乎全部的回調。可是不包括close事件,定時器和setImmediate()的回調。
  • 閒置階段(idle, prepare): 這個階段僅在內部使用,能夠沒必要理會
  • 輪詢階段(poll): 等待新的I/O事件,node在一些特殊狀況下會阻塞在這裏。
  • 檢查階段(check): setImmediate()的回調會在這個階段執行。
  • 關閉事件回調階段(close callbacks): 例如socket.on('close', ...)這種close事件的回調

poll: 這個階段是輪詢時間,用於等待還未返回的 I/O 事件,好比服務器的迴應、用戶移動鼠標等等。 這個階段的時間會比較長。若是沒有其餘異步任務要處理(好比到期的定時器),會一直停留在這個階段,等待 I/O 請求返回結果。 check: 該階段執行setImmediate()的回調函數。

close: 該階段執行關閉請求的回調函數,好比socket.on('close', ...)。

timer階段: 這個是定時器階段,處理setTimeout()和setInterval()的回調函數。進入這個階段後,主線程會檢查一下當前時間,是否知足定時器的條件。若是知足就執行回調函數,不然就離開這個階段。

I/O callback階段: 除了如下的回調函數,其餘都在這個階段執行:

  • setTimeout()和setInterval()的回調函數
  • setImmediate()的回調函數
  • 用於關閉請求的回調函數,好比socket.on('close', ...)

宏任務和微任務

宏任務:

  • setImmediate
  • setTimeout
  • setInterval
  • script(總體代碼)
  • I/O 操做等。

微任務:

  • process.nextTick
  • new Promise().then(回調)

Promise.nextTick, setTimeout, setImmediate的使用場景和區別

Promise.nextTick process.nextTick 是一個獨立於 eventLoop 的任務隊列。 在每個 eventLoop 階段完成後會去檢查 nextTick 隊列,若是裏面有任務,會讓這部分任務優先於微任務執行。 是全部異步任務中最快執行的。

setTimeout: setTimeout()方法是定義一個回調,而且但願這個回調在咱們所指定的時間間隔後第一時間去執行。

setImmediate: setImmediate()方法從意義上將是馬上執行的意思,可是實際上它倒是在一個固定的階段纔會執行回調,即poll階段以後。

經典題目分析

一. 下面代碼輸出什麼

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');
複製代碼

先執行宏任務(當前代碼塊也算是宏任務),而後執行當前宏任務產生的微任務,而後接着執行宏任務

  1. 從上往下執行代碼,先執行同步代碼,輸出 script start
  2. 遇到setTimeout,現把 setTimeout 的代碼放到宏任務隊列中
  3. 執行 async1(),輸出 async1 start, 而後執行 async2(), 輸出 async2,把 async2() 後面的代碼 console.log('async1 end')放到微任務隊列中
  4. 接着往下執行,輸出 promise1,把 .then()放到微任務隊列中;注意Promise自己是同步的當即執行函數,.then是異步執行函數
  5. 接着往下執行, 輸出 script end。同步代碼(同時也是宏任務)執行完成,接下來開始執行剛纔放到微任務中的代碼
  6. 依次執行微任務中的代碼,依次輸出 async1 endpromise2, 微任務中的代碼執行完成後,開始執行宏任務中的代碼,輸出 setTimeout

最後的執行結果以下

  • script start
  • async1 start
  • async2
  • promise1
  • script end
  • async1 end
  • promise2
  • setTimeout

二. 下面代碼輸出什麼

console.log('start');
setTimeout(() => {
    console.log('children2');
    Promise.resolve().then(() => {
        console.log('children3');
    })
}, 0);

new Promise(function(resolve, reject) {
    console.log('children4');
    setTimeout(function() {
        console.log('children5');
        resolve('children6')
    }, 0)
}).then((res) => {
    console.log('children7');
    setTimeout(() => {
        console.log(res);
    }, 0)
})
複製代碼

這道題跟上面題目不一樣之處在於,執行代碼會產生不少個宏任務,每一個宏任務中又會產生微任務

  1. 從上往下執行代碼,先執行同步代碼,輸出 start
  2. 遇到setTimeout,先把 setTimeout 的代碼放到宏任務隊列①中
  3. 接着往下執行,輸出 children4, 遇到setTimeout,先把 setTimeout 的代碼放到宏任務隊列②中,此時.then並不會被放到微任務隊列中,由於 resolve是放到 setTimeout中執行的
  4. 代碼執行完成以後,會查找微任務隊列中的事件,發現並無,因而開始執行宏任務①,即第一個 setTimeout, 輸出 children2,此時,會把 Promise.resolve().then放到微任務隊列中。
  5. 宏任務①中的代碼執行完成後,會查找微任務隊列,因而輸出 children3;而後開始執行宏任務②,即第二個 setTimeout,輸出 children5,此時將.then放到微任務隊列中。
  6. 宏任務②中的代碼執行完成後,會查找微任務隊列,因而輸出 children7,遇到 setTimeout,放到宏任務隊列中。此時微任務執行完成,開始執行宏任務,輸出 children6;

最後的執行結果以下

  • start
  • children4
  • children2
  • children3
  • children5
  • children7
  • children6

三. 下面代碼輸出什麼

const p = function() {
    return new Promise((resolve, reject) => {
        const p1 = new Promise((resolve, reject) => {
            setTimeout(() => {
                resolve(1)
            }, 0)
            resolve(2)
        })
        p1.then((res) => {
            console.log(res);
        })
        console.log(3);
        resolve(4);
    })
}


p().then((res) => {
    console.log(res);
})
console.log('end');
複製代碼
  1. 執行代碼,Promise自己是同步的當即執行函數,.then是異步執行函數。遇到setTimeout,先把其放入宏任務隊列中,遇到p1.then會先放到微任務隊列中,接着往下執行,輸出 3
  2. 遇到 p().then 會先放到微任務隊列中,接着往下執行,輸出 end
  3. 同步代碼塊執行完成後,開始執行微任務隊列中的任務,首先執行 p1.then,輸出 2, 接着執行p().then, 輸出 4
  4. 微任務執行完成後,開始執行宏任務,setTimeout, resolve(1),可是此時 p1.then已經執行完成,此時 1不會輸出。

最後的執行結果以下

  • 3
  • end
  • 2
  • 4

你能夠將上述代碼中的 resolve(2)註釋掉, 此時 1纔會輸出,輸出結果爲 3 end 4 1

const p = function() {
    return new Promise((resolve, reject) => {
        const p1 = new Promise((resolve, reject) => {
            setTimeout(() => {
                resolve(1)
            }, 0)
        })
        p1.then((res) => {
            console.log(res);
        })
        console.log(3);
        resolve(4);
    })
}


p().then((res) => {
    console.log(res);
})
console.log('end');
複製代碼
  • 3
  • end
  • 4
  • 1

最後強烈推薦幾個很是好的講解 event loop 的視頻:

參考

相關文章
相關標籤/搜索