許多網頁當中都有各式各樣的 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
,甚至幫你的任務標註優先度以確保執行順序等等,都是可以優化的地方。
結論
瀏覽器支援度還不算太好:
在 react 的 Fiber 當中其實也是利用類似的機制來完成,過去在執行 work 的時候,可能會因為 call stack 的深度太深,或是 updateQueue
太多,導致 main thread 卡住,更新時間過長。
Fiber 的機制則是把工作拆成一小塊,如果有其他優先度高的 work 就先過去做,再回來繼續處理其他 work。