1. 程式人生 > 其它 >當我們聊定時器時,到底在聊什麼

當我們聊定時器時,到底在聊什麼

當我們聊定時器時,到底在聊什麼 https://mp.weixin.qq.com/s/-zSYdFYlFFYkZZ0bqRHLLg

當我們聊定時器時,到底在聊什麼

00

目錄

  • 背景

    • 定時器在電商平臺的應用

    • 不同場景對定時器精度的要求

  • 常見的定時器方案

    • setInterval

    • 鏈式呼叫 setTimeout 

  • 實現高複雜度計時業務時存在的一些問題

    • 計時補償

    • 定時器計時間隔超過閾值

    • 冗餘計算與卡幀

  • 另一種前端定時器方案

    • 瀏覽器頁面渲染機制

    • requestAnimationFrame

    • requestIdleCallback

    • 定時器設計思想

    • 使用方式與效果

  • 結語 

01

導讀

在前端的業務中,經常會出現需要計時的場景。比如頁面中需要統計停留時長,以達成一些活動任務的要求;又比如頁面需要進行倒計時,用來預熱一些特定時刻才能開啟的活動;再比如頁面中需要展示一些動畫,用於使頁面看起來更美觀。這樣的場景數不勝數,覆蓋了可以說是幾乎所有的行業,其中是以電商、遊戲中最為常見。

02

背景

2.1 定時器在電商平臺的應用

電商行業中,最常見的定時器應用場景就是搶購活動頁面。如下圖所示:

電商平臺搶購頁面

搶購頁面中,通常會出現兩種狀態的搶購活動。一種是正在進行的活動,需要在活動中展示當前活動的剩餘時間;另一種是即將開始的活動,展示的是活動開始時間。從頁面的表現來看,正在進行的活動需要不停地更新活動倒計時,而即將開始的活動則展示一個靜態的時間,實際上兩種活動狀態都需要進行定時器計時,只不過一個是顯式的,另一個是隱式的。

相比於其他定時器的應用場景來說,電商平臺對定時器精度的要求可以說是最高的。

2.2 不同場景對定時器精度的要求

顯而易見,不同的業務場景對定時器精度的要求是不一樣的。在一些 APP 中,往往會有一些瀏覽特定頁面換取獎勵的任務,類似下圖這樣:

瀏覽頁面一定時長換取積分

這個頁面也用到了定時器,但幾乎對定時器的精度沒有任何要求,使用者只要在這個頁面停留一定的時長就可以。假如我們設定,需要使用者在頁面停留 10 秒能夠獲取獎勵,實際上定時器不需要精確的計算 10 秒,稍長或者稍短一些也沒有影響。

但是在電商平臺中,由於有些搶購活動的商品價值較高,通常會設定有限的數量,往往會形成購買的人數遠大於商品數量的情況。這種情況下,如果由於定時器計算活動開始時間存在誤差,而導致使用者沒有搶到商品,甚至會讓搶購活動起到相反的作用,造成一些負面的影響。

03

常見的定時器方案

我們來看一下常見的定時器如何實現上面的業務場景。

3.1 setInterval

setInterval 可以說是最常見的定時器方案了,絕大多數需要使用定時器的場景都可以用它來解決。另一個常見的定時器是 setTimeout,不過常用來進行延時任務的處理,原因是 setTimeout 只進行一次計時。

MDN 官網對 setInterval 的描述是該定時器會重複呼叫一個函式或執行一個程式碼段,在每次呼叫之間具有固定的時間延遲。使用它至少需要兩個引數,其中第一個引數是計時到期後執行的函式或程式碼,也就是回撥函式;第二個引數就是計時的毫秒數。

使用 setInterval 實現計時的程式碼如下:

function update() {  ...};
setInterval(() => { update();}, 1000);

這段程式碼建立了一個計時週期為 1000 毫秒的定時器,每次計時到期後都會執行一次 update 函式,用來處理相關的業務邏輯。

在絕大多數情況下,setInterval 都能夠正常的執行,但在一些極端場景下,它也存在著缺陷。

例如定時器內的程式碼存在大量的計算,或者是若干 DOM 操作,這樣一來,回撥函式執行的時間會比較長,就有可能遇到前一次的程式碼還沒有執行完,後一次的程式碼就已經被加入到執行佇列。假如定時器的間隔設定為100 毫秒,而定時器內的程式碼需要執行 300 毫秒,就會出現連續執行兩次回撥函式的情況。比如下面的執行情況:

  1. 0 毫秒時執行 setInterval,100 毫秒後將要執行的程式碼插入事件佇列;

  2. 100 毫秒後,定時器要執行的程式碼進入任務佇列,任務佇列空閒,程式碼執行;

  3. 200 毫秒時,定時器內的程式碼仍在執行之中,第二次的定時器程式碼被推入事件佇列,等待任務佇列空閒時執行;

  4. 300 毫秒時,第一次的定時器程式碼還在執行中,第二次的定時器程式碼在事件佇列中等待執行,因為該定時器已經有第二次的程式碼在事件佇列中等待了,所以這一次的程式碼不會被推入事件佇列;

  5. 400 毫秒時,第一次的定時器程式碼執行完畢,任務佇列空閒,下一個等待的程式碼執行,也就是第二次的定時器程式碼進入任務佇列開始執行,同時第四次的定時器程式碼也被推入事件佇列中等待執行。

極端情況下 setInterval 會連續執行

梳理過前幾次的 setInterval 定時器程式碼執行情況後,發現第一次和第二次的程式碼執行間隔並不是預期的 100 毫秒,而是第一次的程式碼執行完,第二次的程式碼立即就已經在任務佇列中等待執行了。而第三次的定時器程式碼,因為在第 300 毫秒時事件佇列中已經有第二次的程式碼在等待,而當事件佇列中沒有該定時器的程式碼時,程式碼才會被推入事件佇列排隊,所以第三次的程式碼沒有被推入事件佇列。由此可見,在這種極端情況下,setInterval 定時器中的程式碼會連續執行,從而影響到我們的頁面表現。

那麼如何解決上述的這個問題呢?

3.2 鏈式呼叫 setTimeout

與 setInterval 相似,setTimeout 也是使用率非常高的定時器,但它只能計時一次。通過鏈式呼叫 setTimeout 定時器,則可以實現 setInterval 的功能。

let myInterval = (func, delay) => {  setTimeout(() => {    func();    myInterval(func, delay);  }, delay);};

myInterval 內部用 setTimeout 定時器,在指定的延時後將匿名函式推入事件佇列,匿名函式中包含要執行的 func,以及 myInterval(func, delay)。匿名函式執行時,會先執行 func,然後遞迴呼叫 myInterval 來模擬 setInterval,遞迴呼叫的 myInterval中,又將執行 setTimeout,在 delay 延時後,將下一次的定時器程式碼推入任務佇列等待執行。

可以發現,在將下一次的定時器程式碼推入事件佇列時,上一次的程式碼無論如何都已經執行完了,所以不會出現 setInterval 的缺陷。

鏈式呼叫 setTimeout

使用鏈式呼叫 setTimeout 實現計時的程式碼如下:

myInterval(() => {  update();}, 1000);

可以看到,使用方式幾乎與 setInterval 一模一樣,只是需要在使用前先實現鏈式呼叫的函式。

鏈式呼叫 setTimeout 解決了 setInterval 在極端情況下的缺陷問題,同時,也帶來了新的問題。

04

實現高複雜度計時業務時存在的一些問題

我們知道,當業務複雜度越高時,越會有一些意想不到的情況出現。在使用定時器的計時業務也是如此,下面來看一下在使用定時器時都可能會遇到哪些問題。

4.1 計時補償

第一種情況,在業務中要求所有的計時都要在整點時進行,但我們無法保證程式碼執行的時間恰好是整點時間。遇到這種情況的時候,就需要我們對定時器執行做計時補償,通過一些時間計算,確保定時器的執行能儘量靠近整點時間。

常見於計時單位為 1 秒的定時器中。

const now = Date.now();const nextFullSecond = (Math.floor(now / 1000) + 1)  * 1000;const fixTime = nextFullSecond - now;
function update() { ...};
function startInterval() { setInterval(() => { update(); }, 1000);};
setTimeout(() => { startInterval();}, fixTime);

第二種情況,長期使用鏈式呼叫 setTimeout 時引起的計時誤差。由於 setTimeout 定時器的機制,會在執行時先繼續執行當前任務佇列中剩餘的任務,再進行計時。這會導致回撥函式執行的時間往往會略大於期望的計時時長,長此以往,誤差會越來越大,這時也需要對定時器進行計時補償。

let myInterval = (func, delay, fixTime = 0) => {  const startTime = Date.now();  setTimeout(() => {    func();    const endTime = Date.now();    fixTime = 1 + endTime - startTime - delay;    myInterval(func, delay, fixTime);  }, delay - fixTime);};

由於函式巢狀(層級達到一定深度),或者是由於已經執行的定時器回撥函式阻塞,setTimeout 函式在零延遲的情況下等待時間仍會不小於10毫秒,所以我們在程式碼中設定的計時補償值最小為 1。

4.2 定時器計時間隔超過閾值

定時器可接受的計時間隔是有上限的,目前 Chrome 瀏覽器下的最大值是 2147483647,即 2 的 32 次方減 1。在不大於這個值的情況下, 定時器可以正常計時執行,一旦超出這個值,定時器會將計時間隔設為 1 然後立即執行其中的回撥函式。

定時器設定計時間隔超過閾值時的表現

由圖中可以看到,第一個定時器執行後,回撥函式中的程式碼始終沒有執行,證明定時器在正常計時。第二個定時器設定的計時間隔超過了閾值,可以看到定時器回撥函式中的程式碼立即執行了。

4.3 冗餘計算與卡幀

目前大多數裝置的螢幕重新整理率為 60Hz(60次/秒)。而瀏覽器的功能主要是基於網站提供的內容(HTML、CSS、JavaScript 以及其他資源),經過解析、排版、繪製、格柵、合成等一系列瀏覽器核心的處理流程,把處理結果通過系統呼叫傳送給作業系統,最終呈現在螢幕上。也就是說,如果在頁面中有一個動畫或者漸變的效果,或者使用者正在滾動頁面,那麼瀏覽器渲染動畫或頁面的速率應當與裝置螢幕的重新整理率保持一致。

如果瀏覽器多次重繪都集中在裝置螢幕的一次重新整理週期內,那麼最終呈現在螢幕上的,將只是最後一次重繪的內容,也就是說在本次重新整理週期中,額外的重繪與計算都將帶來額外的效能損耗。

而瀏覽器如果在處理內容的流程上花費了過多的時間,那麼瀏覽器重繪的內容將落後於螢幕的重新整理率,這個時候頁面就會看起來變得卡頓,對使用者體驗產生負面影響。

05

另一種前端定時器方案

既然 JavaScript 提供的定時器存在一些弊端,那有沒有一種定時器能夠解決以上這些問題呢?答案當然是有的。接下來,結合定時器原理與瀏覽器的渲染機制,介紹一個新的定時器方案。

5.1 瀏覽器頁面渲染機制

不同的瀏覽器渲染機制雖然略有不同,但整體思路仍然相差不多。下面我們著重介紹一下 Chrome 瀏覽器中,每一幀的影象從計算到呈現在螢幕之上的過程。

影象進入螢幕的完整過程

從上圖能夠看到,瀏覽器渲染主要由渲染程序(Renderer Process)負責,其中大部分階段都集中在主程序(Main Thread)。

  1. 新的一幀開始(Frame Start),由作業系統垂直同步訊號觸發,開始渲染新的一幀影象;

  2. 輸入事件的處理(Input event handlers)之前,合成執行緒(Compositor Thread)接收到的使用者 UI 互動輸入在這一刻會被傳入主執行緒(Main Thread),觸發相關事件的回撥,包括 touch event、input event、scroll event、click event 等;

  3. 執行 requestAnimationFrame 回撥。這是更新螢幕顯示內容的理想位置,因為現在有全新的輸入資料,又非常接近即將到來的垂直同步訊號。其他的視覺化任務,比如樣式計算,因為是在本次任務之後,所以現在是變更元素的理想位置。但需要注意的是,要避免強制同步佈局;

  4. 解析HTML(parse HTML),如果有 DOM 變動,那麼會有解析 DOM 這一過程;

  5. 重新計算樣式(Recalc Styles)沒如果在 JS 執行過程中修改了樣式或改動了 DOM,那麼便會執行這一步,重新計算指定元素及其子元素的樣式。可能要計算整個 DOM 樹,也可能縮小範圍,取決於具體改動了什麼;

  6. 佈局(Layout),如果有涉及元素位置資訊的 DOM 改動或樣式改動,那麼瀏覽器會重新計算所有元素的位置、尺寸資訊,計算成本通常和 DOM 元素大小成比例。而單純修改 color、background 等資訊則不會觸發迴流;

  7. 更新圖層樹(Update Layer Tree),這一步建立層疊上下文,為元素的深度進行排序;

  8. 繪製(paint),計算得出更新圖層的繪製指令。過程分兩步:第一步,對所有新加入的元素,或進行改變現實狀態的元素,記錄 draw 呼叫;第二步是格柵化,在這一步實際執行了 draw 的呼叫,並進行紋理填充;

  9. 合成(Composite),圖層和圖塊資訊計算完成後,被傳回合成執行緒進行處理;

  10. 如果此時主執行緒在下一幀到來之前還有時間的話,會執行 requestIdleCallback 回撥;

  11. 幀結束(Frame End),各個層的所有的塊都被格柵化成點陣圖後,新的塊和輸入資料被提交給 GPU 執行緒;

  12. 最後,圖塊被 GPU 執行緒上傳到 GPU,GPU 使用四邊形和矩陣將圖塊 draw 在螢幕上。

在一個完整的渲染週期中,有兩個階段提供了回撥函式供我們來執行程式碼,它們就是 requestAnimationFrame 和 requestIdleCallback。其中前者能夠穩定執行,而後者則只會在當前這一幀還有空餘時間時執行。

5.2 requestAnimationFrame

requestAnimationFrame 使用一個回撥函式作為引數,這個回撥函式會在瀏覽器重繪之前呼叫。回撥函式會被傳入 DOMHighResTimeStamp 引數,這個引數表示當前被 requestAnimationFrame 觸發的回撥函式開始執行的時間點。也可以通俗的理解為從頁面開始到 requestAnimationFrame 的回撥函式開始執行的時間。

正是如此,requestAnimationFrame 成為了我們解決定時器問題的首選方案,讓我們再來梳理一下這個回撥函式的特點:

  1. 每個幀週期都會執行一次;

  2. 執行時機恰好是在瀏覽器開始進入渲染動作之前;

  3. 提供一個回撥函式作為引數,可以執行 JavaScript 程式碼。

值得注意的是,瀏覽器的節能機制也會影響到 requestAnimationFrame。為了節省 CPU、GPU和電力,瀏覽器會在頁面處於非啟用狀態時停時重新整理,同時 requestAnimationFrame 回撥函式的執行也會被停職,直到頁面再次被啟用。

5.3 requestIdleCallback

MDN 官網上對 requestIdleCallback 的介紹是這樣的:個函式將在瀏覽器空閒時期被呼叫。這使開發者能夠在主事件迴圈上執行後臺和低優先順序工作,而不會影響延遲關鍵事件,如動畫和輸入響應。是除了 requestAnimationFrame 之外在幀週期中另一個可以執行程式碼的地方。

使用 requestIdleCallback,將函式執行放在渲染之後的空閒時間之中。與 requestAnimationFrame 相比,區別是一個在渲染之前,一個在渲染之後,換句話說就是使用 requestIdleCallback 會比使用requestAnimationFrame 在渲染過程中整體落後一幀,但優點是這種方案可以最大化的利用 JS 執行程序。使用方式如下:

const tasks = [];const unnecessaryWork = (deadlint) => {  while(deadlint.timeRemaining() > 0 && tasks.length > 0) {    const task = tasks.shift();    ...  }  if (tasks.length > 0) {    requestIdleCallback(unnecessaryWork);  }};requestIdleCallback(unnecessaryWork);

需要注意的是,requestIdleCallback 還只是一個實驗中的功能,如果我們想要使用它,需要進行 polyfill。

requestIdleCallback 的相容性

5.4 定時器設計思想

JavaScript 是通過事件迴圈機制來實現任務排程的,當我們用 setTimeout 註冊了一個非同步任務後,這個非同步任務會在適當的時候被推入任務佇列。不同的 Runtime(如 Chrome、Node.js、Deno等)雖然對 setTimeout 的實現都不一樣,但大體的思路是相同的。就是在執行 setTimeout 的時候將 timer 插入一個優先佇列(或紅黑樹),然後在事件迴圈的每一個 tick 去檢查 timer 事件,當檢查到 timer 事件觸發的時候,取出它對應的回撥函式進行操作。

定時器設計思想

與 setTimeout 類似,我們在註冊一個計時任務時,也將 timer 插入一個優先佇列。只不過事件迴圈是在每一個 tick 檢查 timer 事件,而我們是在每一個幀迴圈中進行檢查。

定時器有兩個核心函式。update 是定時器的基礎,通過 requestAnimationFrame 回撥函式形成定時器 tick。在 update 函式中,還會進行時間線維護和回撥任務處理。pollTimerTask 函式負責處理註冊的 timer 回撥,先檢查 timerQueue 中是否存在需要執行的 timer,如果存在的話將這個 timer 中的回撥任務按照次序執行一遍。如果遇到需要週期執行的任務,則在任務執行完畢之後再次推入 timerQueue。pollTimerTask 函式程式碼如下:

...
const pollTimerTask = (time) => { if (timerQueue.length === 0) { return; }
while (timerQueue[0] && time >= timerQueue[0].time) { const timer = timerQueue.shift();
while (timer.tickerQueue.length) { const { id, callback, delay, loop, defer } = timer.tickerQueue.shift();
callback(time);
if (loop && idPool[id].exist) { let nextTime = timer.time + delay;
// 當回撥函式執行時間超過多個執行週期時 if (time - nextTime > delay) { nextTime = nextTime + (Math.floor((time - nextTime) / delay) * delay);
// 延遲執行時,將 nextTime 推遲至下一個執行週期 defer && (nextTime += delay); }
registerTimerWithId({ id, callback, time: nextTime, delay, loop, defer }); } else { // 當回撥函式不需要週期執行或在回撥函式中執行 unregister 時 delete idPool[id]; } } }};
...

5.5 使用方式與效果

為了方便在不同的業務場景中使用,定時器提供了兩個不同的 API。

第一個用於註冊 tick 回撥函式,這個回撥函式完全與幀率同步,適合於執行一些 JavaScript 動畫,以及作為一個遊戲引擎的驅動來使用。

/** * 註冊一個定時器 tick * @param {Function} callback 回撥函式 * @returns {number} 返回一個 tickerId */register (callback: Function): number;

register 演示

可以看到,在啟動定時器之後,register 函式中的回撥函式在執行的時候,都會被傳入一個距離上一次執行的間隔時間。經過計算,這個時間與當前瀏覽器的重新整理頻率也是吻合的,就是 60FPS。

第二個則更適合於大部分前端業務場景,通過不同的引數配置,可以確定不同的回撥函式執行時機。具體配置如下:

/** * 註冊一個定時器 * @param {object} options * @param {Function} options.callback - 定時器的回撥函式 * @param {number} options.startTime - 定時器開始的時間戳,預設為當前定時器的系統時間(基於 start 方法傳入的時間計算) * @param {boolean} options.loop - 定時器回撥函式是否週期執行,預設為 false * @param {number} options.delay - 定時器回撥函數週期執行的間隔,預設為 1000 毫秒,只有當 options.loop 為 true 時生效 * @param {immediate} options.immediate - 是否立即執行,預設為 false * @param {fix} options.fix - 將回調函式執行時間由毫秒修正為秒,預設為 false * @param {defer} options.defer - 當某一次回撥執行時間超過 options.delay 時,是否立即執行,預設為 false。如果設為 true,將會跳過本次,在下個執行週期執行 * @returns {number} 返回一個 tickerId */setTimer (options: TimerConfig): number;

setTimer 常規演示

這是最基本的,類似於 setTimeout 的使用方式。可以看到定時器的回撥函式在 2 秒後被執行了。

setTimer 演示 loop

這是類似 setInterval 的使用方式。定時器回撥函式執行的週期為 2 秒,它也確實被準確地執行了。

setTimer 演示 fix

由於很多業務場景都需要定時器回撥函式在整點時執行,這裡為類似的場景做了擴充套件。當設定了 fix 引數時,定時器會自動修正回撥函式執行的時機。從圖中可以看出,定時器執行的時間並不是整點,但之後回撥函式每次執行的時機都接近於整點。第一次執行會小於設定的間隔,就是因為定時器內部對執行時機進行了修正。

06

結語

常見的定時器 setTimeout 與 setInterval 適用於對計時精度要求不是很高的場景,它們都是 JavaScript 原生的定時器,沒有額外的使用成本。而我們設計的基於幀迴圈的定時器,雖然能夠很好的應對不同的業務場景並解除了諸多原生定時器的限制,但是也有額外的學習成本。當然,由於是基於幀迴圈,這種定時器計時也有著不大於一幀時長的誤差。

我們最終還是要回歸到業務,針對不同的業務場景選擇更合適的技術方案。前端仍在不斷的發展,瀏覽器的進化還會為我們提供更豐富的介面標準與開發工具。在技術發展的同時,我們也可以更優雅的更復雜實現業務場景了。

作者簡介

莫日根,LBG 前端工程師,負責到家 APP 業務的開發工作,開源資料持久化工具 js-van 作者。

參考文獻

[1] MDN Web Docs:https://developer.mozilla.org/zh-CN/

[2] The Anatomy Of A Frame:https://aerotwist.com/blog/the-anatomy-of-a-frame/

[3] 避免大型、複雜的佈局和佈局抖動 | Web | Google Developers:https://developers.google.com/web/fundamentals/performance/rendering/avoid-large-complex-layouts-and-layout-thrashing#avoid-layout-thrashing