- 原文地址:A tour of JavaScript timers on the web
- 原文作者:Nolan Lawson
- 譯文出自:阿里雲翻譯小組
- 譯文連結:github.com/dawn-teams/…
- 譯者:靈沼
- 校對者:也樹、靖鑫、眠雲
JavaScript 計時器之旅
突擊小測驗: JavaScript 各種定時器之間的區別是什麼?
- Promises
- setTimeout
- setInterval
- setImmediate
- requestAnimationFrame
- requestIdleCallback
更具體地講,如果你立刻對這些計時器進行排序,知道他們觸發的順序是什麼嗎?
如果不能,那你可能並不孤獨。我已經寫 JavaScript 和做程式設計許多年,曾經為一家瀏覽器廠商工作超過兩年,直到最近,我才真正瞭解了這些計時器以及如何使用它們。
在這篇文章中,我將高度概述這些定時器工作方式以及使用它們的時機,並且會一起介紹 Lodash 很有用的 debounce()
和 throttle()
函式。
Promises 和 microtasks
讓我們先從這裡開始,因為它大概是最簡單的了。一個 Promise 回撥也被稱為 “microtask”,它以與 MutationObserver 回撥相同的頻率執行。如果 queueMicrotask() 沒有被規範排除並且進入瀏覽器領域,它也會有同樣的結果。
我已經寫過很多關於 promise 的文章。然而值得一提的是,Promise 有一個很容易被誤解的地方是它們不會給瀏覽器留空閒的時間。那是因為處於非同步回撥佇列中,但是並不意味著瀏覽器可以進行渲染,或者處理輸入,或者做其他我們希望瀏覽器做的工作。
舉個例子,假設我們有一個阻塞主執行緒1秒鐘的函式:
function block() {
var start = Date.now()
while (Date.now() - start < 1000) { /* wheee */ }
}
複製程式碼
如果我們用一組 microtasks 來呼叫這個函式:
for (var i = 0; i < 100; i++) {
Promise.resolve().then(block)
}
複製程式碼
這將會阻塞瀏覽器100秒。這與下面的操作一樣:
for (var i = 0; i < 100; i++) {
block()
}
複製程式碼
任何同步任務執行完成後,microtasks 會立即執行。在這兩者之間沒有空閒做其他工作。所以,如果想把一個執行時間較長的任務分解為 microtasks,是不會如你所願的。
setTimeout 和 setInterval
它們是兩兄弟:setTimeout 將任務排在 X 毫秒之後執行,而 setInterval 每隔 X 毫秒執行一次任務。
由於許多網站比如 confetti 到處亂用 setTimeout(0)
。為了避免阻塞瀏覽器主執行緒,瀏覽器必須為 setTimeout(/* ... */, 0)
新增緩解措施。
這就是crashmybrowser.com 中許多技巧不再起作用的原因,比如,在 setTimeout
中呼叫另外兩個呼叫了更多 setTimeout
的 setTimeout
等等。我在 “Improving input responsiveness in Microsoft Edge” 中從邊緣部分介紹了其中一些緩解方法。
寬泛地說,setTimeout(0)
不是真正的在0毫秒之後執行。通常會在4毫秒內執行。有時會在16毫秒內執行(當 Edge 在充電時會這樣)。有時候還會被限制到1秒鐘(例子:when running in a background tab)。這些是瀏覽器必須具備的能力,為了防止不受控制的網頁佔用 CPU 執行無用的 setTimeout
。
所以說,setTimeout
確實允許瀏覽器在回撥函式被呼叫之前做一些工作(和 microtasks 不同)。但是,如果你想在回撥之前進行輸入或是渲染操作,一般來說 setTimeout
不是最好的選擇,因為它只是偶爾允許在回撥之前做其他操作。 現在,有更好的瀏覽器 API 可以更直接地掛到瀏覽器渲染系統中。
setImmediate
在繼續介紹使用“更好的瀏覽器 API ”之前,這裡有件事情值得一提。稱為setImmediate 是因為缺少一個更好的詞語...很奇怪。如果在caniuse.com上查詢,你會發現只有 Microsoft 瀏覽器支援它。但是它也在 node.js 中存在。這到底是個什麼東西?
setImmediate
最初是由微軟提出來解決上述 setTimeout
的問題的。基本上,setTimeout
已經被濫用了,setImmediate(0)
實際上就是 setImmediate(0)
,而不是一個被限制在4毫秒的東西。你可以檢視 some discussion about it from Jason Weber back in 2011。
不幸的是,setImmediate
只被 IE 和 Edge 採用了。仍在使用的部分原因是它在 IE 瀏覽器中作用很大,它允許輸入事件比如鍵盤輸入和滑鼠點選“跳過佇列”並在 setImmediate
回撥之前執行,而 setTimeout
在 IE 中就沒有這麼大魔力。(Edge 最終解決了這個問題,詳細說明在上一篇文章中)。
而且,setImmediate
存在於 Node 中這一事實意味著許多 “Node-polyfilled” 程式碼在瀏覽器中使用它,但是並不真正知道它在做什麼。Node 中 process.nextTick
和 setImmediate
的區別令人很困惑,甚至 Node 的官方文件都說名字應該交換。(然而為了這篇文章的初衷,我會把重心放在瀏覽器而不是 Node 上,因為我不是一個 Node 專家)。
最低原則:如果你知道你要做什麼並且嘗試優化 IE 的輸入效能,就使用 setImmediate
。如果不是,就不用麻煩了。(或者只在 Node 中使用)
requestAnimationFrame
現在,我們有一個最重要的 setTimeout
替代品,一個真正掛在瀏覽器渲染迴圈中的定時器。順便說一句,如果你不知道瀏覽器事件迴圈機制,我強烈推薦 Jake Archibald 的這個演講。
requestAnimationFrame
基本上是這樣工作的:它雖然和 setTimeout
有點像,但是它會在瀏覽器下次重繪時呼叫,而非等待一些無法預測的時間(4毫秒,16毫秒,1秒等)。現在,像 Jake 在他的演講中指出的一樣,這裡有一個小問題,在 Safari 、IE 和 Edge 18以下版本的瀏覽器中,他在樣式/佈局計算之後執行。但是讓我們忽略它,因為這不是一個很重要的細節。
我認為 requestAnimationFrame
的使用方式是這樣的:無論什麼時候,只要我知道我將要修改瀏覽器的樣式或佈局——舉個例子,改變 CSS 屬性或啟動一個動畫——我就會把它放在 requestAnimationFrame
(這裡縮寫為 rAF
)。這樣確保了幾件事情:
- 我不太可能打亂佈局,因為所有的DOM的變化都在排隊和協調。
- 我的程式碼會自然地去適應瀏覽器的效能特點。舉個例子,如果這裡有一個配置較低的裝置正在試圖渲染一些DOM元素,rAF 會自然地從通常的16.7毫秒(在60赫茲的螢幕上)時間間隔慢下來,因此,它不會像執行了大量 setTimeout 或 setInterval 的一樣讓裝置崩潰。
這就是為什麼不依賴 CSS 轉換或 keyframes 的動畫庫的原因,比如 GreenSock or React Motion,通常會在 rAF 回撥中更改。如果一個元素在 opacity: 0
和 opacity: 1
之間進行動畫轉換,那麼排隊等待十億次回撥來對每個可能的中間狀態進行處理是沒有意義的,包括 opacity: 0.0000001
和 opacity: 0.9999999
。
相反,你最好只使用 rAF
,讓瀏覽器告訴你在給定的時間段能繪製多少幀,併為特定幀進行計算。這樣,較慢的裝置自然就會以慢的幀速率結束,較快的裝置以快的幀速率結束,如果使用類似 setTimeout
這種獨立於瀏覽器繪製速度的 API,上述情況都是不可能出現的。
requestIdleCallback
rAF
可能是 toolkit 中最有用的定時器,但是requestIdleCallback
也同樣值得一提。瀏覽器支援不是很好,但是有一個 工作很不錯的polyfill(底層使用了 rAF)。
在很多情況下 rAF
類似於 requestIdleCallback
。(從這開始縮寫為 rIC
)
像 rAF
一樣,rIC
會自然地適應瀏覽器的效能特徵:如果裝置過載,rIC
可能會延遲。rIC
的不同之處在於它會在瀏覽器空閒狀態觸發,比如,當瀏覽器確定它沒有其他任務,microtasks 或輸入事件要處理的時候,你就自由地做想做的工作。它也會給你一個 "deadline" 來追蹤使用的預算值,這是個很不錯的特性。
Dan Abramov 在2018 冰島 JSConf 上有一個精彩講話,在談話中他展示瞭如何使用 rIC
。在談話中,有一個 webapp 在使用者打字的每一次鍵盤輸入的時候會呼叫 rIC
,然後它會更新回撥中的渲染狀態。這很棒,因為一個快速打字的使用者會導致 keydown
/keyup
事件非常快地觸發,但是你並不希望為每個按鍵都重新渲染頁面。
另一個很好的例子是 Twitter 或 MastoDon 上的“剩餘字元計數”指示器。在 Pinafore 中,我使用 rIC
進行操作,因為我不真正關心指示符是否針對我每一次輸入都重新渲染。如果我快速打字,最好優先考慮輸入相應,這樣才不會失去流暢感。
在 Pinafore 中,輸入框下面的小提示條和“剩餘字元”提示會隨著輸入而更新。
我注意到 rIC
在 Chrome 中有點瑕疵。在Firefox 中,每當我直覺的認為瀏覽器是空閒並準備執行一些程式碼的時候,它就會執行。(在 pollyfill 中也是這樣。)不過在 Chrome 的安卓移動模式中,我注意到,每當我觸控滾動的時候,它就會將 rIC
延遲幾秒鐘,即使在我剛觸控完螢幕,瀏覽器也什麼都不會做。(我懷疑我看到的問題是這個.)
更新:來自 Chrome 團隊的 Alex Russell 通知我這是一個已知 bug,應該很快就修復!
無論如何,rIC
是另一個很好地工具。我傾向於這樣想:使用 rAF
來進行關鍵的渲染工作,使用 rIC
來進行非關鍵的渲染工作。
debounce 和 throttle
這裡有兩個非瀏覽器內建的方法,但是它們很有用並值得了解。如果你不熟悉它們,這裡有一個很棒的 CSS 技巧攻略
debounce
的標準用法是在 resize
回撥中。當使用者調整瀏覽器視窗大小的時候,沒必要在每個 resize
回撥中更新佈局,因為觸發太頻繁了。相反,你可以 debounce
幾百毫秒,這會保證回撥在使用者在處理完視窗大小後觸發。
throttle
,另一方面,是我使用得更多的方法。舉個例子,scroll
事件是一個很棒的使用示例。再說一遍,對於每個 scroll
回撥都更新一遍檢視狀態是沒有意義的,因為觸發頻率太高了(頻率在不同瀏覽器,不同輸入法之間是不同的)。使用 throttle
可以規範這個行為,並確保它只在每 X 毫秒後觸發。你可以調整 Lodash 的 throttle
(或者 debounce
)方法啟動延遲的時機,在結束的時候或者不啟動。
相反,我不會在滾動場景中使用 debounce
,因為我不希望 UI 僅在使用者明確停止滾動後才更新。因為這可能會讓使用者苦惱和困惑,並且試圖滾動繼續更新 UI 狀態(例如在無限滾動列表中)。
我在各種使用者輸入和一些定時安排的任務中會使用 throttle
,比如 IndexedDB 清理。也許有一天它會內建到瀏覽器中。
結論
這是我對瀏覽器中各種定時器的快速瞭解以及如何使用它們。我可能漏掉了一些,因為這裡有一些特殊的特性(postMessage
或 lifecycle events
,還有其他的嗎?)。但希望這至少能對我如何看待 JavaScript 中定時器有一個很好地概述。