← 全部材料 React
REACT INTERVIEW · 16 个高频点

别背结论,把它推一遍

下面每一个知识点都用同一个节奏走:先给一句结论,再给一段能跑的例子, 然后解释"为什么会这样",最后一条面试可以直接说出口的答法。 带 ★ 的是几乎每轮都会问的。

01先猜再看看到代码先自己在心里跑一遍输出,再看下面的结果。
02解释"为什么"能说出机制,才算真的会,光记住现象很快会忘。
03用自己的话说考点框里的答法只是骨架,替换成你的表达更稳。
01

JSX 到底编译成了什么

jsx → function call → object
JSX 不是模板语言,也不是 HTML。它只是 函数调用的语法糖, 编译后产出一个普通的 JavaScript 对象(React 管它叫 element,注意它不是 DOM 节点)。

所以 <div /> 里的 div 是字符串, 而 <MyComp /> 里的 MyComp 是一个变量引用—— 这解释了两件事:组件必须先定义再使用;以及 <mycomp /> 之所以被当成原生标签,是因为小写开头会被当作字符串。

面试这样答
JSX 是 createElement / jsx() 的语法糖,编译产物是一个描述 UI 的普通对象,包含 type、props、key、ref。React 拿到这个对象树之后,通过 reconciler 对比新旧树,最终才产生 DOM 操作。所以 JSX 的性能开销主要是"创建对象"和"对比",不是"解析字符串模板"。
02

组件的本质:一个被反复调用的函数

props in → element out
函数组件就是"输入 props、输出 element"的纯函数。React 在每次需要渲染时重新调用它。 理解这句话,后面 80% 的"为什么"都能自己推出来。

关键推论:函数内部声明的所有变量、对象、函数,每次渲染都是全新的。 它们不是"保存下来的状态",只是这次调用过程中的局部变量。 这一点直接导致了后面好几节的坑。

面试这样答
函数组件的每次渲染就是一次函数调用,返回值是 element 树。React 通过比对前后两次返回值来决定 DOM 要不要变。因为函数会重新执行,内部的普通变量、闭包、临时对象每次都是新的——这既是 Hooks 设计的出发点,也是很多"值没更新""重复请求"问题的根源。
03

state 是一次渲染的快照★

snapshot, not a live variable
useState 返回的值,是这一次渲染时的 state 快照。 它是个常量,不会因为你调用了 setState 就在当前函数里变了。
实际输出 点击前界面显示 0
点击后界面变成 1
一秒后控制台打印 0 ← 不是 1

因为 handleClick 这个闭包捕获的是这次渲染的 count, 它的值在函数执行时就已经固定为 0。setCount(1) 触发了一次新的渲染, 新渲染里有一个新的 count = 1,但那是另一次函数调用,跟这个闭包无关。

✕ 直觉上以为

count 像 let count = 0,是个变量,改了就是新的。

✓ 实际是

每次渲染都 const count = 当前值,一次次全新生成,互不影响。

想在异步回调里拿到最新值,有三条路:用函数式更新 setCount(c => c + 1)(如果只是想"基于最新值算"); 用 useRef 存一份最新值(如果要读); 或者把逻辑写进 useEffect,让它跟随 state 变化重新执行。

面试这样答
state 在函数组件里是"每次渲染的快照",不是一个可变的引用。setState 只是告诉 React"下次渲染用这个新值",并触发一次重新执行组件函数,当前这次执行的闭包环境不会被修改。所以在定时器、事件订阅、异步回调里读到的往往是旧值,这类"闭包陷阱"要么用函数式更新,要么用 ref 保存最新值来解决。
04

三次 setState 为什么只加一★

batching + functional update
同一个事件处理函数里的多次 setState 会被批处理合并成一次渲染; 而每次传进去的都是同一个旧快照算出来的值,所以重复覆盖,只生效一次。
△ 可交互:看更新队列怎么走
界面上的 count
0
内部过程
点上面按钮试试
调用 3 次 set进入更新队列
→
批处理同一轮里合并
→
计算最终值值覆盖 vs 按序执行
→
只渲染 1 次函数只被调用一次

要注意的是:批处理不是为了省那点计算,而是为了"同一个事件里多次改状态,界面不应该闪中间态"。 React 18 把这个行为扩展到了所有场景——setTimeout、Promise、原生事件里也会自动批处理。

面试这样答
React 把同一个事件里的多次 setState 收集到更新队列,合并成一次渲染,这叫批处理。直接传值 setCount(count+1) 时,三次传入的都是基于同一份旧快照算出的同一个值,后写的覆盖先写的,所以只加一。传函数 setCount(c => c+1) 时,React 会把函数按顺序依次作用在上一次的结果上,所以加三。这也是"新状态依赖旧状态时要用函数式更新"的原因。
05

"重渲染"到底发生了什么

render ≠ DOM update
"重新渲染"只是重新执行组件函数、得到一棵新的 element 树。 真正碰 DOM 的只有对比出差异的那一小部分。 所以"渲染频繁"不等于"性能差",要看对比成本和真实 DOM 操作量。
1. 触发setState / 父组件重渲染
→
2. render 阶段执行组件函数,生成新 element 树,可被中断
→
3. diff新旧树比对,算出最小变更
→
4. commit 阶段真正写 DOM,同步不可中断
→
5. paint浏览器绘制,之后跑 useEffect
实际输出 每次点按钮都会打印 Child 也渲染了
但 Child 对应的 DOM 一个字节都不会变——因为 diff 结果为空

这是 React 的默认策略:父组件重渲染,所有子组件都会跟着重新执行, 不论 props 有没有变。React 认为"重新执行函数的成本"远小于"漏掉一次更新导致界面错误"的成本。 真要拦下来,用 React.memo 做 props 浅比较,或者把不变的部分 用 children 传进去(元素对象没变,React 会跳过整个子树)。

面试这样答
React 的重渲染分两个阶段。render 阶段是可中断的,只做计算:执行组件函数、生成新的 element 树、对比出差异;commit 阶段是同步的,真正操作 DOM。默认情况下父组件重渲染会让子组件也走一遍 render 阶段,但 diff 没差异就不会产生 DOM 操作。要减少不必要的 render,可以用 React.memo 做 props 浅比较、用 useMemo 稳定引用、或者把静态子树作为 children 传入。
06

key 是列表项的身份证★

identity, not index
diff 时,同一层级的兄弟节点靠 key 来判断"这是不是原来那个"。 用 index 当 key,等于告诉 React"身份就是位置"——一旦顺序变了,身份就错乱。
操作:在输入框里改几个字,然后删掉第一项 列表变成 B、C 两行(正确)
第一行输入框里仍然是 原来 A 那一行的内容
→ 用户看到的内容和实际数据错位了

原因:key=0、1、2。删掉 A 之后,新的列表 key 变成 0、1。 React 一看"key 0 还在,key 1 还在",于是复用了原来的 DOM 节点, 只把文本内容从 A 改成 B、从 B 改成 C。但输入框里的值是用户在 DOM 里敲的, React 不去碰它,那个 3 个字符就留在了第一个节点上。

✕ key={index}

列表不增删、不排序时没问题。一旦插入、删除、排序,就会出现状态错位、动画错乱、输入框串位。

✓ key={item.id}

身份跟着数据走,节点会跟着移动、复用,输入框里的值和动画状态都跟着"那一条数据"。

面试这样答
key 是 React 在同层 diff 时用来匹配新旧节点的唯一标识。React 的 diff 做了三个假设来把 O(n³) 降到 O(n):只比较同层、类型不同直接重建、同层用 key 匹配。用 index 当 key 时,身份绑定在位置上,列表一旦插入或删除,key 和数据就对不上,节点会被错误复用,导致非受控输入、内部 state、动画等出现错位。所以只要有稳定唯一 id,就应该用它。
07

类组件的生命周期★

mount → update → unmount, and why three hooks were deleted
类组件的生命周期分三段:挂载、更新、卸载。真正需要背的只有四个: constructor、render、 componentDidMount、componentWillUnmount。 剩下的那些——尤其是 getDerivedStateFromProps 和 getSnapshotBeforeUpdate——是 Fiber 引入"渲染可中断"之后打的补丁。
执行顺序 挂载:constructor → getDerivedStateFromProps → render → componentDidMount
更新:getDerivedStateFromProps → shouldComponentUpdate → render → getSnapshotBeforeUpdate → componentDidUpdate
卸载:componentWillUnmount
子组件出错:getDerivedStateFromError → componentDidCatch

✕ 被废弃的三个 W

  • componentWillMount
  • componentWillReceiveProps
  • componentWillUpdate

16.3 起标 UNSAFE_ 前缀,17 起移除。原因是 Fiber 的 render 阶段可中断、可丢弃——这三个钩子在 render 之前被调用,一旦这次渲染被高优先级任务打断重来,它们就会执行多次,里面写的副作用(请求、订阅、setState)自然就重复了。

✓ 各自的替代品

  • willMount → 直接用 constructor 或 componentDidMount
  • willReceiveProps → getDerivedStateFromProps
  • willUpdate → getSnapshotBeforeUpdate

注意 getDerivedStateFromProps 是 static 的,拿不到 this,就是逼你别在里面做副作用——只能根据 props 算 state。

这其实是一条主线:凡是"可能在 render 期间被调用多次"的地方,都不许写副作用。 Fiber 之后 React 随时可能把一次渲染丢掉重来,所以能安全放副作用的位置只剩 commit 阶段(componentDidMount / componentDidUpdate / useEffect)。

Hooks 怎么写同样的逻辑

StrictMode 的双调用 开发环境下,<StrictMode> 会把挂载流程走两遍: render → effect → 清理 → effect。类组件对应的是 componentDidMount → componentWillUnmount → componentDidMount。 这不是 bug,是故意让你发现"清理写漏了"。生产环境不会双调用。
面试这样答
类组件生命周期分挂载、更新、卸载三段。挂载走 constructor → getDerivedStateFromProps → render → componentDidMount;更新走 getDerivedStateFromProps → shouldComponentUpdate → render → getSnapshotBeforeUpdate → componentDidUpdate;卸载走 componentWillUnmount。16.3 废弃了三个 will 钩子,根因是 Fiber 的 render 阶段可以被打断和重来,这些在 render 之前执行的钩子会被调用多次,里面的副作用就重复了,所以改成 getDerivedStateFromProps 这种纯函数。用 Hooks 的话,didMount 对应 useEffect 空依赖、willUnmount 对应清理函数、shouldComponentUpdate 对应 memo,但 effect 是在绘制之后异步执行的,didMount 是在绘制之前同步执行,这个差别在需要读布局的时候很关键。
08

useEffect 什么时候执行★

after paint, not after render
useEffect 在 DOM 更新完成、浏览器绘制之后才异步执行。 所以它不会阻塞画面,也不该在里面做需要"在用户看到之前"完成的布局操作。

✕ 把它当生命周期背

"相当于 componentDidMount"——这只对空依赖的情况成立,而且掩盖了真正的心智模型。

✓ 正确的心智模型

"让外部世界和当前 state 保持同步"。每次渲染都有一个对应的 effect,依赖变了就重新同步一次。

React 官方更推荐的描述是"同步"而不是"生命周期":你不是在某个时刻做某件事, 而是在描述"当这些值是这样时,外部世界应该长这样"。 这样写出来的清理逻辑天然是对的。

useEffect(默认选它)

绘制之后异步执行。不阻塞首屏,适合数据请求、订阅、日志、定时器。绝大多数场景用它。

useLayoutEffect

DOM 更新后、绘制之前同步执行。会阻塞绘制。适合"读布局再改样式"——比如测量元素宽高后立即调整,避免闪一下。

面试这样答
useEffect 在 commit 阶段结束、浏览器完成绘制之后异步执行,所以不阻塞渲染;useLayoutEffect 在 DOM 变更后、绘制前同步执行,会阻塞绘制,用来避免视觉闪烁。清理函数在下一次 effect 执行前和组件卸载时调用,所以它清理的是上一轮的副作用,这保证了订阅、定时器不会泄漏。开发环境下 StrictMode 会额外执行一次 mount→unmount→mount,是为了暴露清理逻辑写错的问题,生产环境不会。
09

依赖数组的三个坑★

every render is a new object
依赖比较用的是 Object.is(引用相等)。 而组件里每次渲染都会新建对象和函数,于是"值没变但引用变了",依赖永远判定为变化。
实际输出 请求 → setState → 重渲染 → 新 options → 依赖变了 → 再请求 → 死循环

这个坑很隐蔽:代码不报错、不循环,只是发出去的数据永远是旧的。 解决办法不是加 msg 进依赖(那会导致每次输入都重连), 而是用 useRef 保存最新值,或者用函数式更新。

面试这样答
useEffect 的依赖是逐项用 Object.is 比较的。因为函数组件每次渲染都会重新创建对象和函数,把对象或函数直接写进依赖里会出现"值等价但引用不同"的判定,导致 effect 反复执行甚至无限循环。常见的处理是:依赖收敛到原始值;确实需要稳定引用时用 useMemo/useCallback 包一层;需要读取最新值又不想重跑 effect 时用 useRef 保存。ESLint 的 exhaustive-deps 就是为了防止漏写依赖导致读到过期闭包。
10

useRef:不惊动渲染的盒子

mutable, persistent, silent
useRef 返回一个跨渲染保持同一个的对象,改它的 .current 不会触发重渲染。它适合存"渲染不关心的东西"。
点击三次 ref 打印:1 2 3(跨渲染累加,值保留)
state 打印:0 0 0(每次都是那次渲染的快照)
界面显示:3
面试这样答
useRef 返回一个在组件整个生命周期内保持同一个引用的普通对象,修改 current 不会触发重渲染,也不参与依赖比较。它和 state 的区别是:state 变化驱动界面更新,ref 变化只是"记一笔"。典型用途是访问 DOM 节点、保存定时器 ID、保存上一次的值,以及保存最新 state 来绕开闭包读到旧值的问题。注意不要在渲染过程中依赖 ref 的值来决定输出,那样 React 无法保证一致。
11

memo / useMemo / useCallback★

the same idea, three places
三者本质是同一件事:利用"引用没变"来跳过工作。 区别只在跳过的是什么——组件渲染、计算结果、还是函数的重新创建。

React.memo(Component)

包组件。props 浅比较没变就跳过这个组件的渲染。

useMemo(fn, deps)

包一个计算。依赖没变就返回上次缓存的值,不再重算。

useCallback(fn, deps)

包一个函数。等价于 useMemo(() => fn, deps),返回同一个函数引用。

实际输出 每次点按钮都打印 Child 渲染
memo 完全没起作用——因为 props 的引用每次都变
面试这样答
三者都是靠引用相等来跳过工作。React.memo 比较 props 决定要不要执行组件函数;useMemo 缓存计算结果;useCallback 缓存函数引用,两者本质一样。它们只在"下游有依赖引用相等的优化"时才有意义——比如子组件套了 memo、或者这个值被写进了别的 Hook 的依赖数组。滥用会增加比较和缓存的开销,反而变慢。React 19 的编译器方向是自动做这类记忆化,手写会变少。
12

Context 不是状态管理,它有代价

every consumer re-renders
Provider 的 value 一变, 所有消费这个 Context 的组件都会重渲染,不管它实际只用了其中一小部分。
面试这样答
Context 提供的是跨层级传值,它没有订阅粒度——Provider 的 value 引用一变,所有 useContext 的组件都会重渲染。所以实际项目里通常做两件事:value 用 useMemo 稳定引用,按数据的变化频率拆成多个 Provider。如果数据更新很频繁,或者需要精确到"只更新用到某字段的组件",Context 并不合适,应该用带选择器订阅的外部状态库。
13

自定义 Hook 与两条铁律★

why hooks can't be conditional
自定义 Hook 只是把"用了 Hook 的函数"抽出来复用逻辑,状态不共享。 Hook 不能写在条件和循环里,因为 React 是按调用顺序来认每个 state 的。

为什么"按顺序"这件事这么关键?因为 React 没有名字可以对应,它只能记住 "这个组件的第 1 个 Hook 是 useState,第 2 个是 useEffect……"。

实际输出 首次渲染 isLogin=false:Hook 顺序是 [useState, useEffect]
之后 isLogin=true:顺序变成 [useState, useState, useEffect]
→ React 把 useEffect 的槽位错当成 useState,报 "Rendered more hooks than during the previous render"
面试这样答
React 在 Fiber 节点上用一个链表按调用顺序保存每个 Hook 的状态,没有 key 也没有名字,纯粹靠"第几次调用"来匹配。如果某次渲染多调或少调了一个 Hook,后面所有 Hook 的状态就整体错位了。所以规则是只能在函数组件或自定义 Hook 的顶层调用。自定义 Hook 复用的是逻辑,不是状态——每次调用都会分配到独立的 Hook 槽位,所以两个组件用同一个自定义 Hook,状态是互相独立的。
14

受控 vs 非受控

who owns the value
区别只有一句话:值的真相存在哪里。 存在 React state 里就是受控,存在 DOM 节点里就是非受控。

✓ 受控适合

需要实时校验、联动(比如输入后即时过滤列表)、格式化输入、禁用提交按钮。代价:每次按键都触发一次重渲染。

✓ 非受控适合

大表单、文件上传(<input type="file"> 只能是非受控)、只关心提交时的一次取值。代价:无法在输入过程中干预。

面试这样答
受控组件的值由 React state 驱动,必须同时提供 value 和 onChange,数据流是单向闭环,优点是能实时校验和联动,缺点是每次输入都触发渲染。非受控组件的值由 DOM 自己维护,通过 ref 在需要时读取,defaultValue 只设置初始值,适合大表单和文件上传。实践中大部分场景用受控更安全,性能敏感的巨型表单可以混合使用。
15

React 18:批处理扩展与并发

automatic batching / transition
React 18 把自动批处理从"只在合成事件里"扩展到了所有场景; 并发渲染(Concurrent Rendering)让 render 阶段可以被打断,从而让紧急更新插队。

如果某些场景确实需要"立刻渲染"(比如要马上读 DOM 布局), 用 flushSync 强制同步刷新。但这是逃生舱,别日常用。

面试这样答
React 18 的自动批处理覆盖了包括 setTimeout、Promise、原生事件在内的所有场景,多次 setState 只会产生一次渲染。并发渲染的核心是 render 阶段可中断:通过 Lane 模型给更新分优先级,用户输入这类紧急更新可以打断正在进行的大计算,被打断的那次渲染直接丢弃重来,不会出现半成品界面。API 层面体现为 startTransition / useTransition 和 useDeferredValue,解决的问题是"重计算不该阻塞交互"。
16

Fiber:React 为什么能"打断自己"

recursion → linked list
旧的调和器(Stack Reconciler)是递归遍历,一旦开始就必须走到底, 主线程被占满。Fiber 把树改成了链表,每个节点记住父、子、兄弟, 于是可以随时停下、记住位置、下次接着走。
递归的问题调用栈在 JS 引擎里,React 无法暂停和恢复
→
改成链表return / child / sibling 三个指针,遍历变成可中断的循环
→
时间切片每帧只干约 5ms,让出主线程给浏览器
→
Lane 优先级高优先级插队,低优先级可被丢弃重做
面试这样答
Fiber 是 React 16 重写的调和架构,本质是把组件树变成链表结构,每个节点带 return、child、sibling 指针,这样遍历从"递归"变成"可以随时暂停和恢复的循环",为时间切片和优先级调度提供了基础。整个更新分 render 和 commit 两阶段:render 阶段可以被打断甚至丢弃,commit 阶段必须同步完成,因为要保证 DOM 状态一致。副作用只能放在 commit 之后执行,这也是 useEffect 时机设计的由来。
17

Fiber 的 render 阶段在做什么★

beginWork down, completeWork up, commit without interruption
上一节说了 Fiber 是链表、所以能中断。这一节说中断之后具体怎么接着走: 一次更新分成 render 和 commit 两个阶段,render 阶段是一个 「向下 beginWork、向上 completeWork」的循环,每走完一个节点都问一句 "这一帧还有时间吗",没有就存下 workInProgress 让出主线程; commit 阶段拿到算好的效果链,一口气同步刷到 DOM,中途绝不打盹。
一次更新的完整链路 setState → 调度(Lane 定优先级)→ render 阶段逐节点 beginWork / completeWork → 得到一棵完整的 workInProgress 树 → commit 阶段三段同步刷 DOM → 把 root.current 指向新树,旧的变成下一次的草稿

commit 的三段,各自能干什么

✕ 常见误解

  • "Fiber 让渲染变成异步的" —— 渲染本身还是同步的计算,被切成片的是整个 render 阶段的推进节奏。
  • "commit 也能被打断" —— 不能,它是同步的。
  • "双缓冲就是缓存了一份 DOM" —— 缓存的是 Fiber 节点树,不是 DOM。

✓ 准确的说法

  • current 树是屏幕上正在显示的那棵,workInProgress 是内存里在算的那棵。
  • 两棵树的节点通过 alternate 互相指向,切换只是把 root.current 换个指针。
  • 算到一半被丢弃时,workInProgress 直接扔掉重来——这正是副作用不能放在 render 阶段的原因。
Fiber 节点里都存了什么 tag(函数组件 / 类组件 / 宿主节点…)、 key、stateNode(对应的真实 DOM 或组件实例)、 三个指针 return / child / sibling、 alternate(另一棵树里的自己)、 memoizedState(Hooks 链表就挂在这)、 memoizedProps / pendingProps、 updateQueue(待处理的更新)、 flags / subtreeFlags(自己 / 子树的副作用标记)、 lanes(优先级)

记住一句就能应付大部分追问:render 阶段算的是"要变成什么样",commit 阶段做的是"真的变"。 beginWork 和 completeWork 都只在前者里,所有能摸到真实 DOM 和用户可见效果的事情都在后者。

面试这样答
一次更新分两个阶段。render 阶段是可中断的,核心是一个 workLoop 循环:performUnitOfWork 先调 beginWork 往下走,处理当前节点、算出子节点并和旧的 child 做 diff,直到叶子节点没有子节点了,再调 completeUnitOfWork 往上走,completeWork 负责创建真实 DOM 节点并把子树里的副作用标记汇总上来。循环每一轮都检查 shouldYield,没时间了就把 workInProgress 存下来让出主线程,下次从这儿接着走,这就是时间切片。算完得到一棵完整的 workInProgress 树,进入 commit 阶段。commit 分三段:before mutation 跑 getSnapshotBeforeUpdate,mutation 真正增删改 DOM,layout 跑 componentDidMount 和 useLayoutEffect。commit 必须同步不可中断,因为 DOM 改了一半停下来的话界面就是残缺的。current 和 workInProgress 两棵树通过 alternate 互指,最后切一下 root.current 指针就完成替换,这就是双缓冲。
18

useReducer:状态太多时的退路

useState 是它的特例
同一个组件里三四个 useState 互相依赖时,会出现"改 A 必须同时改 B"的隐式约定。useReducer 把所有转移逻辑收进一个纯函数,让"哪些状态组合是合法的"变成代码里能写出来的东西。

多个 useState

  • loading、data、error 三个状态各自独立
  • 可能出现 loading 和 error 同时为真
  • 每加一种状态就要检查所有 setState 的地方
  • 逻辑散在多个事件处理函数里

useReducer

  • 一个 state 对象,状态组合由类型约束
  • 非法组合根本构造不出来
  • 所有转移集中在一个纯函数里,能单独测
  • dispatch 引用稳定,传给子组件不用 useCallback
面试怎么答当一个组件里有多个互相依赖的 state 时,用多个 useState 会出现"改 A 必须同时改 B"的隐式约定,而且能构造出 loading 和 error 同时为真的非法状态。useReducer 把所有转移逻辑收进一个纯函数,配合 TS 的联合类型,非法状态组合根本构造不出来,而且 reducer 能单独写单元测试。判断标准是更新逻辑有没有多个分支、多个字段是否要一起改,如果只是一两个独立开关就没必要换。
19

错误边界与 Portals

两个经常被放在一起问的 API
错误边界只能接住渲染阶段、生命周期和构造函数里的错误。事件处理、异步回调、以及服务端渲染里的错误它都接不住——这是它最常被误解的地方。Portals 解决的是另一个问题:DOM 挂载位置和 React 树位置解耦。
错误边界接得住什么、接不住什么✅ 接得住 子组件 render 里 throw 生命周期方法里 throw 构造函数里 throw ❌ 接不住 事件处理函数里的 throw ← 用 try/catch setTimeout / Promise 里的错误 ← 用 try/catch 或 .catch 服务端渲染的错误 ← 服务端要单独兜底 错误边界自身抛出的错误 ← 要包在更外层 为什么:错误边界本质是 React 在调和阶段的机制, 事件处理函数是原生事件回调,不经过 React 的调和, 所以它根本没有机会介入。
面试怎么答错误边界只能捕获渲染阶段、生命周期和构造函数里的错误,事件处理函数、setTimeout、Promise 里的错误它都接不住,因为那些是原生回调不经过 React 调和阶段,要用 try/catch。服务端渲染的错误也要单独兜底。它必须是 class 组件,因为依赖 componentDidCatch 这两个 class 独有的生命周期。实际项目至少要放三层:根节点、路由级、关键区块。Portals 解决的是 DOM 挂载位置和 React 树解耦,注意它的事件仍按 React 树冒泡而不是 DOM 树。
20

RSC:服务端组件不是 SSR★

这两个概念被混得最厉害
SSR 是"服务端先渲染出 HTML,客户端再 hydrate",组件代码仍然会打包进 bundle。RSC 是"组件在服务端执行,客户端只收到序列化的结果",这些组件完全不进 bundle。所以 SSR 优化首屏,RSC 优化的是包体积和数据请求。
一句话对比SSR 组件在服务端跑一次 → 输出 HTML 同一个组件在客户端还要跑一次(hydrate) → 组件代码必须在 client bundle 里 → 优化的是"首屏可见时间" RSC 组件只在服务端跑 → 输出一种特殊的序列化数据 客户端拿到的不是组件代码,是渲染结果 → 组件代码不进 client bundle → 优化的是"bundle 体积" + "能不能直接读数据库" 可以同时存在: 一个 RSC 页面里,'use client' 的子组件仍然走 SSR + hydrate
面试怎么答SSR 是服务端先渲染 HTML、客户端再 hydrate,组件代码仍然会打包进 bundle,优化的是首屏可见时间;RSC 是组件只在服务端执行,客户端收到的是序列化结果,组件代码完全不进 bundle,而且能直接读数据库,优化的是包体积和取数链路。两者不冲突,一个 RSC 页面里标了 use client 的子组件照样走 SSR 和 hydrate。要注意 use client 是边界,写在根节点等于放弃 RSC 的收益,而且服务端组件不能用 Hook、props 必须可序列化。
21

速答清单

last 30 minutes before the interview

这些是高频的"一句话问答",每条都对应上面某一节。考前扫一遍,卡住的回去看那一节。

Q类组件的生命周期完整顺序是什么?
挂载:constructor → getDerivedStateFromProps → render → componentDidMount。更新:getDerivedStateFromProps → shouldComponentUpdate → render → getSnapshotBeforeUpdate → componentDidUpdate。卸载:componentWillUnmount。子组件抛错时走 getDerivedStateFromError → componentDidCatch。
Q为什么废弃 componentWillMount、WillReceiveProps、WillUpdate?
这三个都在 render 之前被调用,而 Fiber 的 render 阶段可以被打断、被丢弃重来。一旦这次渲染被高优先级任务打断,同一个钩子会被执行多次,里面写的请求、订阅、setState 就会重复。16.3 起标 UNSAFE_ 前缀,17 起移除,替代品分别是 constructor、getDerivedStateFromProps、getSnapshotBeforeUpdate。
QuseEffect 能完全替代生命周期吗?差别在哪?
逻辑上能覆盖,时机上有两处关键差别:一是 useEffect 在浏览器绘制之后异步执行,componentDidMount 在绘制之前同步执行,所以需要"读布局再改样式"时必须用 useLayoutEffect;二是清理函数不只在卸载时跑,依赖变化时也会先跑一次,这是比 componentWillUnmount 更严格的地方。
QFiber 凭什么能"打断自己"?
因为把递归换成了链表。旧的 Stack Reconciler 用函数递归遍历组件树,调用栈在 JS 引擎里,React 既不能暂停也不能恢复。Fiber 给每个节点加了 return / child / sibling 三个指针,遍历变成 while 循环,随时可以跳出、记住当前节点、下次接着走,这就是时间切片的物理基础。
Qrender 阶段和 commit 阶段分别做什么?为什么 commit 不能中断?
render 阶段跑 beginWork(向下,算子节点、和旧 child 做 diff)和 completeWork(向上,创建 DOM 节点、汇总子树副作用标记),可以中断、可以丢弃。commit 阶段分 before mutation / mutation / layout 三段,真正改 DOM 并执行 didMount、useLayoutEffect。它必须同步完成,因为 DOM 改到一半停下来,用户看到的就是残缺界面;而且此时浏览器已经拿不到这棵树的中间状态来恢复。
Qcurrent 树和 workInProgress 树是什么关系?
双缓冲。current 是屏幕上正在显示的那棵 Fiber 树,workInProgress 是内存里正在构建的那棵,两者节点通过 alternate 互相指向。render 阶段在 workInProgress 上算,算完 commit 之后把 root.current 指过去,一次指针切换就完成替换,旧树留着当下一次的草稿。这样做的目的是让界面永远只看到完整的树,不会看到算了一半的状态。
QsetState 是同步还是异步?
都不是。setState 本身是同步调用,但它不直接改 state,只是把更新入队并调度一次渲染。所以调用后立刻读 state 拿到的是旧快照。渲染的时机由调度器决定,React 18 里全场景自动批处理。
Q为什么说 state 不可变?直接 push 数组会怎样?
React 靠引用相等判断有没有变化。arr.push() 改的是原数组,引用没变,diff 认为没变化,界面不更新。要改成 [...arr, newItem] 生成新数组。所以"不可变"不是道德要求,是这套比较机制的前提。
QuseEffect 和 useLayoutEffect 怎么选?
默认用 useEffect。只有当你要在用户看到之前"读布局并据此改样式"时(测量尺寸、避免闪烁)才用 useLayoutEffect,因为它会阻塞绘制。服务端渲染环境下 useLayoutEffect 会报警告。
QuseMemo 和 useCallback 哪个性能更好?
同一个东西,没有优劣。useCallback(fn, deps) 就等价于 useMemo(() => fn, deps)。区别只是 API 顺手程度:缓存函数用 useCallback 少写一层箭头。
Qkey 为什么不能用 index?什么时候可以?
index 把"身份"绑在"位置"上,插入删除排序后节点会被错误复用,导致非受控输入、内部 state、动画错位。如果列表永远只追加、不删不改不排序,且项内没有状态,用 index 不会出问题——但多数场景不满足这个前提。
QReact.memo 和 shouldComponentUpdate 有什么关系?
memo 是函数组件版的"要不要重渲染"开关,等价于函数组件时代的 PureComponent(都对 props 做浅比较)。类组件里对应的是 shouldComponentUpdate / PureComponent。
QHOC、Render Props、Hooks 的演进关系?
都在解决"逻辑复用"。HOC 是包一层组件,容易产生嵌套地狱和 props 命名冲突;Render Props 是用函数作为 children 传出能力,嵌套后同样难读;Hooks 让逻辑复用时不再改变组件树的形状,也能更自然地组合,所以成了主流。但 HOC 在需要"增强组件本身"的场景(权限、埋点)依然有用。
Q合成事件是什么?为什么要它?
React 自己实现了一套事件系统,用事件委托把监听挂在根容器上(React 17 起从 document 移到 root),派发时包装成 SyntheticEvent。好处是跨浏览器行为一致、减少监听数量、事件对象可以池化复用。注意 React 17 之后移除了事件池,异步里读 e 不再需要 e.persist()。
QStrictMode 为什么把 effect 跑两遍?
只在开发环境,故意 mount → unmount → mount,用来暴露"没写清理函数"的问题。如果你的订阅、定时器、请求没被正确清理,第二次挂载时就会看到重复数据或者报错。生产环境不会这样。
Q一个组件里能写多个 useState 吗?会不会互相影响?
可以,互不影响。每个 useState 在 Fiber 上占一个独立的槽位,按调用顺序排列。这也是为什么 Hook 不能写在条件语句里——顺序一变,槽位就全错位了。