React 面试知识整理
本文按知识依赖关系重新组织,覆盖用法、渲染机制与 Fiber 源码层追问三个层次;每个话题尽量先说清楚"要解决什么问题",再讲机制和代码。文中会区分 React 的通用机制(如 Fiber、协调、批处理)与某个版本或库的具体 API(如 React Router v5 的
Switch与 v6 的Routes),避免把两者混为一谈。用得少、容易生疏的 API(flushSync、useDeferredValue、useSyncExternalStore、useImperativeHandle这类)都配了可以直接照着跑一遍的最小示例,不只是概念描述。
复习建议
- 先掌握五星模块的"面试回答"和代码边界,再复习四星模块。
- 回答原理题时按"结论 → 为什么 → 一个工程影响"组织,不要只背 API 名字。
- 区分 React 的通用机制与某个版本/库的 API:例如 Fiber 是机制,
useHistory是 React Router v5 的 API,useNavigate是 v6 的 API。
一、React 是什么:声明式与组件化
核心思想
React 是用声明式组件描述 UI 的库:开发者根据当前 props、state 和 context 声明"界面应该长什么样",React 负责在状态变化后重新计算、协调并提交必要的 DOM 变更。组件不是 DOM 的简单封装,而是把 UI 结构、事件处理和状态组合成的可复用单元。
面试回答:
React 的核心是声明式和组件化。我们不手动逐步修改 DOM,而是让 UI 成为状态的函数:状态变化后 React 重新计算组件树,协调新旧结果的差异,再把必要的改动一次性提交到 DOM。
JSX、React Element 与组件
JSX 是 JavaScript 的语法扩展,构建时会被编译成创建 React Element 的函数调用;React Element 是对 UI 的一份不可变描述,既不是真实 DOM,也不是组件实例。
const element = <Greeting name="Ada" />;
// 概念上相当于 React.createElement(Greeting, { name: 'Ada' })
- 小写标签(如
div)描述的是宿主 DOM 节点;大写开头的标识符(如Greeting)表示自定义组件。 - 函数组件是普通函数,React 调用它得到一个 Element;类组件则会先创建实例,再调用实例上的
render方法。 key、ref是 React 保留给协调和引用机制使用的特殊字段,组件内部不能像访问普通 props 一样读取它们。
二、props、state 与单向数据流
数据如何流动
props 由父组件传入,组件内部应该把它当作只读数据;state 由组件自身(或组件使用的状态逻辑)维护。数据默认自上而下流动:子组件如果需要影响父组件的状态,正确做法是调用父组件通过 props 传下来的回调函数,这个模式叫"状态提升"。
function PricePicker({ onChange }) {
return <button onClick={() => onChange(100)}>选择商品</button>;
}
function Cart() {
const [price, setPrice] = useState(0);
return <PricePicker onChange={setPrice} />;
}
受控组件与非受控组件
| 类型 | 值的来源 | 常见写法 | 适用场景 |
|---|---|---|---|
| 受控组件 | React state | value + onChange | 表单校验、字段联动、需要唯一数据源 |
| 非受控组件 | DOM 本身 | defaultValue + ref | 简单表单、接入非 React 控件、文件输入 |
受控输入框必须在 onChange 中同步更新对应的 state;只给 value 而不更新 state,会导致这个输入框完全无法编辑——因为每次按键后 React 重新渲染时又把值设回了旧的 value。
事件系统与 this 绑定
React 事件采用 camelCase 命名(如 onClick),并接收一个合成事件对象;需要访问原生事件时可以通过 event.nativeEvent。事件默认沿着 React 组件树冒泡,必要时用 event.stopPropagation() 阻止继续传播。
event 参数不是浏览器原生的 Event 对象,而是 React 包了一层的 SyntheticEvent——这层包装解决的是跨浏览器事件对象字段/行为不一致的问题(不同浏览器对同一个事件暴露的属性、方法有细微差异),SyntheticEvent 对外提供一套统一的接口屏蔽这些差异,同时把所有事件统一委托到应用挂载的根节点,而不是每个元素各自绑定原生监听器,减少内存开销。
一个容易在老资料里踩到的历史坑:React 16 及更早版本会复用(池化)SyntheticEvent 对象——一个事件处理完之后,这个对象的字段会被立即清空以便复用给下一个事件,所以如果要在异步回调(比如 setTimeout)里访问事件对象的字段,必须先调用 event.persist() 阻止对象被回收,否则读到的全是 null:
// React 16 及更早版本
function handleClick(event) {
event.persist(); // 不调用的话,setTimeout 里 event.target 会是 null
setTimeout(() => {
console.log(event.target);
}, 1000);
}
React 17 起彻底移除了事件池化,SyntheticEvent 不再被复用,event.persist() 变成了一个空操作(为了兼容旧代码保留但不再做任何事)。这是一个纯粹的历史遗留问题,现代代码完全不需要关心它,但如果面试官拿一段老代码问"这里为什么要调用 event.persist()",能讲出这段版本变迁背后的原因会比较加分。
类组件的普通方法默认不会自动绑定组件实例:
class Counter extends React.Component {
state = { count: 0 };
handleClick = () => this.setState(({ count }) => ({ count: count + 1 }));
render() { return <button onClick={this.handleClick}>{this.state.count}</button>; }
}
上面用箭头函数类字段的写法可以规避绑定问题;此外也可以在构造器里 bind,或在渲染时包一层箭头函数。现代新代码优先使用函数组件,这个问题在函数组件里不存在(函数组件没有 this 指向组件实例这回事)。
三、状态更新、批处理与函数式更新
setState / 状态 setter 为什么不是"立刻改变量"
调用状态 setter 本质上是向 React 发起一次更新请求,而不是同步修改一个普通变量。React 会把这些请求调度、批处理,并在后续的一次渲染中提供新状态,因此不能依赖"调用之后立刻同步读取到新值"。
setCount(count + 1);
setCount(count + 1); // 两次调用都基于同一个 count 快照,最终只 +1
setCount(c => c + 1);
setCount(c => c + 1); // 函数式更新依次基于前一次的最新结果,可靠地 +2
面试回答:
不能直接修改 state,也不能把状态更新理解成同步赋值。React 会把更新放入队列并批处理;当新值依赖旧值时应该用函数式更新,React 会按顺序把每一次更新应用到最新的待处理状态上,而不是都基于渲染开始时的那个旧快照。
React 18 起,自动批处理覆盖到了更多异步边界(比如 Promise.then、setTimeout、原生事件回调里的多次更新也会被合并),因此不应该再用"合成事件内异步、setTimeout 内同步"这种旧版本的经验去判断是否会被批处理;更准确的心智模型是"把每一次状态更新都当作可以被合并、可以被调度的请求"。
flushSync:跳过批处理,强制同步更新
极少数场景需要"状态更新后立刻、同步地拿到更新后的 DOM"(比如打印前必须先把最新内容渲染出来,或者需要在同一个事件循环里手动测量刚更新完的 DOM 尺寸),这时可以用 flushSync 主动跳过批处理:
import { flushSync } from 'react-dom';
function handleClick() {
flushSync(() => {
setCount(c => c + 1);
});
// flushSync 回调执行完,DOM 已经同步更新完毕,这里能读到最新的 DOM
console.log(document.getElementById('count').textContent);
}
flushSync 包裹的更新会被立即同步应用,不会和同一批次里的其他更新合并——这是一个逃生舱(escape hatch),只应该在明确需要"更新后立刻同步读取 DOM"的场景使用,滥用会让本该合并的多次更新又变回逐次同步渲染,抵消自动批处理带来的性能收益。
不要直接修改状态
直接写 this.state.message = 'x' 或 state.user.name = 'x' 会绕开 React 的更新流程——React 完全不知道发生了修改,因为它是靠 setState/setter 调用来感知"有更新需要处理"的;直接修改还会破坏引用比较(Object.is 判断不出变化),导致依赖引用比较来判断是否更新的机制(比如 memo)也失效。正确做法是创建新的引用:
setUser(user => ({ ...user, profile: { ...user.profile, name: 'Ada' } }));
四、状态保留与重置、useEffect 的时序
状态为何会保留或重置
React 把 state 关联到组件在渲染树中的位置,而不是关联到 JSX 里写出的变量名。在相同的树位置上,如果组件类型相同、key 也相同,React 通常会保留这个组件的 state;一旦类型改变、树中的位置改变,或者 key 改变,旧的 state 就会被重置。
// 切换用户时,主动改变 key 来重置 Profile 内部的表单状态
<Profile key={user.id} user={user} />
这是理解"列表 key 为什么重要""条件渲染切换后表单状态为什么没清空"背后的统一原则。也不要为了图方便在一个组件函数内部定义嵌套的子组件——父组件每次渲染都会创建一个新的组件类型(虽然看起来定义相同,但每次渲染都是一个新的函数引用),React 会认为这是一个全新的组件类型,从而意外重置这个嵌套组件内部原本应该保留的 state。
useEffect 的正确定位
useEffect 用来让组件和 React 之外的系统保持同步,比如订阅、定时器、浏览器 API 或第三方库实例。它不是"每次想在渲染后执行一段代码就往里塞"的万能位置——如果只是想根据现有的 props/state 计算出另一个值,应该直接在渲染过程中计算(或用 useMemo 缓存),而不是用 Effect 先渲染一次再异步算出来触发二次渲染。
useEffect(() => {
const connection = connect(roomId);
return () => connection.disconnect();
}, [roomId]);
- 不传依赖数组:每次组件提交后都会运行。
- 传
[]:只在挂载后运行一次;开发模式下的 Strict Mode 会额外多做一次 setup → cleanup → setup 的检验,用来暴露没有正确清理的副作用。 - 传
[a, b]:首次提交,以及之后a或b发生Object.is意义上的变化后运行。 - Effect 函数的返回值是清理逻辑:会在下一次同一个 Effect 重新执行之前运行,也会在组件卸载时运行。
常见错误是依赖数组里漏写了实际用到的响应式值(导致 Effect 内部使用的是过期闭包里的旧值),或者 cleanup 函数没有正确撤销订阅导致内存泄漏、重复触发。
render、commit 与 effect 的执行时机
一个常被搞混的点是:useEffect 里的代码究竟是在 DOM 更新"之前"还是"之后"执行?完整的顺序是:React 先执行组件函数完成计算(render 阶段)→ 把计算结果提交并写入真实 DOM(commit 阶段)→ 浏览器完成绘制 → 最后才异步执行 useEffect 回调。也就是说 useEffect 里能稳定读取到的是已经更新完成的 DOM,但它的执行本身是被异步调度的,不会阻塞浏览器绘制这一帧——这也是它和同步执行、会阻塞绘制的 useLayoutEffect 之间最核心的差异,useLayoutEffect 只应该用在"必须在绘制前读取或修改 DOM 布局"的少数场景(比如测量元素尺寸后立即定位一个悬浮层,避免用户看到闪烁)。
[!VISUALIZATION] 类型: flow 优先级: high 标题: render、commit 与 useEffect 的执行时序 目的: 展示组件函数执行(render)、DOM 变更写入(commit)、浏览器绘制、useEffect 异步执行这四步的先后顺序。 必须表达: useEffect 在浏览器绘制之后异步执行,不会阻塞首次绘制;useLayoutEffect 在绘制前同步执行。 避免表达: 不要把 useEffect 画成在 commit 之前执行,或者和 componentDidMount 完全等价(时序等价,但调度机制不同)。

五、类组件生命周期与函数组件迁移
生命周期主线
类组件的生命周期可以按创建、更新、卸载三个阶段理解:
- 创建:
constructor→render→componentDidMount - 更新:
getDerivedStateFromProps→shouldComponentUpdate→render→getSnapshotBeforeUpdate→componentDidUpdate - 卸载:
componentWillUnmount
render 必须是纯函数,不能在其中调用 setState 或发起请求。componentDidMount 适合做订阅、发起请求或读取真实 DOM;componentWillUnmount 要清理定时器、订阅和其他外部资源。
容易被追问的方法
getDerivedStateFromProps是静态方法,返回一个新的局部 state 对象或null;只应该在确实需要从 props 派生 state 时使用,绝大多数场景应该避免简单地把 props 复制进 state(复制之后两者容易不同步)。shouldComponentUpdate返回布尔值决定是否继续本次更新;它是一个性能提示,不应该承载任何影响业务正确性的逻辑(比如不能指望它来阻止一次必须发生的状态同步)。getSnapshotBeforeUpdate在 DOM 实际变更之前读取信息(比如滚动位置),它的返回值会作为参数传给componentDidUpdate。componentDidUpdate适合根据更新前后的 props/state 差异执行副作用,但必须包一层条件判断,否则容易在这里再次调用setState触发无限循环更新。
componentWillMount、componentWillReceiveProps、componentWillUpdate 属于已经被标记为不安全的旧生命周期方法,现代代码不应该继续使用。新代码优先使用函数组件配合 Hooks;类组件生命周期和 Hooks 之间不是简单的一对一映射,应该按"渲染需要的计算"和"需要和外部系统同步的副作用"重新拆分理解,而不是机械地找对应关系。
为什么并发模式下 render 阶段的方法可能被多次调用
这是"三个 UNSAFE_ 方法为什么不安全"背后更完整的原因,不只是"官方标记了不安全"这么简单。React 在并发渲染模式下,render 阶段的工作可以被暂停、丢弃、重新开始(见第七、八章的可中断渲染)——这意味着 constructor、render、getDerivedStateFromProps、shouldComponentUpdate 这几个属于 render 阶段的方法,在一次"逻辑上的更新"里可能会被调用不止一次:React 可能算到一半因为更高优先级的更新插进来而放弃这次计算,之后重新算一遍。
如果开发者在 componentWillReceiveProps 或 componentWillMount 里发起了网络请求、订阅了外部事件源这类有副作用的操作,一旦这个方法被重复调用,副作用就会被重复触发——多发几次请求、重复订阅。getDerivedStateFromProps 是静态方法,天然拿不到组件实例(没有 this),也就没办法在里面直接发起请求或修改除返回值以外的任何东西,从写法上直接堵死了在这里写副作用代码的可能性——这也是它被设计成静态方法、而不是简单保留 componentWillReceiveProps 的原因。同样的道理,类组件在开发模式的 StrictMode 下 render 方法也会被有意双调用一次,用来提前暴露"这里是不是偷偷写了副作用"这类问题,和 Hooks 组件里 StrictMode 会把整个函数组件体多跑一次是同一个思路。
getSnapshotBeforeUpdate 的真实用途:保持聊天列表滚动位置
一个具体、真实会用到的场景:聊天类应用里新消息到达时,如果用户正停留在历史消息区域往上翻,直接把新消息插入列表会导致用户当前正在看的内容"跳走"。做法是在 DOM 真正变更之前先记录当前的滚动位置,变更完成后再按记录的位置做补偿:
class MessageList extends React.Component {
listRef = React.createRef();
getSnapshotBeforeUpdate(prevProps) {
// DOM 变更之前调用,此时还能读到"旧" DOM 的滚动高度
if (prevProps.messages.length < this.props.messages.length) {
const list = this.listRef.current;
return list.scrollHeight - list.scrollTop; // 记录"离底部还有多远"
}
return null;
}
componentDidUpdate(prevProps, prevState, snapshot) {
// snapshot 就是 getSnapshotBeforeUpdate 的返回值
if (snapshot !== null) {
const list = this.listRef.current;
list.scrollTop = list.scrollHeight - snapshot; // 按记录的距离补偿滚动位置
}
}
render() {
return <div ref={this.listRef}>{/* 消息列表 */}</div>;
}
}
[!VISUALIZATION] 类型: lifecycle 优先级: high 标题: 类组件创建、更新与卸载生命周期 目的: 区分 render 前后、DOM 提交前后以及卸载时的职责。 必须表达:
getSnapshotBeforeUpdate在 DOM 更新前,componentDidUpdate在更新提交后;render保持纯净。 避免表达: 不要把旧的componentWill*方法画成现代推荐流程。

六、Context、Ref、Portal、高阶组件与错误边界
Context:跨层传递,不等于全局状态方案
Context 适合主题、当前用户、国际化这类"许多层级都可能需要、变化频率相对可控"的数据。由 Provider 提供值,子树用 useContext 读取:
const ThemeContext = createContext('light');
function Button() {
const theme = useContext(ThemeContext);
return <button className={theme}>保存</button>;
}
Provider 的 value 发生变化时,所有使用了这个 Context 的消费者组件都会重新渲染——这意味着如果 value 是一个高频变化的巨大对象,会导致大范围不必要的重渲染,此时应该拆分成多个粒度更小的 Context,或者改用更适合频繁更新场景的状态管理方案,而不是继续往一个 Context 里塞所有东西。
Context 值的传播不是靠"一层层 diff props 传下去"实现的——中间没有读取这个 Context 的组件,并不会仅仅因为 Context 变化而被迫重新渲染。React 在 Fiber 树上维护了一份 Context 值的传播机制:Provider 的值变化后,React 会从 Provider 开始向下查找依赖了这个 Context 的消费者节点,直接让它们重新渲染,不需要真的执行中间每一层组件的 props diff。理解这一点能纠正一个常见误解——"用 Context 会不会导致中间组件也要跟着重渲染一遍",答案是不会,Context 的传播是绕过中间层直接触达消费者的。
ref 的正确边界
ref 用于命令式地访问 DOM 节点,或者保存"不需要触发渲染"的可变值,比如聚焦一个输入框、保存一个定时器 ID、集成地图或播放器这类第三方实例。不要用 ref 替代本该由 state 管理的、会影响渲染结果的数据——修改 ref.current 不会触发组件重新渲染。
function Search() {
const inputRef = useRef(null);
return <button onClick={() => inputRef.current?.focus()}>聚焦</button>;
}
类组件用 createRef,函数组件用 useRef。跨组件传递 ref 需要目标组件显式支持(函数组件默认不能直接接 ref,需要 forwardRef);不要依赖已经被废弃的字符串 ref 写法。
useImperativeHandle:自定义 ref 暴露的内容
默认情况下,forwardRef 转发出去的 ref 直接指向底层 DOM 节点或子组件实例,父组件能拿到子组件内部的一切——这有时候暴露得太多。useImperativeHandle 可以让子组件自己决定"父组件通过 ref 只能调用这几个方法",而不是把整个 DOM 节点原样交出去:
const VideoPlayer = forwardRef((props, ref) => {
const videoRef = useRef(null);
useImperativeHandle(ref, () => ({
// 只暴露这两个方法,父组件拿到的 ref 上没有其他 DOM API
play: () => videoRef.current.play(),
pause: () => videoRef.current.pause(),
}));
return <video ref={videoRef} src={props.src} />;
});
function App() {
const playerRef = useRef(null);
return (
<>
<VideoPlayer ref={playerRef} src="a.mp4" />
<button onClick={() => playerRef.current.play()}>播放</button>
</>
);
}
这个 Hook 用得不多,但一旦被问到"怎么限制父组件通过 ref 能操作子组件的哪些部分",useImperativeHandle 就是标准答案。
Portal:DOM 位置变了,React 树关系不变
Portal 用于把弹窗、浮层等内容渲染到 document.body 这类独立的 DOM 容器里,避免被父元素的 overflow 裁剪或层叠上下文限制:
import { createPortal } from 'react-dom';
function Modal({ children }) {
return createPortal(<div className="modal">{children}</div>, document.body);
}
它只改变最终渲染出的 DOM 挂载位置,不改变组件在 React 树中的父子关系:Context 依然能沿着原本的 React 树读取到,事件依然按照 React 树(而不是实际 DOM 树)的层级冒泡。面试中要把 Portal 和"另外开辟一个独立的 React 根节点"这两个概念区分清楚,它们的行为不同。
高阶组件(HOC)
HOC 是一种"接收一个组件、返回一个增强后的组件"的模式,常见于权限校验、日志记录、数据注入等横切逻辑。
const withLogging = Wrapped => function WithLogging(props) {
console.log('render', Wrapped.displayName);
return <Wrapped {...props} />;
};
它不是继承关系,也不应该在 render 函数内部动态创建 HOC——如果每次渲染都调用一次 withLogging(Wrapped),会得到一个全新的组件类型(和第四章"状态保留/重置"的原理一致),导致子树在每次父组件渲染时都被卸载重建。使用 HOC 时要注意透传无关的 props;ref 不是普通 prop,不会自动透传,旧式 HOC 如果需要转发 ref 要用 forwardRef 显式处理。现代业务代码里,可复用的组合逻辑更多用自定义 Hook 实现,HOC 主要还出现在一些旧代码库或第三方库的封装里。
错误边界(Error Boundary)
错误边界通过 static getDerivedStateFromError 渲染出兜底 UI,通过 componentDidCatch 记录错误信息。它能捕获子树在渲染、生命周期方法及构造函数阶段抛出的错误,但不能捕获事件处理函数里的错误、异步回调(如 setTimeout、Promise 内部)里的错误、服务端渲染时的错误,也不能捕获它自身抛出的错误。
class ErrorBoundary extends React.Component {
state = { hasError: false };
static getDerivedStateFromError() { return { hasError: true }; }
componentDidCatch(error, info) { reportError(error, info); }
render() { return this.state.hasError ? <Fallback /> : this.props.children; }
}
事件处理函数里的错误需要自己用 try/catch 处理;异步操作的错误需要在对应的 .catch() 或 try/catch 里处理并上报。
七、虚拟 DOM、协调与 Fiber 架构
虚拟 DOM 与协调(Reconciliation)
虚拟 DOM 是用普通 JavaScript 对象表达 UI 结构的一层中间表示;它让 React 能够比较前后两次的描述差异,从而只安排真正需要的 DOM 操作,但它的性能优势并不是单纯因为"操作 JS 对象比操作 DOM 快"。它真正的价值在于提供了声明式编程模型、支持批处理,以及让更新过程变得可预测、可中断(见 Fiber 部分)。
协调阶段会比较新旧 Element/Fiber 树,决定复用、创建、删除或移动哪些节点;提交阶段才会把这些决定真正写入 DOM。一个组件的 state 是否会被保留,取决于它在树中的位置、类型与 key(第四章已展开)。
Fiber 是什么
面试回答:
Fiber 是 React 16 引入的协调器重写,也是 React 内部用来表示"一个组件工作单元"的数据结构。旧的递归式协调一旦开始就会占住 JavaScript 调用栈,直到递归完成才能返回;Fiber 把整棵树的更新拆成一个个可以独立处理的节点任务,React 因此可以给不同更新分配优先级,在合适的节点上让出主线程。优先级较低的渲染工作可能会被更高优先级的更新打断、重启,甚至丢弃;只有真正完成之后才会一次性提交到 DOM,所以用户不会看到半更新状态的界面。
为什么旧的 Stack Reconciler 不够用
React 15 的协调过程主要依赖 JavaScript 的函数调用栈:从根节点开始递归处理更新,一旦开始就必须一路执行到返回,中途无法插入其他工作。大型组件树或者计算量较大的渲染很容易占满浏览器的一帧时间,用户的输入响应、动画、绘制就会被延后,表现为页面卡顿。
而 UI 更新本身并不是同等紧急的:输入框的即时回显、点击的即时反馈通常应该优先于列表筛选结果、后台数据刷新这类可以稍等的更新。要实现"先完成重要的工作,其余工作稍后继续",React 需要一种方式把原本隐式保存在函数调用栈里的执行上下文显式地保存下来,可以暂停、可以恢复——Fiber 正是这样一份"可保存、可恢复的工作上下文"。
Fiber、React Element、组件实例与 DOM 的区别
| 对象 | 是什么 | 何时创建 / 是否可变 | 关键作用 |
|---|---|---|---|
| React Element | JSX / createElement 产出的轻量描述 | 每次渲染都可能新建;不可变 | 描述"这次想要什么样的 UI" |
| Fiber | React 内部的工作节点 | 协调过程中创建或复用 | 记录该节点的状态、待处理更新、优先级和树结构关系 |
| 类组件实例 | class extends Component 的实例 | 类组件挂载时创建 | 保存类实例的字段和方法;函数组件没有这个实例 |
| DOM 节点 | 浏览器中真实存在的宿主节点 | 提交阶段创建或更新 | 最终呈现给用户的真实页面 |
Fiber 不是虚拟 DOM 的同义词,也不是 DOM 本身。一个可更新的组件在内部通常会同时存在 current 和 workInProgress 两个互相指向的 Fiber 副本(见下文"双缓冲"),但这不代表对应有两份 DOM——DOM 只有一份,是最终提交后的结果。
一个 Fiber 节点保存什么
具体字段会随 React 版本调整,面试不需要背下源码的逐字段类型;但可以把一个 Fiber 理解为下面这个"面试理解版"的简化模型:
/**
* 用于理解 Fiber 的简化模型,不是 React 对外 API,也不保证和任一源码版本逐字段一致。
*/
type FiberNodeForInterview = {
// ===== 1. 身份:我代表哪个 React 节点 =====
/** 节点类别:函数组件、类组件、原生 DOM 标签、文本、Fragment 等,决定处理方式。 */
tag: number;
/** 列表中的稳定身份;同一层级中与 type 一起决定是否复用旧 Fiber 和 state。 */
key: string | null;
/** JSX 中的类型:例如函数组件本身、class,或 'div' 这样的宿主标签。 */
type: unknown;
/**
* 运行环境对应的"实际对象":类组件通常是实例,DOM Fiber 通常是 DOM 节点。
* 函数组件没有组件实例,因此这里未必是 DOM。
*/
stateNode: unknown;
// ===== 2. 结构:如何不用 JavaScript 调用栈也能遍历整棵树 =====
/** 父 Fiber;字段名叫 return,表示当前节点完成后要回到哪里。 */
return: FiberNodeForInterview | null;
/** 第一个子 Fiber。 */
child: FiberNodeForInterview | null;
/** 下一个兄弟 Fiber;多个子节点由 child + sibling 串成链。 */
sibling: FiberNodeForInterview | null;
/** 当前节点在同级兄弟中的位置,协调列表时会用到。 */
index: number;
/** 本次 render 要附着 / 更新的 ref。 */
ref: unknown;
// ===== 3. 输入、状态和更新:本次工作与上次提交结果的对照 =====
/** 本次 render 正准备处理的 props。 */
pendingProps: unknown;
/** 上一次已经完成并提交的 props,可用于判断是否需要继续更新。 */
memoizedProps: unknown;
/** 上一次已提交的 state:类组件对应 state;函数组件中对应 Hook 链表的入口(见第十章)。 */
memoizedState: unknown;
/** setState、Hook state 更新、回调等排队等待处理的更新信息。 */
updateQueue: unknown;
// ===== 4. 优先级和提交:这次工作何时做、完成后要做什么 =====
/** 当前节点自身待处理更新所在的 lane 位掩码;不同 lane 代表不同优先级/工作集合。 */
lanes: number;
/** 子树中仍待处理的 lane,帮助 React 快速跳过没有工作的分支。 */
childLanes: number;
/** 当前节点在 commit 阶段需要执行的操作,如插入、更新、删除、ref 等。 */
flags: number;
/** 子树中的 flags 汇总,避免 commit 阶段重新全树扫描。 */
subtreeFlags: number;
// ===== 5. 双缓冲:保持屏幕上的 current 树始终完整 =====
/**
* 当前 Fiber 与 work-in-progress Fiber 的配对指针。
* React 在 alternate 上计算下一版 UI;完成后再提交并切换 current。
*/
alternate: FiberNodeForInterview | null;
};
读这个类型时可以记住一句话:Fiber 同时是树节点、可暂停的工作单元,以及保存这次更新中间状态的记录。 早期资料中出现的 effectTag、nextEffect、expirationTime 是较早版本源码使用的字段名,现代实现用 flags/subtreeFlags 和 lanes 表达相近的职责——理解职责本身比记住具体字段名更可靠,字段名会随版本演进变化。
Fiber 树为什么使用 child / sibling / return
这三个指针的作用不是把"DOM diff 树变成链表",而是让协调过程可以脱离 JavaScript 调用栈、手动遍历整棵树:
App
└─ child → Header ─ sibling → Main ─ sibling → Footer
↑ return ↑ return ↑ return
App App App
React 处理完一个节点后,可以直接根据这些指针知道下一个要处理的是子节点还是兄弟节点;如果都没有了,就沿着 return 回到父节点完成收尾工作。因为"下一步该做什么"和"当前的中间状态"都被显式保存在 Fiber 结构里,而不只是隐式存在于 JavaScript 调用栈中,调度器才能够在任意一个节点的边界上暂停,之后再从这个位置继续处理剩余工作。
八、调度、render 与 commit 工作流程
完整流程
setState、状态 setter、Context 变化等会产生一次更新;React 为这次更新标记所在的 lane(优先级通道)。- 调度器从待处理的多个 lane 中选出当前最重要的一个,从根节点开始构造或复用一棵 work-in-progress Fiber 树。
- render / reconciliation 阶段:逐个执行 Fiber 的 begin work 与 complete work,比较新旧子节点,标记出需要执行的副作用;这一阶段在并发渲染模式下可以被让出、之后继续、重试,甚至因为出现了更高优先级的更新而被直接放弃重来。
- commit 阶段:把已经完成的树对应的变更一次性写入真实 DOM(或原生视图),处理
ref,并运行对应的生命周期方法。commit 阶段必须保持原子性、不能被中断,否则用户可能看到只完成了一半的 UI。 - 提交成功后,work-in-progress 树成为新的
current树;随后才会异步运行被动 Effect(即useEffect,见第四章)。
"可中断"这个特性只描述 render 阶段,不代表任意 JavaScript 代码或 DOM 提交操作都能被中断,commit 阶段是同步且不可打断的。也不是每一次被打断的低优先级任务之后都能从精确断点继续执行——当新的更新使得之前正在进行的计算已经过期时,React 可能会直接重新渲染,最终以能够被完整提交的结果为准,而不追求复用每一点已经算过的中间结果。
子节点 Diff 算法具体怎么比较
第 3 步"比较新旧子节点"具体展开,分单节点和多节点两种情况。
单节点 diff:新的 render 只产出一个子节点时,判断能不能复用旧节点,依据是 key 和 type 是否都相同——两者都相同才复用旧 Fiber(更新它的 props),否则删除旧节点、创建一个全新的 Fiber(对应的组件 state 也会丢失,这就是第四章讲过的"类型变了 state 就会重置"的底层依据)。
多节点 diff(列表):React 用一个两轮遍历的策略处理,尽量减少节点的移动次数:
- 第一轮:从头开始逐个比较。同一位置上新旧节点的
key相同就复用、继续往后比;一旦遇到某个位置key对不上,第一轮立即结束(不会跳着继续比较)。 - 第二轮:处理剩下的部分。把剩余的旧子节点按
key存进一个 Map;遍历剩余的新子节点,去 Map 里找有没有key相同的旧节点——找到了就复用,并根据它在旧列表里的位置判断"要不要移动"(用一个"目前已处理节点在旧列表中的最大下标"作参照,如果这个节点在旧列表里的位置比这个参照小,说明它相对顺序被打乱了,需要移动;否则不用动);Map 里找不到就创建新节点;第二轮结束后 Map 里还剩下的旧节点,说明新列表里已经不需要它们了,标记删除。
面试回答:
React 的子节点 diff 分单节点和多节点两种情况。单节点看
key和type是否都相同来决定复用还是重建。多节点走两轮遍历:第一轮按位置顺序比较,遇到key对不上就提前结束;剩下的部分用key建一个 map,第二轮遍历新节点去 map 里找可复用的旧节点,根据它在旧列表里的位置判断需不需要移动,遍历完 map 里剩下的旧节点就是要删除的。这也是为什么列表 key 用 index 在增删排序场景下会出问题——index 本身就是"位置",用它当 key 会让 React 没法正确判断某个节点到底是被移动了还是被替换了。
优先级、Lanes 与 requestIdleCallback 的常见误解
早期资料常说"每个 Fiber 有一个优先级",更准确的现代模型是:更新本身和根节点待处理的工作用 lanes(一种位掩码通道)来表示优先级和可以合并的工作集合,Fiber 及其子树会记录相关联的 lane,以便快速跳过没有对应工作的分支。高优先级的交互更新可以抢占正在进行中的低优先级渲染;被 startTransition 标记的更新通常就属于这类可以被延后处理的工作(见下一章)。
requestIdleCallback 体现的是"利用浏览器空闲时间做低优先级工作"这个早期思路,但 React 并没有把它当作 Fiber 调度机制的必需实现,也不建议业务代码依据它来推断 React 的具体行为——React 使用自己实现的 Scheduler 和一套协作式让出机制(核心是 shouldYield 判断是否应该把主线程让出去),React 的 Scheduler 源码实际上是通过 MessageChannel 来实现调度的。面试中可以说"类似 requestIdleCallback 的可让出调度思想",但说"Fiber 内部调用了 requestIdleCallback 来实现"是不准确的。
双缓冲:current 与 workInProgress
屏幕上正在展示的那棵 Fiber 树叫 current;React 计算下一版 UI 时,是在与之配对的 workInProgress 树上进行的,两者通过 alternate 指针相互关联。这样设计的好处是:即使 render 阶段被中断,正在展示给用户的 current 树依然完整、可交互,不会露出中间状态;等 workInProgress 树完成全部计算后,React 只需要在 commit 阶段把它切换为新的 current,一次性应用所有变更。这正是"允许渲染过程被中断,却不会让用户看到中间态 UI"的关键实现手段。
Fiber 架构在 React 16 就已经作为内部实现落地,但早期版本出于兼容性考虑并没有开放完整的并发能力——可中断渲染、Transition 这类面向应用开发者的并发特性,是在后续版本(尤其是启用了并发根 createRoot 的 React 18)中才成为日常开发会实际用到的能力。Fiber 本身不是一个需要业务代码直接调用的公开 API。
[!VISUALIZATION] 类型: architecture 优先级: high 标题: React 更新:调度、协调与提交 目的: 展示状态更新如何进入调度,协调阶段可分片,提交阶段统一写入 DOM。 必须表达: render/协调可被中断或重试,commit 不可中断;Fiber 节点具有 child、sibling、return 关系。 避免表达: 不要把 Fiber 描述成真实 DOM,或声称它必然使用
requestIdleCallback。

九、并发渲染与 Transition
并发渲染不是"同时运行多个 JavaScript 线程",而是指 React 可以把非紧急的渲染工作暂停、让出主线程、之后再重启(甚至因为有更新的更新到来而直接放弃重新开始)。输入框的即时回显、点击按钮的即时反馈这类更新应该保持同步、即时;筛选大列表、切换内容较重的页面这类可以稍等的更新则可以显式标记为 Transition。
const [isPending, startTransition] = useTransition();
function onFilterChange(value) {
setInput(value); // 紧急:立即回显输入框
startTransition(() => {
setFilter(value); // 非紧急:允许被后续更新打断
});
}
isPending用来在界面上展示"结果正在更新中"这类反馈,避免用户以为操作没有生效。- Transition 不应该用来包裹控制文本输入框实际显示值的那次状态更新,否则用户会感觉输入有明显的滞后延迟。
- 它优化的是交互的响应流畅度,不能替代数据请求的缓存策略,也不能替代虚拟列表这类从根本上减少渲染节点数量的手段。
[!VISUALIZATION] 类型: timeline 优先级: medium 标题: 紧急更新与 Transition 的调度优先级 目的: 展示输入回显可先完成,而大列表的非紧急渲染可暂停并在新输入后重启。 必须表达: 这是可中断的渲染调度,不是多线程并行执行。 避免表达: 不要把 Transition 画成延迟
setTimeout,或用于控制文本输入。

useDeferredValue:延迟某个值本身,而不是包裹一次状态更新
useTransition 包裹的是"触发更新的动作",而 useDeferredValue 包裹的是"某个值",让这个值的更新可以滞后于最新输入,用在你不能直接控制状态更新触发点(比如这个值是从 props 传进来的,不是本地 state)的场景:
function SearchResults({ query }) {
const deferredQuery = useDeferredValue(query); // 允许这个值滞后于最新的 query
const results = useMemo(() => searchExpensive(deferredQuery), [deferredQuery]);
return (
<div style={{ opacity: query !== deferredQuery ? 0.6 : 1 }}>
{results.map(r => <Result key={r.id} data={r} />)}
</div>
);
}
query 变化后,SearchResults 会先用旧的 deferredQuery 快速渲染一次(界面不卡顿),React 随后在后台用新值重新渲染一次并替换——这段"新旧值不一致"的期间可以用来展示一个视觉上变淡的过渡效果。和 useTransition 的选择依据是:能直接包裹触发更新的那次调用(比如一个事件处理函数里的 setState),用 useTransition;只能拿到一个值、不能控制它是怎么被更新的(比如从父组件 props 拿到的搜索词),用 useDeferredValue。两者解决的是同一类"让非紧急更新不阻塞紧急更新"的问题,只是介入的层面不同。
key:身份,不是为了消除控制台警告
在同一层级的同类节点之间,key 用于标识一个节点的稳定身份,React 依据它决定复用哪一个组件实例以及它对应的 state。
items.map(item => <Todo key={item.id} item={item} />)
优先使用列表项自身稳定的业务 ID。完全静态、不会重新排序的列表使用 index 作为 key 影响不大;但只要列表会插入、删除、排序,或者列表项内部包含表单这类需要保留输入状态的内容,就不应该使用 index(原因和 Vue 列表 diff 里的问题完全一致:位置对应关系变了,但 key 没变,会导致状态错误地留在了错误的节点上)。也不要在每次渲染时都生成一个随机 key——这会让 React 认为每次都是全新的节点,强制重建并丢失所有内部状态,等于完全抵消了 key 应该带来的复用能力。
十、Hooks:规则、闭包与实现原理
Hooks 的调用规则和 useState 的基本用法
Hooks 只能在函数组件或自定义 Hook 的顶层调用,不能放进循环、条件判断或嵌套函数内部。这不是一条随意的代码风格约定,而是直接由 Hooks 的底层实现方式决定的——见下文的手写实现。
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}
useState 的 setter 会触发重新渲染;如果新值和旧值按 Object.is 判断相等,React 可以跳过这一次不必要的更新。多个状态之间存在复杂关联时,可以考虑用 useReducer(见后文)替代多个各自独立的 useState。
手写简化版 useState:为什么 Hooks 不能出现在条件或循环里
真实 React 内部用链表存储一个组件的所有 Hook 状态;下面用一个更容易理解的数组版本还原核心逻辑:
let currentFiber = null; // 当前正在渲染的 Fiber
let hookIndex = 0; // 本次渲染中,第几次调用 Hook
function useState(initialValue) {
const fiber = currentFiber;
const hooks = fiber.memoizedState || (fiber.memoizedState = []);
const index = hookIndex++; // 关键:完全依赖调用顺序,不依赖变量名
if (hooks[index] === undefined) {
hooks[index] = { state: initialValue }; // 只有首次渲染会用到初始值
}
const hook = hooks[index];
function setState(newValue) {
hook.state = typeof newValue === 'function' ? newValue(hook.state) : newValue;
scheduleRerender(fiber); // 触发这个 Fiber 重新进入调度、重新渲染
}
return [hook.state, setState];
}
function renderComponent(fiber, Component, props) {
currentFiber = fiber;
hookIndex = 0; // 每次渲染都从第 0 个位置重新开始按顺序对应
const result = Component(props);
currentFiber = null;
return result;
}
这段代码解释了两个关键结论:
- 组件的所有 Hook 状态保存在这个组件对应的 Fiber 节点上(对照第七章的
memoizedState字段),而不是保存在某个全局变量或者 React Element 上——这也是为什么"函数组件没有实例,却能在多次渲染之间记住状态"的原因。 - Hook 之间完全靠调用顺序(也就是
hookIndex递增的顺序)来对应,而不是靠变量名或者其他标识。如果某次渲染因为一个if条件多调用或少调用了一个 Hook,那么这次渲染里hookIndex为 2 的位置,可能对应的其实是上次渲染里hookIndex为 3 的那个 Hook 的状态——状态会被错误地"串号",且通常不会报错,只会得到一个令人困惑的错误结果。这正是"Hooks 只能在顶层无条件调用"这条规则背后的真实原因。
闭包与依赖数组
每一次渲染都会产生这次渲染专属的 props/state 快照,组件内部定义的函数、Effect 回调、定时器回调都会闭包捕获住这份快照。依赖数组不是"用来随意控制 Effect 要不要执行的开关",而是在如实声明"这个 Effect 内部用到了哪些会变化的响应式值"。
useEffect(() => {
const id = setInterval(() => setCount(c => c + 1), 1000);
return () => clearInterval(id);
}, []);
上面这个例子因为用了函数式更新 c => c + 1,所以不需要在依赖数组里读取外层的 count 变量。但如果 Effect 内部读取了 roomId、serverUrl 这类会变化的值,它们就应该老老实实出现在依赖数组里——为了让 lint 检查通过而随意删掉依赖项,只会导致 Effect 内部使用的是某次渲染时捕获到的过期闭包值,而不是最新值。
useRef、useMemo、useCallback 与 memo
| 工具 | 解决的问题 | 不要误解为 |
|---|---|---|
useRef | 跨渲染保存可变值或 DOM 引用,读写不触发重新渲染 | 状态管理的替代品 |
useMemo | 缓存一次计算成本较高的结果,或者缓存一个需要保持引用稳定的对象 | 应该默认打开的性能开关 |
useCallback | 缓存函数引用,常配合已经 memo 过的子组件一起使用 | 让函数本身执行得更快 |
React.memo | 当 props 经过浅比较相等时,跳过这个函数组件的重新渲染 | 对所有组件都有性能收益 |
正确的优先级是:先保证组件本身是纯的、state 尽量下沉到真正用到它的组件、列表 key 正确、传给子组件的对象/函数引用保持稳定;只有通过 Profiler 实际发现了某个具体的性能问题,才针对性地加上记忆化处理。给 React.memo 传自定义比较函数时必须比较所有会影响这次渲染结果的 props,漏掉某个 prop 会导致组件该更新的时候没有更新,界面停留在过期状态。
useReducer 与自定义 Hook
当多个状态彼此存在关联、更新的规则比较复杂,或者希望把"如何根据一个动作计算出新状态"这段逻辑独立出来单独测试时,用 useReducer 通常会比维护一堆各自独立的 setter 更清晰。这里的 reducer 和 Redux 里的 reducer 概念一致:接收旧状态和一个 action,返回新状态,必须保持纯函数。
function reducer(state, action) {
if (action.type === 'submitted') return { ...state, status: 'success' };
return state;
}
const [state, dispatch] = useReducer(reducer, { status: 'idle' });
自定义 Hook 复用的是有状态的逻辑,而不是 UI 本身:比如 useOnlineStatus()、useDebouncedValue()。每一个调用某个自定义 Hook 的组件都会拥有自己独立的一份状态——多个组件共享的是这段逻辑的写法,而不是共享同一份状态实例(这一点和 Vue 的 composable 概念完全一致)。自定义 Hook 的命名必须以 use 开头(这样 lint 工具和 React 才能识别并检查它是否遵守了顶层调用规则),并且同样要遵守"只能在顶层无条件调用"这条规则。
useSyncExternalStore:订阅 React 外部的状态源
如果一个状态压根不是由 React 管理的(比如浏览器的 window.innerWidth、一个第三方状态管理库自己维护的 store),组件想要正确地订阅它、并且在并发渲染下不出现撕裂(同一次渲染里读到不一致的值),标准做法是 useSyncExternalStore:
function useWindowWidth() {
return useSyncExternalStore(
(callback) => {
window.addEventListener('resize', callback); // 订阅:外部状态变化时调用 callback 通知 React 重新读取
return () => window.removeEventListener('resize', callback); // 返回取消订阅函数
},
() => window.innerWidth, // 每次读取当前快照值
() => 0, // 服务端渲染时的快照值(可选)
);
}
这个 Hook 更常见的存在感是在状态管理库的底层实现里,而不是业务代码直接调用——Zustand、Jotai 这类现代轻量状态管理库,内部用它把"外部 store 的变化"接入 React 的渲染调度,保证并发模式下不会读到撕裂的状态(这也是它们不需要像老版本 Redux 那样自己手写一套订阅+强制更新逻辑的原因)。理解了这个 Hook,再看第十三章 Zustand 的实现思路会更清楚——它本质上就是一个符合 useSyncExternalStore 接口的极简 store。
十一、渲染性能、代码分割与动画
React 的重渲染不等于 DOM 重建
父组件更新时,子组件通常也会再次执行自己的渲染函数;但这只是"组件函数被重新调用",随后 React 才会协调比较新旧输出的差异,如果最终产出的结果没有变化,真实 DOM 可能完全不会发生任何改动。因此排查性能问题时,要把"组件函数执行"和"DOM 实际提交变更"这两件事分开看待,不能只看组件函数被调用的次数就断定一定有性能问题。
常见的优化路径(按优先级排列):
- 让 state 尽量靠近真正使用它的组件,避免无关的父组件更新连带影响到不相关的子组件。
- 避免在渲染过程中不必要地创建新的对象/数组/函数引用,合理使用不可变更新,减少"引用变了但内容其实没变"的情况。
- 对已确认渲染成本高、且 props 大多数时候保持稳定的子组件使用
memo/PureComponent。 - 用 React DevTools Profiler 实际测量验证优化效果,而不能仅凭"行内箭头函数一定慢"这类经验之谈就下结论——很多时候这类微小开销远不是真正的瓶颈。
懒加载与 Suspense
lazy 配合 Suspense 可以按需加载组件代码,适合按路由边界或大功能模块边界来切分代码包。
const SettingsPage = lazy(() => import('./SettingsPage'));
<Suspense fallback={<Spinner />}>
<SettingsPage />
</Suspense>
被懒加载的模块必须有默认导出;加载失败的情况还需要配合错误边界提供一个可以重试或者友好提示的恢复体验。不需要把所有细小的组件都拆成单独的包——额外产生的网络请求和加载态之间的抖动切换本身也是一种成本,应该按"用户是否马上就会用到"这个标准来决定拆分粒度。
长列表:虚拟滚动
数据量很大的列表(几千条以上)如果把所有条目一次性渲染成真实 DOM,会有明显的首次渲染卡顿和内存占用问题——即使配合 memo 优化了重渲染开销,"把几千个 DOM 节点都创建出来"这件事本身的成本也省不掉。虚拟滚动(也叫窗口化,windowing)的思路是只渲染当前可视区域内的少量条目,滚动时动态替换内容,通常用 react-window 这类库实现:
import { FixedSizeList } from 'react-window';
function BigList({ items }) {
const Row = ({ index, style }) => (
<div style={style}>{items[index].name}</div> // style 由 react-window 计算好,用来把这一行定位到正确的滚动位置
);
return (
<FixedSizeList height={600} itemCount={items.length} itemSize={40} width="100%">
{Row}
</FixedSizeList>
);
}
FixedSizeList 只会真正渲染出可视区域(加上少量缓冲)内的那几十个 Row,滚动时通过绝对定位动态调整每一行的位置,而不是让浏览器原生滚动几千个真实节点。这一点和 Flutter 的 ListView.builder、Web 端"虚拟列表"要解决的是完全同一个问题——数据量大的列表,只有真正进入视口的部分才值得付出渲染成本。
动画的状态转换
react-transition-group 的 CSSTransition 常见做法是通过 enter/exit 阶段的 CSS class 来控制过渡样式;动态增删的列表动画需要配合 TransitionGroup 统一管理。列表动画同样要求给每一项一个稳定的 key,不能用会随增删变化的 index。应该优先尊重用户系统设置的 prefers-reduced-motion 偏好,并且不要让动画的执行阻塞用户的正常交互。
十二、路由:SPA 原理与版本差异
BrowserRouter 与 HashRouter
两者都负责把浏览器 URL 映射到对应的 UI:
BrowserRouter使用 History API(pushState/replaceState),URL 更简洁自然;但生产环境的服务器必须把所有未知路径都回退到应用入口 HTML,否则用户直接访问某个深层链接会得到服务器返回的 404。HashRouter使用 URL 中#后面的片段和hashchange事件,完全不需要服务器配合改写路由,但 URL 观感上不如前者简洁,并且#后面的内容通常不会被发送给服务器。
前端路由内部的页面跳转应该使用路由库提供的 Link/NavLink 组件,而不是普通的 <a href>(普通链接会触发整个页面的重新加载,丢失 SPA 的所有好处)。
路由匹配、参数与导航
路由参数通常写作 /detail/:id 这种形式,从路径中直接解析;查询字符串参数则要从 location.search 里单独读取。定义路由规则时,应该按从具体到模糊的顺序排列,避免一个像 /:id 这样宽泛的动态路由,抢先匹配掉了本该命中某个固定路径(比如 /settings)的请求。
需要注意 React Router 主要版本之间 API 差异较大:v5 使用 Switch、给 Route 传 component prop、用 useHistory 做编程式导航、用 Redirect 组件做重定向;v6 起改用 Routes、给 Route 传 element prop(传入的是 JSX 而不是组件引用)、用 useNavigate 做编程式导航、用 Navigate 组件做重定向。面试回答时应该先说明自己使用/了解的是哪个版本,再解释具体原理,不要把两套 API 混着写。
嵌套路由与 Outlet
v6 起,子路由通过嵌套声明,父路由组件里用 <Outlet /> 占位,渲染当前匹配到的子路由内容——这比 v5 里"每个页面自己再手动嵌一层 <Route>"更清晰,父子路由的层级关系和组件树的层级关系直接对应:
<Routes>
<Route path="/dashboard" element={<DashboardLayout />}>
<Route index element={<Overview />} /> {/* /dashboard,默认子路由 */}
<Route path="settings" element={<Settings />} /> {/* /dashboard/settings */}
</Route>
</Routes>
function DashboardLayout() {
return (
<div>
<Sidebar />
<Outlet /> {/* 匹配到的子路由(Overview 或 Settings)会渲染在这里 */}
</div>
);
}
v6.4+ 的 data router:路由和数据获取绑定在一起
早期版本里,路由只负责"这个路径该渲染哪个组件",数据请求是组件自己在 useEffect 里发起的——这意味着要等组件渲染出来、useEffect 执行、请求发出、请求返回,用户才能看到真正的数据,中间有一段"路由已经跳转但数据还没来"的等待。v6.4 引入的 data router 把数据获取提前到路由匹配的时候就开始,和组件渲染并行进行:
import { createBrowserRouter, RouterProvider, useLoaderData } from 'react-router-dom';
const router = createBrowserRouter([
{
path: '/users/:id',
element: <UserDetail />,
loader: async ({ params }) => {
// 路由匹配到的同时就发起请求,不需要等组件渲染完再在 useEffect 里发
return fetch(`/api/users/${params.id}`).then(res => res.json());
},
action: async ({ request }) => {
// 处理这个路由下的表单提交(POST/PUT 等),替代手写 onSubmit 里的请求逻辑
const formData = await request.formData();
return fetch('/api/users', { method: 'POST', body: formData });
},
},
]);
function UserDetail() {
const user = useLoaderData(); // 直接拿到 loader 已经请求回来的数据,不需要自己 useEffect + useState
return <div>{user.name}</div>;
}
function App() {
return <RouterProvider router={router} />;
}
这套模式把"进入某个路由需要哪些数据"这件事显式声明在路由配置里,而不是隐藏在某个组件内部的 useEffect 里,路由跳转和数据请求可以同时开始,减少了"先看到空白/loading 页面、请求返回后才看到内容"这段串行等待时间。
受保护路由(Protected Route)
判断用户是否登录、有没有权限访问某个路由,通常包一层组件,未通过校验时用 Navigate 重定向:
function ProtectedRoute({ children }) {
const { isLoggedIn } = useAuth();
const location = useLocation();
if (!isLoggedIn) {
// 带上当前路径,登录后可以跳回原本想访问的页面
return <Navigate to="/login" state={{ from: location }} replace />;
}
return children;
}
<Route path="/dashboard" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
replace 让这次重定向不在浏览器历史记录里留下"未登录时访问过的这个页面"这一条,用户登录后点返回键不会又跳回这个被拦截的中间态。
十三、状态管理生态:Redux、Zustand 与如何选择
现代 React 项目里,全局状态管理不再是 Redux 一家独大,需要能讲清楚几种主流方案的心智模型差异,而不是只会背 Redux 的数据流。
| 方案 | 心智模型 | 模板代码量 | 适合场景 |
|---|---|---|---|
| Redux(Toolkit) | 单一 store、reducer 纯函数、action 描述"发生了什么" | 较多(即使 Toolkit 简化了不少) | 大型应用、需要严格的状态变更可追溯性、团队已有 Redux 经验 |
| Zustand | 一个 hook 风格的 store,直接读写状态,没有 action/reducer 这层强制约束 | 很少 | 中小型应用、想要 Redux 的全局共享能力但不想要它的模板代码 |
| Jotai / Recoil | 原子化状态(atom),每个状态是独立的最小单元,按需订阅 | 少 | 状态之间关联性弱、大量细粒度独立状态(比如复杂表单的每个字段) |
| Context + useReducer | 用 React 自带能力手搓一个简化版全局状态 | 中等(自己维护 Provider 和拆分逻辑) | 状态更新不频繁、不想引入额外依赖的小型场景 |
Redux 数据流与 connect
传统 Redux 的核心数据流是:UI 调用 dispatch(action) → 经过可选的 middleware 处理 → reducer 根据旧 state 和 action 计算出新 state → store 通知所有订阅者 → UI 读取到新的 state 并重新渲染。reducer 必须是纯函数,绝对不能直接修改传入的旧 state。
react-redux 提供的 Provider 把 store 放入 Context,让整棵组件树都能访问到;旧代码常用 connect(mapStateToProps, mapDispatchToProps) 把 state 和 dispatch 映射成组件的 props。现代函数组件通常直接使用 useSelector 读取状态、useDispatch 获取 dispatch 函数,写法更直接。
middleware 与 thunk
middleware 位于 dispatch 和 reducer 之间,典型形态是一个三层柯里化函数:
const middleware = ({ getState, dispatch }) => next => action => {
// 可以在这里记录日志、拦截 action,或者扩展 action 的处理方式
return next(action);
};
redux-thunk 允许 dispatch 一个函数而不只是一个普通 action 对象,让异步请求完成后再把结果 dispatch 成一个真正的 action;redux-logger 一类的 middleware 则用于记录每一次 action 和对应的 state 变化,方便调试。新项目通常推荐直接用 Redux Toolkit,它内置封装了 configureStore、基于 Immer 的"看起来像直接修改、实际上是不可变更新"的写法,以及处理异步逻辑的常见模式,理解 Redux 的基础数据流链路,比手写一遍 applyMiddleware 的源码实现更重要。
为什么强调不可变更新
不可变更新让"数据是否发生了变化"这件事可以通过简单的引用比较(===)快速判断出来,这正是 reducer 判断是否需要更新、memo 判断是否需要跳过渲染、以及调试工具实现"时间旅行"功能的基础。它并不要求每次更新都做一次完整深拷贝——正确的做法是只复制被真正修改到的那条路径上的节点,没有变化的分支可以继续复用原来的引用(这种做法通常被称为结构共享)。
一些旧项目里可能会见到 immutable.js 这类持久化数据结构库提供的 Map、getIn、setIn 等 API;现代 React/Redux 项目更常见的做法是配合 Immer(或者内置了 Immer 的 Redux Toolkit)在普通对象上写"看起来像直接修改"的代码,底层自动帮你生成符合不可变更新规则、且做了结构共享的新对象。不要把"深拷贝"当成"不可变更新"的同义词,两者要解决的问题不同,深拷贝往往是没有必要的额外开销。
Zustand:不需要 Provider 的轻量方案
import { create } from 'zustand';
const useCounterStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}));
function Counter() {
const count = useCounterStore((state) => state.count); // 只订阅 count,count 之外的字段变化不会触发这个组件重渲染
const increment = useCounterStore((state) => state.increment);
return <button onClick={increment}>{count}</button>;
}
Zustand 最大的体验差异是不需要 Provider 包裹整棵组件树——create() 返回的就是一个可以在任意组件里直接调用的 Hook,store 本身是一个模块级别的单例。组件通过传一个 selector 函数(如 state => state.count)只订阅自己关心的那部分状态,其他字段变化不会导致这个组件重渲染,这一点不需要额外配置就是默认行为(对比 Redux 需要 useSelector 也是同样的选择性订阅思路,但 Redux 还需要 Provider、combineReducers 这些额外的样板设置)。它的底层实现正是基于第十章提到的 useSyncExternalStore,把"store 变化"接入 React 的并发渲染调度,保证不会读到撕裂状态。
怎么选
不需要为了"用最新技术"强行替换掉已经跑得好好的 Redux 项目——如果团队已经熟悉 Redux 的数据流、项目本身需要严格的状态变更审计(比如需要时间旅行调试、精确的中间件拦截),继续用 Redux Toolkit 完全合理。新项目如果主要诉求是"少写模板代码、快速搭建全局状态",Zustand 通常是更顺手的起点;如果应用里有大量互相独立、细粒度的状态(尤其是复杂表单场景),原子化方案(Jotai/Recoil)的心智模型会更贴合问题本身。面试里被问"除了 Redux 还了解什么"时,能说出这几种方案背后不同的设计取向(集中式 reducer vs 直接读写 vs 原子化),比"我用过 Zustand,它比较轻量"这种单薄回答更有说服力。
十四、服务端渲染(SSR)与 Hydration
SSR 的完整链路
SSR 在服务器根据请求的 URL 渲染出初始 HTML,浏览器可以先展示这份内容,随后再下载客户端 JavaScript,对已经存在的这份 HTML 做 hydration(水合),让事件处理和状态管理真正接管这个页面、变得可交互。
请求 URL → 服务端匹配路由并渲染 HTML → 浏览器首屏展示内容
→ 下载客户端 JS → hydrate / hydrateRoot → 页面变得可交互
它的优点是能提供更快的首屏内容呈现速度,也更利于搜索引擎抓取到有意义的内容;代价是服务器需要承担渲染计算的成本,还要处理数据一致性和额外的工程复杂度。服务端渲染部分通常使用 StaticRouter(因为服务端没有浏览器的 URL 历史可以监听),客户端接管后使用普通的 BrowserRouter。
Hydration 常见追问
- 服务端生成的 HTML 必须和客户端首次渲染的结果保持一致。如果在首屏渲染逻辑里直接使用了
Date.now()、Math.random(),或者其他只在浏览器端才能访问的 API,服务端和客户端算出来的结果就会不一样,产生 hydration mismatch(React 会检测到不匹配并在控制台报警告,严重时甚至会丢弃服务端渲染的内容,退化成客户端重新渲染,等于白做了 SSR)。 useEffect不会在服务端运行(服务端渲染只是把组件计算成字符串,不存在真实的浏览器环境和绘制这个概念),因此任何需要访问window、document的逻辑都应该放在客户端才会执行的 Effect 里,或者用明确的"只在客户端渲染"的边界包裹起来。- 较旧的资料里使用的是
renderToString和ReactDOM.hydrate;现代客户端入口应该使用hydrateRoot。大型应用通常会直接采用像 Next.js 这类框架提供的流式 SSR、路由和数据加载能力,而不是从零手写这一整套机制。
React 18 的流式 SSR
经典的 renderToString 必须等整个组件树渲染完成才能返回 HTML 字符串——如果页面里有一部分数据获取得特别慢(比如页面底部一个不那么重要的推荐模块),会拖慢整个页面的首字节返回时间,哪怕页面大部分内容早就准备好了。React 18 的 renderToPipeableStream(Node.js 环境)配合 Suspense 边界,可以把这部分慢的内容单独"摘出来",先把其余内容的 HTML 流式发送给浏览器,慢的部分准备好之后再通过一段额外的 script 补发到对应位置:
import { renderToPipeableStream } from 'react-dom/server';
function App() {
return (
<Layout>
<MainContent /> {/* 快速可用的内容 */}
<Suspense fallback={<Spinner />}>
<SlowRecommendations /> {/* 数据获取慢,不阻塞其余内容先发送 */}
</Suspense>
</Layout>
);
}
app.get('/', (req, res) => {
const { pipe } = renderToPipeableStream(<App />, {
onShellReady() {
res.setHeader('Content-Type', 'text/html');
pipe(res); // 外层内容(不依赖 Suspense 里数据的部分)准备好就立即开始流式输出
},
});
});
这个模式把"等最慢的那部分数据"从"阻塞整个页面首字节"变成了"只阻塞它自己包裹的那一小块内容",用户能更快看到页面的大部分内容,SlowRecommendations 对应的内容会在数据就绪后,通过流里追加的一小段 <script> 把对应 DOM 替换/填充进去,整个过程不需要重新请求整个页面。这是 React 18 SSR 相比经典模型最主要的改进,也是 Next.js App Router 这类框架默认的 SSR 实现基础。
[!VISUALIZATION] 类型: flow 优先级: high 标题: SSR 到 Hydration 的请求与交互链路 目的: 区分服务器生成 HTML、浏览器首屏显示与客户端接管交互三个阶段。 必须表达: SSR 只提供初始 HTML;hydration 复用既有 DOM 并绑定交互;首屏两端输出需要一致。 避免表达: 不要画成客户端下载 JavaScript 后重新替换全部 DOM。

十五、面试冲刺索引
⭐⭐⭐⭐⭐ 核心必会
props、state、单向数据流、受控组件与非受控组件的选择。- 状态更新的批处理与函数式更新,为什么不能直接修改 state,
flushSync什么时候需要用。 - JSX → React Element → 虚拟 DOM → 协调(Reconciliation)→ Fiber → commit 的完整链路。
- 子节点 Diff 算法:单节点看 key+type,多节点两轮遍历(位置比较 + key 建 map 判断移动)。
key的身份语义,状态在组件树中保留/重置的判断依据。useState、useEffect的依赖数组、闭包陷阱与 cleanup,render/commit/effect 的执行时序。- Hooks 为什么只能顶层调用(能结合简化版
useState实现说明)。
⭐⭐⭐⭐ 高频重点
- 类组件生命周期与它们在函数组件里的对应关系,以及并发模式下 render 阶段方法为什么可能被多次调用。
Context的传播机制(绕过中间层直达消费者)、ref/useImperativeHandle、错误边界、高阶组件各自的适用边界。memo/useMemo/useCallback该在什么时候引入,而不是默认全部加上;长列表用react-window虚拟滚动。- 并发渲染、
useTransition/useDeferredValue解决什么问题,和多线程并行的区别。 useSyncExternalStore是什么、为什么现代状态管理库依赖它。- BrowserRouter/HashRouter 的差异,React Router v5 与 v6 的 API 变化,v6.4+ data router 的 loader/action。
- Redux 的 dispatch → middleware → reducer 数据流,为什么强调不可变更新;Zustand 等轻量方案的心智模型差异。
- React 18 流式 SSR(
renderToPipeableStream+ Suspense)相比经典renderToString解决了什么问题。
容易连续追问的知识链
- 状态更新 → 批处理 → 闭包快照 → 函数式更新 → Effect 依赖数组 →
flushSync逃生舱。 - JSX → React Element → 虚拟 DOM → Reconciliation → Fiber → 子节点 Diff(单/多节点)→ commit → useEffect。
- 列表渲染 →
key→ 组件身份判断 → state 保留/重置 → diff 算法里 key 的作用。 - Hooks 调用顺序 → Fiber 上的 Hook 链表 → 为什么不能条件调用 →
useSyncExternalStore如何对接外部 store。 - Fiber → 可中断渲染 → lanes 优先级 → 紧急更新与 Transition →
useDeferredValue。 - 路由跳转 → 组件渲染 → useEffect 发请求(旧模型的串行等待)→ data router 的 loader 并行取数。
- SSR → 首屏 HTML → hydration → 两端渲染结果必须一致 → 流式 SSR 用 Suspense 拆分慢内容。