React

S 级 · 前端面试必考 · 结合项目经验作答效果更好

1 Hooks 原理

useState 的原理?为什么不能在条件语句里使用?
React 用链表(或数组)按顺序存储每个组件的 hooks 状态,挂载在当前 Fiber 节点上。每次渲染时按调用顺序逐个读取,所以 hooks 必须在组件顶层调用,不能放在 if/loop 里,否则顺序错乱导致状态对不上。
// ❌ 条件调用会破坏链表顺序
if (condition) {
  const [a, setA] = useState(0);
}
const [b, setB] = useState(0); // 顺序错乱

// ✅ 始终在顶层调用
const [a, setA] = useState(0);
const [b, setB] = useState(0);
useEffect 的执行时机?和 useLayoutEffect 的区别?
useEffect:在浏览器绘制(paint)之后异步执行,不阻塞渲染。
useLayoutEffect:在 DOM 变更后、浏览器绘制之前同步执行,会阻塞渲染。

使用场景:需要在渲染前读取/修改 DOM(避免闪烁)用 useLayoutEffect,其他情况都用 useEffect。

清理函数:返回的函数在下次 effect 执行前和组件卸载时调用,用于取消订阅、清除定时器等。

useMemo / useCallback / React.memo 的区别?
React.memo:高阶组件,对 props 做浅比较,props 没变就跳过重渲染。
useMemo:缓存计算结果(值),依赖不变就返回上次的值。
useCallback:缓存函数引用,等价于 useMemo(() => fn, deps)

搭配使用:父组件传函数给 React.memo 包裹的子组件时,需要 useCallback 保持引用不变,否则 memo 无效。

const Parent = () => {
  const handleClick = useCallback(() => { /* ... */ }, []);
  return <MemoChild onClick={handleClick} />;
};
const MemoChild = React.memo(({ onClick }) => { /* ... */ });
useRef 的使用场景?为什么它不会触发重渲染?
useRef 返回一个 { current: value }可变对象,在整个组件生命周期内保持同一个引用。修改 .current 不会触发重渲染,因为 React 不跟踪 ref 的变化。

使用场景:① 引用 DOM 节点 ② 存储不需要触发渲染的值(如 timer ID、上一次的 props/state)③ 跨渲染保持引用(闭包陷阱的解法)

自定义 Hook 的设计原则?
自定义 Hook 就是一个以 use 开头的普通函数,内部可以调用其他 Hooks。核心目的是逻辑复用,将有状态逻辑从组件中抽离。
  • 单一职责:一个 Hook 做一件事(如 useDebounce、useLocalStorage)
  • 参数化:通过参数控制行为,返回值包含状态和操作方法
  • 不共享状态:每次调用都有独立的状态副本
  • 组合优于继承:复杂逻辑通过组合多个简单 Hook 实现

2 Fiber 架构

Fiber 是什么?解决了什么问题?
Fiber 是 React 16+ 的协调引擎。之前的 Stack Reconciler 递归遍历树,一旦开始就无法中断,如果组件树很大,会长时间占用主线程导致卡顿。Fiber 把渲染工作拆分成一个个小单元(Fiber 节点),可以暂停、恢复、丢弃,实现可中断的渲染

核心概念:

  • Fiber 节点:一个 JS 对象,包含组件类型、状态、子/兄弟/父指针,形成链表结构
  • 双缓冲:current 树(屏幕上的)和 workInProgress 树(正在构建的),构建完后一次性切换
  • 时间切片:每帧分出时间执行渲染工作,超时就让出主线程给浏览器,下帧继续
React 的渲染流程?Render 和 Commit 阶段?
Render 阶段(可中断):遍历 Fiber 树,对比新旧节点(reconciliation),标记需要变更的节点(effectTag)。纯计算,不操作 DOM。
Commit 阶段(不可中断):一次性执行所有 DOM 变更,然后执行 useLayoutEffect → 浏览器绘制 → useEffect。
React 18 Concurrent Mode?
Concurrent Mode 是 Fiber 的延伸,允许 React 同时准备多个版本的 UI。核心能力:
startTransition:标记非紧急更新,让用户输入等紧急更新优先。
useDeferredValue:延迟更新某个值,类似防抖但由 React 调度。
Suspense:配合 lazy 实现代码分割和数据加载的优雅降级。

3 Diff 算法

React Diff 算法的三个假设?
同层比较:只比较同一层级的节点,不会跨层移动(O(n) 而非 O(n³))。
类型不同直接替换:不同类型的元素(如 div → span)直接销毁旧树,建新树。
key 标识同一节点:通过 key 判断哪些节点是"同一个",优化列表的增删移操作。
为什么不要用 index 作为 key?
当列表发生插入/删除/排序时,index 会变化,React 会误判节点身份,导致:① 不必要的 DOM 更新 ② 组件状态错乱(如输入框内容错位)③ 性能劣化。

应该用:数据的唯一标识(id、uuid)。只有列表是静态不变的纯展示时才可以用 index。

4 状态管理

React 状态管理方案对比?Context / Redux / Zustand
Context + useReducer:轻量,适合低频更新的全局状态(主题、用户信息)。缺点是 Context 值变化时,所有消费者都重渲染。
Redux / RTK:成熟的状态管理,适合复杂应用。通过 selector 细粒度订阅,避免不必要渲染。
Zustand:极简 API(类似 useState),基于发布-订阅,自带 selector,无 Provider 包裹。

面试话术:小项目 Context 够用,中大型项目用 Zustand(API 简单、性能好)或 Redux Toolkit(生态成熟、DevTools 强)。

为什么 React 强调不可变数据?
React 通过引用比较(===)判断状态是否变化来决定是否重渲染。如果直接修改对象/数组,引用不变,React 认为没变化就不更新。所以必须返回新引用。
// ❌ 直接修改
state.items.push(newItem);
setState(state); // 引用没变,不会更新

// ✅ 返回新引用
setState({ ...state, items: [...state.items, newItem] });

5 性能优化

React 性能优化有哪些手段?
渲染优化:React.memo、useMemo、useCallback 减少不必要渲染。
状态优化:状态就近原则(下沉到需要的组件)、Context 拆分、选择性订阅。
列表优化:稳定 key、虚拟滚动(react-window)、分页。
代码分割:React.lazy + Suspense、路由级懒加载。
避免陷阱:不在 render 中创建新对象/函数、注意 useEffect 依赖、避免大组件。

💡 详细代码示例可参考 网易腹稿 · React 性能优化

虚拟滚动的原理?
只渲染可视区域内的 DOM 节点(通常加上前后缓冲区),通过计算滚动位置动态替换内容。列表容器用一个大的 padding 或空 div 撑开总高度,保持滚动条正确。常用库:react-window、react-virtuoso。

6 生命周期

函数组件如何模拟类组件生命周期?
componentDidMountuseEffect(() => { ... }, [])(空依赖数组)
componentDidUpdateuseEffect(() => { ... }, [deps])(有依赖)
componentWillUnmountuseEffect(() => { return () => { cleanup }; }, [])(返回清理函数)

注意:useEffect 每次都是"全新的"闭包。如果需要获取最新的 state/props 而不想加依赖,可以用 useRef 保存。

React 严格模式为什么 useEffect 执行两次?
React 18 的 StrictMode 在开发环境下会挂载 → 卸载 → 重新挂载组件,目的是帮你检查 effect 的清理函数是否正确。如果你的 effect 在双重执行后出问题,说明清理逻辑有 bug。

应对:确保每个 effect 的清理函数能正确还原(取消订阅、清除定时器、abort fetch 等)。