Kalan's Blog

本部落主要是關於前端、軟體開發以及我在日本的生活,也會寫一些對時事的觀察和雜感

目前主題 亮色

許多網頁當中都有各式各樣的 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。

上一篇

記一次 debug 流程 — connection refused

下一篇

Goworker 簡介 — 搭配 Redis 實作 worker

如果覺得這篇文章對你有幫助的話,可以考慮到下面的連結請我喝一杯 ☕️ 可以讓我平凡的一天變得閃閃發光 ✨

Buy me a coffee

作者

Kalan 頭像照片,在淡水拍攝,淺藍背景

愷開 | Kalan

愷開。台灣人,在 2019 年到日本工作,目前定居在福岡。除了熟悉前端之外對 IoT、App 開發、後端、電子電路領域都有涉略。最近開始玩電吉他。 歡迎 Email 諮詢或合作,聊聊音樂也可以,希望能透過這個部落格和更多的人交流。