<kbd id="afajh"><form id="afajh"></form></kbd>
<strong id="afajh"><dl id="afajh"></dl></strong>
    <del id="afajh"><form id="afajh"></form></del>
        1. <th id="afajh"><progress id="afajh"></progress></th>
          <b id="afajh"><abbr id="afajh"></abbr></b>
          <th id="afajh"><progress id="afajh"></progress></th>

          JavaScript 事件循環(huán):從起源到瀏覽器再到 Node.js

          共 9947字,需瀏覽 20分鐘

           ·

          2021-06-08 12:53

          點擊上方 程序員成長指北,關(guān)注公眾號

          回復(fù)1,加入高級 Node 進階交流群

          作者:冰森 

          原文地址:https://mp.weixin.qq.com/s/uAU-fZi7ngg0vP6k2k83xg

          很多文章都在討論事件循環(huán) (Event Loop) 是什么,而幾乎沒有人討論為什么 JavaScript 中會有事件循環(huán)。博主認(rèn)為這是為什么很多人都不能很好理解事件循環(huán)的一個重要原因 —— 知其然不知其所以然。所以本文試圖拋磚引玉,從一些更溯源的方式來與大家探討 event loop,希望大家能從中有些收獲。

          本文從三個角度來研究 JavaScript 的事件循環(huán):

          • 為什么是事件循環(huán)

          • 事件循環(huán)是什么

          • 瀏覽器與 Node.js 的事件循環(huán)差異

          為什么是事件循環(huán)

          JavaScript 是網(wǎng)景 (Netscape) 公司為其旗下的網(wǎng)景瀏覽器提供更復(fù)雜網(wǎng)頁交互時所推出的一個動態(tài)腳本語言。其創(chuàng)作者 Eich 在 10 天內(nèi)寫出了 JavaScript 的第一個版本,通過 Eich 在 JavaScript 20 周年的演講回顧中,我們可以發(fā)現(xiàn) JavaScript 在最初設(shè)計的時候沒有考慮所謂的事件循環(huán)。那么事件循環(huán)到底是怎么出現(xiàn)的?

          首先讓我們來看看引入 JavaScript 到網(wǎng)頁端的經(jīng)典用例:一個用戶打開一個網(wǎng)頁,填寫完表單提交之后,等待 30s 的白屏之后發(fā)現(xiàn)表單中的某個地方填寫錯誤了需要重新填寫。在這個場景中,如果我們有 JavaScript 就可以在用戶提交表單之前先在用戶本地的瀏覽器端做一次校驗,避免用戶每次都通過網(wǎng)絡(luò)找服務(wù)端來校驗所浪費的時間。

          分析一下這個場景,我們就可以發(fā)現(xiàn),最早的 JavaScript 的執(zhí)行就是用戶通過瀏覽器的事件來觸發(fā)的,例如用戶填寫完表單之后點擊提交的時候,瀏覽器觸發(fā)一個 DOM 的點擊事件,而點擊事件綁定了對應(yīng)的 JavaScript 代碼來執(zhí)行校驗的過程。在這個過程中,JavaScript 的代碼都是被動被調(diào)用的。

          仔細(xì)思考一下就會發(fā)現(xiàn),JavaScript 所謂的事件和觸發(fā)本質(zhì)上都通過瀏覽器中轉(zhuǎn),更像是瀏覽器行為而不僅僅是 JavaScript 語言內(nèi)的一個隊列。順著這個思路我們順藤摸瓜,就會發(fā)現(xiàn) EcmaScript 的標(biāo)準(zhǔn)定義中壓根 就沒有事件循環(huán),反倒是 HTML 的標(biāo)準(zhǔn)中定義了事件循環(huán)(目前 HTML 有 whatwg 和 w3c 標(biāo)準(zhǔn),這里討論的是 wahtwg 的標(biāo)準(zhǔn)):

          To coordinate events, user interaction, scripts, rendering, networking, and so forth, user agents must use event loops as described in this section. Each agent has an associated event loop, which is unique to that agent.

          根據(jù)標(biāo)準(zhǔn)中對事件循環(huán)的定義描述,我們可以發(fā)現(xiàn)事件循環(huán)本質(zhì)上是 user agent (如瀏覽器端) 用于協(xié)調(diào)用戶交互(鼠標(biāo)、鍵盤)、腳本(如 JavaScript)、渲染(如 HTML DOM、CSS 樣式)、網(wǎng)絡(luò)等行為的一個機制。

          了解到這個定義之后,我們就能夠清楚的知道,與其說是 JavaScript 提供了事件循環(huán),不如說是嵌入 JavaScript 的 user agent 需要通過事件循環(huán)來與多種事件源交互。

          事件循環(huán)是什么

          所以說事件循環(huán)本質(zhì)是一個 user agent 上協(xié)調(diào)各類事件的機制,而這一節(jié)我們主要討論一下瀏覽器中的這個機制與 JavaScript 的交互部分。

          各種瀏覽器事件同時觸發(fā)時,肯定有一個先來后到的排隊問題。決定這些事件如何排隊觸發(fā)的機制,就是事件循環(huán)。這個排隊行為以 JavaScript 開發(fā)者的角度來看,主要是分成兩個隊列:


          • 一個是 JavaScript 外部的隊列。外部的隊列主要是瀏覽器協(xié)調(diào)的各類事件的隊列,標(biāo)準(zhǔn)文件中稱之為 Task Queue。下文中為了方便理解統(tǒng)一稱為外部隊列

          • 另一個是 JavaScript 內(nèi)部的隊列。這部分主要是 JavaScript 內(nèi)部執(zhí)行的任務(wù)隊列,標(biāo)準(zhǔn)中稱之為 Microtask Queue下文中為了方便理解統(tǒng)一稱為內(nèi)部隊列

          值得注意的是,雖然為了好理解我們管這個叫隊列 (Queue),但是本質(zhì)上是有序集合 (Set),因為傳統(tǒng)的隊列都是先進先出(FIFO)的,而這里的隊列則不然,排到最前面但是沒有滿足條件也是不會執(zhí)行的(比如外部隊列里只有一個 setTimeout 的定時任務(wù),但是時間還沒有到,沒有滿足條件也不會把他出列來執(zhí)行)。


          外部隊列

          外部隊列(Task Queue [1]),顧名思義就是 JavaScript 外部的事件的隊列,這里我們可以先列舉一下瀏覽器中這些外部事件源(Task Source),他們主要有:

          • DOM 操作 (頁面渲染)

          • 用戶交互 (鼠標(biāo)、鍵盤)

          • 網(wǎng)絡(luò)請求 (Ajax 等)

          • History API 操作

          • 定時器 (setTimeout 等) [2]

          可以觀察到,這些外部的事件源可能很多,為了方便瀏覽器廠商優(yōu)化,HTML 標(biāo)準(zhǔn)中明確指出一個事件循環(huán)由一個或多個外部隊列,而每一個外部事件源都有一個對應(yīng)的外部隊列。不同事件源的隊列可以有不同的優(yōu)先級(例如在網(wǎng)絡(luò)事件和用戶交互之間,瀏覽器可以優(yōu)先處理鼠標(biāo)行為,從而讓用戶感覺更加流程)。

          內(nèi)部隊列

          內(nèi)部隊列(Microtask Queue),即 JavaScript 語言內(nèi)部的事件隊列,在 HTML 標(biāo)準(zhǔn)中,并沒有明確規(guī)定這個隊列的事件源,通常認(rèn)為有以下幾種:

          • Promise 的成功 (.then) 與失敗 (.catch)

          • MutationObserver

          • Object.observe (已廢棄)

          處理模型

          在標(biāo)準(zhǔn)定義中事件循環(huán)的步驟比較復(fù)雜,這里我們簡單描述一下這個處理過程:


          1. 從外部隊列中取出一個可執(zhí)行任務(wù),如果有則執(zhí)行,沒有下一步。

          2. 挨個取出內(nèi)部隊列中的所有任務(wù)執(zhí)行,執(zhí)行完畢或沒有則下一步。

          3. 覽器渲染。


          案例分析

          根據(jù)上述的處理模型,我們可以來看以下例子:

          console.log('script start');

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

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

          console.log('script end');

          輸出結(jié)果:

          script start
          script end
          promise1
          promise2
          setTimeout

          對應(yīng)的處理過程則是:

          1. 執(zhí)行 console.log (輸出 script start)

          2. 遇到 setTimeout 加入外部隊列

          3. 遇到兩個 Promise 的 then 加入內(nèi)部隊列

          4. 遇到 console.log 直接執(zhí)行(輸出 script end)

          5. 內(nèi)部隊列中的任務(wù)挨個執(zhí)行完 (輸出 promise1 和 promise2)

          6. 外部隊列中的任務(wù)執(zhí)行 (輸出 setTimeout)

          只要理解了外部隊列與內(nèi)部隊列的概念,再看這類問題就會變得很簡單,我們再簡單擴展看看:

          setTimeout(() => {
          console.log('setTimeout1')
          })

          Promise.resolve().then(() => {
          console.log('promise1')
          })

          setTimeout(() => {
          console.log('setTimeout2')
          })

          Promise.resolve().then(() => {
          console.log('promise2')
          })

          Promise.resolve().then(() => {
          console.log('promise3')
          })

          console.log('script end');

          結(jié)果輸出

          script end
          promise1
          promise2
          promise3
          setTimeout1
          setTimeout2

          可以發(fā)現(xiàn)加入內(nèi)部隊列的順序和時間雖然后差異,但是輪到內(nèi)部隊列執(zhí)行的時候,一定會先全部執(zhí)行完內(nèi)部隊列才會繼續(xù)往下走去執(zhí)行外部隊列的任務(wù)。

          最后我們再看一個引入了 HTML 渲染的例子:

          <html>
          <body>
          <pre id="main"></pre>
          </body>
          <script>
          const main = document.querySelector('#main');
          const callback = (i, fn) => () => {
          console.log(i)
          main.innerText += fn(i);
          };
          let i = 1;
          while(i++ < 5000) {
          setTimeout(callback(i, (i) => '\n' + i + '<'))
          }

          while(i++ < 10000) {
          Promise.resolve().then(callback(i, (i) => i +','))
          }
          console.log(i)
          main.innerText += '[end ' + i + ' ]\n'
          </script>
          </html>


          通過這個例子,我們就可以發(fā)現(xiàn),渲染過程很明顯分成三個階段:

          1. JavaScript 執(zhí)行完畢 innerText 首先加上 [end 10001]

          2. 內(nèi)部隊列:Promise 的 then 全部任務(wù)執(zhí)行完畢,往 innerText 上追加了很長一段字符串

          3. HTML 渲染:1 和 2 追加到 innerText 上的內(nèi)容同時渲染

          4. 外部隊列:挨個執(zhí)行 setTimeout 中追加到 innerText 的內(nèi)容

          5. HTML 渲染:將 4 中的內(nèi)容渲染。

          6. 回到第 4 步走外部隊列的流程(內(nèi)部隊列已清空)

          script 事件是外部隊列

          有的同學(xué)看完上面的幾個例子之后可能有個問題,為什么 JavaScript 代碼執(zhí)行到 script end 之后,是先執(zhí)行內(nèi)部隊列然后再執(zhí)行外部隊列的任務(wù)?

          這里不得不把上文總出現(xiàn)過的 HTML 事件循環(huán)標(biāo)準(zhǔn) 再拉出來一遍:

          To coordinate events, user interaction, scripts, rendering, networking, and so forth, user agents must use event loops as described in this section...

          看到這里,大家可能就反應(yīng)過來了,scripts 執(zhí)行也是一個事件,我們只要歸類一下就會發(fā)現(xiàn) JavaScript 的執(zhí)行也是一個瀏覽器發(fā)起的外部事件。所以本質(zhì)的執(zhí)行順序還是:

          1. 一次外部事件

          2. 所有內(nèi)部事件

          3. HTML 渲染

          4. 回到到 1

          瀏覽器與 Node.js 的事件循環(huán)差異

          根據(jù)本文開頭我們討論的事件循環(huán)起源,很容易理解為什么瀏覽器與 Node.js 的事件循環(huán)會存在差異。如果說瀏覽端是將 JavaScript 集成到 HTML 的事件循環(huán)之中,那么 Node.js 則是將 JavaScript 集成到 libuv 的 I/O 循環(huán)之中。

          簡而言之,二者都是把 JavaScript 集成到他們各自的環(huán)境中,但是 HTML (瀏覽器端) 與 libuv (服務(wù)端) 面對的場景有很大的差異。首先能直觀感受到的區(qū)別是:

          1. 事件循環(huán)的過程沒有 HTML 渲染。只剩下了外部隊列和內(nèi)部隊列這兩個部分。

          2. 外部隊列的事件源不同。Node.js 端沒有了鼠標(biāo)等外設(shè)但是新增了文件等 IO。

          3. 內(nèi)部隊列的事件僅剩下 Promise 的 then 和 catch。

          至于內(nèi)在的差異,有一個很重要的地方是 Node.js (libuv)在最初設(shè)計的時候是允許執(zhí)行多次外部的事件再切換到內(nèi)部隊列的,而瀏覽器端一次事件循環(huán)只允許執(zhí)行一次外部事件。這個經(jīng)典的內(nèi)在差異,可以通過以下例子來觀察:

          setTimeout(()=>{
          console.log('timer1');
          Promise.resolve().then(function() {
          console.log('promise1');
          });
          });

          setTimeout(()=>{
          console.log('timer2');
          Promise.resolve().then(function() {
          console.log('promise2');
          });
          });


          這個例子在瀏覽器端執(zhí)行的結(jié)果是 timer1 -> promise1 -> timer2 -> promise2,而在 Node.js 早期版本(11 之前)執(zhí)行的結(jié)果卻是 timer1 -> timer2 -> promise1 -> promise2

          究其原因,主要是因為瀏覽器端有外部隊列一次事件循環(huán)只能執(zhí)行一個的限制,而在 Node.js 中則放開了這個限制,允許外部隊列中所有任務(wù)都執(zhí)行完再切換到內(nèi)部隊列。所以他們的情況對應(yīng)為:

          瀏覽器端


          1. 外部隊列:代碼執(zhí)行,兩個 timeout 加入外部隊列

          2. 內(nèi)部隊列:空

          3. 外部隊列:第一個 timeout 執(zhí)行,promise 加入內(nèi)部隊列

          4. 內(nèi)部隊列:執(zhí)行第一個 promise

          5. 外部隊列:第二個 timeout 執(zhí)行,promise 加入內(nèi)部隊列

          6. 內(nèi)部隊列:執(zhí)行第二個 promise


          Node.js 服務(wù)端


          1. 外部隊列:代碼執(zhí)行,兩個 timeout 加入外部隊列

          2. 內(nèi)部隊列:空

          3. 外部隊列:兩個 timeout 都執(zhí)行完

          4. 內(nèi)部隊列:兩個 promise 都執(zhí)行完

          雖然 Node.js 的這個問題在 11 之后的版本里修復(fù)了,但是為了繼續(xù)探究這個影響,我們引入一個新的外部事件 setImmediate。這個方法目前是 Node.js 獨有的,瀏覽器端沒有。

          setImmediate 的引入是為了解決 setTimeout 的精度問題,由于 setTimeout 指定的延遲時間是毫秒(ms)但實際一次時間循環(huán)的時間可能是納秒級的,所以在一次事件循環(huán)的多個外部隊列中,找到某一個隊列直接執(zhí)行其中的 callback 可以得到比 setTimeout 更早執(zhí)行的效果。我們繼續(xù)以開始的場景構(gòu)造一個例子,并在 Node.js 10.x 的版本上執(zhí)行(存在一次事件循環(huán)執(zhí)行多次外部事件):

          setTimeout(()=>{
          console.log('setTimeout1');
          Promise.resolve().then(() => console.log('promise1'));
          });

          setTimeout(()=>{
          console.log('setTimeout2');
          Promise.resolve().then(() => console.log('promise2'));
          });

          setImmediate(() => {
          console.log('setImmediate1');
          Promise.resolve().then(() => console.log('promise3'));
          });

          setImmediate(() => {
          console.log('setImmediate2');
          Promise.resolve().then(() => console.log('promise4'));
          });

          輸出結(jié)果:

          setImmediate1
          setImmediate2
          promise3
          promise4
          setTimeout1
          setTimeout2
          promise1
          promise2

          根據(jù)這個執(zhí)行結(jié)果 [3],我們可以推測出 Node.js 中的事件循環(huán)與瀏覽器類似,也是外部隊列與內(nèi)部隊列的循環(huán),而 setImmediate 在另外一個外部隊列中。



          接下來,我們再來看一下當(dāng) Node.js 在與瀏覽器端對齊了事件循環(huán)的事件之后,這個例子的執(zhí)行結(jié)果為:

          setImmediate1
          promise3
          setImmediate2
          promise4
          setTimeout1
          promise1
          setTimeout2
          promise2

          其中主要有兩點需要關(guān)注,一是外部列隊在每次事件循環(huán)只執(zhí)行了一個,另一個是 Node.js 的固定了多個外部隊列的優(yōu)先級。setImmediate 的外部隊列沒有執(zhí)行完的時候,是不會執(zhí)行 timeout 的外部隊列的。了解了這個點之后,Node.js 的事件循環(huán)就變得很簡單了,我們可以看下 Node.js 官方文檔中對于事件循環(huán)順序的展示:

          其中 check 階段是用于執(zhí)行 setImmediate 事件的。結(jié)合本文上面的推論我們可以知道,Node.js 官方這個所謂事件循環(huán)過程,其實只是完整的事件循環(huán)中 Node.js 的多個外部隊列相互之間的優(yōu)先級順序。

          我們可以在加入一個 poll 階段的例子來看這個循環(huán):

          const fs = require('fs');
          setImmediate(() => { console.log('setImmediate');});
          fs.readdir(__dirname, () => { console.log('fs.readdir');});
          setTimeout(()=>{ console.log('setTimeout');});
          Promise.resolve().then(() => { console.log('promise');});

          輸出結(jié)果(v12.x):

          promise
          setTimeout
          fs.readdir
          setImmediate

          根據(jù)輸出結(jié)果,我們可以知道梳理出來:

          1. 外部隊列:執(zhí)行當(dāng)前 script

          2. 內(nèi)部隊列:執(zhí)行 promise

          3. 外部隊列:執(zhí)行 setTimeout

          4. 內(nèi)部隊列:空

          5. 外部隊列:執(zhí)行 fs.readdir

          6. 內(nèi)部隊列:空

          7. 外部隊列:執(zhí)行 check (setImmediate)

          這個順序符合 Node.js 對其外部隊列的優(yōu)先級定義:

          timer(setTimeout)是第一階段的原因在 libuv 的文檔中有描述 —— 為了減少時間相關(guān)的系統(tǒng)調(diào)用(System Call)。setImmediate 出現(xiàn)在 check 階段是蹭了 libuv 中 poll 階段之后的檢查過程(這個過程放在 poll 中也很奇怪,放在 poll 之后感覺比較合適)。

          idle, prepare 對應(yīng)的是 libuv 中的兩個叫做 idle 和 prepare 的句柄。由于 I/O 的 poll 過程可能阻塞住事件循環(huán),所以這兩個句柄主要是用來觸發(fā) poll (阻塞)之前需要觸發(fā)的回調(diào):

          由于 poll 可能 block 住事件循環(huán),所以應(yīng)當(dāng)有一個外部隊列專門用于執(zhí)行 I/O 的 callback ,并且優(yōu)先級在 poll 以及 prepare to poll 之前。

          另外我們知道網(wǎng)絡(luò) IO 可能有非常多的請求同時進來,如果該階段如果無限制的執(zhí)行這些 callback,可能導(dǎo)致 Node.js 的進程卡死該階段,其他外部隊列的代碼都沒發(fā)執(zhí)行了。所以當(dāng)前外部隊列在執(zhí)行一定數(shù)量的 callback 之后會截斷。由于截斷的這個特性,這個專門執(zhí)行 I/O callbacks 的外部隊列也叫 pengding callbacks

          至此 Node.js 多個外部隊列的優(yōu)先級已經(jīng)演化到類似原版的程度。最后剩下的 socket close 為什么是在 check 和 timers 之間,這個具體的權(quán)衡留待大家一起探討。

          關(guān)于瀏覽器與 Node.js 的事件循環(huán),如果你要問我那邊更加簡單,那么我肯定會說是 Node.js 的事件循環(huán)更加簡單,因為它的多個外部隊列是可枚舉的并且優(yōu)先級是固定的。但是瀏覽器端在對它的多個外部隊列做優(yōu)先級排列的時候,我們一沒法枚舉,二不清楚其優(yōu)先級策略,甚至瀏覽器端的事件循環(huán)可能是基于多線程或者多進程的(HTML 的標(biāo)準(zhǔn)中并沒有規(guī)定一定要使用單線程來實現(xiàn)事件循環(huán))。

          小結(jié)

          我們都知道瀏覽器端是直面用戶的,這也意味著瀏覽器端會更加注重用戶的體驗(如可見性、可交互性),如果有一個優(yōu)化效果是能夠極大的減少 JavaScript 的執(zhí)行時間,但要消耗更多 HTML 渲染的時間的話,通常來說我們都不會做這個優(yōu)化。通過這個例子來觀察,可以發(fā)現(xiàn)我們在瀏覽器并不是主要關(guān)注某件事整體所消耗的時間是否更少,而是用戶是否能快的體驗到交互(感受到 HTML 渲染)。而到了 Node.js 這個服務(wù)端 JavaScript 的場景下,這一點是明確不一樣的。在服務(wù)端為了保持應(yīng)用的流暢,早期甚至出現(xiàn)了一次事件循環(huán)執(zhí)行多個外部事件的優(yōu)化方式。

          很多同學(xué)在理解事件循環(huán)時感到隔靴搔癢的一個重要原因,便是把事件循環(huán)與 JavaScript 的關(guān)系弄錯了。JavaScript 的事件循環(huán)與其說是 JavaScript 的語言特性,更準(zhǔn)確的理解應(yīng)該是某個設(shè)備/端(如瀏覽器)的事件循環(huán)中與 JavaScript 交互的部分。

          造成瀏覽器端與 Node.js 端事件循環(huán)的差異的一個很大的原因在于 。事件循環(huán)的設(shè)計初衷更多的是方便 JavaScript 與其嵌入環(huán)境的交互,所以事件循環(huán)如何運作,也更多的會受到 JavaScript 嵌入環(huán)境的影響,不同的設(shè)備、嵌入式環(huán)境甚至是不同的瀏覽器都會有各自的想法。


          注 [1]: 關(guān)于 Task,常有人稱它為 Marcotask (宏任務(wù)),但 HTML 標(biāo)準(zhǔn)中沒有這種說法。
          注 [2]: 定時器操作主要依賴 JavaScript 外部的 agent 實現(xiàn)。所以歸類為外部事件。
          注 [3]: 這里 setTimeout 在 setImmediate 后面執(zhí)行的原因是因為 ms 精度的問題,想要手動 fix 這個精度可以插入一段 const now = Date.now(); wihle (Date.now() < now + 1) {} 即可看到 setTimeout 在 setImmediate 之前執(zhí)行了。


          參考文獻(xiàn):

          https://html.spec.whatwg.org/multipage/webappapis.html#event-loops
          https://nodejs.org/zh-cn/docs/guides/event-loop-timers-and-nexttick/#what-is-the-event-loop
          https://zhuanlan.zhihu.com/p/34229323
          https://juejin.im/post/5c337ae06fb9a049bc4cd218

          如果覺得這篇文章還不錯
          點擊下面卡片關(guān)注我
          來個【分享、點贊、在看】三連支持一下吧

             “分享、點贊在看” 支持一波  

          瀏覽 46
          點贊
          評論
          收藏
          分享

          手機掃一掃分享

          分享
          舉報
          評論
          圖片
          表情
          推薦
          點贊
          評論
          收藏
          分享

          手機掃一掃分享

          分享
          舉報
          <kbd id="afajh"><form id="afajh"></form></kbd>
          <strong id="afajh"><dl id="afajh"></dl></strong>
            <del id="afajh"><form id="afajh"></form></del>
                1. <th id="afajh"><progress id="afajh"></progress></th>
                  <b id="afajh"><abbr id="afajh"></abbr></b>
                  <th id="afajh"><progress id="afajh"></progress></th>
                  国产精品成人视频在线观看 | 国内免费黄色视频 | 亚洲日本淫色无码视频 | 亚州无码免费 | 国产一区二区三区皇色网站 |