说说对 React Hooks 的理解?解决了什么问题?
理解 Hooks 的状态关联、逻辑复用与规则,并实现依赖完整、正确清理的标题、刷新和滚动订阅 Hook。
说说对 React Hooks 的理解?解决了什么问题?
问题解析
面试官考察点:
- 是否了解 Hooks 的设计动机和解决的问题
- 是否掌握常用 Hooks 的使用方法
- 是否理解 Hooks 的工作原理
- 对函数式编程思想的理解
核心概念
什么是 Hooks
Hooks 是 React 16.8 引入的新特性,它允许你在不编写 class 的情况下使用 state 以及其他的 React 特性。
import React, { useState, useEffect } from 'react';
function Example() {
const [count, setCount] = useState(0);
useEffect(() => {
document.title = `You clicked ${count} times`;
});
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
}
Hooks 解决的问题
在 Hooks 出现之前,React 存在以下问题:
- 难以复用状态逻辑:高阶组件嵌套地狱,render props 代码冗余
- 复杂组件难以理解:生命周期函数中混杂不相关逻辑,相关逻辑分散在不同生命周期
- this 指向问题:需要绑定 this,增加学习成本
- 函数组件无状态:简单组件也需要写成 class
详细解答
1. useState - 状态管理
import React, { useState } from 'react';
function Counter() {
// 声明一个叫 "count" 的 state 变量
const [count, setCount] = useState(0);
// 使用对象作为 state
const [user, setUser] = useState({ name: '', age: 0 });
const updateName = (newName) => {
// 对象需要展开合并
setUser({ ...user, name: newName });
};
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>
Increment
</button>
<p>User: {user.name}</p>
<input
value={user.name}
onChange={(e) => updateName(e.target.value)}
/>
</div>
);
}
2. useEffect - 副作用处理
import React, { useState, useEffect } from 'react';
function Example() {
const [count, setCount] = useState(0);
const [name, setName] = useState('');
// 相当于 componentDidMount + componentDidUpdate
useEffect(() => {
document.title = `Count: ${count}`;
});
// 只在 count 变化时执行
useEffect(() => {
console.log('Count changed:', count);
}, [count]);
// 只在挂载时执行(空依赖数组)
useEffect(() => {
console.log('Component mounted');
// 返回清理函数,相当于 componentWillUnmount
return () => {
console.log('Component will unmount');
};
}, []);
// 多个 effect,分离关注点
useEffect(() => {
// 订阅操作
const subscription = subscribeToFriendStatus(id, handleStatusChange);
return () => {
// 取消订阅
subscription.unsubscribe();
};
}, [id]);
return (
<div>
<p>{count}</p>
<button onClick={() => setCount(count + 1)}>Click</button>
</div>
);
}
3. useContext - 上下文消费
import React, { useContext } from 'react';
const ThemeContext = React.createContext('light');
function ThemedButton() {
// 使用 useContext 替代 Consumer
const theme = useContext(ThemeContext);
return (
<button style={{ background: theme === 'dark' ? '#333' : '#fff' }}>
I am styled by theme context!
</button>
);
}
// 对比 class 组件的写法
class ThemedButtonClass extends React.Component {
static contextType = ThemeContext;
render() {
return (
<button style={{ background: this.context === 'dark' ? '#333' : '#fff' }}>
I am styled!
</button>
);
}
}
4. useRef - 引用
import React, { useRef, useEffect } from 'react';
function TextInputWithFocusButton() {
const inputEl = useRef(null);
const timerRef = useRef(null);
const onButtonClick = () => {
// 访问 DOM 节点
inputEl.current.focus();
};
useEffect(() => {
// 保存任意可变值
timerRef.current = setInterval(() => {
console.log('Tick');
}, 1000);
return () => {
clearInterval(timerRef.current);
};
}, []);
return (
<>
<input ref={inputEl} type="text" />
<button onClick={onButtonClick}>Focus the input</button>
</>
);
}
5. useReducer - 复杂状态逻辑
import React, { useReducer } from 'react';
const initialState = { count: 0 };
function reducer(state, action) {
switch (action.type) {
case 'increment':
return { count: state.count + 1 };
case 'decrement':
return { count: state.count - 1 };
default:
throw new Error();
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<div>
Count: {state.count}
<button onClick={() => dispatch({ type: 'decrement' })}>-</button>
<button onClick={() => dispatch({ type: 'increment' })}>+</button>
</div>
);
}
6. useMemo 和 useCallback - 性能优化
import React, { useMemo, useCallback } from 'react';
function ParentComponent({ data, onUpdate }) {
// 缓存计算结果
const processedData = useMemo(() => {
console.log('Computing...');
return data.map(item => item * 2);
}, [data]);
// 缓存回调函数
const handleClick = useCallback(() => {
onUpdate(processedData);
}, [onUpdate, processedData]);
return (
<div>
<ChildComponent data={processedData} onClick={handleClick} />
</div>
);
}
7. 自定义 Hooks
// useLocalStorage.js
import { useState, useEffect } from 'react';
function useLocalStorage(key, initialValue) {
const [storedValue, setStoredValue] = useState(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
return initialValue;
}
});
const setValue = (value) => {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
console.log(error);
}
};
return [storedValue, setValue];
}
// 使用
function Example() {
const [name, setName] = useLocalStorage('name', 'Bob');
return (
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
/>
);
}
深入理解
Hooks 的工作机制
React 在渲染函数组件时,按调用顺序访问当前 Fiber 上的 Hook 记录。以 React 18/19 的状态 Hook 实现理解,记录通过链表关联,useState / useReducer 还有更新队列;不是整个应用共享一个状态数组。常见“数组 + 游标”只用于解释顺序匹配,既不支持真实的多组件并发,也不能直接当成 React 源码。
函数组件 Fiber.memoizedState
-> 第一个状态 Hook -> 第二个状态 Hook -> 后续 Hook
↓ ↓
更新队列 更新队列
每次调用自定义 Hook 都把它内部的状态记录接到调用组件上,因此两次 useCounter() 默认不会共享计数。条件分支改变状态 Hook 的调用顺序,会让上次的状态与本次调用错配。组件状态的保存位置还受组件类型、树位置与 key 影响,详见 React Keys。
Diff 复杂度与 Hook 更新不是同一层
React 常说的 O(n) Diff,针对基于元素类型、同层位置和 key 的启发式协调,不是任意两棵树的最优编辑距离;它也不包含组件自身昂贵计算、样式布局和全部提交成本。辅助索引等空间最坏可到 O(n),不能从一次 state 更新推断只处理一个节点。
Vue 3 keyed children 的常见实现先同步公共前后缀,再为中间乱序段建立 key 索引,并用最长递增子序列减少 DOM 移动。设该段长度为 m,采用二分维护 LIS 时这一部分最坏为 O(m log m),相关数组和映射为 O(m);已排序、只追加等路径可能不需要完整 LIS。因而不能将 Vue/React 的所有更新路径统一写为“时间空间均 O(n)”,DOM 创建/移动次数和组件执行成本还应分别观察。
自定义 Hook:依赖、更新和资源清理
把代码提取成 useXxx 只是第一步,面试更应说明参数变化、卸载和重挂载时的契约。
标题跟随参数变化
import { useEffect } from 'react';
function useTitle(title) {
useEffect(() => {
document.title = title;
}, [title]);
}
[] 只会捕获首次的标题,参数变化时必须重新同步。这是单一页面所有者的最小实现;多个组件竞争标题、路由回退恢复、SSR 首屏标题,应交由路由/框架的元数据管理。Effect 不在服务端执行,不能用它生成服务端 HTML 的 <title>。
显式刷新不能依赖毫秒时间戳
import { useReducer } from 'react';
function useUpdate() {
const [, update] = useReducer(version => version + 1, 0);
return update;
}
setFlag(Date.now()) 在同一毫秒内可能写入相同值而跳过更新;Reducer 根据前值递增,没有这个碰撞问题,返回的 dispatch 引用也稳定。连续 dispatch 仍可能被 React 批处理成一次提交,它不是“强制每调用一次就立即画一帧”。该 Hook 只适合作为旧命令式接口的桥接;业务可见数据应进入 state,外部 store 订阅优先采用 useSyncExternalStore,不要靠刷新掩盖直接修改数据的问题。
滚动订阅:清理建立订阅时的那个节点
import { useCallback, useEffect, useState } from 'react';
function useScroll() {
const [element, setElement] = useState(null);
const [position, setPosition] = useState([0, 0]);
// 回调 ref 会把节点替换变成响应式输入;ref.current 本身不是依赖通知。
const scrollRef = useCallback(node => { setElement(node); }, []);
useEffect(() => {
if (element === null) return;
const readPosition = () => {
const next = [element.scrollLeft, element.scrollTop];
setPosition(previous => previous[0] === next[0] && previous[1] === next[1]
? previous
: next);
};
readPosition();
element.addEventListener('scroll', readPosition, { passive: true });
return () => {
element.removeEventListener('scroll', readPosition);
};
}, [element]);
return { scrollRef, position: element === null ? [0, 0] : position };
}
function ScrollPanel() {
const { scrollRef, position: [x, y] } = useScroll();
return (
<section>
<div ref={scrollRef} style={{ maxHeight: 200, overflow: 'auto' }}>
<div style={{ height: 1000 }}>滚动内容</div>
</div>
<output>x: {x}, y: {y}</output>
</section>
);
}
清理函数捕获 element 和同一个监听函数,避免卸载时读取已经变成 null 或新节点的 ref.current。节点替换会先清理旧订阅,再读取新节点初始位置;普通 Effect 的同步发生在提交之后,并不保证节点更换当帧就完成测量。开发 Strict Mode 的额外 setup/cleanup 用来检查释放是否对称,应保证重复订阅后只保留一份监听。
这个例子按 scroll 事件更新位置,passive 不会自动节流。高频渲染有成本时按需求做节流/帧合并,并同时取消尾部定时器或 rAF;完整滚动性能策略复用 渲染与运行时优化。
防抖与节流:最新回调、尾调用和卸载
防抖等待最后一次调用后的静默窗口;节流限制调用频率。下面约定 debounce 只有尾调用,throttle 首次立即执行、窗口内最多补一次最新参数的尾调用。计时器晚于截止时间执行很常见,不能把延时当作精确时钟。
先把计时逻辑独立出来,方便验证快速连续调用、取消和异常后再次使用:
function createRateLimiter(callback, delay, mode) {
if (!Number.isFinite(delay) || delay < 0) throw new RangeError('invalid delay')
if (mode !== 'debounce' && mode !== 'throttle') throw new TypeError('invalid mode')
let timer = null
let lastInvoke = null
let pending = null
function invoke() {
timer = null
const call = pending
pending = null
lastInvoke = performance.now()
return callback.apply(call.context, call.args)
}
function limited(...args) {
pending = { context: this, args }
if (mode === 'debounce') {
clearTimeout(timer)
timer = setTimeout(invoke, delay)
return
}
const remaining = lastInvoke === null ? 0 : delay - (performance.now() - lastInvoke)
if (remaining <= 0) {
clearTimeout(timer)
return invoke()
}
if (timer === null) timer = setTimeout(invoke, remaining)
}
limited.cancel = () => {
clearTimeout(timer)
timer = null
pending = null
lastInvoke = null
}
return limited
}
再在客户端 Hook 中管理引用和生命周期:
import { useLayoutEffect, useMemo, useRef } from 'react';
function useRateLimitedCallback(callback, delay, mode = 'debounce') {
const callbackRef = useRef(callback);
useLayoutEffect(() => {
callbackRef.current = callback;
}, [callback]);
const limited = useMemo(() => createRateLimiter(function (...args) {
return callbackRef.current.apply(this, args);
}, delay, mode), [delay, mode]);
useLayoutEffect(() => () => limited.cancel(), [limited]);
return limited;
}
// 在组件顶层调用:只传递需要保留的数据,不延后读取事件对象。
// const search = useRateLimitedCallback(query => submit(query), 300);
// <input onChange={event => search(event.currentTarget.value)} />
// const report = useRateLimitedCallback(position => track(position), 100, 'throttle');
这里在提交阶段更新 callbackRef,避免并发渲染尚未提交的回调被旧计时器观察到。回调变化保留待执行任务,但任务使用最新已提交的回调;delay/mode 变化和卸载取消旧任务,Strict Mode 的额外清理也不留下计时器。配置不变时返回函数引用稳定;取消已发出的网络请求仍需单独使用 AbortController,取消计时器不会撤销已经执行的副作用。
这是客户端交互 Hook,SSR 首屏应输出不依赖测量的内容。同步首调用的返回值可以直接取得,延后的尾调用没有可同步返回的结果;异步回调的拒绝需要在回调内显式处理,不能把包装函数当作“每次调用都返回 Promise”的 API。函数节流也不能替代请求层的并发限制,后者复用 请求队列。
React 19 的 useDeferredValue 初始值
useDeferredValue(value, initialValue) 在首次渲染时可以先返回 initialValue,再安排使用当前 value 的后台渲染;省略它则首次直接返回 value。在后续紧急更新中先保留旧的 deferred value,后台再尝试新值,更新被再次打断时可以丢弃中间渲染。若更新本身已在 Transition 中,它不再安排一次重复的延后渲染。
import { useDeferredValue } from 'react';
function SearchResults({ query }) {
const deferredQuery = useDeferredValue(query, '');
return <ResultList query={deferredQuery} />;
}
这里的初始空字符串必须是 ResultList 支持的有效输入;对象初始值应保持稳定引用。它没有固定等待时间,不会自动减少网络请求次数,也不会取消过期请求,因此不能代替防抖或 AbortController。是否能改善交互还取决于昂贵子树是否能跳过紧急渲染以及 Suspense/数据缓存的设计。
Hooks 规则
- 只在最顶层使用 Hooks:不要在循环、条件或嵌套函数中调用
- 只在 React 函数中调用 Hooks:函数组件或自定义 Hooks
上面针对 useState、useEffect 等普通 Hooks。React 19 的 use(resource) 允许在循环和条件中调用,但仍必须位于组件/Hook 中,不能包在 try/catch 里;它不是状态 Hook 顺序规则的任意例外。
// 错误示例
function BadExample() {
if (condition) {
// 在条件中调用 Hook 是错误的
const [state, setState] = useState(0);
}
for (let i = 0; i < 10; i++) {
// 在循环中调用 Hook 是错误的
const [state, setState] = useState(0);
}
}
// 正确示例
function GoodExample() {
const [state, setState] = useState(0);
useEffect(() => {
if (condition) {
setState(1);
}
}, [condition]);
}
最佳实践
- 使用 ESLint 插件:
eslint-plugin-react-hooks自动检查规则 - 合理拆分 Hooks:将相关逻辑封装到自定义 Hooks
- 注意依赖数组:确保 effect 的依赖项完整
- 避免过度优化:不是所有情况都需要 useMemo/useCallback
// 依赖项完整示例
useEffect(() => {
fetchData(id);
}, [id]); // 确保包含所有依赖
// 使用函数式更新避免依赖
useEffect(() => {
const timer = setInterval(() => {
setCount(c => c + 1); // 不依赖外部 count
}, 1000);
return () => clearInterval(timer);
}, []); // 空依赖数组是安全的
面试要点
- Hooks 解决的问题:复用逻辑、分离关注点、消除 this(知识点:Hooks 解决的问题)
- 常用 Hooks:useState、useEffect、useContext、useRef、useReducer、useMemo、useCallback(知识点:详细解答)
- Hooks 规则:只在顶层调用、只在 React 函数中调用(知识点:Hooks 规则)
- 自定义 Hooks:以 use 开头,封装可复用逻辑(知识点:自定义 Hooks)
- 与 Class 组件对比:更简洁、更符合函数式编程思想(知识点:Hooks 解决的问题)
常见追问:
- 为什么 Hooks 不能在循环或条件中使用?(知识点:Hooks 规则)
- useEffect 的清理函数什么时候执行?(知识点:useEffect)
- useMemo 和 useCallback 的区别是什么?(知识点:useMemo 和 useCallback)
- 如何模拟 componentDidMount?(知识点:useEffect)
- useState 和 useReducer 如何选择?(知识点:useReducer)
资料来源与补充边界
来源:FE-006《React进阶&工程化面试全解.pdf》第 5–7 页。补充 useTitle、useUpdate、useScroll 的依赖与清理,修正空依赖、时间戳碰撞和清理读取可变 ref 的问题;常用 Hook API 与逻辑复用动机复用本篇前半部分。HOC 对比统一见 HOC 选型。
参考:复用自定义 Hook、useEffect、use。