在React项目里引入TypeScript后,很多开发者会优先给组件的props和state加上类型,却容易忽略自定义Hook的类型设计。自定义Hook本质上是一个返回数据或操作函数的普通JavaScript函数,如果不做类型约束,在跨组件复用时会逐渐失去可追踪性。本文从Hook的基础类型标注讲起,逐步延伸到泛型封装和高阶场景,帮助你把Hook的类型安全做得更扎实。

一、内置Hook的基础类型标注
TypeScript对React内置Hook提供了完善的类型定义,大部分场景不需要手动标注。例如useState会根据初始值自动推断状态类型,但当初始值为null或需要联合类型时,就必须显式指定泛型参数。看一个计数器的例子:
import { useState } from 'react';
const useCounter = (initialValue: number = 0) => {
const [count, setCount] = useState<number>(initialValue);
const increment = () => setCount(prev => prev + 1);
const reset = () => setCount(initialValue);
return { count, increment, reset };
};
这里useState<number>明确了状态是number类型,如果传入字符串初始值会直接报错。当状态可能为null时,推荐使用联合类型useState<number | null>(null),这样后续访问count时TypeScript会强制你做空值判断,避免运行时崩溃。
useEffect和useCallback的类型推断依赖回调函数,通常无需额外处理。但要注意依赖项数组不能遗漏外部变量,虽然TypeScript不能检测依赖项完整性,但配合eslint-plugin-react-hooks可以补足这一环。例如:
import { useEffect } from 'react';
const useDocumentTitle = (title: string) => {
useEffect(() => {
document.title = title;
}, [title]);
};
对于useReducer,最好为action定义可辨识联合类型,这样可以获得完整的类型提示和case穷尽检查。
type Action =
| { type: 'increment'; payload: number }
| { type: 'reset' };
const reducer = (state: number, action: Action) => {
switch (action.type) {
case 'increment':
return state + action.payload;
case 'reset':
return 0;
default:
return state;
}
};
const useCounterReducer = () => {
const [state, dispatch] = useReducer(reducer, 0);
return { state, dispatch };
};
二、自定义Hook的参数与返回值类型封装
自定义Hook最常见的困境是返回值结构不固定,有的返回对象,有的返回数组,调用方经常需要猜测。建议统一返回值风格:如果Hook只暴露一个主要状态和几个操作函数,优先返回对象;如果需要像useState那样返回一组值,则返回元组并使用as const防止类型拓宽。
以下是一个本地存储Hook的封装示例:
import { useState, useEffect } from 'react';
function useLocalStorage<T>(key: string, initialValue: T) {
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key);
return item ? (JSON.parse(item) as T) : initialValue;
} catch {
return initialValue;
}
});
useEffect(() => {
try {
window.localStorage.setItem(key, JSON.stringify(storedValue));
} catch {
// 忽略写入错误
}
}, [key, storedValue]);
return [storedValue, setStoredValue] as const;
}
这里的泛型T让Hook可以存储任意可序列化类型,调用时useLocalStorage<number>('count', 0)或useLocalStorage<string[]>('tags', [])都能获得精确类型。as const把返回值从普通的(T | Dispatch<SetStateAction<T>>)[]转为只读元组,调用方通过解构得到storedValue为T类型,setStoredValue为正确的更新函数类型。
如果需要暴露多个状态,建议返回对象而不是元组,因为对象属性名具有语义,修改时不破坏调用方。下面是一个异步数据请求Hook:
import { useState, useEffect } from 'react';
interface FetchState<T> {
data: T | null;
loading: boolean;
error: Error | null;
}
function useFetch<T>(url: string): FetchState<T> {
const [state, setState] = useState<FetchState<T>>({
data: null,
loading: true,
error: null,
});
useEffect(() => {
let cancelled = false;
setState({ data: null, loading: true, error: null });
fetch(url)
.then(res => res.json())
.then((data: T) => {
if (!cancelled) setState({ data, loading: false, error: null });
})
.catch((error: Error) => {
if (!cancelled) setState({ data: null, loading: false, error });
});
return () => {
cancelled = true;
};
}, [url]);
return state;
}
useFetch返回一个统一的FetchState对象,三个字段类型清晰,调用方可以根据loading和error决定渲染分支。注意这里返回的是对象,因此不需要元组,也不需要as const。
三、高阶Hook与类型工具的结合
当多个Hook存在相同的状态重置、日志记录或权限校验逻辑时,可以设计高阶Hook(Hook返回Hook)来复用。TypeScript的泛型约束和条件类型能让高阶Hook在保持灵活性的同时不丢失类型信息。
一个常见场景是为所有Hook添加调试日志:
import { useEffect, useRef } from 'react';
function useDebugValueChange<T>(value: T, name: string) {
const previous = useRef<T>(value);
useEffect(() => {
if (previous.current !== value) {
console.log(`[${name}] changed from`, previous.current, 'to', value);
previous.current = value;
}
}, [value, name]);
}
// 业务Hook中直接调用
function useUserProfile(userId: string) {
const profile = useFetch<{ name: string; age: number }>(`/api/users/${userId}`);
useDebugValueChange(profile.data, 'userProfile');
return profile;
}
上面的useDebugValueChange借助泛型T保留传入值的类型,内部useRef<T>初始值必须匹配。如果传入的值可能是null,需要调整类型为T | null,否则useRef<T>的初始值会报错。
另一个实用工具是条件类型,比如根据传入的参数决定返回类型。假设要在Hook返回的对象中,根据withTimestamp参数决定是否包含时间戳字段:
type Result<T, WithTimestamp extends boolean> = T &
(WithTimestamp extends true ? { timestamp: number } : {});
function useEnrichedData<T, WithTimestamp extends boolean = false>(
data: T,
withTimestamp?: WithTimestamp
): Result<T, WithTimestamp> {
if (withTimestamp) {
return { ...data, timestamp: Date.now() } as Result<T, WithTimestamp>;
}
return data as Result<T, WithTimestamp>;
}
这样调用useEnrichedData(user, true)时返回值包含timestamp,而useEnrichedData(user, false)不包含。但要注意这种条件类型在实现时通常需要类型断言,因为TypeScript难以自动收窄条件类型。
四、类型调试与常见陷阱
在自定义Hook中,最容易被忽略的是依赖项数组中的引用相等性问题。比如Hook接收一个对象参数,每次渲染都会创建新对象,导致useEffect无限执行。TypeScript可以在类型层面提醒你使用useMemo或useRef稳定引用,但不会自动修复。
另外,当Hook返回值是数组时,如果忘了加as const,TypeScript会把数组推断成(string | number)[]这样的联合数组,解构后每个元素都是联合类型,使用时会频繁报错。下面展示错误和正确的对比:
// 错误:类型被拓宽
function useToggle(initial: boolean) {
const [value, setValue] = useState(initial);
return [value, setValue]; // (boolean | Dispatch<SetStateAction<boolean>>)[]
}
// 正确:元组类型
function useToggleFixed(initial: boolean) {
const [value, setValue] = useState(initial);
return [value, setValue] as const;
}
还有一个陷阱是useState的惰性初始化函数。如果初始值依赖复杂计算,传函数可以避免每次渲染都计算,但TypeScript要求该函数必须返回正确的状态类型。
const [list, setList] = useState<string[]>(() => {
const saved = localStorage.getItem('list');
return saved ? JSON.parse(saved) : [];
});
如果这里的JSON.parse返回any,不会触发类型错误,但会导致后续操作失去类型保护。建议在初始化函数内部做类型守卫或显式断言,例如return saved ? (JSON.parse(saved) as string[]) : [];。
最后总结,TypeScript对React Hooks的支持并非自动完成所有类型安全,很多约束需要开发者主动设计。通过为自定义Hook定义明确的参数类型和返回值契约,并善用泛型、条件类型和元组,可以让Hook的复用效率大幅提升,同时减少运行时类型问题。
TypeScriptReact Hooks自定义Hook类型封装修改时间:2026-10-04 06:09:56