設計一個 React Hook 達到選項惰性載入以及顯示數控制的效果

Peace Pan
萬達寵物系統發展部
7 min readDec 23, 2019

選擇一個選項讓使用者選取,幾乎是每個網站都會遇到的需求,但開發要讓使用者選取項目時,可能會遇到兩種狀況:

  1. 選項屬於非同步資料,資料過多抓取時,耗時會造成使用者體驗不佳
  2. 顯示的選項超過上千筆,一次顯示給使用者選取時會造成網頁負擔過大

而會遭遇的使用者情境大概為

  1. 使用者想選取某個選項
  2. 於輸入欄輸入搜尋關鍵字
  3. 查找目標選項
  4. 選取選項

為了解決這個狀況,需要設計一個機制,不過前提是需要搭配能夠提供搜尋功能的 API

Let’s thinking

選項惰性載入可能遭遇的問題

  1. 搜尋的輸入字串儲存

需要有個變數來儲存 onChange 時的字串變化,這邊因為是設計 hook 因此使用 useRef 來儲存。為何使用 useRef 而不使用 useState ? 因為在這個 hook 設計裡 input 的值並不需要反映到畫面上,只需紀錄值即可。

/** 紀錄當前輸入欄的字串值 */
const inputValueRef = useRef<string>('');

2. 何時才去抓取選項?

使用者在輸入搜尋時,input 的 onChange 是一直觸發的,總不能每次觸發時都發出一次的 API 請求吧?因此我們需要一個 Timeout 來處理這個情況,在每次 onChange 時都 clearTimeout 並 setTimeout

// 因為是寫在 hook 裡因此這邊需使用 useRef 來紀錄 Timeout
const timerRef = useRef<number | void>(void 0);
...// 在 onChange 事件裡
if (timerRef.current) window.clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(() => {
timerRef.current = void 0;
// code
})

3. 相同關鍵字重複搜尋

為了避免這個狀況,需要使用一個 map ,在每次搜尋時將關鍵字記錄下來,然後在每次請求前檢查,就可以達到效果。

/** 用一個 map 來記錄 inputValue 有無被執行過,避免重複執行 */
const executedMapRef = useRef<Record<string, boolean>>({});
...// 在要發出搜尋請求前
if (executedMapRef.current[inputValueRef.current]) return;
executedMapRef.current = true;

4. 載入中狀態與非同步工作併發問題

如果要知道現在是否正在進行載入中,就需要用一個 state 來紀錄狀態,但是使用者在搜尋時,需要考慮到網路環境,不可能每次請求的時間都非常快速,有可能第一次搜尋的請求發出還沒回應,下一次的請求就又發出了。如果沒有考慮排程,那麼這時的載入狀態就會產生不正確的結果。
因此需要使用一個 queue 來紀錄 promise 的處理。

/**
* 使用一個序列來紀錄非同步工作\
* 因為有可能上一個非同步工作尚未結束,而下一次的 handle 已觸發因此需要來判斷
*/
const handlePromises: Array<Promise<Options>> = useMemo(() => [], []);
...// 發出搜尋請求
/** 將目前的非同步工作丟到序列裡,處理完後再將自己移除 */
const promise = someHandleFunc(inputValue, lazyOptions);
handlePromises.push(promise);
const newLazyOptions = await promise;
const promiseIdx = handlePromises.findIndex((_promise) => _promise === promise);
handlePromises.splice(promiseIdx, 1);

5. 相同選項重複出現

由於關鍵字搜尋的結果有可能會回傳相同的資料,為了避免相同選項的出現,在將資料組合回資料陣列裡,必須要確保選項裡每個選項的值都是唯一的。
但每次如果都要在整個資料陣列裡跑迴圈做比對,那當資料一多那麼執行代價就越高了,這件事基本上就是在說 lodash 的 uniq 函式而已,為了讓程式有好的效能,所以就使用 map 來記憶處理。

/** 用一個 map 來記錄所有選項的 value 鍵值,用來快速比對選項是否已存在 */
const optionValueMapRef = useRef<Record<string, boolean>>({});
...const newLazyOptions = await fromSomeFunc;
setLazyOptions((currentLazyOptions) => {
/** 排除新選項裡重複的 value */
const _options = newLazyOptions.filter((option) => {
if (optionValueMapRef.current[option.value]) return false;
return optionValueMapRef.current[option.value] = true;
});
if (_options.length === 0) return currentLazyOptions;
return currentLazyOptions.concat(_options);
});

總合上述幾種問題,可以設計出一個 hook 函式。

惰性載入選項 hook 的示意代碼

渲染前控制顯示數

處理完大量惰性載入選項資料後,當選項資料太多時,甚至超過上千筆時,全部都渲染到 DOM 上面時,肯定會造成網頁渲染負擔,畫面就會卡卡的。

為了避免這個狀況,必須要控制要掛到 DOM 上面的選項數,因此再渲染前可以使用 useMemo 來根據輸入的關鍵字處理要顯示的選項,邏輯大致是這樣

const maxDisplayAmount = 30;
const optionsForRender = useMemo(() => {
if (!options) return [];
const _options: Array<SelectOption<Value>> = [];
for (const _option of options) {
const ov = _option.display.toLocaleLowerCase();
const iv = inputValue.toLocaleLowerCase();
if (ov.includes(iv)) _options.push(_option);
if (_options.length >= maxDisplayAmount) break;
}
return _options;
}, [inputValue, options]);

實際實作

這邊使用 blueprintjs 的 @blueprintjs/select 來搭配這個設計的 hook,此套件內提供了 5 種元件分別是

  1. Select (提供基本的選項選取功能)
  2. Suggest (如同自動完成的輸入欄)
  3. MultiSelect (提供選項可複選的元件)
  4. Omnibar (Modal 式的搜尋選擇元件)
  5. QueryList (客製化的 Select)

這裡使用 1 元件來搭配這個 hook 使用,blueprintjs 在元件裡有提供 itemListPredicate 這個 props ,剛好可以把顯示數的控制邏輯代碼寫到裡面

示意畫面

--

--