高頻面試題:JavaScript事件循環(huán)機制解析

文章首次發(fā)表在 個人博客

前言

最近面試了很多家公司雷绢,這道題幾乎是必被問到的一道題徽惋。之前總覺得自己了解得差不多锨推,但是當?shù)谝淮伪粏柕降臅r候谈跛,卻不知道該從哪里開始說起羊苟,涉及到的知識點很多。于是花時間整理了一下感憾。并不僅僅是因為面試遇到了,而是理解JavaScript事件循環(huán)機制會讓我們平常遇到的疑惑也得到解答令花。

一般面試官會這么問阻桅,出道題,讓你說出打印結果兼都。然后會問分別說說瀏覽器的node的事件循環(huán)嫂沉,區(qū)別是什么,什么是宏任務和微任務扮碧,為什么要有這兩種任務...

本篇文章參考了很多文章趟章,同時加上自己的理解,如果有問題希望大家指出慎王。

事件循環(huán)

  1. JavaScript是單線程蚓土,非阻塞的
  2. 瀏覽器的事件循環(huán)
    • 執(zhí)行棧和事件隊列
    • 宏任務和微任務
  3. node環(huán)境下的事件循環(huán)
    • 和瀏覽器環(huán)境有何不同
    • 事件循環(huán)模型
    • 宏任務和微任務
  4. 經(jīng)典題目分析

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

單線程:

JavaScript的主要用途是與用戶互動赖淤,以及操作DOM蜀漆。如果它是多線程的會有很多復雜的問題要處理,比如有兩個線程同時操作DOM咱旱,一個線程刪除了當前的DOM節(jié)點确丢,一個線程是要操作當前的DOM階段绷耍,最后以哪個線程的操作為準?為了避免這種鲜侥,所以JS是單線程的褂始。即使H5提出了web worker標準,它有很多限制描函,受主線程控制崎苗,是主線程的子線程。

非阻塞:通過 event loop 實現(xiàn)赘阀。

2. 瀏覽器的事件循環(huán)

執(zhí)行棧和事件隊列

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

Help, I'm stuck in an event-loop

執(zhí)行棧: 同步代碼的執(zhí)行,按照順序添加到執(zhí)行棧中

function a() {
    b();
    console.log('a');
}
function b() {
    console.log('b')
}
a();

我們可以通過使用 Loupe(Loupe是一種可視化工具基公,可以幫助您了解JavaScript的調(diào)用堆棧/事件循環(huán)/回調(diào)隊列如何相互影響)工具來了解上面代碼的執(zhí)行情況幅慌。

調(diào)用情況
  1. 執(zhí)行函數(shù) a()先入棧
  2. a()中先執(zhí)行函數(shù) b() 函數(shù)b() 入棧
  3. 執(zhí)行函數(shù)b(), console.log('b') 入棧
  4. 輸出 bconsole.log('b')出棧
  5. 函數(shù)b() 執(zhí)行完成轰豆,出棧
  6. console.log('a') 入棧胰伍,執(zhí)行,輸出 a, 出棧
  7. 函數(shù)a 執(zhí)行完成酸休,出棧骂租。

事件隊列: 異步代碼的執(zhí)行,遇到異步事件不會等待它返回結果斑司,而是將這個事件掛起渗饮,繼續(xù)執(zhí)行執(zhí)行棧中的其他任務。當異步事件返回結果宿刮,將它放到事件隊列中互站,被放入事件隊列不會立刻執(zhí)行起回調(diào),而是等待當前執(zhí)行棧中所有任務都執(zhí)行完畢僵缺,主線程空閑狀態(tài)胡桃,主線程會去查找事件隊列中是否有任務,如果有磕潮,則取出排在第一位的事件翠胰,并把這個事件對應的回調(diào)放到執(zhí)行棧中,然后執(zhí)行其中的同步代碼自脯。

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

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

此時的執(zhí)行過程如下


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

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

執(zhí)行棧和事件隊列

宏任務和微任務

為什么要引入微任務,只有一種類型的任務不行么冤今?

頁面渲染事件闺兢,各種IO的完成事件等隨時被添加到任務隊列中,一直會保持先進先出的原則執(zhí)行,我們不能準確地控制這些事件被添加到任務隊列中的位置屋谭。但是這個時候突然有高優(yōu)先級的任務需要盡快執(zhí)行脚囊,那么一種類型的任務就不合適了,所以引入了微任務隊列桐磁。

不同的異步任務被分為:宏任務和微任務
宏任務:

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

微任務:

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

運行機制

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

在當前執(zhí)行棧為空時衬以,主線程會查看微任務隊列是否有事件存在

  • 存在,依次執(zhí)行隊列中的事件對應的回調(diào)校摩,直到微任務隊列為空看峻,然后去宏任務隊列中取出最前面的事件,把當前的回調(diào)加到當前指向棧衙吩。
  • 如果不存在互妓,那么再去宏任務隊列中取出一個事件并把對應的回到加入當前執(zhí)行棧;

當前執(zhí)行棧執(zhí)行完畢后時會立刻處理所有微任務隊列中的事件坤塞,然后再去宏任務隊列中取出一個事件冯勉。同一次事件循環(huán)中,微任務永遠在宏任務之前執(zhí)行摹芙。

在事件循環(huán)中灼狰,每進行一次循環(huán)操作稱為 tick,每一次 tick 的任務處理模型是比較復雜的浮禾,但關鍵步驟如下:

  • 執(zhí)行一個宏任務(棧中沒有就從事件隊列中獲冉慌摺)
  • 執(zhí)行過程中如果遇到微任務,就將它添加到微任務的任務隊列中
  • 宏任務執(zhí)行完畢后盈电,立即執(zhí)行當前微任務隊列中的所有微任務(依次執(zhí)行)
  • 當前宏任務執(zhí)行完畢承绸,開始檢查渲染,然后GUI線程接管渲染
  • 渲染完畢后挣轨,JS線程繼續(xù)接管,開始下一個宏任務(從事件隊列中獲刃伞)

簡單總結一下執(zhí)行的順序:
執(zhí)行宏任務卷扮,然后執(zhí)行該宏任務產(chǎn)生的微任務,若微任務在執(zhí)行過程中產(chǎn)生了新的微任務均践,則繼續(xù)執(zhí)行微任務晤锹,微任務執(zhí)行完畢后,再回到宏任務中進行下一輪循環(huán)彤委。

宏任務和微任務

深入理解js事件循環(huán)機制(瀏覽器篇) 這邊文章中有個特別形象的動畫鞭铆,大家可以看著理解一下。

console.log('start')

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

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

console.log('end')
瀏覽器事件循環(huán)
  1. 全局代碼壓入執(zhí)行棧執(zhí)行,輸出 start
  2. setTimeout壓入 macrotask隊列车遂,promise.then 回調(diào)放入 microtask隊列封断,最后執(zhí)行 console.log('end'),輸出 end
  3. 調(diào)用棧中的代碼執(zhí)行完成(全局代碼屬于宏任務)舶担,接下來開始執(zhí)行微任務隊列中的代碼坡疼,執(zhí)行promise回調(diào),輸出 promise1, promise回調(diào)函數(shù)默認返回 undefined, promise狀態(tài)變成 fulfilled 衣陶,觸發(fā)接下來的 then回調(diào)柄瑰,繼續(xù)壓入 microtask隊列,此時產(chǎn)生了新的微任務剪况,會接著把當前的微任務隊列執(zhí)行完教沾,此時執(zhí)行第二個 promise.then回調(diào),輸出 promise2
  4. 此時译断,microtask隊列 已清空授翻,接下來會會執(zhí)行 UI渲染工作(如果有的話),然后開始下一輪 event loop, 執(zhí)行 setTimeout的回調(diào)镐作,輸出 setTimeout

最后的執(zhí)行結果如下

  • start
  • end
  • promise1
  • promise2
  • setTimeout

node環(huán)境下的事件循環(huán)

和瀏覽器環(huán)境有何不同

表現(xiàn)出的狀態(tài)與瀏覽器大致相同藏姐。不同的是 node 中有一套自己的模型。node 中事件循環(huán)的實現(xiàn)依賴 libuv 引擎该贾。Node的事件循環(huán)存在幾個階段羔杨。

如果是node10及其之前版本,microtask會在事件循環(huán)的各個階段之間執(zhí)行杨蛋,也就是一個階段執(zhí)行完畢兜材,就會去執(zhí)行 microtask隊列中的任務。

node版本更新到11之后逞力,Event Loop運行原理發(fā)生了變化曙寡,一旦執(zhí)行一個階段里的一個宏任務(setTimeout,setInterval和setImmediate)就立刻執(zhí)行微任務隊列,跟瀏覽器趨于一致寇荧。下面例子中的代碼是按照最新的去進行分析的举庶。

事件循環(huán)模型

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

事件循環(huán)各階段詳解

node中事件循環(huán)的順序

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

這些階段大致的功能如下:

  • 定時器檢測階段(timers): 這個階段執(zhí)行定時器隊列中的回調(diào)如 setTimeout() 和 setInterval()。
  • I/O事件回調(diào)階段(I/O callbacks): 這個階段執(zhí)行幾乎所有的回調(diào)揩抡。但是不包括close事件户侥,定時器和setImmediate()的回調(diào)。
  • 閑置階段(idle, prepare): 這個階段僅在內(nèi)部使用峦嗤,可以不必理會
  • 輪詢階段(poll): 等待新的I/O事件蕊唐,node在一些特殊情況下會阻塞在這里。
  • 檢查階段(check): setImmediate()的回調(diào)會在這個階段執(zhí)行烁设。
  • 關閉事件回調(diào)階段(close callbacks): 例如socket.on('close', ...)這種close事件的回調(diào)

poll:
這個階段是輪詢時間替梨,用于等待還未返回的 I/O 事件,比如服務器的回應、用戶移動鼠標等等副瀑。
這個階段的時間會比較長弓熏。如果沒有其他異步任務要處理(比如到期的定時器),會一直停留在這個階段俗扇,等待 I/O 請求返回結果硝烂。
check:
該階段執(zhí)行setImmediate()的回調(diào)函數(shù)。

close:
該階段執(zhí)行關閉請求的回調(diào)函數(shù)铜幽,比如socket.on('close', ...)滞谢。

timer階段:
這個是定時器階段,處理setTimeout()和setInterval()的回調(diào)函數(shù)除抛。進入這個階段后狮杨,主線程會檢查一下當前時間,是否滿足定時器的條件到忽。如果滿足就執(zhí)行回調(diào)函數(shù)般婆,否則就離開這個階段橙依。

I/O callback階段:
除了以下的回調(diào)函數(shù)露戒,其他都在這個階段執(zhí)行:

  • setTimeout()和setInterval()的回調(diào)函數(shù)
  • setImmediate()的回調(diào)函數(shù)
  • 用于關閉請求的回調(diào)函數(shù)占哟,比如socket.on('close', ...)

宏任務和微任務

宏任務:

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

微任務:

  • process.nextTick
  • new Promise().then(回調(diào))

Promise.nextTick翩迈, setTimeout, setImmediate的使用場景和區(qū)別

Promise.nextTick
process.nextTick 是一個獨立于 eventLoop 的任務隊列持灰。
在每一個 eventLoop 階段完成后會去檢查 nextTick 隊列,如果里面有任務负饲,會讓這部分任務優(yōu)先于微任務執(zhí)行堤魁。
是所有異步任務中最快執(zhí)行的。

setTimeout:
setTimeout()方法是定義一個回調(diào)返十,并且希望這個回調(diào)在我們所指定的時間間隔后第一時間去執(zhí)行妥泉。

setImmediate:
setImmediate()方法從意義上將是立刻執(zhí)行的意思,但是實際上它卻是在一個固定的階段才會執(zhí)行回調(diào)洞坑,即poll階段之后盲链。

經(jīng)典題目分析

一. 下面代碼輸出什么

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');

先執(zhí)行宏任務(當前代碼塊也算是宏任務),然后執(zhí)行當前宏任務產(chǎn)生的微任務迟杂,然后接著執(zhí)行宏任務

  1. 從上往下執(zhí)行代碼匈仗,先執(zhí)行同步代碼,輸出 script start
  2. 遇到setTimeout逢慌,現(xiàn)把 setTimeout 的代碼放到宏任務隊列中
  3. 執(zhí)行 async1(),輸出 async1 start, 然后執(zhí)行 async2(), 輸出 async2间狂,把 async2() 后面的代碼 console.log('async1 end')放到微任務隊列中
  4. 接著往下執(zhí)行攻泼,輸出 promise1,把 .then()放到微任務隊列中;注意Promise本身是同步的立即執(zhí)行函數(shù)忙菠,.then是異步執(zhí)行函數(shù)
  5. 接著往下執(zhí)行何鸡, 輸出 script end。同步代碼(同時也是宏任務)執(zhí)行完成牛欢,接下來開始執(zhí)行剛才放到微任務中的代碼
  6. 依次執(zhí)行微任務中的代碼骡男,依次輸出 async1 endpromise2, 微任務中的代碼執(zhí)行完成后傍睹,開始執(zhí)行宏任務中的代碼隔盛,輸出 setTimeout

最后的執(zhí)行結果如下

  • 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)
})

這道題跟上面題目不同之處在于,執(zhí)行代碼會產(chǎn)生很多個宏任務拾稳,每個宏任務中又會產(chǎn)生微任務

  1. 從上往下執(zhí)行代碼吮炕,先執(zhí)行同步代碼,輸出 start
  2. 遇到setTimeout访得,先把 setTimeout 的代碼放到宏任務隊列①中
  3. 接著往下執(zhí)行龙亲,輸出 children4, 遇到setTimeout,先把 setTimeout 的代碼放到宏任務隊列②中悍抑,此時.then并不會被放到微任務隊列中鳄炉,因為 resolve是放到 setTimeout中執(zhí)行的
  4. 代碼執(zhí)行完成之后,會查找微任務隊列中的事件搜骡,發(fā)現(xiàn)并沒有拂盯,于是開始執(zhí)行宏任務①,即第一個 setTimeout浆兰, 輸出 children2磕仅,此時,會把 Promise.resolve().then放到微任務隊列中簸呈。
  5. 宏任務①中的代碼執(zhí)行完成后榕订,會查找微任務隊列,于是輸出 children3蜕便;然后開始執(zhí)行宏任務②劫恒,即第二個 setTimeout,輸出 children5轿腺,此時將.then放到微任務隊列中两嘴。
  6. 宏任務②中的代碼執(zhí)行完成后,會查找微任務隊列族壳,于是輸出 children7憔辫,遇到 setTimeout,放到宏任務隊列中仿荆。此時微任務執(zhí)行完成贰您,開始執(zhí)行宏任務坏平,輸出 children6;

最后的執(zhí)行結果如下

  • 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. 執(zhí)行代碼,Promise本身是同步的立即執(zhí)行函數(shù)锦亦,.then是異步執(zhí)行函數(shù)舶替。遇到setTimeout,先把其放入宏任務隊列中杠园,遇到p1.then會先放到微任務隊列中顾瞪,接著往下執(zhí)行,輸出 3
  2. 遇到 p().then 會先放到微任務隊列中抛蚁,接著往下執(zhí)行陈醒,輸出 end
  3. 同步代碼塊執(zhí)行完成后,開始執(zhí)行微任務隊列中的任務篮绿,首先執(zhí)行 p1.then孵延,輸出 2, 接著執(zhí)行p().then, 輸出 4
  4. 微任務執(zhí)行完成后,開始執(zhí)行宏任務亲配,setTimeout, resolve(1)尘应,但是此時 p1.then已經(jīng)執(zhí)行完成,此時 1不會輸出吼虎。

最后的執(zhí)行結果如下

  • 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 的視頻:

參考

最后編輯于
?著作權歸作者所有,轉載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末玷犹,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子洒疚,更是在濱河造成了極大的恐慌歹颓,老刑警劉巖,帶你破解...
    沈念sama閱讀 217,084評論 6 503
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件油湖,死亡現(xiàn)場離奇詭異巍扛,居然都是意外死亡,警方通過查閱死者的電腦和手機乏德,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,623評論 3 392
  • 文/潘曉璐 我一進店門撤奸,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人喊括,你說我怎么就攤上這事胧瓜。” “怎么了郑什?”我有些...
    開封第一講書人閱讀 163,450評論 0 353
  • 文/不壞的土叔 我叫張陵府喳,是天一觀的道長。 經(jīng)常有香客問我蘑拯,道長劫拢,這世上最難降的妖魔是什么肉津? 我笑而不...
    開封第一講書人閱讀 58,322評論 1 293
  • 正文 為了忘掉前任,我火速辦了婚禮舱沧,結果婚禮上,老公的妹妹穿的比我還像新娘偶洋。我一直安慰自己熟吏,他們只是感情好,可當我...
    茶點故事閱讀 67,370評論 6 390
  • 文/花漫 我一把揭開白布玄窝。 她就那樣靜靜地躺著牵寺,像睡著了一般。 火紅的嫁衣襯著肌膚如雪恩脂。 梳的紋絲不亂的頭發(fā)上帽氓,一...
    開封第一講書人閱讀 51,274評論 1 300
  • 那天,我揣著相機與錄音俩块,去河邊找鬼黎休。 笑死,一個胖子當著我的面吹牛玉凯,可吹牛的內(nèi)容都是我干的势腮。 我是一名探鬼主播,決...
    沈念sama閱讀 40,126評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼漫仆,長吁一口氣:“原來是場噩夢啊……” “哼捎拯!你這毒婦竟也來了?” 一聲冷哼從身側響起盲厌,我...
    開封第一講書人閱讀 38,980評論 0 275
  • 序言:老撾萬榮一對情侶失蹤署照,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后吗浩,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體建芙,經(jīng)...
    沈念sama閱讀 45,414評論 1 313
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,599評論 3 334
  • 正文 我和宋清朗相戀三年拓萌,在試婚紗的時候發(fā)現(xiàn)自己被綠了岁钓。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 39,773評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡微王,死狀恐怖屡限,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情炕倘,我是刑警寧澤钧大,帶...
    沈念sama閱讀 35,470評論 5 344
  • 正文 年R本政府宣布,位于F島的核電站罩旋,受9級特大地震影響啊央,放射性物質發(fā)生泄漏眶诈。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 41,080評論 3 327
  • 文/蒙蒙 一瓜饥、第九天 我趴在偏房一處隱蔽的房頂上張望逝撬。 院中可真熱鬧,春花似錦乓土、人聲如沸宪潮。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,713評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽狡相。三九已至,卻和暖如春食磕,著一層夾襖步出監(jiān)牢的瞬間尽棕,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 32,852評論 1 269
  • 我被黑心中介騙來泰國打工彬伦, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留滔悉,地道東北人。 一個月前我還...
    沈念sama閱讀 47,865評論 2 370
  • 正文 我出身青樓媚朦,卻偏偏與公主長得像氧敢,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子询张,可洞房花燭夜當晚...
    茶點故事閱讀 44,689評論 2 354