跳到主要内容

React 热门面试题

使用说明

本篇共 75 道题。

React 面试通常从渲染与 Hooks 追问到并发、状态管理、性能和服务端能力。回答时区分“React 的语义保证”和“某个版本或框架的具体实现”。

Q1: React 组件在什么情况下会重新渲染?

答案

常见触发是自身 state 更新、父组件重新渲染、订阅的 Context 变化,以及外部状态库通知。重新渲染表示函数再次执行并生成新的元素描述,不等于 DOM 一定改变。

React 会在协调阶段比较结果,只提交必要的宿主环境修改。性能分析应先用 Profiler 找真实重复工作,而不是看到函数执行就盲目 memo。

Q2: Virtual DOM 的价值是什么?

答案

它是 UI 的 JavaScript 描述,让开发者用声明式状态表达界面,React 再统一协调更新、批处理和跨平台渲染。

它的核心价值是可组合和可预测的编程模型,不是“JavaScript 比原生 DOM 快”。极小的手工 DOM 更新可能更快,但复杂状态下维护成本更高。

Q3: React 的 Diff/Reconciliation 基于哪些假设?

答案

Reconciliation(协调)是 React 比较新旧虚拟 DOM 树差异,并高效更新真实 DOM 的算法。

核心思想:

  1. 同层比较:不跨层级比较,O(n3)O(n^3)O(n)O(n)
  2. 类型判断:类型不同直接替换子树
  3. Key 标识:通过 key 识别列表项移动
// Reconciliation 决定如何更新
<div> <div>
<A /><A /> // 复用
<B /> <C /> // B→C 类型不同,替换
</div> </div>

Q4: 列表中的 key 为什么不能随便用索引?

答案

key 代表元素在兄弟列表中的稳定身份。插入、删除或排序时用索引,会让状态和 DOM 复用到错误的数据项。

静态、不会重排的展示列表可以用索引;可编辑、可排序列表应使用稳定业务 ID。key 只需在当前兄弟列表中唯一。

  • key 用来标识同级节点身份。插入、删除或排序时,索引会随位置变化,React 可能把旧组件状态错误复用到另一条数据。
  • 只有列表静态、没有重排且条目无本地状态时索引才相对安全;常规业务应使用数据自身稳定且唯一的 ID。

Q5: Fiber 架构解决了什么问题?

答案

Fiber 是 React 16 引入的新协调引擎,解决了 React 15 的同步渲染阻塞问题。

React 15React 16+ (Fiber)
Stack ReconcilerFiber Reconciler
同步递归可中断的循环
无法中断可以暂停、恢复、放弃
长任务阻塞时间切片,及时响应

Fiber 的三层含义

  1. 架构:新的可中断协调算法
  2. 数据结构:每个组件对应一个 Fiber 节点
  3. 工作单元:最小的可执行单位

Q6: Render 阶段和 Commit 阶段有什么区别?

答案

阶段Render 阶段Commit 阶段
可中断性✅ 可中断❌ 不可中断
主要工作构建 Fiber 树、Diff、收集副作用执行副作用、更新 DOM
执行函数beginWork、completeWorkcommitMutationEffects 等
副作用只标记,不执行执行副作用(DOM 操作、生命周期)
与 DOM不触及 DOM操作 DOM

Q7: React 中的 state 更新为什么看起来是异步的?

答案

一次渲染中的 state 是快照,调用 setter 是请求后续渲染,当前闭包里的值不会立刻改变。React 还会批处理同一批更新减少重复渲染。

依赖上一次值时使用函数式更新;业务逻辑不要在 setter 后立即读取旧闭包并假设它已变化。

Q8: 自动批处理是什么?

答案

React 17 只在 React 事件处理器中批处理。React 18 所有场景都自动批处理

// React 18 全场景批处理
setTimeout(() => {
setCount(1);
setFlag(true);
// 一次渲染(React 17 是两次)
}, 0);

fetch('/api').then(() => {
setCount(1);
setFlag(true);
// 一次渲染
});

退出批处理:使用 flushSync

Q9: Hooks 为什么不能放在条件语句中?

答案

Hooks 依赖调用顺序来确定每个 Hook 对应的状态:

// React 内部大致逻辑
// 第一次渲染
useState('A') // 第 1 个 Hook → 状态 A
useState('B') // 第 2 个 Hook → 状态 B

// 如果条件语句导致顺序变化
// 第二次渲染(假设条件不满足)
// 跳过了第一个 useState
useState('B') // 第 1 个 Hook → 错误地读取到状态 A!

React 使用链表按顺序存储 Hooks,每次渲染通过顺序匹配 Hook 和状态。条件语句会破坏顺序,导致状态错乱。

Q10: useEffect 应该怎么理解?

答案

useEffect 用来把组件与 React 之外的外部系统同步,例如订阅、计时器、浏览器 API、网络连接或第三方组件。它不是“组件渲染后统一放业务逻辑的地方”。

一个 Effect 应描述一段独立的建立与清理流程:

useEffect(() => {
const connection = createConnection(roomId);
connection.connect();

return () => connection.disconnect();
}, [roomId]);

依赖变化时,React 会先用旧值执行清理,再用新值建立同步;卸载时也会清理。能在渲染期间根据 props/state 直接计算的值不要放进 Effect,用户事件引起的动作放进事件处理器,避免“Effect 驱动一切”形成循环更新和竞态。

Q11: Effect 依赖数组应该怎么写?

答案

依赖应包含 Effect 读取的所有响应式值,而不是手工挑选“希望何时执行”。缺依赖会产生陈旧闭包,随意添加对象又可能导致频繁重连。

解决方向是重构数据流、把稳定逻辑移出组件、在 Effect 内创建对象,或使用合适的事件抽象,而不是关闭 lint。

Q12: useEffectuseLayoutEffect 有什么区别?

答案

特性useEffectuseLayoutEffect
执行时机浏览器绘制浏览器绘制
是否阻塞异步,不阻塞同步,阻塞绘制
适用场景数据获取、订阅DOM 测量、同步 DOM 操作
// 闪烁问题示例
function Tooltip() {
const [position, setPosition] = useState({ x: 0, y: 0 });
const ref = useRef<HTMLDivElement>(null);

// ❌ useEffect: 可能闪烁
useEffect(() => {
const rect = ref.current?.getBoundingClientRect();
setPosition({ x: rect.x, y: rect.y });
}, []);

// ✅ useLayoutEffect: 无闪烁
useLayoutEffect(() => {
const rect = ref.current?.getBoundingClientRect();
setPosition({ x: rect.x, y: rect.y });
}, []);
}

Q13: useRef 有哪些用途?

答案

useRef 的本质是创建一个在整个组件生命周期内保持不变的可变容器对象 { current: T }。修改 .current 不会触发重新渲染,这使它有很多超越 DOM 引用的用途:

1. 存储可变值(跨渲染周期保持引用)

存储定时器 ID
function Timer() {
const [count, setCount] = useState(0);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);

const start = () => {
timerRef.current = setInterval(() => {
setCount(prev => prev + 1);
}, 1000);
};

const stop = () => {
if (timerRef.current) {
clearInterval(timerRef.current); // 跨渲染访问同一个 timer ID
timerRef.current = null;
}
};

return (
<>
<span>{count}</span>
<button onClick={start}>开始</button>
<button onClick={stop}>停止</button>
</>
);
}

2. 保存上一次的状态值(usePrevious)

自定义 usePrevious Hook
function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T | undefined>(undefined);

useEffect(() => {
ref.current = value; // effect 在渲染后执行,此时 ref 保存的还是旧值
}, [value]);

return ref.current; // 返回上一次的值
}

// 使用
function PriceDisplay({ price }: { price: number }) {
const prevPrice = usePrevious(price);
const trend = prevPrice !== undefined && price > prevPrice ? '📈' : '📉';
return <span>{trend} {price}</span>;
}

3. 解决闭包陷阱(保存最新值)

useLatest Hook
function useLatest<T>(value: T) {
const ref = useRef(value);
ref.current = value; // 同步更新,确保始终是最新值
return ref;
}

function SearchBox() {
const [keyword, setKeyword] = useState('');
const latestKeyword = useLatest(keyword);

const handleSearch = useCallback(() => {
// 即使 useCallback 依赖为空,也能读到最新 keyword
fetch(`/api/search?q=${latestKeyword.current}`);
}, []); // 不需要依赖 keyword

return <input onChange={e => setKeyword(e.target.value)} />;
}

4. 标记组件是否已挂载(避免内存泄漏)

防止卸载后更新状态
function useIsMounted() {
const isMounted = useRef(false);

useEffect(() => {
isMounted.current = true;
return () => {
isMounted.current = false; // 卸载时标记
};
}, []);

return isMounted;
}

function DataLoader() {
const [data, setData] = useState(null);
const isMounted = useIsMounted();

useEffect(() => {
fetchData().then(result => {
if (isMounted.current) { // 只在组件还挂载时更新状态
setData(result);
}
});
}, []);

return <div>{data}</div>;
}

5. 记录渲染次数(调试用)

渲染计数器
function useRenderCount() {
const count = useRef(0);
count.current += 1; // 每次渲染都递增,但不触发重渲染
return count.current;
}
useRef vs useState vs 普通变量
特性useRefuseState普通变量
修改是否触发重渲染
跨渲染周期保持值否(每次渲染重新创建)
可以同步读取最新值否(闭包)否(重新创建)
适用场景可变引用、DOMUI 状态派生计算

Q14: React.memouseMemouseCallback 分别做什么?

答案

特性useMemouseCallback
缓存内容计算结果(任意值)函数引用
返回值执行函数的返回值函数本身
使用场景复杂计算、稳定引用事件处理函数、传递给子组件
// useMemo:缓存计算结果
const expensiveValue = useMemo(() => {
return computeExpensiveValue(a, b);
}, [a, b]);

// useCallback:缓存函数
const handleClick = useCallback(() => {
doSomething(a, b);
}, [a, b]);

// 等价关系
useCallback(fn, deps) === useMemo(() => fn, deps)

Q15: Context 为什么可能引起性能问题?

答案

Provider 的 value 变化会让读取该 Context 的消费者重新渲染。把高频变化和大量无关字段放在一个对象中,会扩大更新范围。

可以拆分 Context、稳定 value、下移 Provider,或对高频外部状态使用带选择器的状态库。Context 适合跨层传递,不自动等于完整状态管理。

Q16: 受控组件和非受控组件怎么选?

答案

受控组件的值由 React state 驱动,便于即时校验、联动和统一状态;非受控组件由 DOM 保存当前值,通过 ref 或提交事件读取,接入简单、更新更少。

复杂业务表单常由表单库折中管理。关键是一个字段在生命周期内保持一致的控制模式。

Q17: React 状态应该放在哪里?

答案

放在需要共同读取或修改它的最近公共拥有者,并尽量保持单一事实源。能从 props/state 计算出的值不重复存储。

服务端数据、URL 状态、表单状态和客户端全局状态生命周期不同,应使用对应工具,而不是全部塞进一个全局 Store。

  • 状态应放到所有消费者的最近公共拥有者:只有组件自己使用就保留本地,多组件共享再提升或进入状态容器。
  • 能由 props 或其他 state 计算出的值不要重复存储,否则容易出现两个事实来源。服务端数据、URL 状态和表单临时状态也应分开管理。

Q18: React 性能优化的排查顺序是什么?

答案

先用 Profiler、Performance 和用户指标定位是渲染次数、单次计算、DOM、网络还是包体问题,再做针对性优化。

  • 缩小状态和 Context 更新范围。
  • 虚拟化长列表,拆分大组件和代码包。
  • 对昂贵且输入稳定的计算做缓存。
  • 优化后重新测量,避免 memo 化所有组件。

Q19: Suspense 解决什么问题?

答案

Suspense 让树中的某部分在依赖尚未就绪时声明一个 fallback 边界,并与框架的数据加载、代码分割和流式服务端渲染协作。

它不是给任意 useEffect 请求自动加 loading。数据源需要支持 Suspense 协议或由框架集成,并合理设计边界避免整页闪烁。

Q20: startTransition 适合什么场景?

答案

它把不紧急、可中断的状态更新标记为 Transition,让输入等紧急交互优先响应,例如筛选条件改变后渲染昂贵结果列表。

它不会让计算本身变快,也不适合控制文本输入的值。还要配合 pending 状态提供明确反馈。

  • startTransition 把非紧急更新标记为可中断,让输入、点击等紧急交互先响应,适合搜索结果、标签切换和大列表刷新。
  • 它不会让计算本身更快,也不适合控制输入值;如果慢点来自同步重计算,仍要做缓存、拆分或移到 Worker。

Q21: Error Boundary 能捕获哪些错误?

答案

错误边界捕获其子树在渲染、生命周期和构造过程中的错误,并展示降级 UI。它通常不捕获事件处理、普通异步回调、服务端渲染或边界自身错误。

事件和请求错误需要各自处理。边界应按功能区域设置,并把错误上报与恢复操作设计清楚。

  • Error Boundary 能捕获其子树在渲染、构造和生命周期中的错误,并展示降级 UI、上报组件栈。
  • 它不能捕获事件回调、异步任务、服务端渲染和边界自身的错误,这些仍需 try/catch、Promise 错误处理或框架级错误页。

Q22: Strict Mode 为什么会让某些逻辑执行两次?

答案

开发环境中 React 会额外调用部分纯函数和重新执行 Effect 建立/清理,以暴露非纯渲染和清理不完整问题;生产环境不会简单地把每次行为都重复。

正确做法是修复副作用和清理,而不是为了“只执行一次”绕过 Strict Mode。

  • 开发模式下 Strict Mode 会额外执行渲染和 Effect 的“挂载—清理—再挂载”,用来暴露不纯渲染和缺失清理。
  • 生产环境不会因此执行两次。正确做法是让 Effect 可重复建立和清理,而不是用全局标记绕过检查。

Q23: SSR 和 Hydration 分别是什么?两者怎样配合?

答案

SSR 是服务端先把 React 树渲染成 HTML 并发送给浏览器,让用户和爬虫更早获得内容;Hydration 是客户端 React 根据相同组件树接管这些既有 DOM,绑定事件并建立后续更新所需的内部状态。

它们是两个阶段,不是同一个概念:

  1. 服务端生成 HTML,可以配合流式传输逐步发送。
  2. 浏览器先显示 HTML,但此时部分交互可能尚未可用。
  3. 客户端加载对应 JavaScript,调用 hydrateRoot 完成接管。
  4. 之后的更新按普通 React 客户端渲染处理。

SSR 不必然更快,Hydration 也有 CPU 和 JavaScript 成本。现代框架会结合流式 SSR、选择性 Hydration 和 Server Components 缩短关键交互路径。

Q24: React Server Components 与传统 SSR 有什么区别?

答案

SSR 描述的是在服务端生成 HTML;React Server Components(RSC)描述的是组件在哪个环境执行、其代码和数据是否进入客户端模块图。二者可以组合,但不是替代关系。

  • SSR 中,Client Component 也可以在服务端预渲染 HTML,随后仍需下载其客户端代码并 Hydrate。
  • Server Component 只在服务端执行,可以直接访问服务端数据和模块,其组件实现不会作为客户端 JavaScript 下发。
  • RSC 通过专用数据流描述组件树,不只返回一段 HTML;框架可把它与流式 SSR、路由和缓存结合。
  • 需要状态、事件或浏览器 API 的部分必须落在 Client Component 边界内。

收益是减少客户端 JavaScript 和数据拼装层,代价是边界、序列化、缓存和框架心智模型更复杂。

Q25: React 19 方向上的 Actions 和表单能力解决什么问题?

答案

Actions 是 React 19 处理数据变更的新范式,解决了手动管理异步状态的繁琐:

之前的问题

// 手动管理 pending、error 状态
const [isPending, setIsPending] = useState(false);
const [error, setError] = useState(null);

async function handleSubmit() {
setIsPending(true);
setError(null);
try {
await submit();
} catch (e) {
setError(e);
} finally {
setIsPending(false);
}
}

Actions 解决方案

// 自动管理状态
const [state, formAction, isPending] = useActionState(submitAction, initialState);

<form action={formAction}>
<button disabled={isPending}>提交</button>
</form>

优势

  • 自动管理 pending 状态
  • 与 Suspense、Error Boundary 集成
  • 支持渐进增强

Q26: useId 解决什么问题?能用它生成列表 key 吗?

答案

useId 生成在服务端渲染和客户端 Hydration 之间稳定的唯一 ID,主要用于关联可访问性属性,例如 label/htmlForaria-describedby

function PasswordField() {
const hintId = useId();
return (
<>
<input aria-describedby={hintId} />
<p id={hintId}>至少 8 个字符</p>
</>
);
}

它不适合列表 key。列表 key 应来自数据自身的稳定身份,而 useId 不能在循环中调用,也不能表达某条业务数据是谁。数据库 ID、稳定 slug 或创建数据时生成的 ID 才是合适选择。

Q27: React 元素、组件和组件实例有什么区别?

答案

  • React 元素:JSX 产生的不可变描述对象,表示“想渲染什么”,不是 DOM 节点。
  • 组件:接收 props 并返回 React 节点的函数或类,是可复用的定义。
  • 组件实例:类组件会有类实例;函数组件没有供业务代码持有的组件实例,每次渲染只是重新调用函数,状态由 React 按 Fiber 位置保存。

不要把 JSX 元素当成已经创建的 DOM,也不要用 new FunctionComponent() 管理函数组件。需要访问 DOM 时使用 ref;需要跨渲染保存数据时使用 state 或 ref,而不是依赖某次调用的局部变量。

Q28: React 的更新优先级和 Lane 模型怎么理解?

答案

React 会给更新标记优先级类别,并用 Lane 位集合组织哪些更新可以一起处理。用户输入等紧急更新可以优先于 Transition 等非紧急更新,低优先级渲染还可能被暂停、重试或合并。

面试不必死背所有 Lane 常量,可以抓住三点:

  1. 优先级属于更新,不等于某个组件永久更重要。
  2. Render 阶段可中断,Commit 阶段仍需要一致地提交。
  3. startTransition 只是标记更新优先级,不会让昂贵计算自动变快。

Lane 是 React 内部实现细节,具体位定义可能演进;业务代码应使用公开 API 表达紧急与非紧急更新。

Q29: 为什么通常不建议把可以计算出的值再存一份 state?

答案

如果某个值能由当前 props 和 state 直接计算出来,再保存一份会制造两个事实来源。更新其中一个却漏掉另一个时,界面就会不同步;用 Effect 去同步还会多一次渲染,并引入依赖和竞态问题。

优先在渲染期间直接计算,只有计算确实昂贵时再基于测量考虑 useMemo。适合独立存 state 的情况是用户能单独编辑它、需要保留历史快照,或它来自不能由现有状态重建的外部事件。

从 props 初始化 state 只会使用初始值,后续 props 变化不会自动重置;若确实需要重置,应明确同步规则或通过组件身份/key 表达新的实体。

Q30: useImperativeHandle 适合解决什么问题?

答案

它允许组件通过 ref 暴露一个受控的命令式接口,而不是把内部 DOM 节点或全部实现细节交给父组件。典型场景是输入框的 focus()、播放器的 play()/pause(),或弹窗的少量命令式能力。

设计时要注意:

  • 默认优先通过 props 描述状态;只有操作天然是命令式、难以声明化时才使用。
  • 暴露最小稳定 API,不要把内部节点、状态对象和任意方法全部透出。
  • 句柄依赖内部值时要正确声明依赖,避免闭包读取旧值。
  • React 版本在 ref 传递语法上可能不同,但“最小化命令式表面”的原则不变。

Q31: React.lazy 和 Suspense 的原理是什么?

答案

React.lazy 原理

  1. lazy() 接收一个返回 Promise 的函数
  2. 首次渲染时,调用该函数加载组件
  3. 在组件加载完成前,抛出一个 Promise

Suspense 原理

  1. Suspense 捕获子组件抛出的 Promise
  2. 显示 fallback 内容
  3. 等待 Promise resolve 后,重新渲染子组件
// 简化实现
function lazy<T extends ComponentType<any>>(
load: () => Promise<{ default: T }>
): LazyExoticComponent<T> {
let Component: T | null = null;
let promise: Promise<void> | null = null;

return function LazyComponent(props: any) {
if (Component) {
return <Component {...props} />;
}

if (!promise) {
promise = load().then(module => {
Component = module.default;
});
}

throw promise; // 抛出 Promise,Suspense 捕获
};
}

Q32: 为什么不应该在另一个组件函数内部定义组件?

答案

父组件每次渲染都会创建新的内部组件函数引用。React 会把它视为不同的组件类型,因此对应子树可能被卸载并重新挂载,导致 state、焦点和副作用被重置,也增加无意义工作。

应把组件定义移动到模块顶层,通过 props 传入所需数据。如果只是想复用一小段无状态结构,可以提取普通渲染函数,但不要在其中使用 Hooks,并保持调用语义清晰。

相比“函数创建本身的一点成本”,组件身份不稳定才是更重要的问题。

Q33: Redux 的三大原则是什么?

答案

  1. 单一数据源:整个应用状态存储在一个 store 中
  2. 状态只读:只能通过 dispatch action 修改状态,不能直接修改
  3. 纯函数修改:Reducer 是纯函数,相同输入产生相同输出
// 单一数据源
const store = configureStore({ reducer: rootReducer });

// 状态只读 - 通过 action 修改
dispatch({ type: 'INCREMENT' }); // ✅
// state.count++; // ❌

// 纯函数
function reducer(state, action) {
switch (action.type) {
case 'INCREMENT':
return { ...state, count: state.count + 1 }; // 返回新对象
default:
return state;
}
}

Q34: SWR 的 stale-while-revalidate 策略是什么?

答案

stale-while-revalidate 源自 HTTP 缓存头 Cache-Control: max-age=1, stale-while-revalidate=59,核心思想是:

  1. 先返回缓存(stale):从缓存中立即返回可能过期的数据,用户无需等待
  2. 后台重新验证(revalidate):同时在后台发起真实请求获取最新数据
  3. 静默更新:如果新数据与缓存不同,更新缓存并触发 UI 重渲染

这种策略兼顾了即时响应(用户立刻看到内容)和数据新鲜(后台悄悄更新)。

Q35: React 事件和原生事件有什么区别?

答案

区别React 合成事件原生事件
绑定位置根容器(React 17+)目标元素
命名驼峰(onClick)小写(onclick)
处理器函数函数或字符串
事件对象SyntheticEventEvent
跨浏览器✅ 兼容❌ 需处理
默认行为必须 preventDefault()可 return false
// React
<button onClick={(e) => { e.preventDefault(); }}>

// 原生
button.addEventListener('click', (e) => { e.preventDefault(); });

Q36: React 在什么情况下保留或重置组件 state?

答案

State 不是存放在 JSX 标签里,而是 React 按组件在渲染树中的类型、位置和 key关联保存。

  • 同一位置渲染相同组件类型,通常保留 state。
  • 组件类型变化时,该位置的旧子树会卸载并重置。
  • 同一位置的 key 变化会告诉 React 这是另一个实例,从而重置整棵子树。
  • 把组件函数定义在另一个组件内部,会导致每次渲染得到新的组件类型,容易意外重置。

需要在用户切换时清空表单,可以给组件使用稳定业务 key;希望保留状态时,则要保持组件身份和树位置稳定。

Q37: React 为什么要求 Render 保持纯粹?

答案

Render 阶段可能被 React 暂停、重试、放弃或执行多次。只有相同 props、state、context 得到相同描述且不产生外部副作用,React 才能安全地进行并发调度和开发期检查。

渲染期间可以计算 JSX 和派生数据,但不应发请求、订阅事件、修改 DOM、修改传入对象或外部 Store,也不应依赖随机数生成服务端与客户端结构。

用户操作触发的副作用放事件处理器;需要与外部系统同步的逻辑放 Effect,并提供正确清理。纯渲染不是代码风格偏好,而是 React 调度模型的前提。

Q38: React 并发渲染是什么?它等于多线程渲染吗?

答案

并发渲染让 React 能在主线程上暂停、恢复、放弃或重做 Render 工作,从而让更紧急的更新先响应。它是一种可中断调度模型,不等于 React 把组件函数自动放到多个线程并行执行。

只有使用并发能力的更新才体现相应行为,例如 Transition、Suspense 和支持并发的框架路由。Commit 阶段仍需一致完成,长时间同步 JavaScript 计算也不会因为并发渲染自动消失。

因此优化时仍要拆分长任务、减少渲染成本或使用 Worker;并发能力主要改善调度和感知响应,不是提升 CPU 算力。

Q39: useActionState 适合解决什么问题?

答案

useActionState 用来管理 Action 执行后的状态。它接收一个可以是异步的 reducer Action,并返回当前结果、分发函数和 isPending,适合表单提交、服务端校验和有前一次结果依赖的变更流程。

useReducer 不同,传入函数可以执行副作用;多次 Action 还可能按前一次结果排队。使用 Server Function 时,它能配合框架在 Hydration 完成前展示服务端响应,并支持渐进增强。

可预期业务错误通常作为状态返回,未知程序错误可以抛给 Error Boundary。即时反馈可结合 useOptimistic,但不能把所有普通请求都机械改成 Action。

Q40: React 组件通信有哪些方式?

答案

方式方向场景
Props父 → 子传递数据和配置
回调函数子 → 父子组件触发父组件更新
状态提升兄弟共享状态到公共父组件
Context跨层级避免 Prop Drilling
Ref父 → 子命令式操作子组件
状态管理库全局复杂状态管理
发布订阅任意完全解耦的通信

Q41: React 16 为什么废弃 componentWillMount 等生命周期?

答案

主要原因是 Fiber 架构的引入:

  1. Render 阶段可中断:Fiber 架构下,render 阶段可能被中断和重新执行,导致 componentWillMountcomponentWillUpdate 可能执行多次

  2. 副作用问题:在这些方法中执行网络请求、订阅等副作用会导致重复执行

  3. 并发模式准备:为 React 18 的并发模式做准备

// ❌ 之前常见的错误用法
componentWillMount() {
// 可能执行多次!
fetchData();
subscribe();
}

// ✅ 正确用法
componentDidMount() {
// 只执行一次
fetchData();
subscribe();
}

Q42: React Router 的实现原理是什么?

答案

React Router 基于监听 URL 变化条件渲染实现:

// 简化版实现原理
import { createContext, useContext, useState, useEffect } from 'react';

// 1. 创建 Router Context
const RouterContext = createContext<{
pathname: string;
navigate: (to: string) => void;
} | null>(null);

// 2. BrowserRouter 监听 popstate
function BrowserRouter({ children }: { children: React.ReactNode }) {
const [pathname, setPathname] = useState(window.location.pathname);

useEffect(() => {
const handlePopState = () => {
setPathname(window.location.pathname);
};

window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, []);

const navigate = (to: string) => {
window.history.pushState({}, '', to);
setPathname(to);
};

return (
<RouterContext.Provider value={{ pathname, navigate }}>
{children}
</RouterContext.Provider>
);
}

// 3. Route 条件渲染
function Route({ path, element }: { path: string; element: React.ReactNode }) {
const { pathname } = useContext(RouterContext)!;

// 简单匹配(实际实现更复杂)
if (pathname === path) {
return <>{element}</>;
}
return null;
}

// 4. Link 改变 URL
function Link({ to, children }: { to: string; children: React.ReactNode }) {
const { navigate } = useContext(RouterContext)!;

const handleClick = (e: React.MouseEvent) => {
e.preventDefault();
navigate(to);
};

return <a href={to} onClick={handleClick}>{children}</a>;
}

Q43: Next.js 的渲染模式有哪些?分别适用什么场景?

答案

Next.js 支持四种主要渲染模式:

模式说明适用场景App Router 实现
CSR客户端渲染,浏览器执行 JS 渲染后台管理系统、不需要 SEO'use client' 组件
SSR每次请求在服务端渲染 HTML个性化内容、实时数据cache: 'no-store'
SSG构建时生成静态 HTML博客、文档、营销页默认行为 + generateStaticParams
ISR构建时生成 + 按需增量更新电商商品页、新闻列表next: { revalidate: N }

选择策略

代码示例

各模式的 App Router 实现
// SSG(默认)
export default async function Page() {
const data = await fetch('https://api.example.com/data');
return <div>{/* ... */}</div>;
}

// SSR
export default async function Page() {
const data = await fetch('https://api.example.com/data', { cache: 'no-store' });
return <div>{/* ... */}</div>;
}

// ISR
export default async function Page() {
const data = await fetch('https://api.example.com/data', { next: { revalidate: 60 } });
return <div>{/* ... */}</div>;
}

// 也可以使用路由段配置
export const dynamic = 'force-dynamic'; // SSR
export const revalidate = 60; // ISR
export const dynamic = 'force-static'; // SSG

Q44: Server Component 和 Client Component 的边界应怎样划分?

答案

默认把数据读取、权限判断和不需要交互的组合逻辑留在服务端;只有使用 state、Effect、事件处理或浏览器 API 的最小子树进入客户端边界。

边界设计要注意:

  • 从服务端传给客户端的 Props 必须可序列化,不能直接传数据库连接、普通闭包或任意类实例。
  • 'use client' 声明客户端模块图入口,不表示组件完全不参与服务端预渲染。
  • Server Component 可以把可渲染内容作为 children 交给 Client Component 组合,但 Client Component 不能直接导入 Server Component 实现。
  • 边界过高会让大量模块进入客户端包;边界过碎则增加组合复杂度。

判断标准是“哪里真正需要客户端能力”,而不是按页面或团队目录一刀切。

Q45: SSR 和 CSR 的核心区别是什么?

答案

CSR 主要在浏览器执行组件代码并生成初始界面;SSR 先在服务器生成 HTML,再由客户端接管需要交互的部分。区别不仅是执行位置,还涉及数据获取、缓存、错误边界、服务器成本和 Hydration。

SSR 的潜在收益是更早显示内容、支持流式输出和改善某些抓取场景,但不保证所有页面更快或 SEO 自动更好。它会增加服务器工作,并可能把瓶颈转移到 Hydration。

后台系统或高度交互工具可能更适合 CSR;内容页、首屏敏感页面通常由框架组合 SSR、静态生成和客户端渲染,而不是全站只选一种。

Q46: 直接更新和函数式更新 state 有什么区别?

答案

直接写 setCount(count + 1) 使用的是当前渲染快照里的 count。同一批处理中连续调用多次,可能都基于同一个旧值。

函数式更新接收队列中的前一个结果,适合新值依赖旧值的场景:

setCount((value) => value + 1);
setCount((value) => value + 1);
// 最终增加 2

如果新值与旧值无关,直接传值更清晰。Updater 应保持纯函数,因为开发模式可能额外调用它帮助发现副作用。复杂状态转换或多个字段联动时,可以考虑 useReducer

Q47: React Diff 算法的策略是什么?时间复杂度是多少?

答案

React Diff 采用三个策略将时间复杂度从 O(n3)O(n^3) 降低到 O(n)O(n)

策略内容
Tree Diff只比较同层级节点,跨层级移动视为删除+新建
Component Diff相同类型组件继续比较,不同类型直接替换
Element Diff使用 key 标识节点,相同 key 复用节点

Q48: 什么是双缓存?为什么需要双缓存?

答案

双缓存是同时维护两棵 Fiber 树:

作用
current 树当前屏幕显示的内容
workInProgress 树内存中正在构建的新内容

为什么需要

  1. 不影响当前显示:构建过程中,用户看到的仍是 current 树
  2. 可中断:如果构建被中断,current 树不受影响
  3. 避免闪烁:构建完成后一次性切换,用户感知不到中间状态
  4. 复用节点:通过 alternate 指针复用上次的 Fiber 节点

Q49: React 17 和 React 18 的批量更新有什么区别?

答案

特性React 17React 18
React 事件✅ 批量✅ 批量
setTimeout❌ 不批量批量
Promise❌ 不批量批量
原生事件❌ 不批量批量

React 18 的自动批处理让所有场景都能享受批量更新的性能优化。

// React 18
setTimeout(() => {
setCount(1);
setFlag(true);
// 只渲染一次
}, 0);

Q50: 什么时候不应该使用 useMemo/useCallback?

答案

  1. 计算很简单时
// ❌ 不需要 useMemo,简单计算比记忆化开销还小
const sum = useMemo(() => a + b, [a, b]);

// ✅ 直接计算
const sum = a + b;
  1. 依赖项频繁变化时
// ❌ 每次都会重新计算
const value = useMemo(() => compute(data), [data]); // data 每次都变
  1. 组件未使用 React.memo 时
// ❌ 子组件没有 memo,useCallback 没有意义
<Child onClick={useCallback(() => {}, [])} />

// ✅ 配合 React.memo 使用
const MemoChild = React.memo(Child);
<MemoChild onClick={useCallback(() => {}, [])} />

Q51: 代码分割的最佳实践有哪些?

答案

策略说明示例
路由级分割每个路由一个 chunk页面级组件
组件级分割大型/非首屏组件弹窗、图表、编辑器
库分割第三方库单独打包React、lodash
预加载用户可能访问的页面hover 预加载、空闲预加载
// 1. 路由级分割
const Home = lazy(() => import('./pages/Home'));
const Dashboard = lazy(() => import('./pages/Dashboard'));

// 2. 组件级分割 - 大型组件
const RichEditor = lazy(() => import('./components/RichEditor'));

// 3. 预加载 - hover 时
const handleMouseEnter = () => {
import('./pages/Dashboard'); // 预加载
};

// 4. 预加载 - 空闲时
requestIdleCallback(() => {
import('./pages/Settings');
});

Q52: 为什么改变 key 可以重置组件?

答案

React 用“组件类型 + 在父级中的位置 + key”识别组件实例。即使 JSX 位置相同,只要 key 变化,React 就会卸载旧实例并挂载新实例,因此局部 state、ref 和 Effect 生命周期都会重置。

常见场景是切换用户时重置编辑表单:

<ProfileForm key={userId} userId={userId} />

不要用随机 key 强制“刷新”,那会让组件每次渲染都重建,导致输入状态丢失和额外 DOM 工作。只有业务身份确实变化、旧状态不应保留时才改变 key。

Q53: Zustand 和 Redux 的区别?

答案

特性ReduxZustand
包大小~12KB~1KB
样板代码多(action, reducer, dispatch)少(一个 create)
Provider需要 <Provider>不需要
中间件复杂配置简单组合
异步需要 thunk/saga直接 async
调试Redux DevTools同样支持
// Redux
const store = configureStore({ reducer });
dispatch(increment());

// Zustand
const useStore = create((set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 })),
}));
const increment = useStore(s => s.increment);
increment();

Q54: TanStack Query 的 staleTime 和 gcTime 有什么区别?

答案

const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60, // 1 分钟
gcTime: 1000 * 60 * 5, // 5 分钟(v5 前叫 cacheTime)
},
},
});
配置含义默认值影响
staleTime数据多久后变"过期"0(立即过期)过期后再次使用会触发后台 revalidate
gcTime数据多久后从缓存中删除5 分钟删除后再次请求无法使用缓存

生命周期示意:

请求完成 ─── staleTime ──→ 变为 stale(过期)

├─ 再次访问:先返回缓存,后台 revalidate

─── gcTime ──→ 缓存被垃圾回收

└─ 再次访问:全新加载(显示 loading)

Q55: React 17 事件系统有什么变化?

答案

  1. 事件绑定位置改变:从 document 改为 React 根容器
// React 16
document.addEventListener('click', handler);

// React 17
rootElement.addEventListener('click', handler);
  1. 好处

    • 多个 React 版本可以共存
    • 与其他框架更好地集成
    • 微前端场景下事件不冲突
  2. 移除事件池化:不再需要 e.persist()

Q56: React 在哪些位置可能跳过一次子树更新?

答案

常见的 bailout 机会包括:state 新旧值按 Object.is 相同、React.memo 的 props 比较通过、类组件 PureComponent/shouldComponentUpdate 判定无需更新,以及复用完全相同的 React 元素引用。

但“父组件重新渲染,子组件一定完全重做 DOM”也不对:默认子组件函数可能再次执行,Reconciliation 会比较结果,只有 Commit 中确实变化的部分才更新宿主环境。

优化时先用 Profiler 找到昂贵且频繁的路径,再稳定 props 或拆分订阅。盲目加 memo 会增加比较、缓存和理解成本,也可能因对象/函数每次新建而无法命中。

Q57: Commit 阶段的三个子阶段分别做什么?

答案

子阶段时机操作
Before MutationDOM 操作前getSnapshotBeforeUpdate
Mutation执行 DOM插入、更新、删除 DOM
LayoutDOM 操作后componentDidMount、useLayoutEffect
// 顺序
1. Before Mutation → getSnapshotBeforeUpdate
2. Mutation → 实际 DOM 操作
3. Layout → componentDidMount/useLayoutEffect
4. 浏览器绘制
5. useEffect(异步)

Q58: useTransition 和 useDeferredValue 有什么区别?

答案

特性useTransitionuseDeferredValue
控制对象setState 函数
返回值[isPending, startTransition]deferredValue
使用场景控制自己的状态更新延迟使用传入的 props
// useTransition:你控制状态
const [isPending, startTransition] = useTransition();
const handleChange = (value) => {
setInput(value);
startTransition(() => {
setFilteredList(filter(value)); // 低优先级
});
};

// useDeferredValue:你只接收值
function List({ searchQuery }) {
const deferredQuery = useDeferredValue(searchQuery);
// searchQuery 快速变化时,deferredQuery 延迟更新
}

Q59: use() 和其他 Hooks 有什么区别?

答案

use() 是一个特殊的钩子,打破了传统 Hooks 规则

特性use()其他 Hooks
条件调用✅ 可以❌ 不可以
循环中调用✅ 可以❌ 不可以
读取 Promise✅ 可以❌ 不可以
在 try/catch 中✅ 可以❌ 不可以
function Component({ condition }: { condition: boolean }) {
// ✅ use() 可以条件调用
if (condition) {
const data = use(promise);
const theme = use(ThemeContext);
}

// ❌ useState 不能条件调用
// if (condition) {
// const [state, setState] = useState(0);
// }
}

Q60: Context 的 defaultValue 什么时候会生效?

答案

只有组件上方完全没有匹配的 Provider 时,createContext(defaultValue) 的默认值才会被读取。Provider 明确传入 undefined 时,消费者得到的就是 undefined,不会回退到默认值。

默认值适合测试兜底或稳定的静态缺省,不适合掩盖“组件必须位于 Provider 内”的约束。对于必需上下文,常见做法是默认值设为 null,再封装自定义 Hook,在缺少 Provider 时抛出清晰错误。

这样能让配置遗漏尽早暴露,也避免把伪造的认证信息、主题或客户端实例带入生产逻辑。

Q61: useSyncExternalStore 解决什么问题?

答案

它为 React 提供一致的外部可变数据订阅协议,适合状态库、浏览器在线状态和其他不由 React 管理的数据源。

调用时提供:

  • subscribe(callback):订阅变化并返回取消函数。
  • getSnapshot():读取当前快照;数据没变时应返回稳定的同一值。
  • 可选 getServerSnapshot():SSR 和首次 Hydration 使用,必须与客户端初始值一致。

它能让外部 Store 在并发渲染和 SSR 下保持一致,避免撕裂。普通组件内部状态仍优先使用 useState/useReducer,不要为了跨组件共享就手写外部 Store。

Q62: BrowserRouter 和 HashRouter 的区别?

答案

特性BrowserRouterHashRouter
URL/users/123/#/users/123
APIhistory.pushStatelocation.hash
服务器需配置 fallback无需配置
SEO友好不友好
原理监听 popstate监听 hashchange
// BrowserRouter 需要服务器配置
// Nginx 示例
// location / {
// try_files $uri $uri/ /index.html;
// }

// HashRouter 无需配置,因为 # 后的内容不会发送到服务器

Q63: App Router 和 Pages Router 有什么区别?为什么推荐 App Router?

答案

App Router 是 Next.js 13+ 引入的新路由架构,相比 Pages Router 有以下核心改进:

1. Server Components 默认启用

// Pages Router: 所有组件都是 Client Components
// 数据获取需要通过特定函数
export async function getServerSideProps() {
const data = await fetch('...');
return { props: { data: await data.json() } };
}

export default function Page({ data }: { data: any }) {
return <div>{/* 使用 data */}</div>;
}
// App Router: 默认 Server Components,直接 async/await
export default async function Page() {
const data = await fetch('...');
const json = await data.json();
return <div>{/* 直接使用 json */}</div>;
}

2. 嵌套布局取代全局布局

// Pages Router: 只能通过 _app.tsx 设置全局布局
// App Router: 每个路由层级都可以有独立 layout.tsx,且跨导航保持状态

3. 粒度化的 Loading 和 Error 处理

Pages Router 只有全局的 _error.tsx,而 App Router 每个路由段都可以有独立的 loading.tsxerror.tsx,结合 Suspense 实现更细粒度的加载状态。

4. 推荐 App Router 的原因

  • 默认 Server Components,减小 JS Bundle 体积
  • Streaming + Suspense 提升首屏体验
  • 嵌套布局更灵活,导航更高效
  • Server Actions 简化数据变更
  • 与 React 19 特性深度集成(参考 React 19 新特性
  • Pages Router 已进入维护模式,新特性只在 App Router 上开发

Q64: 'use client' 和 'use server' 指令分别是什么作用?

答案

  • 'use client' — 声明客户端边界。该文件及其所有导入会被打包到客户端 JS Bundle 中。它不是说"这个组件只在客户端运行"(SSR 时也会在服务端预渲染),而是标记"从这里开始是 Client 领域"。
  • 'use server' — 标记 Server Action 函数。在客户端调用时会自动生成 HTTP 请求发送到服务端执行。它不是用来标记 Server Component 的(默认就是 SC)。
// 'use client' — 标记客户端边界
'use client';
export function Button() { /* 可用 useState、onClick */ }

// 'use server' — 标记 Server Action
'use server';
export async function saveData(data: FormData) { /* 服务端执行 */ }

Q65: Hydration mismatch 常见原因和处理方式是什么?

答案

Hydration 要求客户端首次渲染与服务端 HTML 结构一致。常见不一致来源包括时间和随机数、服务端与客户端数据不同、非法 HTML 嵌套、按 window 条件分叉,以及浏览器扩展或 CDN 修改 HTML。

处理顺序:

  1. 根据 React/框架报错定位最小不一致节点。
  2. 把请求级数据安全序列化给客户端,保证首次快照相同。
  3. 浏览器专属逻辑放到 Effect 或明确的 Client Component 中。
  4. 对不可避免的单个文本差异才谨慎使用 suppressHydrationWarning,它不是整页消警告工具。
  5. 不要简单关闭 SSR,因为那只是隐藏根因并牺牲服务端渲染收益。

Q66: useReducer 比多个 useState 更适合什么场景?

答案

当多个字段由同一业务事件共同变化、状态转换规则较多,或希望集中测试转换逻辑时,useReducer 更清晰。例如请求状态可以用 start/success/failure 事件驱动,而不是分别维护 loading、data、error 并产生矛盾组合。

Reducer 必须保持纯函数;副作用放在事件处理、Effect 或 Action 层。简单独立字段不需要为了“架构感”改成 reducer。

如果状态存在互斥阶段,可辨识联合加 reducer 能同时约束运行时流程和 TypeScript 类型,减少非法状态。

Q67: 什么是时间切片?React 是如何实现的?

答案

时间切片是把可中断的 Render 工作拆成工作单元,在调度预算不足或有更高优先级任务时让出主线程,之后再继续或重做。

React Scheduler 根据任务优先级和当前运行环境决定何时继续工作,浏览器中可借助消息调度机制安排后续执行。时间预算和内部实现属于版本细节,不应把“固定每 5ms 切一次”当成稳定 API。

它只适用于 React 可中断的协调工作;组件里一段不返回控制权的巨大同步循环仍会阻塞主线程,需要拆分、缓存或移到 Worker。

Q68: 调用 state setter 后,怎样使用“下一次状态”?

答案

调用 setter 后,当前事件处理函数读取到的仍是本次渲染的 state 快照,没有通用 API 能把当前闭包立刻改成新值。

根据目的选择:

  • 下一值可由当前值计算:先存入局部变量,或使用函数式更新。
  • 需要在 DOM 提交后同步外部系统:使用依赖该 state 的 Effect。
  • 极少数必须同步提交 DOM 的第三方集成可用 flushSync,但它会破坏批处理并影响性能。
  • 使用 class 组件时,setState 回调可以在提交后执行;函数组件没有对应 setter 回调。

不要用 ref 同时充当渲染状态和“最新值”,否则 UI 与业务读取容易形成两个事实源。

Q69: React.memo 的浅比较是什么意思?

答案

浅比较(Shallow Compare)只比较对象的第一层属性

// 浅比较的实现
function shallowEqual(objA: any, objB: any): boolean {
if (Object.is(objA, objB)) return true;

if (typeof objA !== 'object' || typeof objB !== 'object') return false;
if (objA === null || objB === null) return false;

const keysA = Object.keys(objA);
const keysB = Object.keys(objB);

if (keysA.length !== keysB.length) return false;

for (const key of keysA) {
if (!Object.hasOwn(objB, key) || !Object.is(objA[key], objB[key])) {
return false;
}
}

return true;
}
// 示例
shallowEqual({ a: 1 }, { a: 1 }); // true
shallowEqual({ a: { b: 1 } }, { a: { b: 1 } }); // false(嵌套对象引用不同)
shallowEqual({ a: [1, 2] }, { a: [1, 2] }); // false(数组引用不同)

Q70: 如何处理懒加载失败的情况?

答案

  1. 使用 Error Boundary
<ErrorBoundary fallback={<ErrorUI onRetry={retry} />}>
<Suspense fallback={<Loading />}>
<LazyComponent />
</Suspense>
</ErrorBoundary>
  1. 实现重试机制
const LazyComponent = lazy(async () => {
for (let i = 0; i < 3; i++) {
try {
return await import('./Component');
} catch (error) {
if (i === 2) throw error;
await new Promise(r => setTimeout(r, 1000));
}
}
throw new Error('Failed to load');
});
  1. 提供离线降级方案
const LazyComponent = lazy(async () => {
try {
return await import('./Component');
} catch {
return { default: OfflineFallback };
}
});

Q71: 什么是虚拟列表?如何实现?

答案

虚拟列表只渲染可见区域的列表项,而不是全部渲染。

核心原理

// 1. 计算可见范围
const startIndex = Math.floor(scrollTop / itemHeight);
const endIndex = startIndex + Math.ceil(containerHeight / itemHeight);

// 2. 只渲染可见项
const visibleItems = items.slice(startIndex, endIndex);

// 3. 用 transform/padding 定位
<div style={{ transform: `translateY(${startIndex * itemHeight}px)` }}>
{visibleItems.map(item => ...)}
</div>

// 4. 撑开滚动高度
<div style={{ height: items.length * itemHeight }} />

常用库

  • react-window:轻量级,性能好
  • react-virtuoso:功能丰富,支持动态高度
  • @tanstack/react-virtual:Headless,灵活性高

Q72: 什么是原子化状态管理?Jotai 和 Recoil 有什么区别?

答案

原子化状态管理:将状态分割成独立的原子,每个原子只触发订阅它的组件更新。

特性JotaiRecoil
创建方式atom(initialValue)atom({ key, default })
Key 要求不需要必须唯一
包大小~3KB~20KB
API 风格更简洁更完整
维护方社区Facebook
// Jotai - 无需 key
const countAtom = atom(0);

// Recoil - 需要 key
const countState = atom({
key: 'countState',
default: 0,
});

Q73: 如何实现乐观更新?为什么需要?

答案

乐观更新是指在服务端确认之前就先更新 UI,让用户操作感觉"即时生效"。典型场景:点赞、收藏、切换开关。

以 TanStack Query 为例:

const likeMutation = useMutation({
mutationFn: (postId: string) => api.likePost(postId),
onMutate: async (postId) => {
// 1. 取消进行中的查询,防止覆盖乐观数据
await queryClient.cancelQueries({ queryKey: ['post', postId] });
// 2. 保存当前数据(用于回滚)
const previous = queryClient.getQueryData(['post', postId]);
// 3. 乐观更新缓存
queryClient.setQueryData(['post', postId], (old: Post) => ({
...old,
liked: true,
likeCount: old.likeCount + 1,
}));
return { previous };
},
onError: (err, postId, context) => {
// 4. 请求失败时回滚
queryClient.setQueryData(['post', postId], context?.previous);
},
onSettled: (data, err, postId) => {
// 5. 无论成功失败,最终都重新验证确保数据一致
queryClient.invalidateQueries({ queryKey: ['post', postId] });
},
});

Q74: React 事件中调用 stopPropagation() 后,为什么原生监听器仍可能执行?

答案

React 合成事件按 React 树和自身事件系统传播;原生监听器则按 DOM 树、捕获/冒泡阶段和注册位置执行。若原生捕获监听器早于 React 处理,或监听器挂在不同根节点/宿主边界,它可能已经执行,调用合成事件的 stopPropagation() 无法让时间倒流。

排查时要确认原生监听器的位置和阶段。React Portal 的合成事件按 React 树冒泡,但原生事件仍遵循实际 DOM 路径;多个 React 根、第三方组件和 Shadow DOM 也会引入额外边界。

最好让同一交互尽量由一种事件系统管理;确需混用时,用最小示例验证实际注册顺序。

Q75: Portal 中的事件冒泡按 DOM 树还是 React 树?

答案

Portal 只改变宿主 DOM 的挂载位置,组件在 React 树中的父子关系不变。因此合成事件会沿 React 组件树冒泡,即使 Portal 节点实际挂在 document.body 下。

这让父组件可以统一处理弹窗内部事件,但也可能让人误以为 DOM 祖先会收到事件。原生 addEventListener、CSS 继承和布局仍按真实 DOM 树工作。

实现模态框时还要处理焦点、滚动锁定、背景不可交互和层叠;Portal 只解决挂载位置,不等于完整的 Dialog 方案。