React 面试集锦(一):Hooks、组件通信与 Fiber
社区来稿 · batype由用户发布,不代表 batype 本人观点
这篇整理三道常见 React 面试题:
- React Hooks 为什么不能写在条件语句里?
- React 组件通信有哪些方式?
- React Fiber 解决了什么问题?
我会按面试回答的方式写:先给结论,再展开原理和示例。
1. React Hooks 为什么不能写在条件语句里?#
结论:Hooks 不能写在条件语句里,因为 React 是靠 Hook 的调用顺序把状态和组件对应起来的;一旦某次 render 跳过了某个 Hook,后面的 Hook 就会整体错位。
错误示例:
function Demo({ visible }: { visible: boolean }) {
if (visible) {
const [count, setCount] = useState(0);
}
return null;
}
当 visible 从 true 变成 false 时,这个 useState 会被跳过。React 下一次 render 时,Hook 的调用顺序就变了。
1.1 Hooks 如何和组件状态对应?#
React 并不是通过变量名来识别状态的。
比如:
const [count, setCount] = useState(0);
const [name, setName] = useState('batype');
React 不关心变量叫 count 还是 name,它关心的是:
第 1 个 Hook
第 2 个 Hook
第 3 个 Hook
...
简化理解,函数组件对应的 Fiber 节点上会挂一条 Hook 链表:
fiber.memoizedState = hook1;
hook1.next = hook2;
hook2.next = hook3;
每个 Hook 节点大概保存这些信息:
type Hook = {
memoizedState: any; // 当前 Hook 的状态
baseState: any; // 基础状态
queue: any; // 更新队列
next: Hook | null; // 下一个 Hook
};
所以,Hook 状态和组件的对应关系,本质上是:
当前 Fiber 节点 + Hook 调用顺序
1.2 为什么必须保持调用顺序一致?#
因为 React 每次 render 都会按顺序读取 Hook。
正常情况:
function Demo() {
const [name, setName] = useState('batype'); // 第 1 个 Hook
const [age, setAge] = useState(18); // 第 2 个 Hook
return null;
}
第一次 render:
hook1.memoizedState = 'batype'
hook2.memoizedState = 18
第二次 render:
第 1 个 useState -> 读取 hook1
第 2 个 useState -> 读取 hook2
顺序稳定,状态就能正确对应。
但如果写成条件调用:
function Demo({ visible }: { visible: boolean }) {
if (visible) {
const [name, setName] = useState('batype');
}
const [age, setAge] = useState(18);
return null;
}
当 visible = true:
第 1 个 Hook -> name
第 2 个 Hook -> age
当 visible = false:
第 1 个 Hook -> age
此时 age 会去读取原来第 1 个 Hook 的状态,后面的 Hook 也会跟着错位。
这就是为什么 React 要求:
- 不要在
if里调用 Hook - 不要在
for/while里调用 Hook - 不要在普通嵌套函数里调用 Hook
- Hook 必须放在函数组件或自定义 Hook 的顶层
正确写法是:条件逻辑放到 Hook 内部,而不是条件调用 Hook。
function Demo({ visible }: { visible: boolean }) {
const [count, setCount] = useState(0);
useEffect(() => {
if (!visible) return;
// visible 为 true 时才执行逻辑
console.log(count);
}, [visible, count]);
return null;
}
1.3 自定义 Hook 的本质是什么?#
自定义 Hook 本质上就是:封装了其他 Hook 调用的普通函数。
比如:
function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue);
const increment = () => setCount((c) => c + 1);
const decrement = () => setCount((c) => c - 1);
return {
count,
increment,
decrement,
};
}
使用:
function Demo() {
const { count, increment } = useCounter(0);
return <button onClick={increment}>{count}</button>;
}
它没有创建新的组件实例,也不是特殊语法。它只是把多个 Hook 调用按固定顺序组织起来。
所以自定义 Hook 也必须遵守 Hook 规则:
// 错误
if (visible) {
useCounter();
}
正确写法:
function Demo({ visible }: { visible: boolean }) {
const counter = useCounter();
if (!visible) return null;
return <button onClick={counter.increment}>{counter.count}</button>;
}
2. React 组件通信有哪些方式?#
结论:React 组件通信优先用简单方案:近距离用 props / callback,兄弟组件用状态提升,跨层级用 Context,复杂全局状态再考虑 Redux、Zustand、Jotai、unstated-next。事件总线能不用就不用。
可以按数据作用范围来分。
2.1 父传子:props#
最基础,也最推荐。
function Parent() {
return <Child name="batype" />;
}
function Child({ name }: { name: string }) {
return <div>{name}</div>;
}
如果传的是 UI 内容,可以用 children:
import type { ReactNode } from 'react';
function Card({ children }: { children: ReactNode }) {
return <div className="card">{children}</div>;
}
function App() {
return (
<Card>
<h2>标题</h2>
<p>内容</p>
</Card>
);
}
ReactNode 可以表示任何能被 React 渲染的内容,比如字符串、数字、React 元素、数组、Fragment 等。
2.2 子传父:回调函数#
子组件不能直接改父组件状态,通常由父组件传一个 callback 给子组件。
import { useState } from 'react';
function Parent() {
const [value, setValue] = useState('');
return (
<>
<Child onChange={setValue} />
<p>{value}</p>
</>
);
}
function Child({ onChange }: { onChange: (value: string) => void }) {
return <input onChange={(e) => onChange(e.target.value)} />;
}
本质还是 props,只是传的是函数。
2.3 兄弟组件通信:状态提升#
两个兄弟组件要共享状态时,把状态放到它们共同的父组件里。
import { useState } from 'react';
function Parent() {
const [count, setCount] = useState(0);
return (
<>
<Display count={count} />
<Button onClick={() => setCount((c) => c + 1)} />
</>
);
}
function Display({ count }: { count: number }) {
return <div>{count}</div>;
}
function Button({ onClick }: { onClick: () => void }) {
return <button onClick={onClick}>+1</button>;
}
这就是 React 里最常见的“单向数据流”:
父组件持有状态
↓
通过 props 传给子组件
↓
子组件通过 callback 通知父组件更新
2.4 跨层级通信:Context#
当数据需要跨多层传递时,可以用 Context,避免层层传 props。
import { createContext, useContext, useState } from 'react';
const ThemeContext = createContext<{
theme: string;
setTheme: (theme: string) => void;
} | null>(null);
function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState('light');
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('useTheme must be used inside ThemeProvider');
return ctx;
}
function Toolbar() {
return <ThemeButton />;
}
function ThemeButton() {
const { theme, setTheme } = useTheme();
return (
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
当前主题:{theme}
</button>
);
}
Context 适合:
- 主题
- 语言
- 当前用户信息
- 权限信息
- 全局配置
但不要把所有状态都塞进 Context。Context 更新会影响消费它的组件,滥用后性能和维护性都会变差。
2.5 全局状态:Redux、Zustand、Jotai、unstated-next 等#
当状态跨页面、跨模块、更新逻辑复杂时,可以引入全局状态管理。
常见选择:
- Redux:规范强,适合复杂中大型项目
- Zustand:轻量,API 简单
- Jotai:原子化状态模型
- unstated-next:基于 React Context 和 Hook 的轻量状态管理
简单示例,以 Zustand 为例:
import { create } from 'zustand';
const useCounterStore = create<{
count: number;
increment: () => void;
}>((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}));
function Counter() {
const count = useCounterStore((state) => state.count);
const increment = useCounterStore((state) => state.increment);
return <button onClick={increment}>{count}</button>;
}
我的倾向是:能不用全局状态就不用。
如果状态只在一小块组件里用,状态提升就够了;如果只是跨几层传递,Context 就够了。全局状态不是默认答案。
2.6 URL 状态:query、params#
有些状态天然应该放在 URL 里,比如:
- 搜索关键词
- 页码
- 筛选条件
- tab 类型
- 详情页 id
示例:
/products?page=2&keyword=react
/user/123
URL 状态的好处是:
- 可分享
- 可刷新恢复
- 支持浏览器前进后退
- 更适合和路由绑定
比如搜索页的筛选条件,不一定要放 Redux;很多时候放 query 更合理。
2.7 事件总线:少用#
事件总线类似这样:
eventBus.emit('user:update', user);
eventBus.on('user:update', handler);
它的问题是:数据流不清晰,依赖关系隐蔽,项目大了容易失控。
组件 A 发事件,组件 B 监听事件,过一段时间没人知道谁影响了谁。调试时也很难追踪。
所以我一般只在非常明确的边界场景里用事件总线,比如:
- 非 React 模块和 React 模块通信
- 微前端应用之间的轻量通知
- 一些临时性的 SDK 回调桥接
普通 React 组件通信,不建议优先考虑事件总线。
3. React Fiber 解决了什么问题?#
结论:Fiber 解决的核心问题,就是把旧版 React 一次性、不可中断的递归渲染,改造成可拆分、可中断、可恢复、可调度的渲染过程。
我的观点是:旧版 React 基于递归渲染,在复杂应用里可能造成长时间主线程阻塞,影响用户体验。Fiber 的价值就在于把渲染工作拆开,让调度成为可能。
3.1 老版本递归渲染有什么问题?#
老版本 React 的渲染过程可以理解成递归遍历组件树:
App
└─ A
└─ B
└─ C
递归的问题是:一旦开始,就很难中途停下来。
如果组件树很大、更新很重,React 会长时间占用 JS 主线程。主线程被占住以后,浏览器就没法及时处理:
- 用户点击
- 输入
- 滚动
- 动画
- 页面绘制
最终表现就是卡顿、掉帧、交互不灵敏。
浏览器一帧大概是 16.6ms,如果 JS 执行时间过长,就会挤压布局、绘制和用户输入响应的时间。
3.2 Fiber 为什么可以中断?#
因为 Fiber 把原来“一整棵树的递归任务”拆成了一个个小的工作单元。
每个 Fiber 节点可以理解成一个执行单元。React 每处理完一个 Fiber,就可以检查:
- 当前帧还有没有剩余时间
- 有没有更高优先级的任务
- 是否应该把控制权交还给浏览器
简化流程:
处理 Fiber A
↓
检查是否还有时间
↓
处理 Fiber B
↓
检查是否有更高优先级任务
↓
处理 Fiber C
Fiber 能中断,关键在于两点:
- 任务被拆小了:每个 Fiber 节点都是一个可处理的工作单元。
- 进度可以保存:Fiber 节点之间通过
return、child、sibling形成链式结构,React 可以知道暂停后从哪里继续。
所以 Fiber 不是让 JavaScript 变成多线程。它仍然运行在主线程上,只是把长任务切成小片段,分批执行。
3.3 Fiber 节点里大概保存了什么?#
Fiber 节点本质上是 React 对组件或 DOM 节点的内部描述。
可以粗略理解成:
type Fiber = {
// 节点类型信息
tag: number;
type: any;
key: string | null;
// 对应的真实 DOM 或组件实例
stateNode: any;
// Fiber 树结构
return: Fiber | null; // 父 Fiber
child: Fiber | null; // 第一个子 Fiber
sibling: Fiber | null; // 下一个兄弟 Fiber
// 状态和属性
pendingProps: any;
memoizedProps: any;
memoizedState: any;
updateQueue: any;
// 副作用标记
flags: number;
subtreeFlags: number;
deletions: Fiber[] | null;
// 调度优先级
lanes: number;
childLanes: number;
// 双缓存结构
alternate: Fiber | null;
};
重点记这些就够了:
type:组件类型,比如div、函数组件、类组件stateNode:对应的真实 DOM 或组件实例return:父节点child:子节点sibling:兄弟节点memoizedState:状态,Hooks 链表也挂在这里updateQueue:更新队列flags:副作用标记,比如插入、更新、删除alternate:当前 Fiber 和 workInProgress Fiber 的互相引用
Fiber 节点保存这些信息,是为了让 React 能够:
- 知道当前处理到哪里
- 暂停后继续工作
- 比较新旧树差异
- 收集副作用
- 最后一次性提交 DOM 变更
3.4 render 阶段和 commit 阶段有什么区别?#
React 更新可以粗略分成两个阶段:
render 阶段:计算要改什么
commit 阶段:真正改 DOM
render 阶段#
render 阶段做的是“计算”:
- 根据新状态生成新的 Fiber 树
- 执行 diff
- 找出需要新增、更新、删除的节点
- 标记副作用
这个阶段可以被中断。
因为 render 阶段只是计算,还没有真正修改 DOM。即使中断、丢弃、重来,也不会让页面处于不一致状态。
commit 阶段#
commit 阶段做的是“提交”:
- 执行 DOM 插入、更新、删除
- 执行 ref 绑定
- 执行生命周期
- 执行 layout effect 等
这个阶段不能被中断。
因为 commit 阶段会真实修改页面。如果改到一半被中断,用户可能看到不完整 UI,DOM 状态也可能不一致。
所以面试里可以这样回答:
Fiber 让 render 阶段可中断,因为 render 只是构建 workInProgress 树和收集副作用;commit 阶段会真实修改 DOM,所以必须同步完成,不能中断。
总结#
这三类题其实都在考 React 的核心模型:
- Hooks:考状态如何和函数组件关联,本质是 Fiber 上的 Hook 链表和调用顺序。
- 组件通信:考单向数据流、状态归属和复杂度控制。
- Fiber:考 React 为什么要重构渲染架构,以及可中断、可调度是怎么来的。
我的建议是,面试回答时不要只背 API,要说清楚背后的设计约束:
Hooks 依赖调用顺序
组件通信依赖状态归属
Fiber 依赖任务拆分和调度
这比单纯列概念更有说服力。
相关文章
- 我对 React Server Components 的看法:方向对了,代价被低估了
RSC 解决的是真问题,但它给心智模型和生态带来的撕裂,比宣传里说的大得多。
- Fiber 架构工作原理
“双缓存” 是一种常见的图形处理技术,用于在图像渲染中实现平滑的、无闪烁的更新效果。它通过使用两个缓冲区(即两块内存区域)来完成。其中一个缓冲区用于显示图像,而另一个缓冲区则用于在后台进行图像的更新和
- Fiber 架构实现流程
Fiber 架构是 React 中用于实现虚拟 DOM 的一种算法架构。它的目标是提高渲染性能和用户体验,通过将渲染工作分割成多个小任务,在不阻塞主线程的情况下逐步完成整个渲染过程。