React 面试集锦(一):Hooks、组件通信与 Fiber

社区来稿 · batype由用户发布,不代表 batype 本人观点

·2324 字 · 约 6 分钟#React#Hooks#Fiber#面试#前端

这篇整理三道常见 React 面试题:

  1. React Hooks 为什么不能写在条件语句里?
  2. React 组件通信有哪些方式?
  3. 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;
}

visibletrue 变成 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 能中断,关键在于两点:

  1. 任务被拆小了:每个 Fiber 节点都是一个可处理的工作单元。
  2. 进度可以保存:Fiber 节点之间通过 returnchildsibling 形成链式结构,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 的一种算法架构。它的目标是提高渲染性能和用户体验,通过将渲染工作分割成多个小任务,在不阻塞主线程的情况下逐步完成整个渲染过程。