React面试宝典【Part2:Hooks 深度面试】
二、Hooks 深度面试
1. Hooks使用规则
- 只在顶层调用,不能if/for/循环内部
- 只能在函数组件/自定义Hook调用
原理:React依靠调用顺序维护Hook链表;顺序错乱会导致状态错乱。
详解 - 只在函数组件/自定义 Hook 顶层调用
不能在循环、条件、嵌套函数里用,保证调用顺序稳定。
function Demo() {
// ✅ 顶层,顺序稳定
const [count, setCount] = useState(0);
useEffect(() => {}, []);
return <div>{count}</div>;
}
function Demo2() {
const [show, setShow] = useState(true);
if (show) {
// ❌ 禁止:条件里调用 Hook
const [name, setName] = useState('');
}
for (let i = 0; i < 3; i++) {
// ❌ 禁止:循环里调用
useState();
}
}
function Demo3() {
function handleClick() {
// ❌ 禁止:嵌套普通函数
const [value, setValue] = useState('');
}
useEffect(() => {
setTimeout(() => {
// ❌ 禁止:异步回调里
useState();
}, 1000);
}, []);
}
-
只在 React 函数组件 / 自定义 Hook 中使用
不能在普通 JS 函数、类组件、事件回调里随意调用。 -
自定义 Hook 必须以
use开头
便于 ESLint 插件识别、检查规则,也方便语义化区分。
// ✅ 以 use 开头
function useUser() {
const [user, setUser] = useState(null);
return user;
}
function Page() {
const user = useUser(); // ✅ 顶层调用
return <div>{user}</div>;
}
- 不要在异步回调里调用 Hook
如setTimeout、Promise.then、async内,会破坏顺序。
总结
只能在组件顶层用,不能嵌套/条件/异步,自定义 Hook 以 use 开头。
2. useState原理,setState同步还是异步?
useState 原理
- 函数组件执行时,React 按调用顺序在对应 Fiber 节点上维护一个链表式 Hook 队列。
useState从队列中取出当前 state 和对应的 dispatch 方法。- 调用
setState会标记组件为待更新,触发组件重新渲染,并在下次渲染时拿到新 state。
setState 同步还是异步?
结论:批量更新时表现为异步,同步代码里也并非立即赋值。
- React 合成事件 / 生命周期 / useEffect 中
- 是异步批量更新
- 多次 setState 会合并,只触发一次重渲染
- 执行完回调后才更新 state
const [count, setCount] = useState(0)
const handleClick = () => {
setCount(count + 1)
setCount(count + 1)
console.log(count) // 0,没变
}
-
原生事件、setTimeout、Promise.then 中
- React 18 之前:同步更新
- React 18 之后:统一自动批处理,全部异步
-
如何拿到最新值
- 用函数式更新:
setCount(c => c + 1) - 或在 useEffect 里监听 count 变化
- 用函数式更新:
总结
- 原理:按顺序存在 Fiber 的 Hook 链表,set 标记更新并重渲染
- 同步/异步:React 控制的场景都是异步批量更新
3. useEffect 完整理解
作用:处理副作用(请求、订阅、定时器DOM操作)
执行时机:渲染完成后执行;
依赖数组:空数组=仅mount;不写依赖每次渲染执行;
清理函数:相当于componentWillUnmount,同时每次依赖变更前先执行清理;
> React18严格模式:开发环境组件会挂载→卸载→重新挂载,effect执行两次,用于检测内存泄漏。
详解
useEffect作用
处理函数组件的副作用:
- 异步请求、定时器
- 订阅、DOM 操作
- 事件监听
- 清理操作
相当于类组件生命周期的组合:
componentDidMount + componentDidUpdate + componentWillUnmount
语法
useEffect(() => {
// 执行副作用
return () => {
// 清理函数
}
}, [依赖项])
依赖项三种情况
- 不传依赖
每次渲染都会执行 - 空数组 []
只在挂载执行一次,清理在卸载时 - [state, props]
依赖变化时执行,重新渲染前会先执行清理
执行顺序
- 组件渲染完成
- 执行上一轮的清理函数(如果有)
- 执行当前 useEffect 回调
> 注意:useEffect 是异步延迟执行,不阻塞浏览器渲染。
清理函数的意义
- 清除定时器
- 取消网络请求
- 移除事件监听
- 取消订阅
防止内存泄漏、重复触发、状态错乱
示例(定时器):
useEffect(() => {
const timer = setInterval(() => {}, 1000);
return () => clearInterval(timer);
}, []);
❗闭包陷阱
useEffect 捕获的是渲染时的快照,依赖漏写会拿到旧值。
useEffect(() => {
setInterval(() => {
console.log(count); // 永远是旧值
}, 1000);
}, []); // 依赖漏写
解决:
- 补全依赖
- 或用
useRef存最新值 - 或用函数式更新
与 useLayoutEffect 区别
- useEffect:DOM 渲染后执行,不阻塞绘制
- useLayoutEffect:DOM 更新后、绘制前同步执行,会阻塞
适合需要获取 DOM 布局、避免闪烁的场景
总结
useEffect 管理副作用,依赖决定执行时机,清理防止泄漏,闭包陷阱是高频坑。
🔥4. Hook闭包陷阱
现象:useEffect/回调捕获到旧state/props值;
原因:每次渲染创建独立函数作用域,捕获当前渲染快照;
✅解决方案:
- 补齐依赖数组(eslint-plugin-react-hooks)
- 使用useRef保存可变最新值
- useReducer规避依赖问题
- useEvent(React实验API)
例子
定时器闭包陷阱
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
// 每隔 1s 打印 count
const timer = setInterval(() => {
console.log(count); // 永远打印 0
}, 1000);
return () => clearInterval(timer);
}, []); // 依赖为空
return (
<div>
<p>count: {count}</p>
<button onClick={() => setCount(count + 1)}>+1</button>
</div>
);
}
现象
- 页面 count 一直在增加
- 控制台永远打印 0
原因
- useEffect 依赖为空,只执行一次
- 定时器捕获了第一次渲染的 count 快照
- 后续渲染产生新 count,但定时器里还是旧闭包值
解决方法
方法1:补上依赖
useEffect(() => {
const timer = setInterval(() => {
console.log(count);
}, 1000);
return () => clearInterval(timer);
}, [count]); // 依赖 count
方法2:用函数式更新(不依赖外部值)
useEffect(() => {
const timer = setInterval(() => {
setCount(c => c + 1);
}, 1000);
return () => clearInterval(timer);
}, []);
方法3:用 useRef 存最新值
const countRef = useRef(count);
countRef.current = count;
useEffect(() => {
const timer = setInterval(() => {
console.log(countRef.current); // 永远最新
}, 1000);
return () => clearInterval(timer);
}, []);
总结
闭包陷阱 = 副作用捕获了旧的 state/props 快照,依赖没更新导致一直用旧值。
5. useRef / createRef区别
- createRef:每次渲染创建新对象,不要在函数组件直接使用
- useRef:组件生命周期内保持同一个引用;除DOM外,可存储可变数据,修改ref不会触发重渲染
-
详解
本质区别
- useRef:React Hooks API,只用于函数组件
- createRef:普通 API,主要用于类组件
ℹ️创建时机
- useRef:组件整个生命周期只创建一次,每次重渲染返回同一个 ref 对象
- createRef:每次渲染都会创建新的 ref 对象,旧的被丢弃
例子对比
函数组件里使用
//createRef(错误示范)
function App() {
// ❌ 每次渲染都新建一个 ref
const ref = React.createRef();
return <div ref={ref}>{/* 会频繁丢失、重建 */}</div>
}
//useRef(正确)
function App() {
// ✅ 全程同一个 ref
const ref = useRef(null);
return <div ref={ref}></div>
}
类组件用 createRef(标准写法)
class App extends Component {
// ✔ 构造器只执行一次
constructor() {
this.ref = createRef();
}
render() {
return <div ref={this.ref}></div>
}
}
总结
- useRef:函数组件专用,持久化存储,适合存 DOM、定时器 ID、最新值
- createRef:每次渲染都新建,只适合类组件
- 口诀:函数用 useRef,类用 createRef
🔥6. useMemo vs useCallback vs React.memo
- React.memo:高阶组件,浅比较props,阻止函数组件不必要渲染(对比类组件PureComponent)
- useMemo(()=>计算值, deps):缓存计算结果,避免重复昂贵计算
- useCallback(fn, deps):缓存函数引用,配合React.memo,防止子组件因为函数引用变化重渲染
⚠️重要:不要盲目加这三个API!
缓存本身有开销;简单组件、廉价计算不需要缓存;先通过React DevTools Profiler定位性能瓶颈再优化。
详解
React.memo
- 作用:组件级缓存,对组件做浅比较,props 不变则复用组件,不重新渲染
-
用法:包装函数组件
export default React.memo(MyComponent); - 场景:防止父组件渲染导致子组件不必要更新
useCallback
- 作用:缓存函数引用,返回相同的函数实例
-
用法:
const handleClick = useCallback(() => { setCount(c => c + 1); }, [依赖]); - 场景:函数传给 React.memo 包裹的子组件时,避免子组件重复渲染
useMemo
- 作用:缓存计算结果,避免每次渲染重复计算
- 用法:
const double = useMemo(() => count * 2, [count]);
- 场景:复杂计算、过滤、排序等开销大的逻辑
一句话区分
- React.memo:缓存组件
- useCallback:缓存函数
- useMemo:缓存计算值
核心关系
React.memo 只有配合 useCallback / useMemo 才能真正防止子组件重渲染,否则引用类型 props 每次都变,memo 无效。
7. useContext 缺陷
context更新时,所有消费组件全部重渲染,无法精准订阅;
优化方案:拆分多个Context、状态原子化、搭配useReducer/zustand等外部状态库。
详解
useContext 缺陷
-
值一变化,所有消费组件全量重渲染
只要context value变,所有用到该Context的组件都会重新渲染,无法精准更新,性能易崩。 -
无法细分状态
一个 Context 通常存多状态,任意一个状态变,全部消费者重渲染,不能单独订阅部分状态。 -
层级嵌套地狱
多业务场景需要多个 Context 时,代码会出现层层嵌套,可读性差。<AProvider> <BProvider> <CProvider>...</CProvider> </BProvider> </AProvider> -
调试困难
状态来源不直观,跨层级传递后难以追踪更新源头。 -
不支持派生状态缓存
派生值每次都重新计算,无内置缓存,需配合 useMemo 优化。
优化方案
- 按模块拆分为多个小 Context
- 使用
useMemo缓存 Context 的 value - 搭配状态库:Redux、Zustand、Jotai 等
- 使用
useContextSelector(社区方案或 React 18+ 实验性)实现精准订阅
8. 自定义Hook
本质:封装可复用逻辑,抽离组件公共副作用、请求、表单逻辑;面试常手写useFetch、useDebounce。
例子
自定义 Hook 规范
- 函数名以
use开头 - 内部可使用其他 Hook
- 只封装状态逻辑,不渲染 UI
useDebounce(防抖 Hook)
import { useState, useEffect } from 'react';
function useDebounce(value, delay = 500) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
使用:
const [keyword, setKeyword] = useState('');
const searchWord = useDebounce(keyword, 600);
手写 useFetch(基础请求 Hook)
import { useState, useEffect } from 'react';
function useFetch(url, options = {}) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
const signal = controller.signal;
setLoading(true);
fetch(url, { ...options, signal })
.then(res => res.json())
.then(data => {
setData(data);
setError(null);
})
.catch(err => {
if (err.name !== 'AbortError') setError(err);
})
.finally(() => setLoading(false));
return () => controller.abort();
}, [url, options]);
return { data, loading, error };
}
使用:
const { data, loading, error } = useFetch('/api/list');
9. React18新增Hooks
useTransition:区分紧急更新与过渡更新(搜索联想场景,输入高优,列表渲染低优)useDeferredValue:延迟某个值更新,和useTransition能力相似,作用于值useId:生成服务端渲染一致唯一ID,解决SSR hydration不匹配
详解
React18 新增 Hook 依托并发渲染(Concurrent Rendering)能力:
useTransition、useDeferredValue、useId、useSyncExternalStore、useInsertionEffect
useTransition
作用:标记状态更新为低优先级任务,高优先级操作(输入、点击)可以打断它,保证界面流畅。
返回 [isPending, startTransition]
isPending:过渡是否进行中(用来展示 loading)startTransition:包裹低优先级 setState
const [text, setText] = useState('');
const [list, setList] = useState([]);
const [isPending, startTransition] = useTransition();
const onChange = (e) => {
// 高优先级:输入框实时更新
setText(e.target.value);
// 低优先级:大列表过滤,可被打断
startTransition(() => {
setList(filterBigData(e.target.value));
});
};
> 场景:搜索大列表、Tab切换、昂贵列表渲染
useDeferredValue
作用:延迟一个值的更新,生成该值的低优先级副本。
> 和 useTransition 核心区别:
> ✅ useTransition:控制状态更新函数
> ✅ useDeferredValue:控制传入的值(适合props、外部值,无法修改setState场景)
const [keyword, setKeyword] = useState('');
// 延迟副本,不会立刻更新
const deferredKey = useDeferredValue(keyword);
// 使用延迟值做昂贵计算
const result = useMemo(() => filterBigData(deferredKey), [deferredKey]);
> 不要和防抖混淆:它是优先级调度,没有固定延时;防抖是固定时间延迟。
useId
作用:生成唯一稳定ID,解决SSR水合(hydration)不匹配问题。
> 不能用作列表key!
function InputItem() {
const id = useId();
return (
<>
<label htmlFor={id}>用户名</label>
<input id={id} />
</>
);
}
场景:无障碍 label、表单关联ID,组件库开发。
useSyncExternalStore
作用:订阅外部状态(非React管理的store:redux、mobx、原生全局变量),替代手写useEffect订阅方案,并发模式下保证状态一致性。
官方推荐状态库底层实现API。
// 简易结构
const state = useSyncExternalStore(
subscribe, // 订阅函数
getSnapshot, // 获取当前状态
getServerSnapshot // SSR快照(可选)
);
useInsertionEffect
作用:仅限 CSS-in-JS库使用!
执行时机:DOM变更之前,早于 useLayoutEffect、useEffect。
用来动态插入style标签,避免布局抖动。
⚠️ 业务开发禁止使用,不能访问DOM。