一、痛点引入:写数据请求时的“重复劳动陷阱”
做前端开发的人,大概率都遇过这种情况:一个页面里有3个不同的接口请求,比如拉商品列表、用户信息、订单状态,每个请求都得写一套差不多的逻辑:先设一个加载状态,请求开始时改成true,结束时改成false;再设一个错误状态,请求失败时存错误信息,成功时清空;还要写请求方法,处理返回结果。 要是页面多了,比如商品页、个人中心页、订单页,每个页面都要重复写这些逻辑,不仅代码冗余,改起来还麻烦——比如要给所有请求加个超时处理,得一个个页面改,很容易漏。更头疼的是,不同人写的逻辑还可能不一样,比如有的把加载状态叫isLoading,有的叫loading,有的错误处理只弹个提示,有的存到状态里,维护起来一团乱。
二、核心思路:用自定义Hook把“通用逻辑”打包
要解决这个问题,核心就是把所有数据请求都要用到的“加载、错误、请求逻辑”抽出来,做成一个可以重复用的工具。这个工具就是React里的自定义Hook——它本质就是一个普通的函数,只是名字必须以use开头,能用到React的状态(比如useState、useEffect),还能返回给组件用的状态和方法。 举个最简单的例子,比如我们要抽一个叫useFetch的自定义Hook,它的作用就是:接收一个接口地址,自动处理加载状态、错误状态、请求结果,最后把这些内容返回给调用它的组件。
三、完整实现:useFetch自定义Hook的开发与解析
3.1 技术栈说明
本次所有示例统一使用React 18 + TypeScript,用原生fetch做请求(不用axios,方便大家理解核心逻辑,后续换成axios也很简单)。
3.2 基础版useFetch实现
先写一个最基础的版本,覆盖最核心的功能:
// 定义请求返回的类型,方便组件调用时知道返回值的结构
type FetchState<T> = {
data: T | null; // 请求成功返回的数据,初始为null
loading: boolean; // 加载状态,初始为true(刚发起请求时就是加载中)
error: Error | null; // 错误信息,初始为null
};
// 自定义Hook:接收请求地址,返回状态和刷新方法
function useFetch<T = unknown>(url: string) {
// 用useState管理三个状态:data、loading、error
const [state, setState] = React.useState<FetchState<T>>({
data: null,
loading: true,
error: null,
});
// 定义发起请求的方法,抽出来方便后续手动刷新
const fetchData = React.useCallback(async () => {
// 每次发起新请求时,重置状态:加载中,错误清空
setState({ data: null, loading: true, error: null });
try {
// 发起请求
const response = await fetch(url);
// 要是请求失败(比如404、500),主动抛出错误
if (!response.ok) {
throw new Error(`请求失败:状态码${response.status}`);
}
// 解析返回的JSON数据
const result = await response.json() as T;
// 请求成功,更新状态:加载结束,存数据
setState({ data: result, loading: false, error: null });
} catch (err) {
// 捕获错误,更新状态:加载结束,存错误
setState({ data: null, loading: false, error: err as Error });
}
}, [url]); // 依赖项只有url,url变了才会重新生成fetchData
// 组件挂载时自动发起第一次请求
React.useEffect(() => {
fetchData();
}, [fetchData]); // 依赖fetchData,fetchData变了(也就是url变了)就重新请求
// 返回给组件用的状态和刷新方法
return { ...state, refetch: fetchData };
}
3.3 基础版的使用示例
写一个商品列表组件,用这个useFetch来拉数据:
// 定义商品的类型,方便类型检查
type Product = {
id: number;
name: string;
price: number;
};
function ProductList() {
// 调用useFetch,指定返回的data是Product[]类型
const { data: products, loading, error, refetch } = useFetch<Product[]>(
"https://api.example.com/products"
);
// 加载中显示提示
if (loading) return <div>商品加载中...</div>;
// 出错显示错误信息和重试按钮
if (error) return <div>加载失败:{error.message} <button onClick={refetch}>重试</button></div>;
// 有数据显示列表
return (
<div>
<h2>商品列表</h2>
<button onClick={refetch}>刷新列表</button>
<ul>
{products?.map((product) => (
<li key={product.id}>{product.name} - ¥{product.price}</li>
))}
</ul>
</div>
);
}
3.4 进阶版useFetch:增加更多实用功能
基础版已经能解决大部分问题,但实际开发中还需要更多功能,比如请求参数、请求方法(GET/POST)、超时控制、缓存(避免重复请求)、取消请求(组件卸载时取消未完成的请求,避免内存泄漏)。 下面是进阶版的实现:
// 定义请求配置的类型
type FetchOptions = {
method?: "GET" | "POST" | "PUT" | "DELETE"; // 请求方法
body?: BodyInit; // 请求体(POST等方法用)
headers?: HeadersInit; // 请求头
timeout?: number; // 超时时间(毫秒)
};
type FetchState<T> = {
data: T | null;
loading: boolean;
error: Error | null;
};
function useFetch<T = unknown>(url: string, options?: FetchOptions) {
const [state, setState] = React.useState<FetchState<T>>({
data: null,
loading: true,
error: null,
});
// 用Ref存AbortController,用来取消请求
const abortControllerRef = React.useRef<AbortController | null>(null);
const fetchData = React.useCallback(async () => {
// 取消上一次未完成的请求,避免内存泄漏
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// 创建新的AbortController
abortControllerRef.current = new AbortController();
const signal = abortControllerRef.current.signal;
// 重置状态
setState({ data: null, loading: true, error: null });
// 处理超时:创建一个定时器,到时间就取消请求
const timeoutId = options?.timeout
? setTimeout(() => {
abortControllerRef.current?.abort();
setState({ data: null, loading: false, error: new Error("请求超时") });
}, options.timeout)
: null;
try {
const response = await fetch(url, {
method: options?.method || "GET",
body: options?.body,
headers: options?.headers,
signal, // 把AbortController的signal传给fetch,用来取消请求
});
// 清除超时定时器
if (timeoutId) clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`请求失败:状态码${response.status}`);
}
const result = await response.json() as T;
setState({ data: result, loading: false, error: null });
} catch (err) {
// 清除超时定时器
if (timeoutId) clearTimeout(timeoutId);
// 忽略AbortError(主动取消请求的错误,不用显示给用户)
if ((err as Error).name !== "AbortError") {
setState({ data: null, loading: false, error: err as Error });
}
}
}, [url, options]); // 依赖项加上options,options变了就重新请求
React.useEffect(() => {
fetchData();
// 组件卸载时取消请求
return () => {
abortControllerRef.current?.abort();
};
}, [fetchData]);
return { ...state, refetch: fetchData };
}
3.5 进阶版的使用示例
写一个提交表单的组件,用POST请求:
// 定义表单数据的类型
type SubmitFormData = {
name: string;
email: string;
};
// 定义接口返回的类型
type SubmitResponse = {
success: boolean;
message: string;
};
function SubmitForm() {
const [formData, setFormData] = React.useState<SubmitFormData>({
name: "",
email: "",
});
// 调用useFetch,配置POST方法、请求体、超时时间
const { data, loading, error, refetch } = useFetch<SubmitResponse>(
"https://api.example.com/submit",
{
method: "POST",
body: JSON.stringify(formData),
headers: {
"Content-Type": "application/json",
},
timeout: 5000, // 5秒超时
}
);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
refetch(); // 提交时重新发起请求
};
return (
<form onSubmit={handleSubmit}>
<div>
<label>姓名:</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
/>
</div>
<div>
<label>邮箱:</label>
<input
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
required
/>
</div>
<button type="submit" disabled={loading}>
{loading ? "提交中..." : "提交"}
</button>
{error && <div className="error">提交失败:{error.message}</div>}
{data?.success && <div className="success">提交成功:{data.message}</div>}
</form>
);
}
四、关键技术解析:自定义Hook的核心设计细节
4.1 useCallback的作用
在useFetch里,我们把fetchData用useCallback包起来,是为了避免不必要的重新生成。因为useEffect的依赖项是fetchData,如果fetchData每次都变,useEffect就会反复执行,导致重复请求。useCallback会缓存fetchData,只有当它的依赖项(url、options)变化时,才会重新生成,这样就能保证useEffect的执行次数是可控的。
4.2 AbortController的作用
AbortController是浏览器原生的API,用来取消fetch请求。在进阶版useFetch里,我们用它的Ref存实例,每次发起新请求时,先取消上一次的请求,避免出现“先发起的请求后返回”的问题——比如用户快速切换商品列表的分类,第一次请求还没完成,第二次请求已经发起,要是第一次请求后返回,就会覆盖第二次请求的结果,导致显示错误的数据。另外,组件卸载时取消请求,还能避免内存泄漏。
4.3 类型定义的作用
用TypeScript定义FetchState、FetchOptions、接口返回类型,能让代码更严谨,减少错误。比如调用useFetch时指定返回的data是Product[],TypeScript就会检查你能不能对data做数组操作,要是写错了(比如把data当对象用),编译时就会报错,不用等到运行时才发现问题。
五、应用场景与优缺点分析
5.1 应用场景
这种自定义Hook的设计,适合所有需要统一管理数据请求的场景:
- 前端项目中多个页面需要调用类似的接口(比如用户信息、商品列表);
- 表单提交类的请求(需要处理加载、错误、成功状态);
- 项目中需要统一的请求规范(比如超时时间、错误处理逻辑);
- 小型项目或中大型项目的基础请求层(要是项目用了React Query、SWR等请求库,也可以用自定义Hook再做一层封装,适配项目的需求)。
5.2 优点
- 代码复用性高:不用每个组件都写一套加载、错误、请求逻辑,一个useFetch就能搞定;
- 维护成本低:要是要修改请求逻辑(比如加全局的错误处理、换axios),只需要改useFetch内部的代码,所有调用它的组件都会生效;
- 逻辑统一:所有请求的处理逻辑一致,不会出现不同人写的逻辑不一样的问题;
- 类型安全:用TypeScript定义类型,能提前发现错误,减少调试时间。
5.3 缺点
- 灵活性有限:自定义Hook的功能是固定的,要是某个组件需要特殊的请求逻辑(比如不需要自动发起请求、需要多个请求),可能需要修改useFetch,或者写新的自定义Hook;
- 学习成本:对刚接触React自定义Hook的开发者来说,需要理解useCallback、useEffect、AbortController等概念,才能完全掌握;
- 不适合复杂请求场景:要是项目需要复杂的缓存、分页、乐观更新等功能,自定义Hook的实现会变得很复杂,不如用React Query、SWR等成熟的请求库。
六、注意事项
6.1 依赖项的处理
在useFetch里,useCallback和useEffect的依赖项一定要写对,要是漏了依赖项,会导致逻辑错误。比如useCallback的依赖项漏了options,那么options变化时,fetchData不会重新生成,useEffect也不会重新请求,导致请求参数不对。
6.2 内存泄漏的避免
一定要处理组件卸载时的请求取消,要是不取消,组件卸载后请求返回的结果会尝试更新已经不存在的组件状态,导致控制台报错(比如“Can't perform a React state update on an unmounted component”)。
6.3 错误处理的统一
可以在useFetch里加全局的错误处理,比如所有请求失败都弹一个提示框,或者把错误信息上报到监控系统。比如在catch块里加:
// 全局错误处理示例
if ((err as Error).name !== "AbortError") {
console.error("请求错误:", err);
alert(`请求出错:${err.message}`);
setState({ data: null, loading: false, error: err as Error });
}
这样所有调用useFetch的组件,都会自动有全局的错误处理,不用每个组件都写一遍。
6.4 状态的复用
要是多个组件需要调用同一个接口,可以把useFetch的返回值提升到父组件,或者用Context API把状态存到全局,避免重复请求。比如用户信息,所有页面都要用到,可以在根组件里调用useFetch拉用户信息,然后通过Context传给所有子组件,这样只会请求一次。
七、总结
自定义Hook封装数据请求逻辑,是一种非常实用的代码优化方式,能有效解决重复劳动、逻辑不统一、维护成本高的问题。通过把加载、错误、请求逻辑抽成一个通用的useFetch,不仅能提升代码的复用性,还能让项目的代码更规范、更易维护。 当然,这种方式也不是万能的,要是项目的请求逻辑很复杂,还是建议用成熟的请求库(比如React Query、SWR),但自定义Hook的设计思路,同样可以用到这些请求库的封装上,让项目的代码更符合自己的需求。
评论
围绕“自定义Hook封装数据获取逻辑时统一错误处理与加载状态,提升代码复用性的最佳实践方案深度解析”参与讨论