天天看點

JavaScript 計時器之旅--------------------------------引用

   Promises 和 microtasks

         因為它大概是最簡單的了。一個 Promise 回調也被稱為 “microtask”,它以與 MutationObserver 回調相同的頻率運作。如果 queueMicrotask() 沒有被規範排除并且進入浏覽器領域,它也會有同樣的結果。

我已經寫過很多關于 promise 的文章。然而值得一提的是,Promise 有一個很容易被誤解的地方是它們不會給浏覽器留白閑的時間。那是因為處于異步回調隊列中,但是并不意味着浏覽器可以進行渲染,或者處理輸入,或者做其他我們希望浏覽器做的工作。

舉個例子,假設我們有一個阻塞主線程1秒鐘的函數:

function(){
  var=Date.now()
  while(Date.now()-<1000){/* wheee */}
}      

如果我們用一組 microtasks 來調用這個函數:

for(var=0;<100;++){
  Promise.resolve().then(block)
}      

這将會阻塞浏覽器100秒。這與下面的操作一樣:

for(var=0;<100;++){
  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)。這樣確定了幾件事情:

  1. 我不太可能打亂布局,因為所有的DOM的變化都在排隊和協調。
  2. 我的代碼會自然地去适應浏覽器的性能特點。舉個例子,如果這裡有一個配置較低的裝置正在試圖渲染一些DOM元素,rAF 會自然地從通常的16.7毫秒(在60赫茲的螢幕上)時間間隔慢下來,是以,它不會像運作了大量 setTimeout 或 setInterval 的一樣讓裝置崩潰。

這就是為什麼不依賴 CSS 轉換或 keyframes 的動畫庫的原因,比如 GreenSockor 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 進行操作,因為我不真正關心訓示符是否針對我每一次輸入都重新渲染。如果我快速打字,最好優先考慮輸入相應,這樣才不會失去流暢感。

JavaScript 計時器之旅--------------------------------引用

在 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 清理。也許有一天它會内置到浏覽器中。

結論