- React 为什么要用 Virtual DOM?它真的一定更快吗?
- React 中 key 的作用是什么?为什么不能乱用 index?
- React Diff 算法的核心策略是什么?
- React Fiber 是什么?解决了什么问题?
- React 合成事件
- 为什么React 不再把事件统一绑定在 document,而是绑定在 root 容器
- 为什么说 Hooks 必须按顺序调用?
- useEffect 和 useLayoutEffect 的区别是什么?
- React 18 的并发特性是什么?和“并行”有什么区别?
- React 18 的自动批处理(Automatic Batching)是什么?
- 什么是闭包陷阱(stale closure)?在 React 中怎么解决?
- useMemo 和 useCallback 的区别与使用场景是什么?
- React.memo 的原理是什么?什么时候会失效?
- 什么情况下会导致组件重新渲染?
- 受控组件和非受控组件的区别是什么?
- 为什么不要直接修改 state?
- useRef 有哪些典型用途?
- forwardRef 和 useImperativeHandle 是做什么的?
- React Context 的缺点是什么?为什么说它不等于状态管理方案?
- Redux 为什么要求 reducer 必须是纯函数?
- 你会怎么做 React 性能优化?
- 什么是状态提升?它的利弊是什么?
- React.lazy 和 Suspense 的原理与使用场景是什么?
- useTransition 的作用是什么?
- useDeferredValue 和 useTransition 有什么区别?
- useDeferredValue 和 debounce的区别
- React 中如何避免 Effect 写成“副作用垃圾场”?
- 自定义 Hook 的价值是什么?
- HOC、Render Props、Hooks 三种逻辑复用方案怎么比较?
- React 中怎么设计一个高性能的大列表?
- 说说 SSR、CSR、SSG 的区别
- 什么是 Hydration?Hydration mismatch 为什么会发生?
- 什么是错误边界(Error Boundary)?它能捕获哪些错误?
- React 为什么推荐单向数据流?
- 如何理解“React 是 UI = f(state)”?
- 你如何理解 React 中的“可组合性”?
- React 项目中如何做工程化与架构治理?
# React test
# React 为什么要用 Virtual DOM?它真的一定更快吗?
React 使用 Virtual DOM 的核心目的,不是“绝对更快”,而是:
- 提供声明式 UI 编程模型:
开发者只需要描述“状态对应什么 UI”,不用自己操作真实 DOM。 - 让跨平台渲染成为可能:
React 的核心 reconciler['rekənsaɪlə] 不直接依赖浏览器 DOM,因此可以扩展到 React Native,实现一套代码多端渲染。 - 性能优化:
DOM 操作成本通常高于 JS计算,React 先在内存中Diff,再最小化更新真实 DOM。
但它不一定绝对更快:
- 在非常简单、变化极少的页面中,直接操作 DOM 可能更直接。
- Virtual DOM 本身也有计算成本,包含创建 vnode、diff、调度等过程。
- React 的优势更体现在 复杂交互、可维护性、状态驱动 UI、一致性更新模型 上,而不只是速度。
总结: Virtual DOM 的优势在于:在保证性能的同时,极大地提高了开发效率和代码的可维护性。
# React 中 key 的作用是什么?为什么不能乱用 index?
key 的作用是帮助 React 在同层级节点比较时,识别哪个元素是同一个,从而提升 Diff 的准确性和性能。
React 在列表更新时会根据 key 判断:
- 节点是复用
- 节点是新增
- 节点是删除
- 节点是否需要移动
如果使用 index 作为 key,在以下场景容易出问题:
- 列表头部插入元素
- 列表排序
- 删除中间某一项
因为这些操作会导致后续项的 index 全部变化,React 会错误地复用节点,可能带来:
- 输入框内容错位
- 组件内部 state 错乱
- 不必要的重新渲染
{list.map((item, index) => (
<InputItem key={index} value={item.value} />
))}
2
3
如果删除第一项,第二项会变成第一项,但 key 也变了,React 可能把旧组件实例错误复用到新数据上。
正确做法
优先使用:
- 数据库唯一 id
- 后端返回的稳定唯一标识
- 本地生成且稳定的 uuid
注意:
key 不是给组件拿 props 用的,它是给 React 内部 Diff 用的,不会通过 props 传递给子组件。
# React Diff 算法的核心策略是什么?
React 没有做传统意义上的完整树编辑距离算法,因为复杂度太高。它采用了基于经验的启发式策略,把 Diff 复杂度从 O(n³) 降到了接近 O(n)。
核心假设有两个:
- 不同类型的元素会产生不同的子树
例如
<div>变成<span>,React 会直接销毁旧树并重建新树。 - 开发者可以通过 key 告诉 React 哪些子元素是稳定的 这样 React 可以高效比较同层级列表。
Diff 的主要规则
1)节点类型不同,直接替换
<div />
<span />
2
会直接卸载旧节点、挂载新节点。
2)节点类型相同,比较属性
<div className="a" />
<div className="b" />
2
只更新变更的属性。
3)递归比较子节点 对子节点继续执行同样策略。
4)列表比较依赖 key 如果没有 key,只能按顺序比较,遇到头部插入时会产生大量无效更新。
可以补充:
- React 只做同层比较,不会跨层级移动节点比较。
- key 的存在,是为了让React 在同级节点比较时,判断是否可以复用以及如何复用。
# React Fiber 是什么?解决了什么问题?
Fiber 是 React 16 引入的新协调架构,本质上是:
把原本不可中断的递归渲染过程,改造成可中断、可恢复、可分优先级调度的任务系统。
它解决的问题
在旧版 React 中,渲染过程一旦开始,就会同步递归执行直到完成。如果组件树很大,会长时间占用主线程,导致:
- 动画掉帧
- 输入卡顿
- 页面失去响应
Fiber 解决方案:
- 任务切片 把渲染工作拆成很多小单元,而不是一次性做完。
- 可中断 浏览器有更高优先级任务时,可以先暂停 React 工作。
- 优先级调度 用户输入、动画这类高优先级任务可以先处理。
- 支持并发特性 比如 Concurrent([kənˈkʌrənt]) Rendering、Suspense、Transition 等。
Fiber 节点是什么
Fiber 节点本质是一个 JS 对象,保存:
- 组件类型
- props
- state
- child(第一个子节点) / sibling(下一个兄弟节点) / return(父节点)
- effect 信息
它把组件树表示成一个可遍历、可恢复的链表树结构。
总结:
Fiber 不是某个 API,而是 React 底层协调器的重写。好处是让 React 从“同步递归渲染框架”升级成“可调度渲染框架”。
# ReactElement, Fiber, DOM 三者的关系
JSX → ReactElement:
jsx在编译阶段被转换为函数调用(createElement/jsx),在运行时执行函数生成ReactElement对象。
ReactElement → Fiber:
在render阶段,react会根据ReactElement构建Fiber tree
- 初次渲染:根据ReactElemen创建Fiber节点;
- 更新阶段:则将新的ReactElement与旧的Fiber进行Diff,生成/更新Fiber节点。
- 在这个过程中,如果发现变化,会在对应的 Fiber 上做标记(如 Placement、Update、Deletion)
- 同时将这些带有副作用标记的 Fiber 收集成 effect list。
Fiber → DOM:
在commit阶段,react会遍历 effect list,根据 Fiber 上的标记将变更应用到真实的 DOM 上。
# fiber为什么是链表结构而不是树结构
为了实现可中断、可恢复、可控优先级调度的异步渲染。
- 1、摆脱递归限制
- 树结构:通常使用递归遍历,一旦开始,就必须等整个函数调用栈完成才会停止。如果树很深,主线程会被卡死,导致页面卡顿。
- 链表:将任务拆解为一个个“工作单元”。React 可以在处理完一个 Fiber 节点后,通过 while 循环检查是否有剩余时间。如果没有时间,就先退出循环把控制权还给浏览器,等空闲了再回来。
- 2、灵活的导航,在链表结构中,每个节点都有三个关键指针:
- child:深度优先进入子节点。
- sibling:子节点处理完,横向跳到兄弟节点。
- return:兄弟节点也处理完了,顺着指针回到父节点继续。
这种结构让 React 即使在渲染中途停下,也能通过保存的指针精准定位下一步该去哪。
- 3、支撑并发调度(优先级插队):
由于是链表结构,react可以给不同的fiber节点分配不同的lanes(优先级)。 当高优先级的任务进来时,react可以丢弃当前链表遍历的进度,优先处理紧急任务,处理完成后再恢复。 - 4、增量对比的基石:
通过链表结构将之前的深度递归两棵树变为可调度的增量对比,实现任务的暂停、恢复和优先级调度。
# Fiber 怎么做到让出控制权?
requestIdleCallback:浏览器API,允许开发者在浏览器空闲时间做一些低优先级的任务,而不影响页面渲染。
react使用MessageChannel模拟requestIdleCallback。
为什么不使用原生的requestIdleCallback或者 scheduler.yield呢?
原生的requestIdleCallback不稳定,有兼容性问题。
React Fiber 和 Scheduler 的设计远早于scheduler.yield,而且scheduler.yield兼容性差。而且React 要的是“可控调度”,不只是“让一下”。
# 为什么不用 setTimeout,而用 MessageChannel?
浏览器为了防止定时器滥用,会对setTimeout做最小延迟限制,最少是4ms。
MessageChannel没有最小延迟限制。如果浏览器不支持 MessageChannel,React 才会 fallback 到 setTimeout。
# React 的 render 阶段和 commit 阶段有什么区别?
React 更新大致分为两个阶段:
1)Render 阶段
主要做:
- 根据最新 state/props 计算新的 React 元素树
- 执行 Diff,找出哪些地方要更新
- 生成副作用列表
特点:
- 可以中断
- 可以重复执行
- 不应该有副作用
这也是为什么在 render 期间不能做请求、订阅、DOM 操作等副作用:因为render阶段时可中断和可重复执行的,如果执行副作用,可能会导致副作用被执行多次或状态不一致。
2)Commit 阶段
主要做:
- 把 render 阶段计算出来的变更真正应用到 DOM
- 执行 ref 更新
- 执行生命周期 / effect
特点:
- 不可中断
- 必须一次性完成
- 会影响页面可见结果
Hooks 对应关系
- useMemo / useCallback:render 阶段参与计算
- useLayoutEffect:DOM 变更后、浏览器绘制前执行
- useEffect:浏览器绘制后异步执行
总结: 因为render阶段只是计算虚拟DOM diff,不会修改真实DOM,所以可以安全的暂停、恢复和丢弃。而commit阶段会真正修改DOM、更新ref和执行生命周期,这些操作必须保证原子性,如果中断会导致UI状态不一致,因此commit阶段必须同步一次性执行完成。
# React 合成事件
React 合成事件是 React 模拟原生 DOM 事件所有能力的一个事件对象。它的作用是为了解决浏览器兼容性问题。 在 React(17+)中: React 不再把事件统一绑定在 document,而是绑定在 root 容器,在原生事件冒泡到 root 时会触发合成事件,因此合成事件本质上是原生事件冒泡阶段的一个回调。 与原生事件相比有以下好处:
- 跨浏览器兼容性
- 批量更新
- 事件委托(减少内存消耗)
- 跨平台
阻止原生事件冒泡,会导致合成事件无法触发。
阻止合成事件冒泡:
- 原生监听器在Root容器内部:不会影响原生事件触发
- 原生监听器在Root容器外部:会影响原生事件触发
# 为什么React 不再把事件统一绑定在 document,而是绑定在 root 容器
- 1、解决多 React 版本冲突问题
- 2、提高应用隔离性
- 3、保证 stopPropagation 正常
# 为什么说 Hooks 必须按顺序调用?
React 通过 调用顺序 来关联每个 Hook 对应的状态槽位。
例如:
function Demo() {
const [a, setA] = useState(0);
const [b, setB] = useState(0);
}
2
3
4
React 记住的是:
- 第 1 个 Hook 是 a
- 第 2 个 Hook 是 b
如果你把 Hook 写进条件语句:
function Demo({ visible }) {
const [a, setA] = useState(0);
if (visible) {
useEffect(() => {}, []);
}
const [b, setB] = useState(0);
}
2
3
4
5
6
7
8
9
当 visible 从 true 变成 false 时,Hook 调用顺序发生变化,React 会把原本属于 b 的状态错位绑定,导致状态混乱。
规则
- 只在函数组件顶层调用 Hook
- 不要在条件、循环、嵌套函数中调用 Hook
- 只在 React 函数组件或自定义 Hook 中调用 Hook
总结:
react 内部是通过链表来存储 Hooks 的,通过调用顺序定位状态。如果改变调用规则,会破坏Hooks的调用顺序。
# useEffect 和 useLayoutEffect 的区别是什么?
两者都会在组件渲染后执行副作用,但时机不同。
useEffect
- 在浏览器完成绘制后执行
- 不会阻塞页面渲染
- 适合大多数副作用:
- 请求
- 订阅
- 日志
- 定时器
useLayoutEffect
- 在 DOM 更新完成后、浏览器绘制前同步执行
- 会阻塞浏览器绘制
- 适合需要读取布局并立即同步修改 DOM 的场景:
- 获取元素尺寸
- 滚动位置修正
- 防止闪烁的布局调整
示例
useLayoutEffect(() => {
const height = ref.current.offsetHeight;
ref.current.style.top = `${-height}px`;
}, []);
2
3
4
如果用 useEffect,页面可能先闪一下,再调整位置。
总结:
- 优先用 useEffect
- 只有在“必须绘制前执行”的场景才用 useLayoutEffect
- SSR 环境里 useLayoutEffect 还可能有警告问题,需要做兼容处理
# React 18 的并发特性是什么?和“并行”有什么区别?
并发(Concurrent Rendering)本质:任务的可中断性和渲染优先级的调度。
在React18之前,渲染是阻塞式的:一旦开始更新DOM,浏览器无法响应用户输入,直到渲染完成。
并发引入了“可中断的渲染”机制,react可以:
- 暂停渲染,优先响应用户输入
- 在内存中准备多个版本的UI,并按优先级调度更新。
它的重点是“可按优先级调度主线程上的渲染工作”。
核心API:
- useTransition
- useDeferredValue
- Suspense 更自然地配合异步 UI
和并行的区别
- 并发:多个任务在一段时间内交替推进
- 并行:多个任务在同一时刻同时执行
浏览器 JS 主线程本质上仍是单线程,所以 React 并发更多是调度层面的改进。
总结:
并发并不是让渲染变快了,而是让渲染可中断:知道什么时候该停下来把主线程让给用户,从而解决了大型应用在更新时的卡顿问题。
# React 18 的自动批处理(Automatic Batching)是什么?
批处理指 React 把多个 state 更新合并成一次渲染,以减少不必要的重新渲染(re-render)。
在 React 18 以前,批处理主要发生在 React 事件回调里:
onClick={() => { setA(1); setB(2); }}
这通常只渲染一次。
但在 setTimeout、Promise、原生事件中,旧版本未必会自动批处理。
React 18 开始,更多异步场景也支持自动批处理:
setTimeout(() => { setA(1); setB(2); }, 0);
现在通常也只触发一次渲染。
好处
- 减少渲染次数
- 提升性能
- 行为更一致
注意
如果确实需要立即看到更新结果,可以用 flushSync 强制同步刷新,但要谨慎使用,因为它会破坏调度优化。
# 什么是闭包陷阱(stale closure)?在 React 中怎么解决?
闭包陷阱常见于事件处理函数和Hooks中:函数捕获了旧的 state/props,后续异步执行时拿到的不是最新值。
例子
function Counter() {
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(count + 1)
setTimeout(() => {
console.log(count);
}, 1000);
};
return <button onClick={handleClick}>count: {count}</button>;
}
2
3
4
5
6
7
8
9
10
11
12
如果点击后继续更新 count,1 秒后打印的仍是点击之前的旧值。
解决方式
1)函数式状态更新
当你只是基于旧值计算新值时:
setCount(prev => prev + 1);
2)useRef 保存最新值
const latestCount = useRef(count);
useEffect(() => { latestCount.current = count; }, [count]);
异步回调里用 latestCount.current。
3)正确声明依赖
很多 stale closure 问题来自依赖数组不完整,导致 effect/callback 持有旧变量。
注意:
闭包不是 React 的 bug,而是 JS 闭包 + React 渲染模型共同作用的结果。
# useMemo 和 useCallback 的区别与使用场景是什么?
区别
- useMemo:缓存计算结果
- useCallback:缓存函数引用
示例
const expensiveValue = useMemo(() => compute(a, b), [a, b]);
const handleClick = useCallback(() => doSomething(a), [a]);
2
使用场景
useMemo:
- 昂贵计算缓存
- 避免每次 render 重新创建复杂对象
- 配合 memo 减少子组件重渲染
useCallback:
- 把函数传给 React.memo 子组件
- 作为 effect 依赖,避免函数引用频繁变化
- 避免事件处理器无意义重建导致的联动更新
常见误区
误区 1:到处用就是优化
不是。useMemo/useCallback 本身也有维护依赖和缓存的成本。
误区 2:用了就一定减少渲染
不一定。父组件 render 时子组件默认也可能 render;是否真正减少,要看是否配合 memo、是否命中引用稳定性等。
注意:
useMemo 和 useCallback 是“引用稳定性工具”,不是万金油性能优化按钮。
# React.memo 的原理是什么?什么时候会失效?
React.memo 用于缓存函数组件的渲染结果。当每一项 Props 都没有变化(引用相等)时,React 可以跳过该组件重新渲染。
const Child = React.memo(function Child({ value }) {
return <div>{value}</div>;
});
2
3
默认比较方式
默认是浅比较(shallow compare):
- 基本类型比较值
- 引用类型比较地址
所以它容易在以下情况失效:
1)传入了新对象/新数组
<Child style={{ color: 'red' }} />
每次 render 都创建新对象,memo 会失效。
2)传入了新函数
<Child onClick={() => {}} />
每次 render 函数引用都不同。
3)组件内部状态 (State) 或 Context 更新
即使 props 不变,如果内部有 state 或 context 变了,组件仍会 re-render。
解决办法
- 对对象用 useMemo
- 对函数用 useCallback
- 拆分 context 粒度
- 必要时传入自定义比较函数
React.memo(Component, (prevProps, nextProps) => {
return prevProps.id === nextProps.id;
});
2
3
注意:
memo 不是阻止组件“渲染”,而是告诉 React 在 props 未变化时可以跳过这次渲染工作。
# 什么情况下会导致组件重新渲染?
常见触发 re-render 的原因:
- 组件自身 state 变化
- 父组件重新渲染
- props 变化
- context 值变化
- hooks 依赖变化导致内部逻辑更新
- 强制刷新
常见误区
误区 1:父组件 render,子组件一定 DOM 更新
不一定。子组件函数可能执行,但最终 DOM 不一定变化。
误区 2:props 没变就不会重新执行
也不一定。如果父组件重新 render,子组件默认也会重新执行,除非用 React.memo 等优化。
注意区分:
- render phase 执行
- commit phase DOM 真更新
组件函数执行了,不代表页面真实 DOM 发生变化。
# 受控组件和非受控组件的区别是什么?
受控组件
表单值由 React state 控制:
const [value, setValue] = useState('');
<input value={value} onChange={e => setValue(e.target.value)} />
2
3
特点:
- 数据来源统一
- 容易做校验、联动、格式化
- 更符合 React 数据流
非受控组件
表单值保存在 DOM 自身,通过 ref 获取:
const inputRef = useRef();
<input ref={inputRef} />
2
3
特点:
- 更接近原生表单
- 简单场景写起来更省事
- 某些场景性能更轻
如何选择
- 复杂表单、联动校验:优先受控
- 简单输入、一次性读取:可用非受控
- 文件上传 input 通常天然偏非受控
注意:
大型业务表单通常会在“受控”和“性能”之间做平衡,比如使用表单库降低重渲染成本。
# 为什么不要直接修改 state?
React 依赖 state 的引用变化来感知更新。如果直接修改原对象,可能导致:
- React 无法识别变更,页面不更新
- 破坏不可变数据原则,增加调试难度
- 导致历史状态不可追踪,不利于优化和回溯
错误示例
const [user, setUser] = useState({ name: 'A' });
user.name = 'B';
setUser(user);
2
3
这里对象引用没变,React 可能认为没有变化。
正确写法
setUser(prev => ({ ...prev, name: 'B' }));
不可变更新不仅是 React 的约定,也让:
- 浅比较优化更容易成立
- memo / PureComponent 更有效
- 状态回溯和调试工具更友好
# useRef 有哪些典型用途?
useRef 原理是:初次渲染时在Fiber节点上挂载一个 memoizedState 对象,之后渲染直接返回这个对象,不进行任何比对或更新逻辑。
useRef 返回一个可变对象:
const ref = useRef(initialValue);
它的 .current 在整个组件生命周期中保持稳定,修改它不会触发重新渲染。
典型用途
1)获取 DOM 引用
const inputRef = useRef(null);
<input ref={inputRef} />
2
2)保存跨渲染周期的可变值
比如:
- 定时器 id
- 上一次值
- 是否首次渲染标记
3)解决 stale closure
保存最新 state/props 给异步回调读取。
4)暴露实例能力给父组件
配合 forwardRef 和 useImperativeHandle。
总结:
useRef 像“组件实例字段”,适合存放不影响视图但需要持久存在的数据。
# forwardRef 和 useImperativeHandle 是做什么的?
函数组件默认不能直接接收 ref 到内部 DOM。forwardRef 可以把父组件传入的 ref 转发下去(可以让父组件拿到子组件的DOM)。
forwardRef
const Input = React.forwardRef((props, ref) => {
return <input ref={ref} />;
});
2
3
父组件就能拿到内部 input。
useImperativeHandle[ɪmˈperətɪv]:
有时不希望父组件直接拿整个 DOM,而是只暴露有限能力:
const Input = React.forwardRef((props, ref) => {
const innerRef = useRef();
useImperativeHandle(ref, () => ({
focus() {
innerRef.current.focus();
},
clear() {
innerRef.current.value = '';
}
}));
return <input ref={innerRef} />;
});
2
3
4
5
6
7
8
9
10
11
12
13
14
父组件:
inputRef.current.focus();
使用场景
- 聚焦输入框
- 打开/关闭弹窗
- 表单重置
- 暴露受控的 imperative API
# React Context 的缺点是什么?为什么说它不等于状态管理方案?
使用 Context 的好处是可以更好的管理全局状态,减少 props 过深的问题
但它也有缺点:
- 1)粒度较粗:当 Provider 的 value 引用变化时,所有消费该 context 的组件都可能重新渲染。
- 2)不适合高频更新的大型状态:比如表单输入、复杂全局状态,如果全部塞进一个 Context,性能会比较差。
- 3)可维护性问题:Context 过多或 value 结构过大时,依赖关系不清晰,调试困难。
一个完整的状态管理方案通常还包含:
- 更细粒度订阅
- 中间件机制
- 异步流程管理
- 调试工具
- 时间旅行/日志跟踪
- 模块拆分能力
Context 本质只是状态传递机制,不是完整的状态治理系统。
优化建议
- 拆分多个 Context
- 保持 value 引用稳定
- 高频状态避免直接全局 Context 广播
- 必要时使用 Zustand、Redux Toolkit、Jotai 等方案
# Redux 为什么要求 reducer 必须是纯函数?
Reducer 的职责是:
根据旧 state 和 action,计算出新 state。
它必须是纯函数,意味着:
- 相同输入一定得到相同输出
- 不依赖外部可变状态
- 不产生副作用
- 不修改原对象
原因
- 1)可预测:状态变更逻辑稳定,便于推理和排查问题。
- 2)方便测试:纯函数天然容易单测。
- 3)支持时间旅行和日志回放:如果 reducer 有副作用,重放 action 时结果可能不一致。
- 4)利于性能优化:不可变数据更容易做浅比较。
// 错误示例
function reducer(state, action) {
state.count++;
return state;
}
// 正确示例
function reducer(state, action) {
return { ...state, count: state.count + 1 };
}
2
3
4
5
6
7
8
9
10
# 你会怎么做 React 性能优化?
先定位问题 -> 再做优化
1)先定位瓶颈
工具:
- React DevTools Profiler
- Performance 面板
先确认到底是:
- 组件重复渲染
- 大列表渲染慢
- 计算开销大
- 网络慢
- 资源加载慢
2)常见优化手段
组件层面
- React.memo
- useMemo
- useCallback
- 合理拆分组件,缩小更新范围
- 避免状态提升过度
数据层面
- 保持不可变更新
- 减少无意义对象/函数新建
- 精细化状态粒度
列表层面
- 虚拟列表,如 react-window
- 正确使用 key
- 分页 / 懒加载
交互层面
- 防抖 / 节流
- useTransition
- useDeferredValue
资源层面
- 代码分割 React.lazy
- 路由懒加载
- 图片/资源按需加载
3)避免过度优化
- 不要为了 memo 而 memo
- 优化前要有数据依据
- 优化要考虑可维护性
总结:
我一般先用 Profiler 看具体是哪个组件在高频重复渲染,再判断是 props 引用不稳定、或者context 扩散、或者状态设计不合理,还是列表渲染量过大。然后再针对性使用 memo、useMemo、useCallback、虚拟列表、代码分割等手段进行针对性优化。
# 什么是状态提升?它的利弊是什么?
状态提升指把多个组件共享的状态,提升到它们最近的公共父组件管理,再通过 props 传递下去。
优点
- 单一数据源
- 便于组件间同步
- 状态关系更清晰
缺点
- 容易导致父组件过重
- props drilling
- 更新范围扩大,带来性能问题
实践建议
- 共享范围小:状态提升
- 层级深:Context
- 全局复杂:状态管理库
- 本地 UI 状态尽量本地化,不要一股脑提升
总结:
“不要为了统一管理而过度提升状态”,否则容易造成顶层组件频繁更新。
# React.lazy 和 Suspense 的原理与使用场景是什么?
React.lazy 用于组件懒加载,Suspense 用于在异步组件未加载完成时显示 fallback。
示例
const UserPage = React.lazy(() => import('./UserPage'));
<Suspense fallback={<div>loading...</div>}>
<UserPage />
</Suspense>
2
3
4
5
原理理解
懒加载组件在首次渲染时会触发异步加载,React 暂时无法立即得到组件结果,于是“挂起”当前分支,显示 fallback,等资源准备好后再恢复渲染。
使用场景
- 路由级代码分割
- 大模块按需加载
- 降低首屏 bundle 体积
注意:
- 不要把 Suspense 边界包得过大,否则一个模块加载会让大片 UI 一起 fallback
- 要合理设计 loading 边界体验
总结:
Suspense 不只是“loading 组件”,它本质上是一种 React 处理异步渲染依赖的机制。
# useTransition 的作用是什么?
useTransition 用于把某些更新标记为低优先级更新,避免它们阻塞高优先级交互,比如输入。
示例场景
搜索框输入时:
- 输入框内容更新应立即响应
- 搜索结果列表渲染可能很重,可以稍后进行
const [isPending, startTransition] = useTransition();
const onChange = e => {
const value = e.target.value;
setInput(value);
startTransition(() => {
setKeyword(value);
});
};
2
3
4
5
6
7
8
9
10
这里:
- setInput 是高优先级,保证输入流畅
- setKeyword 是低优先级,可被打断
适用场景
- 搜索过滤
- 大列表切换(>=100 条)
- 列表排序
- tab 切换中的重 UI 更新
- 非紧急内容刷新
它不是“减少计算量”,而是“改善用户感知优先级”。
# useDeferredValue 和 useTransition 有什么区别?
两者都和“延迟非紧急更新”有关,但侧重点不同。
useTransition
用于包裹状态更新动作,告诉 React:这次更新不紧急。
useDeferredValue
用于延迟消费某个值。也就是说,值本身先更新,但某些依赖这个值的重计算内容可以晚一点跟上。
示例
const deferredKeyword = useDeferredValue(keyword);
const filteredList = useMemo(() => {
return heavyFilter(list, deferredKeyword);
}, [list, deferredKeyword]);
2
3
4
输入框可以先响应,复杂过滤稍后完成。
区别总结
- useTransition:标记某个 state 更新为低优先级,保障高优先级交互。
- useDeferredValue:延迟状态依赖值的变化,从而避免高频更新导致的性能问题。
总结:
useTransition 适合在触发更新的地方使用,而 useDeferredValue 适合在消费数据的地方使用,当无法控制更新来源时可以使用 useDeferredValue。
为什么要设计两个API?
因为更新发生的位置和计算发生的位置可能不同。
# useDeferredValue 和 debounce的区别
debounce 是基于时间的优化策略,用来减少函数执行次数。通常用来减少请求次数。
useDeferredValue 是 React 的并发调度优化,它会把某个值的更新标记为低优先级,让高优先级交互(比如输入)优先执行,从而避免 UI 卡顿。
debounce 一定会丢弃中间值,而 useDeferredValue 不保证,如果计算很快,不会丢弃,如果计算很慢,中间的 render 被新的render 打断,那么这些未完成的 render 可能会被丢弃,最终只会 commit 最新的状态。
# React 中如何避免 Effect 写成“副作用垃圾场”?
很多项目最大的问题不是不会写 Effect,而是把所有逻辑都塞进 useEffect。
原则
1)先判断是否真的需要 Effect,以下逻辑往往不需要 effect:
- 从 props/state 派生值
- 纯计算
- 事件触发逻辑
这些通常应放在 render、事件回调、useMemo 中完成。
2)Effect 应聚焦“与外部系统同步”。真正适合 effect 的场景:
- 网络请求
- 订阅/取消订阅
- 定时器
- DOM API
- 浏览器事件
- 第三方库接入
3)一个 Effect 只做一类事,不要在一个 effect 里同时做:
- 请求
- 订阅
- DOM 操作
- 日志
应该拆分,便于维护和依赖控制。
4)依赖数组要真实反映使用到的值,不要为了“只执行一次”强行省略依赖,否则极易制造 bug。
总结::
Effect 的本质不是生命周期替代品,而是让 React 状态与外部世界保持同步。
# 自定义 Hook 的价值是什么?
自定义 Hook 的核心价值是:
复用状态逻辑,而不是复用 UI。
例如把“请求 + loading + error + 重试”逻辑封装成:
function useRequest(api) { // ... }
好处
- 抽离重复逻辑
- 提高可测试性
- 提升可读性
- 保持组件更关注 UI 展示
注意点
- 自定义 Hook 不是 service 层替代品
- 不要把它写成“大而全黑盒”
- 输入输出要清晰,命名语义化
总结:
HOC、render props、hooks 都能做逻辑复用,但 Hooks 在组合性和嵌套使用体验上通常更自然。
# HOC、Render Props、Hooks 三种逻辑复用方案怎么比较?
相同点:都是为了解决代码复用问题
1)HOC(高阶组件):接收一个组件,加上一些逻辑后返回一个新组件,容易产生嵌套地狱
2)Render Props:把逻辑结果通过函数子组件传出去,JSX 嵌套较深。
3)Hooks:可以让你在不编写 Class 的情况下,使用 state,复用状态逻辑
总结:
现代 React 项目里,Hooks 已经是主流方案。HOC 和 Render Props 仍可能在历史项目或特定库中见到。
# React 中怎么设计一个高性能的大列表?
关键思路
1)虚拟化:只渲染可视区域内容,而不是一次渲染几千条。
常见方案:- react-window
- react-virtualized
2)稳定 key:避免列表更新时错误复用。
3)减少 item 重渲染
- item 组件 memo
- props 引用稳定
- 避免把整个大对象传进去
4)按需加载
- 无限滚动
- 分页
- 懒加载
5)避免复杂计算阻塞
- 过滤/排序结果缓存
- 结合 useMemo
- 必要时配合 useTransition
如果列表项高度动态、包含图片、展开折叠、拖拽排序,需要特别考虑:
- 动态高度测量
- 滚动定位修正
- 缓存策略
- 滚动性能与交互体验平衡
# 说说 SSR、CSR、SSG 的区别
# CSR(Client Side Rendering)
浏览器先拿到一个基础 HTML,再下载 JS,最后在客户端渲染页面。
优点:
- 前后端分离体验好
- 交互强
缺点:
- 首屏可能慢
- SEO 较弱
# SSR(Server Side Rendering)
服务器先生成完整 HTML 返回给浏览器,首屏更快、SEO 更好,然后客户端再 hydrate[ˈhaɪˌdreɪt] 接管交互。
优点:
- 首屏快
- SEO 友好
缺点:
- 服务端压力更大
- 工程复杂度更高
# SSG(Static Site Generation)
构建时预生成 HTML,发布成静态文件。
优点:
- 极快
- 成本低
- SEO 好
缺点:
- 不适合强实时动态内容
- 更新内容可能要重新构建
Hydration:
SSR 返回的是可展示 HTML,但要等客户端 JS 执行后,React 才会把静态页面“激活”为可交互应用,这个过程就是 hydration[haɪ'dreɪʃ(ə)n]。
# 什么是 Hydration?Hydration mismatch 为什么会发生?
Hydration 指客户端 React 接管服务端输出的 HTML,并绑定事件、恢复组件树的过程。
mismatch 的原因
当服务端渲染结果和客户端首次渲染结果不一致时,就会出现 hydration mismatch。
常见原因:
- 服务端用了 window、document
- 首次渲染依赖随机数、当前时间
- 服务端和客户端数据不一致
- 条件渲染分支不同
- 国际化、时区格式化不一致
错误示例:服务端和客户端时间不同,必然不一致。
<div>{Date.now()}</div>
解决思路
- 保证首屏渲染确定性
- 浏览器专属逻辑放到 useEffect
- 时间/随机值做延后处理
- 保证服务端与客户端拿到相同初始数据
# 什么是错误边界(Error Boundary)?它能捕获哪些错误?
错误边界是 React 中用于捕获子组件树渲染错误的机制。通常通过类组件实现:
- static getDerivedStateFromError
- componentDidCatch
能捕获的错误
- 子组件 render 期间错误
- 生命周期中的错误
- 构造函数中的错误
不能捕获的错误
- 事件处理函数里的错误
- 异步代码错误(如 setTimeout、Promise)
- 服务端渲染错误
- 错误边界自身内部抛出的错误
价值:
避免某个局部组件报错导致整个应用白屏。
现代项目虽然多用函数组件,但错误边界目前依然主要靠类组件或者框架封装实现。
# React 为什么推荐单向数据流?
单向数据流指:
- 数据从父到子通过 props 传递
- 子组件不能直接修改父组件状态
- 状态变更通过事件/回调上报
好处
- 数据来源清晰
- 状态流向可预测
- 更容易调试
- 降低双向绑定带来的隐式耦合
总结:
React 不是不能双向交互,而是把“视图更新状态”的行为显式化成事件回调,而不是隐式自动同步。
# 如何理解“React 是 UI = f(state)”?
表示:页面是状态的映射结果。在同样的 state 下,组件应该产生同样的 UI 输出。
这体现了 React 的声明式思想:
- 你不手动操作 DOM
- 你只描述状态
- React 负责把状态映射成界面并完成更新
意义
- 降低命令式 DOM 操作复杂度
- 提升可预测性
- 更利于测试和推理
总结:
这也是为什么 render 应该尽量保持纯函数性质。
# 你如何理解 React 中的“可组合性”?
可组合性是 React 很核心的设计理念,指:
通过小而清晰的组件、Hook、上下文、组合模式,把复杂界面逐步搭出来。
体现
- 组件可以嵌套组合
- Hook 可以组合逻辑
- children / slots 模式增强复用
- 小组件比大组件更容易维护
价值
- 提升复用
- 降低复杂度
- 便于替换和扩展
- 更适合团队协作
总结:
相比继承,React 更推崇组合,因为组合更灵活、耦合更低。
# React 项目中如何做工程化与架构治理?
1)目录做分层,常见分层:
- pages
- components
- hooks
- services
- store
- utils
- constants
避免随意堆文件。
2)状态管理边界 区分:
- 页面状态
- 组件局部状态
- 全局状态
- 服务端缓存状态
不要把所有状态都进 Redux/Zustand。
3)请求治理
- 统一请求层封装
- 错误处理
- 重试策略
- 鉴权与拦截器
- 请求取消
4)性能治理
- 路由懒加载
- 包体积分析
- 监控渲染性能
- 长列表优化
5)规范治理
- ESLint
- Prettier
- TypeScript
- Git hooks
- 单元测试 / E2E
6)可观测性
- 错误监控
- 性能监控
- 埋点
- 日志追踪
总结:
按业务边界组织代码,而不是纯技术类型组织