[JavaScript] 有關 Event Loop 的演說節錄筆記 (上篇)

一位名叫 Jake Archibald 的 Google 工程師透過 JSConf 的安排來到新加坡演講。 演講內容主要是限制自己三十分鐘內讓觀眾了解 Event Loop 的特性和應用, 還有就是發表自己對於喝碳酸水的獨特見解:] 。

JSConf 是一個熱愛 JavaScript 並推動各地同好去舉辦有關主題演講的組織,目的是讓世界各地的網頁工程師聚首一堂,分享這個圈子內的技術主題,由其是專注於革命性的主題上。在 https://jsconf.com/ 的自我介紹中也描述到他們組織是個比較鬆散的聯盟,並努力地扮演著月老的角色。一切的功勞和演講活動的成功都還是要靠著各位有心人的付出。

本文將節錄有關 Jake Archibald 的 In The Loop 的演講內容,文章合共有兩篇,本文為上篇:

演講內容

前言

好,回到這次演講的主題 In The Loop 。故名思意,內容都圍繞在講解 Loop 內的東西。正式進入主題之前,講者就以兩行「有問題」的代碼引出更新 DOM 時的問題。

以下為講者認為有問題的代碼:

document.body.appendChild(el)
el.style.display = 'none'

這兩行代碼的問題有兩點:
1. 末端缺少了 semicolon ( ; ) (嗯~這應該不是重點。)
2. 在 DOM 加了 el ,卻把它隱藏起來。

這個做法的問題在於它有機會使 el 在使用者的瀏覽器閃過一下,所以講者認為在觀感上,這不是一個好的做法。曾幾何時,講者也想強行把兩句交換寫,先隱藏 el,後創造 el,但無奈編譯會出問題啊!

為什麼講者會用這兩行代碼去開始說明 Event Loop 這個機制呢?這是因為他想指出 JS 不允許同時創造一個東西又同時隱藏它,只能逐個處理任務。原因就是 Event Loop 嚴密定義了渲染 (Rendering) 和運行代碼的時機,在大部份情況下都不包含任何隨機成份 (deterministic system),所以亦不存在競態條件 (race condition),也是說 JS 不存在同時修改 DOM 的情況。

The Event Loop

網頁存有主線程 (main thread) 這東西,它負責了渲染和執行 JS 等工作。重申:大部份網頁上的東西有明確處理順序,它不存在同時修改 DOM 的情況,也不導致競態條件的出現。

但因為這個特性,如果有其中一個任務運行時間過長,即使是 200ms,它也會明顯延遲其他渲染工作和網頁互動的反應。

講者也用了一個較活生生的例子去講解「主線程」,就是我們人體。我們人體並不存在主線程,因為我們可以一邊打字,一邊聽音樂和一邊吃東西。我們都是多線程 (multi-threaded) 運行的,除了遇到一種情況,打噴嚏。當我們打噴嚏,我們的其他工作都一下子暫停,不幸地變成了單線程 :[。而我們也不希望寫出「會打噴嚏」的代碼。

所以除了主線程,還有其他線程去處理網路、編解碼、監視輸入裝置、加密工作等。當這些線程完成工作後,就會把訊息交回主線程,而 Event Loop 控制了這一切。

Task Queues

甚麼情況下,會出現任務排列 (Task Queues) 呢?
1. 使用多個 setTimeOut(callback,ms) 時,為避免同時間執行多個 callback 。
2. 在瀏覽器點擊滑鼠時,指令會排列並提交給 JS 。
3. 請求 fetch 時,把回應排列並提交給 JS 。
4. 在頁面發送訊息給員工時, 訊息會排列並提交。

任務排列是 Event Loop 的一個重要機制。你可以想像原本 Event Loop 是個一直在無限走的大圓,它會先處理好不需等待的東西 ( 圓圈內的東西 ) 。每當有有新任務排列,大圓會產生一條繞道 (detour) 去處理任務。完成任務後,返回大圓。當有新任務排列,再走繞道去處理任務。完成任務後,再返回大圓。重覆不變。

注意:這裡講者沒提及 Asynchronous Event 這個名字,不過都是描述同一樣東西。你可以把它想成是需要時間處理的任務,所以它會需要任務排列。而 Event Loop 不會即時處理它,又或是在滿足條件下才會處理它。 (Asynchronous 的概念可能會再寫份筆記)

假設我們要執行兩句 setTimeOut(callback, 1000) 。它們會同時地 (in parallel) 各自等待 1000 ms,然後當倒數完成,它會開始排列任務,此時會先返回主線程。之後瀏覽器會跟 Event Loop 說有新任務加入,Event Loop 走入繞道去處理 callback 。完成後,回到主線程,再進入繞道處理排較後的 callback 。這樣就避免了「因為同時在 1000 ms 完結,而不知處理哪個 callback」的問題。

由於 setTimeOut 是個 Asynchronous Event,而且是個 Macro Task (另外還有 Micro 的),所以它只會在下一個 Event loop 內執行。 (將來有機會寫 Async Await 的筆記時會再提及)

到這裡我們知道,要排列的任務都會放在繞道裡,只有準備好的時候,才會呼叫 Event Loop 進入繞道處理。而每次進入繞道只會處理一個任務,完成後返回大圓。

如果世事是那麼簡單就好了,Event Loop 可不是只會處理這邊的繞道,還有另一條繞道,就是 Render 工作的這一邊。

The Render Steps

渲染 (Render) 是一個瀏覽器把網頁文本以畫面形式呈現的工作,工作流程如下:
1. 風格計算 (Style Calculation) ,查看並實現所有 CSS 定義的風格
2. 創造渲染樹 (Render Tree) / 結構,決定物件在網頁要出現的位置
3. 創造像素資料,就是畫畫

所以每當瀏覽器想更新畫面一次,它就會叫 Event Loop 走入那條繞道,然後執行上面三項工作。

講者在此時舉了一個很好的例子去描述「一個圓有兩條繞道」(嗯??) 這個機制中可能出現的潛在問題。

我們都知道無限迴圈 (Infinite Loop) 是寫程式的大忌,但是如果把無限迴圈放到瀏覽器執行,會出現甚麼有趣結果?

測試代碼如下:

button.addEventListener( 'click', event => {
   while(true);
});

//一個呼叫 while(true) 的按鈕

由於瀏覽器是需要不斷地更新畫面,我們才可以看到會動的 Gif 圖 (因為它是一張又一張圖片去更換的) 。如果我們加入一個包含 while(true) 的任務,Event Loop 會一直不斷地處理那項任務,然後停留在繞道,返回不了主線程,也去不了渲染的那邊。結果就是因為不能執行渲染工作,使整個畫面都停止了。就連選取文字都不行 (指令動作都會排列在那不會完結的任務之後),猶如按了時間暫停器般。

到這裡我明白了一件事:如果我們想用戶感受到流暢的使用體驗,就盡量不要做些影響渲染工作的事,哪怕只是 200ms 之間的事。

就在此時,講者改了一改那句煩人的 while loop ,換成 setTimeOut(loop,0) ,結果又會是如何呢?

結果是有好消息,也有壞消息。好消息是,每完成一次 loop 任務,它都能返回主線程。每完成一件事都會走一次主線程。這意味着它不會阻礙到瀏覽器去更新畫面。證明 setTimeOut 並不是 render-blocking 。結果當然是我們都可以看見 Gif 的動畫。

而壞消息就是如果要拿它來做一些觸及渲染工作 (例如:更新 CSS 、動畫之類),它就會影響渲染的表現。問題多數是畫面不流暢。講者如此說:如果要做那樣的工作,我們就得把代碼放到 Render 那邊。而瀏覽器其實允許我們這樣做,就是利用 requestAnimationFrame (簡稱 RAF) 這屬於 Window 的 method 。

requestAnimationFrame()

講者用 RAF 去更新正方形的位置,以每次向右增加一格像素的方式去製作動畫。運行代碼後,正方形能順暢地向移動。

代碼如下:

function callback() {
   moveBoxForwardOnePixel();
   requestAnimationFrame(callback);  //使用了 RAF
}

callback();

但如果使用 setTimeOut 去更新正方形的位置又會有甚麼結果?它會以 3.5 倍速向右移動。這代表執行 callback 的次數比較執行渲染的多 ( 向右增加像素的頻率> 渲染頻率) 。正方形的位置向右增加了幾次像素才會更新一次畫面。

更改代碼如下:

function callback() {
   moveBoxForwardOnePixel();
   setTimeOut(callback, 0);   //使用 setTimeOut
}

callback();

講者又提到:瀏覽器只會有東西需要更新才會渲染,並不會無時無刻地執行。例如瀏覽器分頁只有在被看到時才會渲染。你把它收起來,它並不會渲染,因為在背景中渲染是沒有任何意義。另外,因為多數的屏幕更新只是每秒 60 次,如果修改頁面風格的頻率是每秒 1000 次,渲染工作的頻率都不會跟上這頻率 (每秒 1000 次) 。而是它只會同步到與屏幕更新的頻率一致 (一般是每秒 60 次,不同屏幕有不同頻率),因為用戶根本不會看到那 940 次的渲染。

此外,即使把 setTimeOut 以人手設定為每 0 ms 執行 callback 一次,經講者測試過,它只會接近每 4.7 ms 執行一次任務排列 (這是瀏覽器對於接收到 0 ms 的標準參數) 。

所以他把 setTimeOut(loop, 0) 改成 setTimeOut(loop, 4.7),再運行一次,讓畫面更像真實運行時的畫面。

function callback() {
   moveBoxForwardOnePixel();
   setTimeOut(callback, 4.7);   //從 0 ms 改成 4.7 ms
}

callback();

三項結果如下:

結果,正方形不停地在瀏覽器閃爍,位置也像是隨機且水平地出現,這根本不是一個連續向右移動的動畫。講者說:我們正在以每毫秒 200 次的頻率接收任務!即使能夠順利渲染,在渲染工作之間已經出現了成千上萬的任務!

接著,講者以幀格單位組成一條時間軸來講解 setTimeOut 的問題。

首先,每一幀格都包含一次渲染工作,但它不一定包括全部三項工作,這視乎有甚麼東西需要更新。我們只需知道渲染的工作時序都是一項緊接一項,而且整齊集中在一幀格裡的一少部份。而其他任務就比較鬆散,其等待時間又不一。這因為 Event Loop 只確保了任務排列的順序,在一幀格作單位的世界裡,它並無排序可言。至於 setTimeOut ,它會有畫一的延遲時間,所以比較工整地分散。但它可能會令四個 callback 在一幀格內執行,這意味着執行了三次無用的任務。

也許有些人會把它改成 setTimeOut(animFrame,1000/60) 來強行得到每秒 60 次執行 callback 的結果,意圖跟 60 Hertz 幀率同步,來減少執行無用任務。但很不幸,它有可能因為不夠精準而把一次 animFrame 轉移到下一個幀格,結果畫面斷格。又或者如果有一次任務運行過久,它會把下一幀格的渲染工作推遲,導致畫面時快時慢。

同樣的情況,用 RAF 就很不一樣了。它的工作時序會比渲染工作早 ,並緊貼着渲染工作,解決上面兩個問題,提升用戶體驗。所以講者很鼓勵我們用 RAF 打包動畫。

接下來就是講者發表自己對於喝碳酸水的獨特見解。「我對待任務,如對待喝碳酸水的人一樣,我認同他們是存在的,但盡可能減少我們之間的互動,因為他們並不可信。」(反正就是他覺得碳酸水很噁心 XD) 他甚至開了「愛喝碳酸水」推特頁面,看看到底哪些「古怪」的人會出現。經一番調查,他發現了原來 Tim Berners-Lee (W3C 的主席) 都在喝碳酸水 :] 。

回到正題,我們都知道 RAF 是放在處理 CSS 與畫畫之前的,而 JavaScript 會處理好任務才把工作流程交到負責渲染的瀏覽器手上。所以當我們貪心地想改變同一個 DOM 物件的同一風格兩次以上,瀏覽器只理會最底一行的改變 (因為那是在更新 CSS 之前最新的變化,舊的變化已被覆蓋) 。

講者舉了一個例子,他想做一個按鈕,而那個按鈕按下去之後會令一個方形由位置 0 px,改到位置 1000 px 。然後,用動畫從位置 1000 px 移動到位置 500 px 。於是,他打出以下代碼:

button.addEventListener('click', () => {
   box.style.transform = 'translateX(1000px)';   //這行被忽略
   box.style.transition = 'transform 1s ease-in-out';
   box.style.transform = 'translateX(500px)';   //瀏覽器只理會這行
});

但是,因為「JavaScript 會處理好任務才把工作流程交到負責渲染的瀏覽器手上」這個原則下,瀏覽器只會看最新的值,也就是 translateX(500px) 。方形也只能由 0 px 移動到 500px 。

那麼,我們要怎樣做才可以解決這個問題呢?很抱歉,筆者要在這裡腰斬一下了。因為我突然發現,這章的篇幅好像有點過長 (到這裡近 4600 字了) 。所以我決定把餘下內容寫到下篇。以下來回顧一下本篇提及了甚麼吧!

總結

首先,本篇內容圍繞在說明 Event Loop 的基礎概念。其中包括:

  1. JS 用單線程處理任務
  2. 任務排列用於處理非同步任務
  3. Event Loop 常在處理非同步任務與處理主線程任務之間來回走動
  4. 瀏覽器嚴格地處理渲染工作的三項流程
  5. 渲染工作在有需要時才會執行
  6. 利用 RAF 處理任何涉及渲染工作的任務
  7. JS 處理任務與瀏覽器處理渲染工作的時機如何

其中,又提及一些要留意的毛病:

  1. 同時在 DOM 加物件,又把它隱藏起來
  2. 用 setTimeOut 處理涉及渲染工作的任務
  3. 同時更改 DOM 物件的風格兩次或上

那麼,下篇會有甚麼呢?

  1. 繼續上面的基礎概念第 7 項的內容
  2. Microtask 的概念

就是這樣,本筆記結束。

在〈[JavaScript] 有關 Event Loop 的演說節錄筆記 (上篇)〉中有 1 則留言

發佈留言