半熟前端

軟體工程師 / 台灣人 / 在前端的路上一邊探索其他領域的可能性

前端

requestIdleCallback - 善用空閑時間

許多網頁當中都有各式各樣的 script 需要執行,當然也會有優先程度,像是比較重要的:渲染 UI,註冊相關的互動事件,呼叫 API 拿取資料等等是高優先的任務,而像是比較不重要的任務有:Analytic 的腳本、lazy loading、初始化比較不重要的事件。

怎樣才算 Idle?

怎樣才能知道瀏覽器處於 Idle 的狀態?這是一個相當複雜的問題,瀏覽器幫我們排程了一連串的任務,解析 HTML, CSS, Javascript、渲染 UI、API calls、抓取圖片並且解析、GPU 加速等等,要知道什麼時候閒置勢必得了解瀏覽器的排程工作。不過很幸運地是,requestIdleCallback 幫我們解決了這個問題。

requestIdleCallback 簡介

requestIdleCallback 會在 frame 的最後執行,但並不是每一個 frame 都保證會執行 requestIdleCallback。這個原因很簡單,我們無法保證每一個 frame 結束時我們還有時間,所以並不能保證 requestIdleCallback 的執行時間。

仔細一看,會覺得 requestIdleCallback 有點像是 context switch 的感覺,你可以在 frame 與 frame 之間完成一些工作,中斷一下,然後再繼續執行。

requestIdleCallback(fn, {timeout})

const myWork = deadline => {
  console.log("not important job.")
  while (deadline.timeRemaining() > 0) {
    // sending logging
    // fetch non-essential data.
    // use your imaginary
  }
  abortMyJob()
}

另外第二個參數 options 裡頭有個 timeout 選項,如果在 timeout 期間瀏覽器都還沒有呼叫的話,你可以用這個 timeout 讓瀏覽器停下手邊的工作強制呼叫。

這不是一個適當的使用方式,因為我們使用 idle 的原因就是因為這個任務是不重要的,沒必要為了它而打斷手邊的工作。瀏覽器提供了這份彈性給我們,有時你還是會希望事件在某個時間點以內觸發。

cancelIdleCallback(id)

對應到 requestIdleCallback() 會回傳一個 id,我們也可以呼叫 cancelIdleCallback(id) 來取消不需要的 idle callback。

requestIdleCallback 會不會被中斷?

deadline 參數表示這一個 frame 當中,你有多少時間可以完成這個任務,傳入的 callback 可以取得這個參數,根據官方的說法,就算超過了這個時間,瀏覽器也不會強制中斷你的任務,只是希望你能夠在 deadline 完成,讓使用者有最佳體驗。

deadline.timeRemaining() 回傳當前的可利用的時間還有多少。

在 requestIdleCallback 執行 DOM 操作會怎樣?

不妨設想一下,前文有提到,requestIdleCallback 會在 frame 的最後才執行,表示瀏覽器已經做完 recaculate, layout, paint 的工作了,在這時修改 DOM 的話,等於強迫瀏覽器又要再一次做 recaculate style, layout, paint 的排程。

在 requestIdleCallback 裡呼叫 requestIdleCallback 會怎樣?

requestIdleCallback 裡頭呼叫 requestIdleCallback 是合法的。不過這個 callback 會被安排到下一個 frame。(實際上並不一定是下一個 frame,視瀏覽器的排程而定)

Example, please!

假設我們有幾個使用者,當滑鼠移至大頭照上方時,會出現個人簡介。為了善用 idle 期間,我們可以在 requestIdleCallback 就先偷偷抓取需要的 API,如果都還沒有 fetch 過資料,再呼叫 API 去拿資料。

function fetchUser(name) {
  const users = {
    kalan: "food, coffee, life",
    jack: "woman, coffee, life",
  }
  return Promise.resolve(users[name])
}

const userIntro = {}

const queue = [
  { name: "kalan", fetched: false },
  { name: "jack", fetched: false },
]

requestIdleCallback(deadline => {
  while (deadline.timeRemaining() > 0) {
    let q = queue.pop()
    fetchUser(q.name).then(user => {
      if (deadline.timeRemaining() > 0) {
        userIntro[user.name] = user
        q.fetched = true
      }
    })
  }
}, 500)

avatar.addEventListener("mouseover", e => {
  const name = e.target.getAttribute("data-name")
  if (userIntro[name]) {
    // show intro
  } else {
    fetchUser(name).then(user => showInfo(user))
  }
})

這個例子當中,我們利用 requestIdleCallback 先去抓取 user 的資料存起來,之後若使用者點擊大頭照就可以直接秀出來。如果還沒有抓到的話再重新 fetchUser 一次。

為了示範 requestIdleCallback 的效果,這個例子看起來挺麻煩的,不但需要維護一組 queue 來判斷是否已經 fetch 過,如果 mouseover 觸發了,還要另外判斷一次 userIntro 是否有值,開發上反而變得比較麻煩一些,一次抓取多位使用者的資料還比較省事一些,不過這完全根據需求與使用場景而定。

Analytic

另外一個常見的場景是做追蹤,例如追蹤使用者點擊按鈕,點擊播放,觀看時間等等。我們可以先蒐集事件,再利用 requestIdleCallback 的方式一次送出。像是:

const btns = btns.forEach(btn => // buttons you want to track.
btn.addEventListener('click', e => {
    // do other interactions...
    //...
    putIntoQueue({
      type: 'click'
      // collect your data
    }));
    schedule();
});

function schedule() {
    requestIdleCallback(
      deadline => {
          while (deadline > 0) {
            const event = queues.pop();
            send(event);
          }
      },
      { timeout: 1000 }
   );
}

這裡加上了 timeout 來確保瀏覽器會呼叫 schedule 函數。

How about React?

為了避免其他不必要的工作阻斷 main thread,我們可以整合一下 react hooks,將不必要的工作全部丟進去 useIdleCallback 裡頭。

import { useEffect, useRef } from "react"

function useIdleCallback(callback, timeout) {
  useEffect(() => {
    let id = requestIdleCallback(deadline => {
      callback(deadline)
    }, timeout)
    return () => cancelIdleCallback(id)
  }, [callback, timeout])
}

function UserIntro({ data }) {
  useIdleCallback(() => {
    sendLog(data)
  })
  return // your awesome UI
}

幾個問題

以上這些例子只是為了示範方便,實際上要處理的問題很多,例如如何管理 queue、如何管理 timeout,甚至幫你的任務標註優先度以確保執行順序等等,都是可以優化的地方。

結論

瀏覽器支援度還不算太好:

螢幕快照 2019-03-15 上午10.42.06

在 react 的 Fiber 當中其實也是利用類似的機制來完成,過去在執行 work 的時候,可能會因為 call stack 的深度太深,或是 updateQueue 太多,導致 main thread 卡住,更新時間過長。 Fiber 的機制則是把工作拆成一小塊,如果有其他優先度高的 work 就先過去做,再回來繼續處理其他 work。