首頁 > web前端 > js教程 > 主體

一起來聊聊JavaScript事件循環的原理與實例

WBOY
發布: 2022-11-10 21:00:06
轉載
2131 人瀏覽過

這篇文章為大家帶來了關於JavaScript的相關知識,其中主要介紹了事件循環的相關內容,下面一起來看一下,希望對大家有幫助。

一起來聊聊JavaScript事件循環的原理與實例

【相關推薦:JavaScript影片教學web前端

理解JavaScript 的事件循環常伴隨著巨集任務和微任務、JavaScript 單執行緒執行過程及瀏覽器非同步機制等相關問題,而瀏覽器和 NodeJS  中的事件循環實作也是很大差異。熟悉事件循環,了解瀏覽器運行機制將對我們理解 JavaScript 的執行過程,以及在排查程式碼執行問題時有很大幫助。

瀏覽器JS 非同步執行的原理

JS 是單執行緒的,也就是同一個時刻只能做一件事情,那麼思考:為什麼瀏覽器可以同時執行非同步任務呢?

因為瀏覽器是多執行緒的,當 JS 需要執行非同步任務時,瀏覽器會另外啟動一個執行緒去執行該任務。也就是說,「JS 是單線程的」指的是執行 JS 程式碼的線程只有一個,是瀏覽器提供的 JS 引擎線程(主執行緒)。瀏覽器中還有定時器執行緒和 HTTP 請求執行緒等,這些執行緒主要不是來跑 JS 程式碼的。

例如主執行緒中需要發一個AJAX 請求,就把這個任務交給另一個瀏覽器執行緒(HTTP 請求執行緒)去真正發送請求,待請求回來了,再將callback 裡需要執行的JS回調交給JS 引擎線程去執行。 **即瀏覽器才是真正執行發送請求這個任務的角色,而 JS 只是負責執行最後的回調處理。 **所以這裡的非同步不是 JS 本身實現的,其實是瀏覽器提供的能力。

以 Chrome 為例,瀏覽器不僅有多個線程,還有多個進程,如渲染進程、GPU 進程和插件進程等。而每個 tab 標籤頁都是獨立的渲染進程,所以一個 tab 異常崩潰後,其他 tab 基本上不會被影響。 作為前端開發者,主要專注於其渲染進程,渲染進程下包含了 JS 引擎執行緒、HTTP 請求執行緒和定時器執行緒等,這些執行緒為 JS 在瀏覽器中完成非同步任務提供了基礎。

事件驅動淺析

瀏覽器非同步任務的執行原理背後其實是一套事件驅動的機制。事件觸發、任務選擇和任務執行都是由事件驅動機制來完成的。 NodeJS 和瀏覽器的設計都是基於事件驅動的,簡而言之就是由特定的事件來觸發特定的任務,這裡的事件可以是用戶的操作觸發的,如click 事件;也可以是程式自動觸發的,例如瀏覽器中定時器執行緒在計時結束後會觸發定時器事件。而本文的主題內容事件循環其實就是在事件驅動模式中來管理和執行事件的一套流程

以簡單場景為例,假設遊戲介面上有一個移動按鈕和人物模型,每次點擊右移後,人物模型的位置都需要重新渲染,右移 1 像素。根據渲染時機的不同我們可以用不同的方式來實現。

實作方式一:事件驅動。 點選按鈕後,修改座標 positionX 時,立即觸發介面渲染的事件,觸發重新渲染。

實作方式二:狀態驅動或資料驅動。 點選按鈕後,只修改座標 positionX,不觸發介面渲染。在此之前會啟動一個定時器 setInterval,或是利用 requestAnimationFrame 來不斷地偵測 positionX 是否有變化。如果有變化,則立即重新渲染。

瀏覽器中的點擊事件處理也是典型的基於事件驅動。在事件驅動中,當有事件觸發後,被觸發的事件會依序暫時存在一個佇列中,待 JS 的同步任務執行完成後,會從這個佇列中取出要處理的事件並進行處理。那麼具體什麼時候取任務、優先取哪些任務,這就由事件循環流程來控制了。

瀏覽器中的事件循環

執行堆疊與任務佇列

JS 在解析一段程式碼時,會將同步程式碼依序排在某個地方,也就是執行棧,然後依序執行裡面的函數。當遇到非同步任務時就交給其他執行緒處理,待當前執行棧所有同步程式碼執行完成後,會從一個佇列中去取出已完成的非同步任務的回調加入執行端繼續執行,遇到非同步任務時又交給其他線程,.....,如此循環往復。而其他非同步任務完成後,將回呼放入任務佇列中待執行堆疊中取出執行。

JS 依序執行執行堆疊中的方法,每次執行一個方法時,都會為這個方法產生獨有的執行環境(上下文context),待這個方法執行完成後,銷毀目前的執行環境,並從堆疊中彈出此方法(即消費完成),然後繼續下一個方法。

可見,在事件驅動的模式下,至少包含了一個執行迴圈來偵測任務佇列是否有新的任務。透過不斷循環去取出非同步回調來執行,這個過程就是事件循環,而每個循環就是一個事件週期或稱為一次 tick。

巨集任務和微任務

任務佇列不只一個,依任務的種類不同,可以分為微任務(micro task)佇列和巨集任務(macro task)佇列。

事件循環的過程中,執行堆疊在同步程式碼執行完成後,優先檢查微任務佇列是否有任務需要執行,如果沒有,再去巨集任務佇列檢查是否有任務執行,如此往復。微任務一般在當前循環就會優先執行,而巨集任務會等到下一次循環,因此,微任務一般比巨集任務先執行,且微任務佇列只有一個,巨集任務佇列可能有多個。另外我們常見的點擊和鍵盤等事件也屬於巨集任務。

下面我們來看看常見巨集任務和常見微任務。

常見巨集任務:

  • setTimeout()
  • setInterval()
  • setImmediate()

#常見微任務:

  • promise.then()、promise.catch()
  • new MutaionObserver()
  • process.nextTick()
console.log('同步代码1');setTimeout(() => {    console.log('setTimeout')
}, 0)new Promise((resolve) => {  console.log('同步代码2')  resolve()
}).then(() => {    console.log('promise.then')
})console.log('同步代码3');// 最终输出"同步代码1"、"同步代码2"、"同步代码3"、"promise.then"、"setTimeout"
登入後複製

上面的程式碼將如下順序輸出為:"同步程式碼1"、"同步程式碼2"、"同步程式碼3"、"promise.then"、 "setTimeout",具體分析如下。

(1)setTimeout 回調和promise.then 都是非同步執行的,將在所有同步程式碼之後執行;

順便提一下,在瀏覽器中setTimeout的延遲設定為0 的話,會預設為4ms,NodeJS 為1ms。具體值可能不固定,但不是為 0。

(2)雖然promise.then 寫在後面,但執行順序卻比setTimeout 優先,因為它是微任務;

(3)new Promise 是同步執行的,promise.then 裡面的回調才是異步的。

下面我們來看上面程式碼的執行過程示範:

#也有人這樣去理解:微任務是在目前事件循環的尾部去執行;巨集任務是在下一次事件循環的開始去執行。讓我們來看看微任務和宏任務的本質差異是什麼。

我們已經知道,JS 遇到非同步任務時會將此任務交給其他執行緒去處理,自己的主執行緒繼續往後執行同步任務。例如 setTimeout 的計時會由瀏覽器的定時器執行緒來處理,待計時結束,就將定時器回呼任務放入任務佇列等待主執行緒來取出執行。前面我們提到,因為 JS 是單執行緒執行的,所以要執行非同步任務,就需要瀏覽器其他執行緒來輔助,也就是多執行緒是 JS 非同步任務的一個明顯特徵。

我們再來分析下 promise.then(微任務)的處理。當執行到promise.then 時,V8 引擎不會將非同步任務交給瀏覽器其他線程,而是將回呼存在自己的一個佇列中,待當前執行棧執行完成後,立刻去執行promise.then 存放的佇列,promise.then 微任務沒有多執行緒參與,甚至從某些角度說,微任務都不能完全算是異步,它只是將書寫時的程式碼修改了執行順序而已。

setTimeout 有「定時等待」這個任務,需要定時器執行緒執行;ajax 請求有「發送請求」這個任務,需要HTTP 執行緒執行,而promise.then 它沒有任何非同步任務需要其他執行緒執行,它只有回調,即使有,也只是內部嵌套的另一個宏任務。

簡單小結一下微任務和巨集任務的本質差異。

  • 巨集任務特徵:有明確的非同步任務需要執行和回呼;需要其他非同步執行緒支援。
  • 微任務特徵:沒有明確的非同步任務需要執行,只有回呼;不需要其他非同步執行緒支援。

定時器誤差

事件循環中,總是先執行同步程式碼後,才會去任務佇列中取出非同步回呼來執行。當執行 setTimeout 時,瀏覽器啟動新的執行緒去計時,計時結束後觸發定時器事件將回呼存入巨集任務佇列,等待 JS 主執行緒來取出執行。如果這時主執行緒還在執行同步任務的過程中,那麼此時的巨集任務就只有先掛起,這就造成了計時器不準確的問題。同步程式碼耗時越長,計時器的誤差就越大。不僅同步程式碼,由於微任務會優先執行,所以微任務也會影響計時,假設同步程式碼中有一個死循環或微任務中遞歸不斷在啟動其他微任務,那麼巨集任務裡面的程式碼可能永遠不會得到執行。所以主執行緒程式碼的執行效率提升是一件很重要的事情。

一個很簡單的場景就是我們介面上有一個時鐘精確到秒,每秒更新一次時間。你會發現有時候秒數會直接跳過 2 秒間隔,就是這個原因。

視圖更新渲染

微任務佇列執行完成後,也就是一次事件循環結束後,瀏覽器會執行視圖渲染,當然這裡會有瀏覽器的最佳化,可能會合併多次循環的結果做一次視圖重繪,因此視圖更新是在事件循環之後,所以並不是每一次操作Dom 都一定會立刻刷新視圖。在視圖重繪之前會先執行 requestAnimationFrame 回調,那麼對於 requestAnimationFrame 是微任務還是巨集任務是有爭議的,在這裡看來,它應該既不屬於微任務,也不屬於巨集任務。

NodeJS 中的事件循環

JS 引擎本身不實作事件循環機制,這是由它的宿主實現的,瀏覽器中的事件循環主要由瀏覽器來實現,而在NodeJS 中也有自己的事件循環實作。 NodeJS 中也是循環 任務佇列的流程以及微任務優先於巨集任務,大致表現和瀏覽器是一致的。不過它與瀏覽器中也有一些差異,並且新增了一些任務類型和任務階段。接下來我們介紹下 NodeJS 中的事件循環流程。

NodeJS 中的非同步方法

因為都是基於 V8 引擎,瀏覽器中包含的非同步方式在 NodeJS 中也是一樣的。另外 NodeJS 中還有一些其他常見非同步形式。

  • 檔案 I/O:非同步載入本機檔案。
  • setImmediate():與 setTimeout 設定 0ms  類似,在某些同步任務完成後立刻執行。
  • process.nextTick():在某些同步任務完成後立刻執行。
  • server.close、socket.on('close',...)等:關閉回呼。

想像一下,如果上面的形式和 setTimeout、promise 等同時存在,如何分析出程式碼的執行順序呢?只要我們了解 NodeJS 的事件循環機制,也就清楚了。

事件循環模型

NodeJS 的跨平台能力和事件循環機制都是基於 Libuv 函式庫實現的,你不用關心這個函式庫的具體內容。我們只需要知道 Libuv 函式庫是事件驅動的,並且封裝和統一了不同平台的 API 實作。

NodeJS   中 V8 引擎將 JS 程式碼解析後呼叫 Node API,然後 Node API 將任務交給 Libuv 去分配,最後再將執行結果傳回 V8 引擎。在 Libux 中實作了一套事件循環流程來管理這些任務的執行,所以 NodeJS 的事件循環主要是在 Libuv 中完成的

下面我們來看看 Libuv 中的迴圈是怎麼樣的。

事件循環各階段

在NodeJS   中JS 的執行,我們主要需要關心的過程分為以下幾個階段,下面每個階段都有自己單獨的任務隊列,當執行到對應階段時,就判斷目前階段的任務佇列是否有需要處理的任務。

  • timers 阶段:执行所有 setTimeout() 和 setInterval() 的回调。
  • pending callbacks 阶段:某些系统操作的回调,如  TCP  链接错误。除了 timers、close、setImmediate 的其他大部分回调在此阶段执行。
  • poll 阶段:轮询等待新的链接和请求等事件,执行 I/O 回调等。V8 引擎将 JS 代码解析并传入 Libuv 引擎后首先进入此阶段。如果此阶段任务队列已经执行完了,则进入 check 阶段执行 setImmediate 回调(如果有 setImmediate),或等待新的任务进来(如果没有 setImmediate)。在等待新的任务时,如果有 timers 计时到期,则会直接进入 timers 阶段。此阶段可能会阻塞等待。
  • check 阶段:setImmediate 回调函数执行。
  • close callbacks 阶段:关闭回调执行,如 socket.on('close', ...)。

上面每个阶段都会去执行完当前阶段的任务队列,然后继续执行当前阶段的微任务队列,只有当前阶段所有微任务都执行完了,才会进入下个阶段。这里也是与浏览器中逻辑差异较大的地方,不过浏览器不用区分这些阶段,也少了很多异步操作类型,所以不用刻意去区分两者区别。代码如下所示:

const fs = require('fs');
fs.readFile(__filename, (data) => {    // poll(I/O 回调) 阶段
    console.log('readFile')    Promise.resolve().then(() => {        console.error('promise1')
    })    Promise.resolve().then(() => {        console.error('promise2')
    })
});setTimeout(() => {    // timers 阶段
    console.log('timeout');    Promise.resolve().then(() => {        console.error('promise3')
    })    Promise.resolve().then(() => {        console.error('promise4')
    })
}, 0);// 下面代码只是为了同步阻塞1秒钟,确保上面的异步任务已经准备好了var startTime = new Date().getTime();var endTime = startTime;while(endTime - startTime < 1000) {
    endTime = new Date().getTime();
}// 最终输出 timeout promise3 promise4 readFile promise1 promise2
登入後複製

另一个与浏览器的差异还体现在同一个阶段里的不同任务执行,在 timers 阶段里面的宏任务、微任务测试代码如下所示:

setTimeout(() => {  console.log('timeout1')    Promise.resolve().then(function() {    console.log('promise1')
  })
}, 0);setTimeout(() => {  console.log('timeout2')    Promise.resolve().then(function() {    console.log('promise2')
  })
}, 0);
登入後複製
  • 浏览器中运行

    每次宏任务完成后都会优先处理微任务,输出“timeout1”、“promise1”、“timeout2”、“promise2”。

  • NodeJS 中运行

    因为输出 timeout1 时,当前正处于  timers 阶段,所以会先将所有 timer 回调执行完之后再执行微任务队列,即输出“timeout1”、“timeout2”、“promise1”、“promise2”。

上面的差异可以用浏览器和 NodeJS 10 对比验证。是不是感觉有点反程序员?因此 NodeJS 在版本 11 之后,就修改了此处逻辑使其与浏览器尽量一致,也就是每个 timer 执行后都先去检查一下微任务队列,所以 NodeJS 11 之后的输出已经和浏览器一致了。

nextTick、setImmediate 和 setTimeout

实际项目中我们常用 Promise 或者 setTimeout 来做一些需要延时的任务,比如一些耗时计算或者日志上传等,目的是不希望它的执行占用主线程的时间或者需要依赖整个同步代码执行完成后的结果。

NodeJS 中的 process.nextTick() 和 setImmediate() 也有类似效果。其中 setImmediate() 我们前面已经讲了是在 check 阶段执行的,而 process.nextTick() 的执行时机不太一样,它比 promise.then() 的执行还早,在同步任务之后,其他所有异步任务之前,会优先执行 nextTick。可以想象是把 nextTick 的任务放到了当前循环的后面,与 promise.then() 类似,但比 promise.then() 更前面。意思就是在当前同步代码执行完成后,不管其他异步任务,先尽快执行 nextTick。如下面的代码,因此这里的 nextTick 其实应该更符合“setImmediate”这个命名才对。

setTimeout(() => {    console.log('timeout');
}, 0);Promise.resolve().then(() => {    console.error('promise')
})
process.nextTick(() => {    console.error('nextTick')
})// 输出:nextTick、promise、timeout
登入後複製

接下来我们再来看看 setImmediate 和 setTimeout,它们是属于不同的执行阶段了,分别是 timers 阶段和 check 阶段。

setTimeout(() => {  console.log('timeout');
}, 0);setImmediate(() => {  console.log('setImmediate');
});// 输出:timeout、 setImmediate
登入後複製

分析上面代码,第一轮循环后,分别将 setTimeout   和 setImmediate 加入了各自阶段的任务队列。第二轮循环首先进入  timers 阶段,执行定时器队列回调,然后  pending callbacks 和 poll 阶段没有任务,因此进入check 阶段执行 setImmediate 回调。所以最后输出为“timeout”、“setImmediate”。当然这里还有种理论上的极端情况,就是第一轮循环结束后耗时很短,导致 setTimeout 的计时还没结束,此时第二轮循环则会先执行 setImmediate 回调。

再看这下面一段代码,它只是把上一段代码放在了一个 I/O 任务回调中,它的输出将与上一段代码相反。

const fs = require('fs');
fs.readFile(__filename, (data) => {    console.log('readFile');    setTimeout(() => {        console.log('timeout');
    }, 0);    setImmediate(() => {        console.log('setImmediate');
    });
});// 输出:readFile、setImmediate、timeout
登入後複製

如上面代码所示:

  • 第一輪循環沒有需要執行的非同步任務佇列;
  • 第二輪循環timers 等階段都沒有任務,只有poll 階段有I/O 回呼任務,即輸出“readFile” ;
  • 參考前面事件階段的說明,接下來,poll 階段會偵測如果有setImmediate 的任務佇列則進入check 階段,否則再進行判斷,如果有定時器任務回調,則回到timers 階段,所以應該進入check 階段執行setImmediate,輸出「setImmediate」;
  • 然後進入最後的close callbacks 階段,本次循環結束;
  • 最後進行第三輪循環,進入timers 階段,輸出「timeout」。

所以最終輸出「setImmediate」在「timeout」之前。可見這兩者的執行順序與目前執行的階段有關係。

【相關推薦:JavaScript影片教學web前端

以上是一起來聊聊JavaScript事件循環的原理與實例的詳細內容。更多資訊請關注PHP中文網其他相關文章!

相關標籤:
來源:juejin.im
本網站聲明
本文內容由網友自願投稿,版權歸原作者所有。本站不承擔相應的法律責任。如發現涉嫌抄襲或侵權的內容,請聯絡admin@php.cn
熱門教學
更多>
最新下載
更多>
網站特效
網站源碼
網站素材
前端模板
關於我們 免責聲明 Sitemap
PHP中文網:公益線上PHP培訓,幫助PHP學習者快速成長!