关于react-hooks:不满意社区的轮子我们自创了一套-React-Hooks-风格的数据加载方案

9次阅读

共计 8401 个字符,预计需要花费 22 分钟才能阅读完成。

作者:leancloud 李叶

React Hooks 自公布以来,因其简略合乎直觉的 API 与灵便的组合能力,很快就在 LeanCloud 控制台的重构我的项目中失去了宽泛应用。对于「从服务端加载组件所需数据」这一需要,因为最开始的需要比较简单,咱们没有引入第三方库而是本人封装了一些 API 来实现。随着重构的进行,这些 API 逐步演变并造成了一套绝对残缺的计划。在比照了社区中其余的一些热门「加载数据 Hook 库」之后,咱们发现社区中很少有对相似的设计方案的探讨。这篇文章将介绍这个计划是如何演进,以及它是如何以一种更加合乎「Hook」设计格调的形式来满足咱们遇到的各种需要的。

计划的源码凋谢在了 GitHub 上:https://github.com/leancloud/use-resource

内容分为三个局部:

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

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

扩大

以上就是 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>
  );
};

这外面有一个非凡的需要是给 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 咱们如何解决不同页面之间共享数据的需要。

正文完
 0