最近在准备面试,翻到几道关于 Custom Hook 的模拟题。表面上看各不相同——轮询、筛选、防抖搜索——但仔细分析之后,发现它们背后有一套共同的思维框架。这篇文章是我整理这套框架的笔记,希望对同样在备战面试的你有参考价值。
三道题,三个场景
先简单描述一下这三道题在考什么:
- useRideTracking:行程进行中轮询状态,每 5s 请求一次,页面隐藏时暂停,连续失败 3 次停止
- useExpenseFilter:报表筛选 Hook,多维联动筛选,需要
useMemo优化 - useEmployeeSearch:员工搜索,防抖 500ms +
AbortController取消请求
三个场景,但核心都指向同一个问题:如何在 Hook 里正确管理「副作用」和「派生状态」?
归纳出的通用思维框架
在我看来,一个合格的 Custom Hook 需要从四个维度去思考:
1. 状态层(State) ── 管什么数据?
2. 副作用层(Effect) ── 什么时候做什么?
3. 清理层(Cleanup) ── 离开时怎么收尾?
4. 优化层(Optimization) ── 怎么不做多余的工作?
下面逐层展开,结合题目来理解。
第一层:状态层 — 先想清楚「管什么」
拿到题目,第一步应该问自己:这个 Hook 需要对外暴露哪些状态?
这三道题都有一个共同的「三元组」:
// 几乎所有「异步请求型」Hook 的状态骨架
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
除了这个骨架,每道题还有「额外状态」:
useRideTracking:需要failCount(连续失败次数),但这是内部状态,不对外暴露useExpenseFilter:需要filters对象,并对外暴露setFilter/resetFiltersuseEmployeeSearch:需要keyword,并对外暴露setKeyword
一个实用技巧:区分「对外暴露」和「内部管理」的状态。对外的是接口契约,对内的是实现细节。面试中如果能主动说出这种区分,往往加分。
// useRideTracking 的状态设计示意
// 对外:{ status, loading, error }
// 对内:failCountRef(用 ref 而非 state,因为改变它不需要触发重渲染)
const failCountRef = useRef(0);
第二层:副作用层 — 明确「触发时机」
useEffect 的依赖数组,本质上是在描述「什么变化了我才需要重新执行」。
模式 A:挂载即执行 + 定时触发(useRideTracking)
// 环境:React 18+
// 场景:行程状态轮询
function useRideTracking(rideId) {
const [status, setStatus] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const failCountRef = useRef(0);
const timerRef = useRef(null);
const stoppedRef = useRef(false);
const fetchStatus = async () => {
if (stoppedRef.current) return;
setLoading(true);
try {
const res = await fetch(`/api/rides/${rideId}`);
if (!res.ok) throw new Error(`HTTP error: ${res.status}`);
const data = await res.json();
setStatus(data.status);
setError(null);
// success: reset fail counter
failCountRef.current = 0;
} catch (err) {
failCountRef.current += 1;
if (failCountRef.current >= 3) {
stoppedRef.current = true;
setError(err);
setLoading(false);
clearInterval(timerRef.current);
return;
}
} finally {
if (!stoppedRef.current) setLoading(false);
}
};
useEffect(() => {
// fetch immediately on mount
fetchStatus();
timerRef.current = setInterval(fetchStatus, 5000);
const handleVisibility = () => {
if (document.visibilityState === 'hidden') {
clearInterval(timerRef.current);
} else {
fetchStatus(); // refetch immediately on visible
timerRef.current = setInterval(fetchStatus, 5000);
}
};
document.addEventListener('visibilitychange', handleVisibility);
return () => {
clearInterval(timerRef.current);
document.removeEventListener('visibilitychange', handleVisibility);
stoppedRef.current = true;
};
}, [rideId]);
return { status, loading, error };
}
这道题的难点有两个:
visibilitychange事件——很多人第一反应想不到,但这是真实产品里节省资源的常见做法- 连续失败计数用
ref还是state——改变它不需要重渲染,用ref更合适
模式 B:受控输入 + 派生计算(useExpenseFilter)
// 环境:React
// 场景:多维度联动筛选
const emptyFilter = {
departments: [],
dateRange: null,
statuses: [],
amountRange: null,
};
function useExpenseFilter(data) {
const [filters, setFilters] = useState({ ...emptyFilter });
const setFilter = useCallback((key, value) => {
setFilters((prev) => ({ ...prev, [key]: value }));
}, []);
const resetFilters = useCallback(() => {
setFilters({ ...emptyFilter });
}, []);
const filteredData = useMemo(() => {
return data.filter((trip) => {
if (filters.departments.length && !filters.departments.includes(trip.department)) return false;
if (filters.statuses.length && !filters.statuses.includes(trip.status)) return false;
if (filters.amountRange) {
const [min, max] = filters.amountRange;
if (trip.amount < min || trip.amount > max) return false;
}
if (filters.dateRange) {
const [start, end] = filters.dateRange;
if (trip.date < start || trip.date > end) return false;
}
return true;
});
}, [data, filters]);
return { filters, setFilter, filteredData, resetFilters };
}
这道题相对直接,但有两个容易踩的坑:
resetFilters里要用{ ...emptyFilter }而非直接传引用——否则emptyFilter对象可能被意外修改setFilter要用useCallback包裹——否则每次渲染都会生成新函数,可能导致消费方的memo失效
模式 C:防抖 + 请求竞态处理(useEmployeeSearch)
// 环境:React
// 场景:带防抖的搜索请求,需要处理竞态
function useEmployeeSearch() {
const [keyword, setKeyword] = useState('');
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const abortControllerRef = useRef(null);
useEffect(() => {
const trimmed = keyword.trim();
// empty keyword: reset state immediately
if (!trimmed) {
setResults([]);
setError(null);
setLoading(false);
return;
}
const timer = setTimeout(async () => {
// abort previous pending request
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
const controller = new AbortController();
abortControllerRef.current = controller;
setLoading(true);
setError(null);
try {
const res = await fetch(
`/api/employees/search?q=${encodeURIComponent(trimmed)}`,
{ signal: controller.signal }
);
if (!res.ok) throw new Error(`HTTP error: ${res.status}`);
const data = await res.json();
setResults(data);
} catch (err) {
if (err.name === 'AbortError') return; // ignore abort errors
setError(err);
} finally {
setLoading(false);
}
}, 500);
return () => {
clearTimeout(timer);
// abort on cleanup (keyword changed or unmount)
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, [keyword]);
return { keyword, setKeyword, results, loading, error };
}
这道题的核心考点是「竞态条件」(race condition):用户快速输入时,后发出的请求可能比先发出的先返回,导致界面显示旧数据。AbortController 是解决这个问题的标准方案。
第三层:清理层 — 「离开时」的责任感
这是很多初学者写 Hook 时最容易忽略的部分,但在面试中往往是区分「会用」和「理解」的分水岭。
一个简单的清理检查清单:
□ 定时器(setInterval / setTimeout)需要 clearInterval / clearTimeout
□ 事件监听器需要 removeEventListener
□ 进行中的网络请求需要 AbortController.abort()
□ 组件卸载后不应再 setState(会产生 warning)
上面三道题都涉及清理,总结一下各自的清理策略:
| Hook | 需要清理的东西 |
|---|---|
| useRideTracking | clearInterval + removeEventListener + 标记 stoppedRef 防止 setState |
| useExpenseFilter | 无(纯状态计算,无副作用) |
| useEmployeeSearch | clearTimeout + AbortController.abort() |
第四层:优化层 — 「不做多余的工作」
优化不是一开始就要做的事,但 Hook 里有几个固定场景需要考虑:
场景一:派生状态用 useMemo
useExpenseFilter 里的 filteredData 是典型案例。如果直接在函数体里 data.filter(...),每次任何状态变化都会重新过滤,即使 data 和 filters 没有变化。
// 不好:每次渲染都重新计算
const filteredData = getFilterData(data);
// 好:只在 data 或 filters 变化时重新计算
const filteredData = useMemo(() => getFilterData(data), [data, filters]);
场景二:回调函数用 useCallback
暴露给外部的函数,如果作为 props 传递给子组件,或者出现在其他 Hook 的依赖数组里,应该用 useCallback 包裹。
场景三:不需要触发重渲染的值用 useRef
failCountRef、timerRef、abortControllerRef 都属于这类。它们是「进行中的工作凭证」,改变它们不需要更新 UI。
一个「答题」的思维顺序
整理完这三道题,我发现面试时可以按这个顺序思考:
1. 明确返回值契约
└── 对外暴露哪些状态和方法?
2. 识别副作用触发时机
└── 依赖什么变化?立即执行还是延迟?
3. 规划清理策略
└── 定时器 / 事件 / 请求,哪些要清理?
4. 考虑优化点
└── 有无派生状态?回调需不需要 useCallback?
这个顺序不是铁律,但至少能保证不遗漏关键点。
延伸思考
整理这几道题时,有几个问题让我觉得值得继续探索:
useReducervs 多个useState:useExpenseFilter里的多个筛选条件,用useReducer管理会更清晰吗?什么情况下应该做这个选择?- 请求库的抽象层:SWR / React Query 的
revalidateOnFocus本质上就是useRideTracking里的visibilitychange逻辑,只是封装层次不同。面试中能提到这层联系,可能会有加分 - TypeScript 的泛型设计:这几个 Hook 如果要做成通用的,类型怎么设计?这可能是下一篇笔记的方向
小结
Custom Hook 的核心,我理解是「把复杂的副作用逻辑封装成可复用的、有明确接口的黑盒」。面试考这类题,考的其实不只是「能不能写出来」,更是「能不能清晰地描述你在解决什么问题」。
这篇文章是我自己的思考整理,不一定全对,如果你有不同的看法或者更好的方案,欢迎交流讨论。
参考资料
- React 官方文档 - Hooks API Reference - useState / useEffect / useMemo / useCallback 的官方说明
- MDN - AbortController - 请求取消的标准 Web API
- MDN - visibilitychange event - 页面可见性 API
- React 官方文档 - You Might Not Need an Effect - 什么时候真的需要 useEffect