React Hook 風格的數據加載方案

React Hook 自發布以來,因其簡單符合直覺的 API 與靈活的組合能力,很快就在 LeanCloud 控制檯的重構項目中得到了廣泛使用。隨着重構的進行,從服務端加載組件所需數據的實現方式逐漸演化並形成了一套相對完整的方案 [1]。在對比了社區中其他的一些熱門「加載數據 hook 庫」之後,我們發現社區中很少有對類似的設計方案的討論。這篇文章將介紹這個方案是如何演進,以及它是如何以一種更加符合「hook」設計風格的方式來滿足我們遇到的各種需求的。

內容分爲三個部分:

  1. 核心方法(createResourceHook)

  2. 擴展功能

  3. 特點與優勢

createResourceHook

這個方案的核心是一個叫做 createResourceHook 的方法,他會將一個請求數據的方法(比如 fetch)轉換爲一個 hook,其定義如下:

function createResourceHook<Args extends unknown[], T>(
  request: (...args: Args) => Promise<T>
): ResourceHook<Args, T>;

type ResourceHook<Args, T> = (
  requestArgs: Args,
  options?: { deps?: DependencyList; pending?: boolean; }
) => Resource<T | undefined>;

以 fetch 爲例,我們可以使用 createResourceHook 創建一個 useFetch hook 來獲取當前時間:

const fetchJSON = async (...args) => (await fetch(...args)).json();

const useFetch = createResourceHook(fetchJSON);

const Clock = () => {
  const [data, { error, loading }] = useFetch(['<https://worldtimeapi.org/api/timezone/etc/utc>']);

  return (
    <div>
      {loading && 'Loading...'}
      {error && error.message}
      {data && data.datetime}
    </div>
  );
};

useFetch 的第一個參數是 fetch 的參數列表,返回三個狀態 data、error 與 loading。因爲 data 幾乎是一定會用到的,同時爲了方便使用的地方對其重命名,我們將其單獨作爲 tuple 的第一個元素返回。

除了最基礎的用法,通過 createResourceHook 創建的 hook(下文統稱爲 useResource)還支持以下的特性:

  • 指定依賴

  • Reload

  • Abort

  • 條件加載

指定依賴

上面的例子中,如果我們把 url 換成一個變量,會發現返回的 data 是不會隨之更新的。這是因爲每次 render 的時候傳入 useFetch 的都是一個全新的數組,如果直接將該參數作爲內部觸發請求副作用的依賴的話會導致每次 render 都會觸發請求(直接將該數組展開作爲依賴也不可靠,因爲 fetch 的第二個參數是一個每次都會新構造的 Object),因此我們在生成的 useFetch 中增加了 deps 參數將觸發請求副作用的依賴暴露給調用的組件,同時將其默認值置爲 [] 以保證即時忘了設置也只會導致數據不更新,而不是死循環。我們需要顯式地將 url 指定爲 useFetch 的依賴:

const Clock = () => {
  const url = `https://worldtimeapi.org/api/timezone/${timezone}`;
  const [data, { error, loading }] = useFetch([url], {
    deps: [url] 
  });

  return (
    <div>
      {loading && 'Loading...'}
      {error && error.message}
      {data && data.datetime}
    </div>
  );
};

加上這個參數後代碼看起來有些囉嗦,實際上在 LeanCloud 我們並不直接使用 useFetch,而是在其基礎上繼續封裝,將 [url] 作爲默認的 deps 並調整參數的順序從而簡化調用:

const useFetch = (
  url: string,
  options?: RequestOptions,
  deps: DependencyList = [url],
) => useFetchJSON([url, options], {
  deps,
})

const Clock = () => {
  const url = `https://worldtimeapi.org/api/timezone/${timezone}`;
  const [data, { error, loading }] = useAPI(url);

  return (
    <div>
      {loading && 'Loading...'}
      {error && error.message}
      {data && data.datetime}
    </div>
  );
};

實際上我們封裝的也不是原生的 fetch / useFetch,而是更上層的 request / useRequst 方法,其中封裝了設置 XSRF-TOKEN、序列化 body 與 query、異常處理等業務邏輯。在這篇文章裏我們將繼續以 useFetch 爲例進行介紹。

Reload

有了上面的 deps 參數,我們可以很方便的實現「刷新」功能:只需在 deps 中增加一個 boolean 類型的變量,每次該變量改變的時候就會觸發重新請求。這個功能是如此的常用以至我們無法抵制誘惑將其內置到了 useResource hook 中 [2]:

const Clock = () => {
  // 多返回了一個 reload 方法
  const [data, { error, loading, reload }] = useFetch(['<https://worldtimeapi.org/api/timezone/etc/utc>']);

  return (
    <div>
      {loading && 'Loading...'}
      {error && error.message}
      {data && data.datetime}
      <button onClick={reload}>Reload</button>
    </div>
  );
};

Abort

我們的另一個需求是在組件銷燬時,以及資源的依賴更新導致重新發起請求時,仍在加載中的請求應該被取消。爲此,我們爲 createResourceHook 實現了一個重載,如果傳入的 request 方法同時返回一個 promise 與一個 abort 方法,得到的 useResource hook 會自動 abort 不再需要的請求。我們仍然以 fetch 爲例:

const abortableFetchJSON = (url: string, init?: RequestInit) => {
  const abortController = new AbortController();
  const { signal, abort } = abortController;
  const promise = fetchJSON(url, { signal, ...init });
  return {
    promise,
    abort: abort.bind(abortController),
  };
};

const useFetch = createResourceHook(abortableFetchJSON);

const Clock = () => {
  const [data, { error, loading, reload, abort }] = useFetch(['<https://worldtimeapi.org/api/timezone/etc/utc>']);

  return (
    <div>
      {loading && {<>'Loading...' <button onClick={abort}>Abort</button></>}}
      {error && error.message}
      {data && data.datetime}
      <button onClick={reload}>Reload</button>
    </div>
  );
};

此外,儘管我們沒有遇到實際的需求,出於能力的完整性,我們仍然在 useResource hook 的返回值中保留了 abort 方法。

條件加載

有時候,組件僅在滿足某些條件的時候才需要某些數據。因爲 hook 不能用在條件判斷內部,我們通常會首先考慮是否應該增加一個新的子組件,將「滿足條件加載數據」變爲「滿足條件加載組件」。然而仍然有一些情況並不適用(例如下一篇中會討論的「懶加載數據」的例子),而這個功能是不可能在 useResource hook 外部實現的,因此我們爲 useResource 增加了一個 condition 參數 [3]:

const Clock = () => {
  const [on, toggle] = useToggle(false);
  const [data, { error, loading }] = useFetch(['<https://worldtimeapi.org/api/timezone/etc/utc>'], {
    condition: on
  });

  return (
    <div>
      <Switch checked={on} onChange={toggle} />
      {loading && 'Loading...'}
      {error && error.message}
      {data && data.datetime}
    </div>
  );
};

擴展

以上就是 createResourceHook 的全部功能了。可能有些身經百戰的開發者會覺得這也沒比一個 usePromise 強多少嘛,實際業務需求的複雜度不知道比這些例子高到哪裏去了。確實如此,也正是因爲業務邏輯的多變,我們從一開始就非常謹慎地向核心的 useResource 中添加新功能,而是先在具體的業務組件中實現,再對多次用到的邏輯進行抽象。在這個過程中,我們發現大部分的需求實質上都是對 useResource 返回的結果進行處理與變換,同時也提煉了一些工具方法來實現常見的需求。接下來我們以需求爲線索介紹我們在 useResource 之上封裝的工具。

變換數據

很多時候,在 render 之前,我們需要對 Rest API 返回的原始數據進行一些處理,通常我們會使用 useMemo 來緩存處理後的數據,例如:

const getTime = (rawData) => rawData ? moment(rawData.datetime) : undefined;

const Clock = () => {
  const [rawData] = useFetch(['<https://worldtimeapi.org/api/timezone/etc/utc>']);
  const time = useMemo(() => getTime(rawData), [rawData]);

  return (
    <div>
      {time && time.format("LL LTS")}
    </div>
  );
};

我們可以引入一個 useTransform 來簡化對 rawData 的處理 [4]:

const getTime = (rawData) => rawData ? moment(rawData.datetime) : undefined;

const Clock = () => {
  const [time] = useTransform(
    useFetch(["<https://worldtimeapi.org/api/timezone/etc/utc>"]),
    getTime
  );

  return (
    <div>
      {time && time.format("LL LTS")}
    </div>
  );
};

這裏面有一個特殊的需求是給 data 指定一個默認值。useResource 的設定是,如果一個資源正在加載(loading 爲 true),那麼 data 一定是 undefined,有了解構賦值語法,我們很容易寫出下面這種有問題的代碼:

const List = () => {
  const [items = [] ] = useFetch(["<https://api.service/path/to/resources>"]);
  useEffect(sideEffect, [items]); // ????
  // ...
};

在數據加載的過程中,這個組件每次 render useFetch 返回的 data 都是 undefined,這會使 items 被賦予一個全新的 [] 從而觸發意料之外的 sideEffect。這是使用 hook 時經常會掉入的陷阱,儘管可以通過將 [] 移到組件外部或是用 useMemo 包起來解決,我們還是封裝了一個 useDefault 來從設計上避免這類問題(是的,你沒猜錯,useDefault 就是 useRef 與 useTransform 的簡單組合):

const List = () => {
  const [items] = useDefault(
    useFetch(["<https://api.service/path/to/resources>"]),
    []
  );
  useEffect(sideEffect, [items]); // ????‍
  // ...
};

平滑加載

剛纔提到資源正在加載時 useResource 返回的 data 是 undefined。這個設定在絕大部分情況下沒什麼問題,但是在有些場景下,翻頁或刷新操作會因此導致頁面的一部分高度突然發生變化,然後在加載完成之後再次變化。我們希望這個過程更加「平滑」,因此需要組件能在數據加載的過程中「記住」最近的有效的值。我們抽象了一個 useSmoothReload hook 來實現這個需求(這個需求不算複雜,我們就不再展開討論其實現細節了),我們來看一下實際使用的代碼:

const Clock = () => {
  const [time, { loading, reload }] = useSmoothReload(
    useFetch(["<https://worldtimeapi.org/api/timezone/etc/utc>"]),
  );

  return (
    <div>
      {time && time.format("LL LTS")}
      <button onClick={reload} disabled={loading}>{loading ? 'Loading...' : 'Reload'}</button>
    </div>
  );
};

本地狀態

我們有很多表單類型的組件在獲得了數據之後,會維護一個「本地」狀態。「本地」指的是在之後這個值是會被修改的,而在源數據變化後這個本地狀態則會被更新爲源數據(有點類似 getDerivedStateFromProps,只是這個 state 源自 useFetch 的結果而不是 props)。概念解釋起來有些抽象,不如直接上代碼:

// 這是一個鬧鐘的設置組件
const Alarm = () => {
  // 獲取配置項當前的值
  const [serverAlarmTime] = useFetch(["<https://api.service/alarm>"]);
  // 將其作爲初始值創建一個「本地」狀態
  const [alermTime, setAlarmTime] = useState(serverAlarmTime);
  // 在源數據更新時同步更新「本地」狀態
  useEffect(() => {
    setAlarmTime(serverAlarmTime);
  }, [serverAlarmTime]);

  return (
    <div>
      <Input value={alarmTime} onChange={setAlarmTime} />
      <button onClick={sumbit}>設置 Alarm</button>
    </div>
  );
};

我們爲這個模式封裝了一個 useLocalState 的 hook,上面的代碼可以簡化爲:

const Alarm = () => {
  const [alarmTime, { setAlarmTime } ] = useLocalState(
    useFetch(["<https://api.service/alarm>"])
  );

  return (
    <div>
      <Input value={alarmTime} onChange={setAlarmTime} />
      <button onClick={sumbit}>設置 Alarm</button>
    </div>
  );
};

總結

這套方案有哪些優點呢?爲什麼文章一開始說這個方案更加的「hook」呢?我總結了下面幾點。

聲明式的 API 設計

React Hook 給我們的代碼帶來的最大變化是從事件驅動行爲的「指令式」風格轉換成了描述狀態與副作用的「聲明式」風格。隨着組件狀態的增加,狀態之間的轉移將變的很難維護,指令式的抽象方式對這個問題的解決方案是使用 reducer 來描述狀態如何響應事件變化,而 hook 聲明式的抽象則沒有這些負擔,因此更貼近自然的心智模型(並且代碼量也更少)。這個方案中的 hooks 也體現出了「聲明式」的風格,以下面的翻頁場景爲例,我們不再需要關心從 URL 中的 page 參數發生變化到 data 發生變化之間具體都發生了什麼,我們只需要描述這個組件需要什麼數據,這個數據依賴哪些變量(這些依賴可能來自 URL 參數,來自 props,甚至來自另一個 useFetch 的返回值),剩下的具體過程就交給 React 來計算了。

const List = () => {
  const [page, setPage] = useURLParam('page');
  const [data, { error, loading, reload }] = useAPI(
    '/path/to/apps', 
    { query: { page } },
    [page], // deps
  );
  // ... render
}

獨立、原子的功能抽象

相比於基於 Class 的組件 API,React Hook 的一大優點是非常方便進行組合。我們在上面列出的這些擴展 hooks 是我們在實際遇到的需求中提煉的一些工具方法,他們每個都非常簡單,同時也非常原子(只做一件事情),對他們進行組合即可滿足各類複雜的需求。而核心的 useResource 抽象又足夠的簡單,可以非常方便的在其之上封裝出更多 hooks 來實現諸如緩存、重試、自動刷新等功能(我們沒有實現這些是因爲我們還沒有這類需求)。

獨立的擴展 hooks 的另一個優點是它們可以被靜態的導出,結合 bundler 的 tree-shaking 特性可以保證只有代碼中真正用到的功能會被打包。作爲對比,我們在開發的過程中也調研過一些優秀的開源請求庫,他們的一個共同特點是都有 一個 [5] 很長的 [6] 參數列表 [7],這些參數提供的特性大多我們都用不上,但它們卻被整合在了一個 API 中(被一同打包)。

與具體的獲取數據實現無關

儘管上面的討論中我們一直以 useFetch 爲例,但這個方案關注的始終是抽象的「資源」概念,不管你獲取資源的方法是 fetch、GraphQL、AsyncStorage 還是特定的 SDK,只要返回的是 Promise 就可以通過 createResourceHook 包裝爲一個 useResource hook。這也意味着這個方案僅依賴 React Hook API,可以在 React、 React Native 甚至 Taro 等兼容 React API 的環境中使用。

以上介紹的方案中用到的核心 createResourceHook 方法與擴展 hooks 的源碼可以在 這個 repo [8] 裏找到。在下篇文章中,我們將分享在 LeanCloud 我們如何處理不同頁面之間共享數據的需求。

相關鏈接:

[1] https://url.leanapp.cn/6u2q6ZY

[2] https://url.leanapp.cn/Br8zONO

[3] https://url.leanapp.cn/UPFFvEF

[4] https://url.leanapp.cn/INCO2NM

[5] https://url.leanapp.cn/xtOL54k

[6] https://url.leanapp.cn/f0RAGbz

[7] https://url.leanapp.cn/D2YYhcu

[8] https://url.leanapp.cn/6u2q6ZY

Photo by Ivar Asgaut

end

LeanCloud,領先的 BaaS 提供商,爲移動開發提供強有力的後端支持。更多內容請關注「LeanCloud 通訊」

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章