不满意社区的轮子,我们自创了一套 React Hooks 风格的数据加载方案

avatar
技术支持 @LeanCloud

作者:LeanCloud 李叶

React Hooks 自发布以来,因其简单符合直觉的 API 与灵活的组合能力,很快就在 LeanCloud 控制台的重构项目中得到了广泛使用。对于「从服务端加载组件所需数据」这一需求,因为最开始的需求比较简单,我们没有引入第三方库而是自己封装了一些 API 来实现。随着重构的进行,这些 API 逐渐演化并形成了一套相对完整的方案。在对比了社区中其他的一些热门「加载数据 Hook 库」之后,我们发现社区中很少有对类似的设计方案的讨论。这篇文章将介绍这个方案是如何演进,以及它是如何以一种更加符合「Hook」设计风格的方式来满足我们遇到的各种需求的。

方案的源码开放在了 GitHub 上:github.com/leancloud/u…

内容分为三个部分:

  1. 核心方法(createResourceHook
  2. 扩展功能
  3. 特点与优势

核心方法(createResourceHook)

我们的探索开始于最简单的需求:使用 hook 加载一个 REST API。在这个场景中,我们关注的状态有三个:(成功的)结果、异常以及是否正在加载。因此在设计中,我们的代码看起来应该是这个样子的:

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 的参数列表,返回三个状态 dataerrorloading。因为 data 几乎是一定会用到的,同时为了方便使用的地方对其重命名,我们将其单独作为 tuple 的第一个元素返回。

这篇文章接下来讨论的所有功能与扩展都将基于这个useFetch hook。useFetch 是由一个叫做 createResourceHook 的方法创建的。createResourceHook 是这个方案中最核心的 API,它会将一个请求数据的方法(比如 fetch)转换为一个 hook,其定义如下:

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

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

比如上面例子中的 useFetch 就是使用 createResourceHook 「包装」的 fetch

import { createResourceHook } from '@leancloud/use-resource';

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

export const useFetch = createResourceHook(fetchJSON);

除了最基础的用法,通过 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 useAPI = (
  url: string,
  options?: RequestOptions,
  deps: DependencyList = [url],
) => useFetch([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 中:

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>
  );
};

codesandbox.io/s/use-resou…

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 参数:

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>
  );
};

codesandbox.io/s/use-resou…

扩展

以上就是 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 的处理:

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>
  );
};

codesandbox.io/s/use-trans…

这里面有一个特殊的需求是给 data 指定一个默认值。useResource 的设定是,如果一个资源正在加载(loadingtrue),那么 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 就是 useRefuseTransform 的简单组合):

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

平滑加载

刚才提到资源正在加载时 useResource 返回的 dataundefined。这个设定在绝大部分情况下没什么问题,但是在有些场景下,翻页或刷新操作会因此导致页面的一部分高度突然发生变化,然后在加载完成之后再次变化。我们希望这个过程更加「平滑」,因此需要组件能在数据加载的过程中「记住」最近的有效的值。我们抽象了一个 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 特性可以保证只有代码中真正用到的功能会被打包。作为对比,我们在开发的过程中也调研过一些优秀的开源请求库,他们的一个共同特点是都会提供一个 All-in-One 的 API,附带 一个 很长的 参数列表,这些参数提供的特性大多我们都用不上,但它们却被整合在了一个 API 中(被一同打包)。

与具体的获取数据实现无关

尽管上面的讨论中我们一直以 useFetch 为例,但这个方案关注的始终是抽象的「资源」概念,不管你获取资源的方法是 fetch、GraphQL、AsyncStorage 还是特定的 SDK,只要返回的是 Promise 就可以通过 createResourceHook 包装为一个 useResource hook。这也意味着这个方案仅依赖 React Hook API,可以在 React、 React Native 甚至 Taro 等兼容 React API 的环境中使用。

以上介绍的方案中用到的核心 createResourceHook 方法与扩展 hooks 的源码可以在 这个 repo 里找到。在下篇文章中,我们将分享在 LeanCloud 我们如何处理不同页面之间共享数据的需求。