自定义 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 变了就重新请求:
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 模拟这个过程:
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 最终写成这样:
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 记住上一次的值,渲染时发现不一样就更新它。
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 只在真正的依赖变化时重新同步。