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 的算法。
核心思想:
- 同层比较:不跨层级比较, →
- 类型判断:类型不同直接替换子树
- 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 15 | React 16+ (Fiber) |
|---|---|
| Stack Reconciler | Fiber Reconciler |
| 同步递归 | 可中断的循环 |
| 无法中断 | 可以暂停、恢复、放弃 |
| 长任务阻塞 | 时间切片,及时响应 |
Fiber 的三层含义:
- 架构:新的可中断协调算法
- 数据结构:每个组件对应一个 Fiber 节点
- 工作单元:最小的可执行单位
Q6: Render 阶段和 Commit 阶段有什么区别?
答案:
| 阶段 | Render 阶段 | Commit 阶段 |
|---|---|---|
| 可中断性 | ✅ 可中断 | ❌ 不可中断 |
| 主要工作 | 构建 Fiber 树、Diff、收集副作用 | 执行副作用、更新 DOM |
| 执行函数 | beginWork、completeWork | commitMutationEffects 等 |
| 副作用 | 只标记,不执行 | 执行副作用(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: useEffect 和 useLayoutEffect 有什么区别?
答案:
| 特性 | useEffect | useLayoutEffect |
|---|---|---|
| 执行时机 | 浏览器绘制后 | 浏览器绘制前 |
| 是否阻塞 | 异步,不阻塞 | 同步,阻塞绘制 |
| 适用场景 | 数据获取、订阅 | 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. 存储可变值(跨渲染周期保持引用)
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)
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. 解决闭包陷阱(保存最新值)
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 | useState | 普通变量 |
|---|---|---|---|
| 修改是否触发重渲染 | 否 | 是 | 否 |
| 跨渲染周期保持值 | 是 | 是 | 否(每次渲染重新创建) |
| 可以同步读取最新值 | 是 | 否(闭包) | 否(重新创建) |
| 适用场景 | 可变引用、DOM | UI 状态 | 派生计算 |
Q14: React.memo、useMemo、useCallback 分别做什么?
答案:
| 特性 | useMemo | useCallback |
|---|---|---|
| 缓存内容 | 计算结果(任意值) | 函数引用 |
| 返回值 | 执行函数的返回值 | 函数本身 |
| 使用场景 | 复杂计算、稳定引用 | 事件处理函数、传递给子组件 |
// 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,绑定事件并建立后续更新所需的内部状态。
它们是两个阶段,不是同一个概念:
- 服务端生成 HTML,可以配合流式传输逐步发送。
- 浏览器先显示 HTML,但此时部分交互可能尚未可用。
- 客户端加载对应 JavaScript,调用
hydrateRoot完成接管。 - 之后的更新按普通 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/htmlFor、aria-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 常量,可以抓住三点:
- 优先级属于更新,不等于某个组件永久更重要。
- Render 阶段可中断,Commit 阶段仍需要一致地提交。
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 原理:
lazy()接收一个返回 Promise 的函数- 首次渲染时,调用该函数加载组件
- 在组件加载完成前,抛出一个 Promise
Suspense 原理:
- Suspense 捕获子组件抛出的 Promise
- 显示
fallback内容 - 等待 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 的三大原则是什么?
答案:
- 单一数据源:整个应用状态存储在一个 store 中
- 状态只读:只能通过 dispatch action 修改状态,不能直接修改
- 纯函数修改: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,核心思想是:
- 先返回缓存(stale):从缓存中立即返回可能过期的数据,用户无需等待
- 后台重新验证(revalidate):同时在后台发起真实请求获取最新数据
- 静默更新:如果新数据与缓存不同,更新缓存并触发 UI 重渲染
这种策略兼顾了即时响应(用户立刻看到内容)和数据新鲜(后台悄悄更新)。
Q35: React 事件和原生事件有什么区别?
答案:
| 区别 | React 合成事件 | 原生事件 |
|---|---|---|
| 绑定位置 | 根容器(React 17+) | 目标元素 |
| 命名 | 驼峰(onClick) | 小写(onclick) |
| 处理器 | 函数 | 函数或字符串 |
| 事件对象 | SyntheticEvent | Event |
| 跨浏览器 | ✅ 兼容 | ❌ 需处理 |
| 默认行为 | 必须 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 架构的引入:
-
Render 阶段可中断:Fiber 架构下,render 阶段可能被中断和重新执行,导致
componentWillMount、componentWillUpdate可能执行多次 -
副作用问题:在这些方法中执行网络请求、订阅等副作用会导致重复执行
-
并发模式准备:为 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 } |
选择策略:
代码示例:
// 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 采用三个策略将时间复杂度从 降低到 :
| 策略 | 内容 |
|---|---|
| Tree Diff | 只比较同层级节点,跨层级移动视为删除+新建 |
| Component Diff | 相同类型组件继续比较,不同类型直接替换 |
| Element Diff | 使用 key 标识节点,相同 key 复用节点 |
Q48: 什么是双缓存?为什么需要双缓存?
答案:
双缓存是同时维护两棵 Fiber 树:
| 树 | 作用 |
|---|---|
| current 树 | 当前屏幕显示的内容 |
| workInProgress 树 | 内存中正在构建的新内容 |
为什么需要:
- 不影响当前显示:构建过程中,用户看到的仍是 current 树
- 可中断:如果构建被中断,current 树不受影响
- 避免闪烁:构建完成后一次性切换,用户感知不到中间状态
- 复用节点:通过
alternate指针复用上次的 Fiber 节点
Q49: React 17 和 React 18 的批量更新有什么区别?
答案:
| 特性 | React 17 | React 18 |
|---|---|---|
| React 事件 | ✅ 批量 | ✅ 批量 |
| setTimeout | ❌ 不批量 | ✅ 批量 |
| Promise | ❌ 不批量 | ✅ 批量 |
| 原生事件 | ❌ 不批量 | ✅ 批量 |
React 18 的自动批处理让所有场景都能享受批量更新的性能优化。
// React 18
setTimeout(() => {
setCount(1);
setFlag(true);
// 只渲染一次
}, 0);
Q50: 什么时候不应该使用 useMemo/useCallback?
答案:
- 计算很简单时
// ❌ 不需要 useMemo,简单计算比记忆化开销还小
const sum = useMemo(() => a + b, [a, b]);
// ✅ 直接计算
const sum = a + b;
- 依赖项频繁变化时
// ❌ 每次都会重新计算
const value = useMemo(() => compute(data), [data]); // data 每次都变
- 组件未使用 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 的区别?
答案:
| 特性 | Redux | Zustand |
|---|---|---|
| 包大小 | ~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 事件系统有什么变化?
答案:
- 事件绑定位置改变:从
document改为 React 根容器
// React 16
document.addEventListener('click', handler);
// React 17
rootElement.addEventListener('click', handler);
-
好处:
- 多个 React 版本可以共存
- 与其他框架更好地集成
- 微前端场景下事件不冲突
-
移除事件池化:不再需要
e.persist()
Q56: React 在哪些位置可能跳过一次子树更新?
答案:
常见的 bailout 机会包括:state 新旧值按 Object.is 相同、React.memo 的 props 比较通过、类组件 PureComponent/shouldComponentUpdate 判定无需更新,以及复用完全相同的 React 元素引用。
但“父组件重新渲染,子组件一定完全重做 DOM”也不对:默认子组件函数可能再次执行,Reconciliation 会比较结果,只有 Commit 中确实变化的部分才更新宿主环境。
优化时先用 Profiler 找到昂贵且频繁的路径,再稳定 props 或拆分订阅。盲目加 memo 会增加比较、缓存和理解成本,也可能因对象/函数每次新建而无法命中。
Q57: Commit 阶段的三个子阶段分别做什么?
答案:
| 子阶段 | 时机 | 操作 |
|---|---|---|
| Before Mutation | DOM 操作前 | getSnapshotBeforeUpdate |
| Mutation | 执行 DOM | 插入、更新、删除 DOM |
| Layout | DOM 操作后 | componentDidMount、useLayoutEffect |
// 顺序
1. Before Mutation → getSnapshotBeforeUpdate
2. Mutation → 实际 DOM 操作
3. Layout → componentDidMount/useLayoutEffect
4. 浏览器绘制
5. useEffect(异步)
Q58: useTransition 和 useDeferredValue 有什么区别?
答案:
| 特性 | useTransition | useDeferredValue |
|---|---|---|
| 控制对象 | 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 的区别?
答案:
| 特性 | BrowserRouter | HashRouter |
|---|---|---|
| URL | /users/123 | /#/users/123 |
| API | history.pushState | location.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.tsx 和 error.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。
处理顺序:
- 根据 React/框架报错定位最小不一致节点。
- 把请求级数据安全序列化给客户端,保证首次快照相同。
- 浏览器专属逻辑放到 Effect 或明确的 Client Component 中。
- 对不可避免的单个文本差异才谨慎使用
suppressHydrationWarning,它不是整页消警告工具。 - 不要简单关闭 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: 如何处理懒加载失败的情况?
答案:
- 使用 Error Boundary
<ErrorBoundary fallback={<ErrorUI onRetry={retry} />}>
<Suspense fallback={<Loading />}>
<LazyComponent />
</Suspense>
</ErrorBoundary>
- 实现重试机制
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');
});
- 提供离线降级方案
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 有什么区别?
答案:
原子化状态管理:将状态分割成独立的原子,每个原子只触发订阅它的组件更新。
| 特性 | Jotai | Recoil |
|---|---|---|
| 创建方式 | atom(initialValue) | atom({ key, default }) |
| Key 要求 | 不需要 | 必须唯一 |
| 包大小 | ~3KB | ~20KB |
| API 风格 | 更简洁 | 更完整 |
| 维护方 | 社区 |
// 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 方案。