Hooks
闭包陷阱
在 Hook 的应用中(比如 useEffect 或 useState 的回调),经常会遇到读取到的 state 还是“旧值”的情况。
闭包陷阱的本质: 每次函数组件更新,都是一次全新的函数调用。每次调用都有自己独立的词法作用域(闭包)。 如果你在
useEffect里设置了一个setInterval,并且没有把 state 加入依赖数组(或者直接没有传依赖数组但使用了[]),那么这个定时器内部拿到的 state,永远是定时器被创建那次渲染时的 state。 解法:使用useRef保存最新值,或者传递一个回调函数给setState(如setState(prev => prev + 1))。
2. Hooks 的数据结构:单链表
函数组件没有实例(this),那么它的状态存在哪里呢?
答案是:存在它对应的 Fiber 节点的 memoizedState 属性上。
Fiber & Hook
Fiber 对象和 Hook 对象的关系:
函数组件对应的 Fiber 节点中,memoizedState 属性指向了一个 Hook 单向链表。
Hook 链表节点结构
无论是初次挂载还是更新,每调用一次 Hook 函数,都会产生一个 Hook 对象与之对应,Hook 对象结构如下:
type Hook = {
baseQueue: null; // 当前更新队列
baseState: any; // 初始值或上一次更新后的 state
memoizedState: any; // 当前 Hook 保存的状态(如 useState 的值)
queue: null; // 待执行的更新队列(环形链表)
next: Hook | null; // 指向下一个 Hook
};
产生的 Hook 对象依次排列,形成链表。在这个过程中,有一个十分重要的指针:workInProgressHook,它通过记录当前生成(更新)的 Hook 对象,来间接反映组件当前调用到哪个 Hook 函数了。
比如先调用 useState (hookA):
fiber.memoizedState: hookA
^
workInProgressHook
接着调用 useEffect (hookB):
fiber.memoizedState: hookA -> hookB
^
workInProgressHook
3. 面试高频问题
面试题:为什么不能在
if、for循环或嵌套函数中使用 Hook?底层原因:React 依靠 Hook 执行的顺序来匹配对应的 state。因为所有的 Hook 是以单向链表的形式挂载在 Fiber 节点上的。 在更新阶段,React 会按照之前的链表顺序,一个个地把状态取出来赋给当前的 Hook 调用。如果在
if语句中使用 Hook,导致某次渲染时少调用了一个 Hook,那么整个链表的指针就会错位,导致后续所有的 Hook 取到的状态全部错乱崩溃。
面试题:
useState的更新队列为什么是环形链表?
queue.pending永远指向最后一个插入的更新任务(Update)。而最后一个更新任务的next会指向第一个更新任务。这样设计的好处是:当我们要向队列尾部插入新任务时,时间复杂度为 O(1);当我们要从头开始遍历任务时,只需要queue.pending.next即可拿到链表头,时间复杂度也是 O(1)。
面试题:自定义 Hook 是如何工作的? 自定义 Hook 本质上就是一个普通的 JS 函数,只不过它的名字以
use开头,并且在内部调用了其他原生 Hooks。React 底层根本不知道什么是自定义 Hook,它只认你在这个函数里调用的原生 Hooks,这些原生 Hooks 会按照调用顺序,继续追加到当前组件的 Fiber Hooks 链表中。
4. 自定义 Hook
自定义 Hook 是 React 复用逻辑的终极武器
4.1 设计规范 (Best Practices)
- 命名规范:必须以
use开头(让 ESLint 的eslint-plugin-react-hooks能够识别并进行规则校验)。 - 单一职责:一个 Hook 最好只做一件纯粹的事情(如只处理请求,或只处理定时器)。
- 输入输出规范:
- 返回数组:如果返回的值类似于元组(如
[value, setValue]),推荐返回数组,调用方解构时可以随意重命名。 - 返回对象:如果返回的属性较多(如
{ data, loading, error, refresh }),推荐返回对象,调用方可以按需解构。
- 返回数组:如果返回的值类似于元组(如
- 防范闭包陷阱:自定义 Hook 内部暴露给外部的函数,尽量使用
useCallback包裹;内部如果依赖了外部传入的函数,建议使用useRef保存最新的引用。
4.2 高频手写题:4 个必须掌握的自定义 Hook
1. useDebounce (防抖 Hook)
利用 useEffect 的清理函数(cleanup)来实现防抖,代码极其优雅。
function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
// 每次 value 变化,都会设置一个新的定时器
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
// 下一次 useEffect 执行前(或卸载时),清除上一个定时器
return () => clearTimeout(handler);
}, [value, delay]);
return debouncedValue;
}
// 用法:
// const debouncedSearch = useDebounce(keyword, 500);
// useEffect(() => { fetchApi(debouncedSearch) }, [debouncedSearch]);
2. usePrevious (获取上一轮渲染的旧值)
考察你对 useRef 的理解(useRef 改变不会触发渲染,且其赋值操作在 useEffect 中是滞后于渲染的)。
function usePrevious(value) {
const ref = useRef();
useEffect(() => {
// 在本次 DOM 渲染完成后,才把当前值存起来
ref.current = value;
}, [value]);
// 在渲染阶段,返回的是上一轮存进 ref 的旧值
return ref.current;
}
3. useUpdateEffect (跳过首次执行的 useEffect)
模拟 Vue 中的 watch,非常实用的业务 Hook(只在依赖更新时执行,首次挂载时不执行)。
function useUpdateEffect(effect, deps) {
const isMounted = useRef(false);
useEffect(() => {
if (!isMounted.current) {
// 首次挂载,打上标记,但不执行 effect
isMounted.current = true;
} else {
// 后续更新,正常执行
return effect();
}
}, deps);
}
4. useLatest (永远返回最新值的 Hook)
这是专门用来解决闭包陷阱的终极方案。也是开源库 ahooks 里最核心的 Hook 之一。
function useLatest(value) {
const ref = useRef(value);
// 使用 useLayoutEffect 能在 DOM 变更后同步更新,比 useEffect 更早,防止边界情况拿到旧值
useLayoutEffect(() => {
ref.current = value;
});
return ref;
}
// 用法:
// const latestCount = useLatest(count);
// setInterval(() => console.log(latestCount.current), 1000); // 永远打印最新值