# 探索 Browser API (上)

# 前言

最近這幾年許多 Browser 原生的 API 陸續被實作出來,試圖解決前端遇到的問題,這個系列試圖介紹一些比較有趣或是比較被廣泛支援的 API,以後遇到類似的問題或許可以試試看用原生的 API 解決。

# reqeustIdleCallback

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

# requestIdleCallback 簡介

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

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

# 怎樣才算 Idle?

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

# requestIdleCallback(fn, {timeout})


另外第二個參數 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 函數。

# 幾個問題

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