深入讲解React自定义Hook的编写机制与规范,通过循序渐进的方式将前端组件中重复的异步请求逻辑抽象为健壮的useRequest Hook,涵盖状态机推进、参数控制、竞态时序过滤与组件卸载保护。
自定义Hook怎么写?如何把请求逻辑抽成useRequest?
时间:2026-11-13
浏览:232
文章目录
use 为命名前缀的普通 JavaScript 函数,核心机制在于它能够在函数体内调用 React 内置的 useState、useEffect、useCallback 等 Hook,从而把分散在各个业务组件中的状态声明与生命周期副作用完整抽离成可复用的逻辑单元。将异步数据请求抽象为 useRequest,解决路径是将组件中反复出现的 data、loading、error 状态机、Promise 异步决议监听、动态传参调度,以及竞态与卸载防御机制收敛到一个独立函数中,对外导出语义统一的状态对象与触发函数。
一、认识自定义 Hook:基本规则与判定边界
在 React 函数式组件的开发中,随着业务变多,组件往往会堆积大量与 UI 渲染本身无关的状态与逻辑。如果这些逻辑只是单纯的数据计算,可以抽成普通工具函数(utils);但如果这段逻辑需要访问或管理 React 的内部状态(如保持状态、监听组件挂载与更新、触发重渲染),普通函数便无法胜任,这时就必须使用自定义 Hook。
编写自定义 Hook 必须遵循两条核心规则:
- 命名必须以
use开头:例如useRequest、useDebounce、useLocalStorage。这不仅是命名约定,更是 React 官方 ESLint 插件(eslint-plugin-react-hooks)识别并强制检查 Hooks 规则(如不能在循环、条件判断或嵌套函数中调用 Hook)的必要依据。 - 内部复用的是“状态逻辑”,而不是“状态数据本身”:每次在不同组件中调用自定义 Hook,或者在同一个组件中多次调用同一个 Hook,React 都会为每一次调用创建完全独立的状态隔离作用域,二者互不干扰。
最简单的自定义 Hook 结构如下所示:
// 基础自定义 Hook 骨架示例
import { useState, useEffect } from 'react';
export function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(navigator.onLine);
useEffect(() => {
const handleOnline = () => setIsOnline(true);
const handleOffline = () => setIsOnline(false);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
return isOnline;
}
二、组件原生请求逻辑的痛点分析
在没有抽离请求 Hook 之前,一个典型的 React 组件在加载数据时,通常会写出如下代码:
function UserProfile({ userId }) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
let active = true;
setLoading(true);
setError(null);
fetchUserInfo(userId)
.then((res) => {
if (active) setData(res);
})
.catch((err) => {
if (active) setError(err);
})
.finally(() => {
if (active) setLoading(false);
});
return () => {
active = false;
};
}, [userId]);
if (loading) return <div>加载中...</div>;
if (error) return <div>请求出错: {error.message}</div>;
return <div>{data?.name}</div>;
}
当项目中存在几十甚至上百个组件时,这种写法会带来明显的工程痛点:
- 模板代码高度重复:每个组件都在反复声明
data、loading、error三个状态,并手动编写 Promise 决议后的分支赋值。 - 状态流转极易遗漏:在处理异常分支时,极易忘记把
loading重置为false,导致界面卡死在加载态;或者在重新发起请求时忘记清空上一次的error。 - 竞态条件(Race Condition)难以防范:当
userId快速变化时(例如用户在列表中快速切换选项),前一个请求由于网络延迟较长,可能会在后一个请求之后返回,导致旧数据覆盖新数据,产生界面与实际选中项不符的严重 Bug。 - 组件卸载后的内存泄漏告警:若请求在传输途中用户离开页面,Promise 回调在组件卸载后仍然执行
setState,不仅浪费资源,还会引发 React 运行时警告。
三、逐步构建 useRequest:从基础到生产级健壮
为了彻底解决上述样板代码与边界隐患,我们分四个递进阶段实现一个完备的 useRequest 自定义 Hook。
阶段一:最简雏形——封装核心三态与自动触发
首先实现最基础的诉求:传入一个返回 Promise 的异步请求函数(称为 service),组件挂载时自动执行,并返回当前的 data、loading、error 状态。
// 阶段一:基础版本
import { useState, useEffect } from 'react';
export function useRequest(service) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
setError(null);
service()
.then((res) => {
setData(res);
})
.catch((err) => {
setError(err);
})
.finally(() => {
setLoading(false);
});
}, [service]);
return { data, loading, error };
}
这个基础版已经能满足简单的数据展示,但有两大致命缺陷:首先它只能在挂载时自动发请求,无法支持点击按钮或提交表单等“手动触发”;其次它无法给 service 传递动态参数。
阶段二:支持手动触发、动态入参及完成回调
现实业务中大量请求属于交互触发型(例如点击“保存”、“刷新”、“删除”)。我们需要引入 options 配置对象,支持 manual(是否手动触发)、defaultParams(默认入参)以及 onSuccess、onError 回调函数,同时向外暴露出可手动调用的 run 方法。
// 阶段二:支持手动控制与回调
import { useState, useEffect, useCallback, useRef } from 'react';
export function useRequest(service, options = {}) {
const {
manual = false,
defaultParams = [],
onSuccess,
onError,
} = options;
const [data, setData] = useState(null);
const [loading, setLoading] = useState(!manual);
const [error, setError] = useState(null);
// 将 service 与回调保存在 ref 中,避免 run 频繁重新生成
const serviceRef = useRef(service);
serviceRef.current = service;
const optionsRef = useRef(options);
optionsRef.current = options;
// 调度执行函数
const run = useCallback((...params) => {
setLoading(true);
setError(null);
return serviceRef.current(...params)
.then((res) => {
setData(res);
optionsRef.current.onSuccess?.(res, params);
return res;
})
.catch((err) => {
setError(err);
optionsRef.current.onError?.(err, params);
throw err;
})
.finally(() => {
setLoading(false);
});
}, []);
useEffect(() => {
if (!manual) {
run(...defaultParams);
}
}, [manual, run]);
return { data, loading, error, run };
}
阶段三:防御机制——彻底杜绝竞态条件与卸载时状态更新
当网络存在波动,或者用户快速多次触发 run 时(例如搜索框输入防抖后仍产生交错请求),后发出的请求可能先返回,先发出的请求后返回。如果直接把后返回的旧数据赋值给 data,就会呈现严重的“竞态数据错乱”。
解决竞态的经典且开销最小的做法是请求序列号标定(Sequence Count Matching):使用 useRef 在 Hook 内部维护一个自增计数器。每次执行 run 时计数器加一,并把当前计数固化在闭包中。当 Promise 决议时,比对当前闭包中的计数与 ref 中的最新计数是否一致。如果不是最新请求,直接丢弃结果,不更新组件状态。
阶段四:工业级完整的 useRequest 实现
结合前述所有机制,我们整合出一份结构严密、支持参数缓存与重试刷新的完整实现代码:
/**
* 自定义异步请求 Hook: useRequest
* 具备核心状态收敛、手动/自动模式切换、竞态条件防御与组件卸载保护
*/
import { useState, useEffect, useCallback, useRef } from 'react';
export function useRequest(service, options = {}) {
const {
manual = false,
defaultParams = [],
onSuccess,
onError,
} = options;
const [data, setData] = useState(null);
const [loading, setLoading] = useState(!manual);
const [error, setError] = useState(null);
// 保持动态引用的鲜活度,避免闭包过时问题
const serviceRef = useRef(service);
serviceRef.current = service;
const optionsRef = useRef(options);
optionsRef.current = options;
// 保存最近一次请求入参,用于 refresh() 重新调用
const lastParamsRef = useRef(defaultParams);
// 竞态请求计数器:记录当前活跃的最新的请求 ID
const countRef = useRef(0);
// 组件卸载标记,杜绝卸载后的 setState 警告
const unmountedRef = useRef(false);
// 统一调度执行方法
const run = useCallback((...params) => {
// 递增请求序列号
countRef.current += 1;
const currentCount = countRef.current;
lastParamsRef.current = params;
setLoading(true);
setError(null);
return serviceRef.current(...params)
.then((res) => {
// 拦截:如果组件已卸载,或当前响应非最新请求,则丢弃
if (unmountedRef.current || currentCount !== countRef.current) {
return res;
}
setData(res);
setLoading(false);
optionsRef.current.onSuccess?.(res, params);
return res;
})
.catch((err) => {
if (unmountedRef.current || currentCount !== countRef.current) {
throw err;
}
setError(err);
setLoading(false);
optionsRef.current.onError?.(err, params);
throw err;
});
}, []);
// 基于上一轮参数快速刷新
const refresh = useCallback(() => {
return run(...lastParamsRef.current);
}, [run]);
// 自动触发与卸载清理
useEffect(() => {
unmountedRef.current = false;
if (!manual) {
run(...defaultParams);
}
return () => {
unmountedRef.current = true;
};
}, [manual, run]);
return {
data,
loading,
error,
run,
refresh,
params: lastParamsRef.current,
};
}
四、在业务组件中实战验证
完成封装后,我们在实际页面中应用 useRequest,可以看到业务代码获得了极大的精简与可读性提升。
场景 1:自动加载列表数据
function UserListPage() {
const { data: list, loading, error, refresh } = useRequest(fetchUserList);
if (loading) return <p>列表加载中...</p>;
if (error) return <button onClick={refresh}>重试</button>;
return (
<div>
<button onClick={refresh}>刷新列表</button>
<ul>
{list?.map((item) => <li key={item.id}>{item.name}</li>)}
</ul>
</div>
);
}
场景 2:表单提交与动态查询(手动触发模式)
function SearchPanel() {
const [keyword, setKeyword] = useState('');
const { data, loading, run: doSearch } = useRequest(searchApi, {
manual: true,
onSuccess(res) {
console.log('搜索成功,条数:', res.length);
},
onError(err) {
alert('搜索异常:' + err.message);
},
});
return (
<div>
<input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
<button disabled={loading} onClick={() => doSearch(keyword)}>
{loading ? '查询中...' : '搜索'}
</button>
<div>结果条数:{data?.length ?? 0}</div>
</div>
);
}
采用自定义 useRequest 后,单个组件中与异步数据请求相关的代码量从以往的 25~35 行精简至 3~5 行,消除了全部重复的 useState 与 useEffect,且彻底解决了网络延迟导致的竞态显示异常。
五、封装总结与成熟库选型指南
手写自定义 useRequest 能够让我们彻底摸清 React 状态流转、异步生命周期和边界防御的底层运行规律。在实际生产团队中,应该何时坚持自研封装,何时选用开源成熟库?
| 对比维度 | 自研 useRequest (本文方案) | 成熟库 (ahooks useRequest / TanStack Query / SWR) |
|---|---|---|
| 代码体积 | < 80 行原生代码,零外部依赖 | 约 10KB ~ 30KB 运行时引入 |
| 核心功能 | 三态管理、手动触发、入参记忆、竞态过滤、卸载安全 | 覆盖自研全部功能,额外支持全局缓存、SWR 预加载、轮询、防抖节流 |
| 可定制性 | 极高,可随业务网络协议、埋点契约自由定制 | 依赖库提供的插件机制或配置项进行扩展 |
| 适用场景 | 中小型项目、基础中后台系统、轻量级 H5、SDK 内部 | 大型复杂 Web 应用、多视图共享缓存、对离线或高频请求有重度诉求 |
六、实践中的避坑要点
- 必须使用
useRef存储最新回调与入参:由于 JavaScript 的闭包特性,如果在run函数内部直接引用外部传进来的普通函数,很容易捕获到组件初次渲染时的旧变量值。将service和options实时同步到ref.current,是保证在异步决议时能够调用到最新业务逻辑的通用技巧。 - 依赖项不要直接放入
useEffect监听对象字面量:调用useRequest(api, { manual: true })时,每次组件重渲染都会生成新的对象字面量。如果不解构提取manual,直接把options整个放入useEffect依赖数组,会导致死循环发起网络请求。 - 真取消与假取消的边界:本文实现的序列号对比机制属于“逻辑丢弃”(防止脏数据写回 UI)。如果希望在网络协议层真正终止请求以节省客户端带宽,可以进一步结合浏览器原生的
AbortController,在发起新请求或卸载时调用controller.abort()。
实用工具推荐
绿化、压缩、噪点和虚拟水印,自由调节做旧程度。
选择画质和输出格式,预览压缩前后的大小。