前端知库

自定义 Hook 怎样设计?useRequest、usePrevious、useInterval 该怎么写?

自定义 Hook 是名字以 use 加大写字母开头、内部调用了其他 Hook 的函数,用来复用有状态的逻辑,每个调用方各有一份独立状态。设计时名字要说清用途,参数里的响应式值写进 Effect 依赖,请求要在清理函数里忽略过期结果,定时器要在清理函数里清除,传进来的回调用 useEffectEvent 包起来,不要写 useMount 这类生命周期包装。

参考答案

简答

收货表单里,城市列表和区县列表都要按参数请求接口,两段 useEffect 几乎一样。把这段逻辑抽成 useData(url),组件里写 const cities = useData(url),这就是自定义 Hook:一个名字以 use 加大写字母开头、内部调用了其他 Hook 的函数。

抽出来是为了复用有状态的逻辑,同时让数据流一目了然:传进去 url,拿回来 data,Effect 藏在 Hook 里面。它共享的是逻辑,不是状态,两个组件调用同一个 Hook,各有一份独立的状态。

设计时有几条规则。名字要说清用途,比如 useData(url)、useInterval(callback, delay);函数里没调用任何 Hook,就别加 use 前缀。参数会随组件每次渲染传进来,Effect 用到的参数要写进依赖。有副作用就要清理:请求在清理函数里把过期结果丢掉,定时器在清理函数里清除。传进来的回调函数用 useEffectEvent 包起来,免得每次渲染都重建定时器或连接。

要注意,React 文档不建议写 useMount 这类只是包一层 useEffect 的“生命周期 Hook”,因为它不会随参数变化重新同步,lint 也检查不到。网上常见的 usePrevious 在渲染期间读 ref.current,也和文档的要求冲突,文档给的写法是在渲染时比较并更新 state。

什么样的函数算自定义 Hook

React 文档给的命名规则是,组件名以大写字母开头,Hook 名以 use 加大写字母开头。这个前缀不只是约定,看到 use 开头的函数,读代码的人和 lint 工具就知道它里面可能有状态和 Effect,必须遵守 Hook 的调用规则,规则见《为什么 Hook 通常必须在组件顶层调用?》。

反过来,函数里没有调用任何 Hook,就应该写成普通函数。文档举的例子是 useSorted(items) 只做了排序,应该改名 getSorted,这样它能在条件分支里调用。

自定义 Hook 里的代码在组件每次重新渲染时都会重新执行,所以它和组件一样要保持纯粹,可以看成组件函数体的一部分。也正因为每次渲染都会执行,它总能拿到最新的 props 和 state。

自定义 Hook 和高阶组件、Render Props 的取舍,见《高阶组件、Render Props 和自定义 Hook 怎样选择?》。

useRequest:依赖和过期结果

文档里的 useData 就是一个最小的请求 Hook。接收 url,返回数据,url 变了就重新请求:

JSX
function useData(url) {
  const [data, setData] = useState(null);
  useEffect(() => {
    if (url) {
      let ignore = false;
      fetch(url)
        .then((response) => response.json())
        .then((json) => {
          if (!ignore) {
            setData(json);
          }
        });
      return () => {
        ignore = true;
      };
    }
  }, [url]);
  return data;
}

function ShippingForm({ country }) {
  const cities = useData(`/api/cities?country=${country}`);
  const [city, setCity] = useState(null);
  const areas = useData(city ? `/api/areas?city=${city}` : null);
  // ...
}

url 是从组件传进来的响应式值,Effect 里用到了它,所以要写进依赖数组。这样用户切换国家时,Hook 会自动重新请求。

ignore 标志解决的是竞态。用户先选中国、马上又选美国,两个请求都发出去了,可能中国的请求后回来,把美国的结果覆盖掉。切换时 React 会先执行上一次 Effect 的清理函数,把那一次的 ignore 设为 true,旧请求回来也不会写进 state。下面用 Promise 模拟这个过程:

JavaScript
function createFetcher(log) {
  return function useDataEffect(url, response) {
    let ignore = false;
    response.then((data) => {
      if (!ignore) {
        log.push(url + ' -> ' + data);
      }
    });
    return () => {
      ignore = true;
    };
  };
}

const log = [];
const runEffect = createFetcher(log);

let resolveSlow;
const slow = new Promise((resolve) => {
  resolveSlow = resolve;
});
const cleanupA = runEffect('/api/cities?country=cn', slow);
cleanupA();
runEffect('/api/cities?country=us', Promise.resolve('纽约'));
resolveSlow('北京');

Promise.resolve()
  .then(() => Promise.resolve())
  .then(() => {
    console.log(log.length); // 1
    console.log(log[0]); // /api/cities?country=us -> 纽约
  });

中国的请求最后才返回“北京”,可是它所在那次 Effect 已经被清理,ignore 是 true,结果被丢掉,记录里只剩美国的结果。

实际项目里的 useRequest 往往还要返回 loading、error,支持手动刷新。这些都可以在同一个结构上加 state,关键的两点不变:参数写进依赖,清理函数里处理过期结果。文档还提到,React 内置的数据请求写法还在完善中,把请求封装在 useData 这样的 Hook 里,以后迁移到官方推荐的写法时要改的地方更少。

useInterval:清理定时器,回调不进依赖

文档练习题里的 useInterval 最终写成这样:

JSX
function useInterval(callback, delay) {
  const onTick = useEffectEvent(callback);
  useEffect(() => {
    const id = setInterval(onTick, delay);
    return () => clearInterval(id);
  }, [delay]);
}

清理函数里的 clearInterval 保证组件卸载、或者 delay 变化重建定时器时,旧定时器不会继续跑。

读者可能会问,为什么不把 callback 也写进依赖?因为调用方通常写的是内联函数,useInterval(() => setCount((c) => c + 1), 1000),每次渲染都是一个新函数。写进依赖,每次渲染都会清掉定时器重建,间隔就总被重置。useEffectEvent 返回的函数在 Effect 里调用时总是执行最新的 callback,又不需要写进依赖,所以只有 delay 变化才重建定时器。useEffectEvent 的用法和限制见《React useEffectEvent 适合解决什么依赖问题?》。

usePrevious:别在渲染时读 ref

网上流行的 usePrevious 是这样写的:用一个 ref 存值,在 Effect 里更新 ref.current,渲染时返回 ref.current。可是 useRef 文档明确要求,除了初始化,渲染期间不要读写 ref.current,否则组件的行为难以预测。

文档在 useState 页面给了另一种做法,叫“保存上一次渲染的信息”:用一个 state 记住上一次的值,渲染时发现不一样就更新它。

JSX
function usePrevious(value) {
  const [current, setCurrent] = useState(value);
  const [previous, setPrevious] = useState(undefined);
  if (value !== current) {
    setPrevious(current);
    setCurrent(value);
  }
  return previous;
}

在渲染期间调用 set 函数,React 会丢弃这次输出,立即用新 state 重新渲染。文档对这种写法有几个要求:set 调用必须放在 value !== current 这样的条件里,否则会无限循环;只能更新正在渲染的组件自己的 state。文档也说这种写法很少需要、不好理解,一般尽量避免,只是比在 Effect 里更新 state 要好。

不要写 useMount 这类 Hook

有人会把 useEffect(fn, []) 包成 useMount(fn)。文档不推荐这样做。比如 useMount(() => connect(roomId)),roomId 变了它不会重新连接,这是个 bug,可是 lint 只检查直接写出来的 useEffect,查不到 useMount 里面。

所以自定义 Hook 应该对应一个具体的用途,比如 useChatRoom(options)、useOnlineStatus(),而不是给 useEffect 换个名字。文档还给了一个判断方法:如果很难给 Hook 起一个清楚的名字,说明这段 Effect 和组件其他逻辑耦合得太紧,还不适合抽出来。

常见追问

两个组件调用同一个自定义 Hook,状态是共享的吗?

不是。每次调用 Hook,里面的 useState 都是独立的一份,和直接在两个组件里各写一遍效果一样。想让多个组件共享同一份状态,要把状态提升到共同的父组件,或者用 Context、外部 store。

自定义 Hook 里能调用另一个自定义 Hook 吗?

可以。比如 useCounter 里调用 useInterval。一个 Hook 返回的值传给另一个 Hook,每次渲染都会传最新的值,像链条一样串起来。

为什么自定义 Hook 收到的回调要用 useEffectEvent 包?

因为调用方传的回调通常每次渲染都是新函数,直接写进 Effect 依赖,Effect 就会每次渲染都重新执行,比如聊天室每次渲染都断线重连。用 useEffectEvent 包起来以后,Effect 里调用的始终是最新的回调,但它不算依赖,Effect 只在真正的依赖变化时重新同步。