主题
React
说明
共 22 题,难度 ⭐ ~ ⭐⭐⭐,覆盖 Fiber 架构、Hooks 原理、性能优化、并发模式、状态管理、SSR/RSC 等 5 年经验 React 面试重点。
1. React 的 Fiber 架构是什么?解决了什么问题? ⭐⭐⭐
阐述 Fiber 的设计动机、数据结构和工作流程。
考察点:Fiber、可中断渲染、时间切片
React 15 的问题(Stack Reconciler)
Stack Reconciler 的递归渲染:
setState() → 递归 diff 整棵树 → 同步更新 DOM
↑ 不可中断!一旦开始必须走完
问题: 大组件树 diff 耗时 > 16ms → 掉帧 → 页面卡顿
用户输入得不到响应,因为 JS 线程被 diff 独占Fiber 是什么
Fiber 是 React 16 引入的新架构,本质是一个链表结构的虚拟 DOM 节点,使渲染过程可以中断、恢复、分优先级。
typescript
interface FiberNode {
tag: number // 组件类型 (FunctionComponent, HostComponent...)
type: any // div / MyComponent / ...
stateNode: any // 真实 DOM 节点 / 类组件实例
return: FiberNode | null // 父节点
child: FiberNode | null // 第一个子节点
sibling: FiberNode | null // 下一个兄弟节点
pendingProps: any
memoizedProps: any
memoizedState: any // hooks 链表的头节点
flags: number // 副作用标记 (Placement, Update, Deletion)
lanes: number // 优先级
alternate: FiberNode | null // 双缓冲: current ↔ workInProgress
}核心:链表替代递归
旧 Stack(递归调用栈 → 不可中断):
render(A)
render(B)
render(C)
render(D)
render(E)
// 必须全部执行完才能返回
新 Fiber(链表遍历 → 随时可暂停):
A → child → B → child → C → sibling → D → return → B → sibling → E
↑ 每处理一个节点,检查是否需要让出线程
使用 child / sibling / return 指针组成链表:
A
/
B → E
/
C → D两个工作阶段
| 阶段 | Render(Reconciliation) | Commit |
|---|---|---|
| 可中断 | ✅ 可以暂停、恢复 | ❌ 同步执行,不可中断 |
| 做什么 | diff 对比,标记变更(flags) | 将变更应用到真实 DOM |
| 副作用 | 无(纯计算) | DOM 操作、生命周期、Effect |
| 触发时机 | setState / props 变化 | Render 阶段完成后 |
工作流程:
Render Phase Commit Phase
(可中断) (不可中断)
scheduler ─→ beginWork ─→ completeWork ─→ commitRoot
时间切片 ↓ 向下 ↓ 向上 ↓
优先级调度 构建 fiber 收集副作用 更新 DOM
diff 对比 生成 flags 调用 Effect双缓冲(Double Buffering)
current 树: 当前屏幕上显示的
workInProgress 树: 正在后台构建的
setState() → 基于 current 构建 workInProgress
→ Render 完成后,workInProgress 变成新的 current
→ 交换指针,旧 current 留作下次复用
类似游戏引擎的双缓冲: 后台画好 → 一次性切换 → 无闪烁追问延伸
- Fiber 的
lanes优先级模型是什么?和之前的expirationTime有什么区别? - 时间切片(Time Slicing)的具体实现?
requestIdleCallbackvsMessageChannel? - 为什么 Render 阶段可以中断但 Commit 阶段不行?
2. React 的 Diff 算法(Reconciliation)的策略是什么? ⭐⭐⭐
说明 React diff 的三层策略和 key 的作用。
考察点:三层 Diff 策略、O(n) 复杂度、key
为什么需要 Diff
完整的树 diff: O(n³) — 两棵树的节点两两比较 + 最小编辑距离
React 的 diff: O(n) — 通过三个假设大幅简化三层 Diff 策略
① Tree Diff(跨层级比较)
假设: DOM 节点跨层级移动极少
策略: 只比较同一层级的节点,不跨层级
A → B → C A → B → D
→ 直接删除 C,创建 D
不会尝试"移动" C 到 D 的位置② Component Diff(组件级比较)
假设: 相同类型的组件生成相似的树
策略:
类型相同 → 保留实例,递归 diff 子节点
类型不同 → 直接销毁旧组件,创建新组件
<MyList /> → <MyList /> → 保留,对比 props
<MyList /> → <YourList /> → 卸载 MyList,挂载 YourList③ Element Diff(同级元素比较)
假设: 通过 key 标识可以高效匹配同级节点
策略: 遍历新旧子节点列表
- key 相同、type 相同 → 复用
- key 相同、type 不同 → 销毁重建
- 旧列表有多余 → 删除
- 新列表有多余 → 新增Key 的 Diff 过程
旧列表: [A:1] [B:2] [C:3] [D:4]
新列表: [D:4] [A:1] [B:2] [C:3]
React 的处理 (单向遍历):
1. 用 Map 存储旧节点: { 1→A, 2→B, 3→C, 4→D }
2. 遍历新列表,记录 lastPlacedIndex = 0
3. D(key=4): 旧位置 3, 3 >= 0 → 不移动, lastPlacedIndex = 3
4. A(key=1): 旧位置 0, 0 < 3 → 移动!
5. B(key=2): 旧位置 1, 1 < 3 → 移动!
6. C(key=3): 旧位置 2, 2 < 3 → 移动!
结果: 移动了 A、B、C(不是最优,但算法简单快速)
最优解只需移动 D 到开头,但 React 选择了 O(n) 的简单算法为什么不能用 index 做 key
jsx
// ❌ 用 index 做 key
{items.map((item, index) => <Item key={index} data={item} />)}
// 删除第一项时:
// 旧: [key=0: A] [key=1: B] [key=2: C]
// 新: [key=0: B] [key=1: C]
//
// React 看到 key=0 还在 → 复用节点 → 但 props 从 A 变成 B
// 导致: 所有节点都更新了 props,而不是简单删除第一个
// 如果 Item 有内部 state → state 错乱!追问延伸
- React 的 diff 为什么只做单向遍历?Vue 的双端 diff 有什么优势?
key={Math.random()}会怎样?(每次都重建,性能灾难)- React 18 的 Offscreen 组件和 diff 有什么关系?
3. useState 的原理?为什么不能在条件语句中调用? ⭐⭐
解释 Hooks 的链表存储机制。
考察点:Hooks 链表、调用顺序
简化原理
typescript
let hookIndex = 0
let hooks: any[] = []
function useState<T>(initialValue: T): [T, (v: T) => void] {
const currentIndex = hookIndex
if (hooks[currentIndex] === undefined) {
hooks[currentIndex] = initialValue
}
const setState = (newValue: T) => {
hooks[currentIndex] = newValue
rerender()
}
hookIndex++
return [hooks[currentIndex], setState]
}
function rerender() {
hookIndex = 0 // 重置索引
render() // 重新执行组件函数
}真实的 Fiber Hooks 链表
每个 FiberNode 的 memoizedState 指向 hooks 链表:
FiberNode.memoizedState
→ Hook { memoizedState: 'hello', next: ─→ }
Hook { memoizedState: 42, next: ─→ }
Hook { memoizedState: fn, next: null }
useState('hello') useState(42) useEffect(fn)为什么不能在条件语句中调用
jsx
// ❌ 条件调用 Hook
function MyComponent({ show }) {
const [name, setName] = useState('Alice') // Hook 0
if (show) {
const [count, setCount] = useState(0) // Hook 1(有时候)
}
const [age, setAge] = useState(25) // Hook 2 或 Hook 1?
// 第一次渲染 (show=true):
// Hook 0 → name='Alice'
// Hook 1 → count=0
// Hook 2 → age=25
// 第二次渲染 (show=false):
// Hook 0 → name='Alice'
// Hook 1 → age=25 ← 读到了 count 的位置!💥
// 链表顺序错乱!
}正确做法:
// ✅ Hooks 总在顶层调用
function MyComponent({ show }) {
const [name, setName] = useState('Alice')
const [count, setCount] = useState(0) // 始终调用
const [age, setAge] = useState(25)
// 条件逻辑放在 Hook 外面
if (show) {
// 使用 count
}
}useState 的批量更新
jsx
function Counter() {
const [count, setCount] = useState(0)
const handleClick = () => {
setCount(count + 1) // 0 + 1
setCount(count + 1) // 0 + 1(还是旧的 count!)
setCount(count + 1) // 0 + 1
// 最终 count = 1,不是 3
}
const handleClickCorrect = () => {
setCount(c => c + 1) // 0 → 1
setCount(c => c + 1) // 1 → 2
setCount(c => c + 1) // 2 → 3
// ✅ 函数式更新,基于最新值
}
}追问延伸
useReducer和useState的关系?(useState 内部就是 useReducer)- React 用什么机制检测 Hooks 调用顺序?(开发模式的 eslint-plugin-react-hooks)
useState的 initialValue 如果是函数(惰性初始化)有什么好处?
4. useEffect 和 useLayoutEffect 的区别? ⭐⭐
对比两者的执行时机,以及何时选用
useLayoutEffect。
考察点:Effect 生命周期、执行时机
执行时机对比
浏览器渲染流程中的位置:
setState()
→ Render Phase (fiber diff)
→ Commit Phase:
① DOM 变更(同步)
② useLayoutEffect 回调(同步,在绘制前) ← 阻塞浏览器绘制
③ 浏览器绘制(Paint)
④ useEffect 回调(异步,在绘制后) ← 不阻塞
时间线:
─── Render ──→ DOM 变更 ──→ useLayoutEffect ──→ Paint ──→ useEffect ───
(同步,阻塞) (异步)核心区别
| 维度 | useEffect | useLayoutEffect |
|---|---|---|
| 执行时机 | 浏览器绘制之后 | 浏览器绘制之前 |
| 同步/异步 | 异步 | 同步 |
| 是否阻塞渲染 | ❌ | ✅ |
| 适用场景 | 数据获取、订阅、日志 | DOM 测量、防闪烁 |
| SSR 兼容 | ✅ | ⚠️ 服务端会报 Warning |
什么时候用 useLayoutEffect
jsx
// ❌ useEffect → 闪烁
function Tooltip({ targetRef }) {
const [position, setPosition] = useState({ top: 0, left: 0 })
useEffect(() => {
const rect = targetRef.current.getBoundingClientRect()
setPosition({ top: rect.bottom, left: rect.left })
}, [])
// 问题: 先渲染默认位置 → 绘制 → 再更新位置 → 闪烁!
return <div style={position}>Tooltip</div>
}
// ✅ useLayoutEffect → 无闪烁
function Tooltip({ targetRef }) {
const [position, setPosition] = useState({ top: 0, left: 0 })
useLayoutEffect(() => {
const rect = targetRef.current.getBoundingClientRect()
setPosition({ top: rect.bottom, left: rect.left })
}, [])
// DOM 变更后、绘制前就计算好位置,用户看不到中间状态
return <div style={position}>Tooltip</div>
}useEffect 的清理时机
jsx
useEffect(() => {
const handler = () => console.log('scroll')
window.addEventListener('scroll', handler)
return () => {
// 清理函数在以下时机执行:
// 1. 组件卸载时
// 2. 依赖变化导致 effect 重新执行前
window.removeEventListener('scroll', handler)
}
}, [dep]) // dep 变化 → 先执行旧的清理 → 再执行新的 effect追问延伸
useEffect的依赖数组为[]、省略、[dep]分别什么行为?- 为什么 React 18 的 StrictMode 会让 useEffect 执行两次?
useInsertionEffect是什么?和useLayoutEffect有什么区别?
5. useMemo 和 useCallback 什么时候该用? ⭐⭐
说明两者的区别和正确使用时机。
考察点:记忆化、性能优化、过度优化
本质区别
typescript
// useMemo: 缓存计算结果(值)
const memoizedValue = useMemo(() => expensiveComputation(a, b), [a, b])
// useCallback: 缓存函数引用
const memoizedFn = useCallback((x) => doSomething(x, a), [a])
// useCallback 是 useMemo 的语法糖:
useCallback(fn, deps) ≡ useMemo(() => fn, deps)✅ 该用的场景
jsx
// ① 传给 React.memo 子组件的回调
const Parent = () => {
const [count, setCount] = useState(0)
// ❌ 每次渲染都创建新函数 → 子组件 memo 失效
const handleClick = () => setCount(c => c + 1)
// ✅ 引用稳定 → 子组件 memo 生效
const handleClick = useCallback(() => setCount(c => c + 1), [])
return <MemoChild onClick={handleClick} />
}
// ② 真正昂贵的计算
const result = useMemo(() => {
return items
.filter(item => item.category === category)
.sort((a, b) => b.score - a.score)
.slice(0, 100)
}, [items, category])
// ③ 作为其他 Hook 的依赖
const config = useMemo(() => ({ theme, locale }), [theme, locale])
useEffect(() => {
initSDK(config)
}, [config]) // 如果不 memo,每次渲染 config 都是新对象 → effect 无限触发❌ 不该用的场景
jsx
// ❌ 简单计算,memo 本身的开销 > 重新计算
const fullName = useMemo(() => `${first} ${last}`, [first, last])
// 直接写: const fullName = `${first} ${last}`
// ❌ 没有传给 memo 子组件的普通函数
const handleChange = useCallback((e) => {
setName(e.target.value)
}, [])
// 如果子组件没有被 memo,缓存这个函数毫无意义
// 直接写: const handleChange = (e) => setName(e.target.value)
// ❌ 原始值(不存在引用比较问题)
const doubled = useMemo(() => count * 2, [count])
// 直接写: const doubled = count * 2判断决策树
这个值/函数需要缓存吗?
├── 它会作为 props 传给 React.memo 子组件?
│ └── ✅ 用 useCallback / useMemo
├── 计算真的很昂贵(几 ms 以上)?
│ └── ✅ 用 useMemo
├── 它作为 useEffect / useMemo 的依赖?
│ ├── 是对象/数组/函数? → ✅ 用 useMemo / useCallback
│ └── 是原始值? → ❌ 不需要
└── 以上都不是?
└── ❌ 不需要,过度优化追问延伸
- React Compiler(React Forget)是什么?它能自动处理 memo 吗?
useMemo能否保证缓存永远不被丢弃?(不能,React 可能在未来释放)- 为什么说"过早优化是万恶之源"在 React 中尤其适用?
6. React.memo 的原理?浅比较的坑? ⭐⭐
解释
React.memo的工作机制和常见陷阱。
考察点:渲染优化、浅比较
基本原理
jsx
// React.memo 是高阶组件,对 props 做浅比较
// props 不变 → 跳过渲染(返回上次的渲染结果)
const MemoChild = React.memo(function Child({ name, data }) {
console.log('render')
return <div>{name}: {JSON.stringify(data)}</div>
})
// 等价于类组件的 shouldComponentUpdate + 浅比较
// 或 PureComponent浅比较的规则
javascript
function shallowEqual(prevProps, nextProps) {
const prevKeys = Object.keys(prevProps)
const nextKeys = Object.keys(nextProps)
if (prevKeys.length !== nextKeys.length) return false
for (const key of prevKeys) {
if (!Object.is(prevProps[key], nextProps[key])) {
return false
}
}
return true
}
// Object.is 比较:
// 原始值: 值相等即相等
// 引用值: 引用地址相同才相等 ← 坑在这里浅比较的坑
jsx
function Parent() {
const [count, setCount] = useState(0)
// ❌ 坑 1: 每次渲染创建新的对象/数组
const style = { color: 'red' } // 新引用 → memo 失效
const items = [1, 2, 3] // 新引用 → memo 失效
const handleClick = () => {} // 新引用 → memo 失效
// ✅ 修复: useMemo / useCallback
const style = useMemo(() => ({ color: 'red' }), [])
const items = useMemo(() => [1, 2, 3], [])
const handleClick = useCallback(() => {}, [])
return <MemoChild style={style} items={items} onClick={handleClick} />
}
// ❌ 坑 2: children 总是新的
function Parent() {
return (
<MemoChild>
<span>Hello</span> {/* JSX 每次都是新对象!memo 失效 */}
</MemoChild>
)
}
// ❌ 坑 3: 展开运算符
function Parent(props) {
return <MemoChild {...props} />
// 如果 props 中有对象属性,每次父组件渲染 memo 都会失效
}自定义比较函数
jsx
const MemoChild = React.memo(
function Child({ data, timestamp }) {
return <div>{data.name}</div>
},
(prevProps, nextProps) => {
// 返回 true → 跳过渲染(和 shouldComponentUpdate 相反!)
return prevProps.data.id === nextProps.data.id
// 只比较 data.id,忽略 timestamp 的变化
}
)追问延伸
React.memo和useMemo的区别?(组件级 vs 值级缓存)- 为什么 React 不默认对所有组件做 memo?(浅比较也有开销)
- React Compiler 如何自动优化掉手动 memo?
7. useRef 的两种用法?为什么不会触发重渲染? ⭐⭐
解释
useRef的工作原理和典型使用场景。
考察点:Ref 原理、DOM 引用、可变值容器
useRef 的本质
typescript
// useRef 返回一个 { current: T } 对象
// 这个对象在组件的整个生命周期内保持同一引用
function useRef<T>(initialValue: T): { current: T } {
// 首次渲染: 创建 ref 对象
// 后续渲染: 返回同一个对象(不是新建的)
return useMemo(() => ({ current: initialValue }), [])
}为什么不触发重渲染
useState: 调用 setState → 标记组件需要更新 → 触发重渲染
useRef: 修改 ref.current → 只是改了一个普通对象的属性
→ 没有通知 React → 不会重渲染
ref.current = newValue // 静默修改,React 不知道
setState(newValue) // 通知 React,触发调度用法一:访问 DOM 节点
jsx
function TextInput() {
const inputRef = useRef<HTMLInputElement>(null)
const focusInput = () => {
inputRef.current?.focus()
}
return (
<>
<input ref={inputRef} />
<button onClick={focusInput}>聚焦</button>
</>
)
}用法二:保存可变值(跨渲染持久化)
jsx
// ① 保存 timer ID
function Timer() {
const timerRef = useRef<number | null>(null)
const start = () => {
timerRef.current = window.setInterval(() => {
console.log('tick')
}, 1000)
}
const stop = () => {
if (timerRef.current) {
clearInterval(timerRef.current)
}
}
useEffect(() => stop, []) // 组件卸载时清除
return <button onClick={start}>Start</button>
}
// ② 保存前一个值
function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T>()
useEffect(() => {
ref.current = value
})
return ref.current // 返回的是更新前的值
}
// ③ 避免 useEffect 闭包陷阱
function Chat({ onMessage }) {
const onMessageRef = useRef(onMessage)
onMessageRef.current = onMessage // 每次渲染更新
useEffect(() => {
socket.on('message', (msg) => {
onMessageRef.current(msg) // 始终调用最新的 onMessage
})
}, []) // 依赖为空,但通过 ref 访问最新值
}ref vs state 选择
| 需要重渲染? | 需要在 JSX 中显示? | 选择 |
|---|---|---|
| ✅ | ✅ | useState |
| ❌ | ❌ | useRef |
| ❌ | ❌ | 普通变量(如果不需要跨渲染保持) |
追问延伸
useRef(null)和createRef()的区别?(每次渲染是否创建新对象)ref回调函数写法是什么?什么时候需要它?forwardRef解决什么问题?(下第 17 题详解)
8. React 的合成事件系统是什么? ⭐⭐⭐
解释 React 合成事件的原理和与原生事件的区别。
考察点:事件委托、合成事件、事件池
什么是合成事件
React 不直接在 DOM 元素上绑定事件,而是在根节点统一监听,创建自己的 SyntheticEvent 对象。
原生事件:
<button onclick="handleClick()"> → 每个元素各自绑定
React 合成事件:
<button onClick={handleClick}>
↓
实际绑定到 root(React 18)/ document(React 16)
↓
事件冒泡到 root 时,React 查找对应 fiber
↓
构建 SyntheticEvent,模拟捕获→目标→冒泡React 16 vs 18 的变化
| 版本 | 事件委托挂载点 | 原因 |
|---|---|---|
| React 16 | document | 所有 React 实例共享一个 document |
| React 17+ | rootNode(#root) | 支持多个 React 实例共存(微前端) |
React 17+ 架构:
#root1 (React App A)
├── 事件委托绑在 #root1
└── 内部事件不会影响 App B
#root2 (React App B)
├── 事件委托绑在 #root2
└── 内部事件不会影响 App A合成事件 vs 原生事件
| 维度 | 合成事件 SyntheticEvent | 原生事件 |
|---|---|---|
| 跨浏览器 | ✅ 统一接口 | ❌ 各浏览器有差异 |
| 事件委托 | 自动(挂在 root) | 需手动实现 |
| 执行顺序 | 冒泡到 root 后才处理 | DOM 上直接触发 |
e.stopPropagation() | 阻止合成事件冒泡 | 阻止原生冒泡 |
e.nativeEvent | 可以访问原生事件 | — |
| 事件对象复用 | React 16: 事件池复用 / React 17+: 废除 | 无 |
原生事件和合成事件的执行顺序
jsx
function App() {
const divRef = useRef<HTMLDivElement>(null)
useEffect(() => {
// 原生事件
divRef.current!.addEventListener('click', () => {
console.log('原生 div click')
})
document.addEventListener('click', () => {
console.log('原生 document click')
})
}, [])
return (
<div ref={divRef} onClick={() => console.log('合成 div click')}>
<button onClick={() => console.log('合成 button click')}>
Click
</button>
</div>
)
}
// React 17+ 点击 button 的输出顺序:
// 1. 原生 div click ← 原生事件先冒泡到 div
// 2. 合成 button click ← 冒泡到 root,React 开始处理
// 3. 合成 div click ← React 内部的冒泡
// 4. 原生 document click ← 最后到 document追问延伸
- 在合成事件中调用
e.stopPropagation()能阻止原生事件吗?(React 17+ 可以) - React 16 的事件池(Event Pooling)是什么?为什么 17 废除了?
onClickCapture和onClick的区别?
9. React 的批量更新(Batching)机制?React 18 有什么变化? ⭐⭐⭐
解释 React 的批量更新策略和 React 18 的自动批处理。
考察点:自动批处理、同步/异步更新
什么是批量更新
jsx
function handleClick() {
setCount(1) // 不立即渲染
setFlag(true) // 不立即渲染
setName('Bob') // 不立即渲染
// 函数结束后,只渲染一次!→ 这就是批量更新
}React 17 vs 18 的区别
jsx
// React 17: 只有"React 事件处理器"内才批量更新
function handleClick() {
setCount(1)
setFlag(true)
// ✅ 批量更新,只渲染 1 次
}
setTimeout(() => {
setCount(1) // 渲染 1 次 ❌
setFlag(true) // 渲染 1 次 ❌
// 共 2 次渲染!setTimeout 不在 React 的控制范围内
}, 0)
fetch('/api').then(() => {
setCount(1) // 渲染 1 次 ❌
setFlag(true) // 渲染 1 次 ❌
// Promise 回调也不批量
})jsx
// React 18: 所有更新都自动批量处理 🎉
function handleClick() {
setCount(1)
setFlag(true)
// ✅ 1 次渲染
}
setTimeout(() => {
setCount(1)
setFlag(true)
// ✅ 也是 1 次渲染!
}, 0)
fetch('/api').then(() => {
setCount(1)
setFlag(true)
// ✅ 也是 1 次渲染!
})实现原理
React 18 的自动批处理(基于 Concurrent Mode):
更新入队 → 标记 lane → 微任务中调度
↑
所有同步代码执行完毕后
统一处理队列中的更新
只触发一次渲染
关键: React 18 的更新调度默认使用微任务(queueMicrotask)
而不是同步执行,这样同一个事件循环中的所有 setState 都能被收集退出批处理:flushSync
jsx
import { flushSync } from 'react-dom'
function handleClick() {
flushSync(() => {
setCount(1)
})
// 此时 DOM 已更新,count = 1
flushSync(() => {
setFlag(true)
})
// 此时 DOM 已更新,flag = true
// 共 2 次渲染
}对比总结
| 场景 | React 17 | React 18 |
|---|---|---|
| React 事件处理器 | ✅ 批量 | ✅ 批量 |
| setTimeout / setInterval | ❌ 逐个 | ✅ 批量 |
| Promise.then | ❌ 逐个 | ✅ 批量 |
| 原生事件监听 | ❌ 逐个 | ✅ 批量 |
| 强制同步刷新 | ReactDOM.flushSync | flushSync |
追问延伸
createRoot和旧的ReactDOM.render有什么区别?(新 API 才开启并发特性)- 批量更新和并发模式(Concurrent Mode)是什么关系?
- Vue 的
nextTick和 React 的批量更新有什么异同?
10. Suspense 和 lazy 的原理?ErrorBoundary 怎么实现? ⭐⭐
解释代码分割和错误边界的工作机制。
考察点:代码分割、错误边界、Suspense 原理
React.lazy + Suspense 代码分割
jsx
// 动态 import → 返回 Promise<{ default: Component }>
const LazyDashboard = React.lazy(() => import('./Dashboard'))
function App() {
return (
<Suspense fallback={<LoadingSpinner />}>
<LazyDashboard />
</Suspense>
)
}Suspense 的工作原理
Suspense 利用了"抛出 Promise"的机制:
1. lazy 组件首次渲染时,模块还没加载完
2. lazy 内部 throw 一个 Promise(不是 Error!)
3. 最近的 Suspense 捕获这个 Promise
4. Suspense 渲染 fallback
5. Promise resolve 后(模块加载完),Suspense 重新渲染子树typescript
// 简化的 lazy 实现
function lazy(factory) {
let Component = null
let promise = null
return function LazyComponent(props) {
if (Component) {
return <Component {...props} />
}
if (!promise) {
promise = factory().then(module => {
Component = module.default
})
}
throw promise // ← 关键!抛出 Promise,被 Suspense 捕获
}
}路由级别代码分割
jsx
import { lazy, Suspense } from 'react'
import { Routes, Route } from 'react-router-dom'
const Home = lazy(() => import('./pages/Home'))
const About = lazy(() => import('./pages/About'))
const Dashboard = lazy(() => import('./pages/Dashboard'))
function App() {
return (
<Suspense fallback={<PageSkeleton />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/dashboard" element={<Dashboard />} />
</Routes>
</Suspense>
)
}ErrorBoundary 错误边界
tsx
import { Component, ErrorInfo, ReactNode } from 'react'
interface Props {
fallback: ReactNode | ((error: Error) => ReactNode)
children: ReactNode
}
interface State {
hasError: boolean
error: Error | null
}
class ErrorBoundary extends Component<Props, State> {
state: State = { hasError: false, error: null }
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error }
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('ErrorBoundary caught:', error, errorInfo)
// 上报错误到监控系统
}
render() {
if (this.state.hasError) {
const { fallback } = this.props
return typeof fallback === 'function'
? fallback(this.state.error!)
: fallback
}
return this.props.children
}
}
// 使用
<ErrorBoundary fallback={(error) => <ErrorPage message={error.message} />}>
<Suspense fallback={<Loading />}>
<LazyComponent />
</Suspense>
</ErrorBoundary>ErrorBoundary 无法捕获的错误
| 能捕获 | 不能捕获 |
|---|---|
| 渲染过程中的错误 | 事件处理器中的错误(用 try-catch) |
| 生命周期方法的错误 | 异步代码(setTimeout、fetch) |
| 子组件树的错误 | SSR 中的错误 |
| — | ErrorBoundary 自身的错误 |
Suspense 的未来:Data Fetching
jsx
// React 18+ 配合 use() hook(实验性)
function UserProfile({ userId }) {
const user = use(fetchUser(userId)) // 抛出 Promise → Suspense 处理
return <div>{user.name}</div>
}
<Suspense fallback={<Skeleton />}>
<UserProfile userId={1} />
</Suspense>追问延伸
- 为什么 ErrorBoundary 只能用 Class 组件实现?(Hooks 没有
getDerivedStateFromError) Suspense嵌套时的行为是什么?(就近捕获)- React Server Components 中的
Suspense和客户端有什么不同?(流式 HTML)
11. React 的 Context 性能问题?如何优化? ⭐⭐⭐
分析 Context 导致的重渲染问题和优化方案。
考察点:Context 重渲染、性能优化
Context 的重渲染问题
jsx
const ThemeContext = React.createContext({ theme: 'light', locale: 'zh' })
function App() {
const [theme, setTheme] = useState('light')
const [locale, setLocale] = useState('zh')
// ❌ 每次 App 渲染都创建新对象 → 所有 Consumer 都重渲染
return (
<ThemeContext.Provider value={{ theme, locale }}>
<Header /> {/* 只用 theme,但 locale 变化也会重渲染 */}
<Sidebar /> {/* 只用 locale,但 theme 变化也会重渲染 */}
<Content />
</ThemeContext.Provider>
)
}Context 的核心问题:
1. Provider 的 value 变化 → 所有 useContext 的组件都重渲染
2. 即使组件只用了 value 中的一个字段,其他字段变了也会重渲染
3. React.memo 对 useContext 无效(Context 绕过了 props 比较)优化方案
① 拆分 Context
jsx
const ThemeContext = React.createContext('light')
const LocaleContext = React.createContext('zh')
function App() {
const [theme, setTheme] = useState('light')
const [locale, setLocale] = useState('zh')
return (
<ThemeContext.Provider value={theme}>
<LocaleContext.Provider value={locale}>
<Header /> {/* 只订阅 ThemeContext,locale 变化不影响 */}
<Sidebar /> {/* 只订阅 LocaleContext,theme 变化不影响 */}
</LocaleContext.Provider>
</ThemeContext.Provider>
)
}② useMemo 稳定 value
jsx
function App() {
const [theme, setTheme] = useState('light')
const [count, setCount] = useState(0)
// ✅ 只有 theme 变化时才创建新对象
const contextValue = useMemo(() => ({ theme }), [theme])
return (
<ThemeContext.Provider value={contextValue}>
{/* count 变化不会导致 Consumer 重渲染 */}
<Children />
</ThemeContext.Provider>
)
}③ 状态与派发分离
jsx
const StateContext = React.createContext(null)
const DispatchContext = React.createContext(null)
function TodoProvider({ children }) {
const [state, dispatch] = useReducer(todoReducer, initialState)
return (
<StateContext.Provider value={state}>
<DispatchContext.Provider value={dispatch}>
{children}
</DispatchContext.Provider>
</StateContext.Provider>
)
}
// 只需要 dispatch 的组件不会因 state 变化而重渲染
function AddTodo() {
const dispatch = useContext(DispatchContext) // dispatch 引用稳定
return <button onClick={() => dispatch({ type: 'add' })}>Add</button>
}④ 中间组件 + memo 隔离
jsx
function TodoList() {
const { todos } = useContext(StateContext)
return todos.map(todo => (
<MemoTodoItem key={todo.id} todo={todo} />
))
}
const MemoTodoItem = React.memo(function TodoItem({ todo }) {
return <div>{todo.text}</div>
})追问延伸
- 为什么
React.memo对使用了useContext的组件无效? use(Context)(React 19) 和useContext有什么区别?- Zustand / Jotai 如何避免 Context 的重渲染问题?(下一题详解)
12. Redux / Zustand / Jotai 的核心区别?如何选型? ⭐⭐
对比主流 React 状态管理方案的设计理念。
考察点:状态管理
设计理念对比
| 维度 | Redux | Zustand | Jotai |
|---|---|---|---|
| 模型 | 单一 Store + Reducer | 单一 Store(简化版) | 原子化(Atom) |
| 理念 | Flux 单向数据流 | 去 boilerplate 的 Redux | 自底向上的原子状态 |
| 样板代码 | 多(action, reducer, dispatch) | 少 | 极少 |
| 包大小 | ~2KB + toolkit ~11KB | ~1KB | ~2KB |
| DevTools | ✅ 强大 | ✅ 支持 | ✅ 支持 |
| 异步 | middleware(thunk/saga) | 内置支持 | 内置支持 |
| 渲染优化 | selector + memo | 自动(selector) | 自动(原子粒度) |
| 学习曲线 | 陡 | 平缓 | 平缓 |
代码对比
typescript
// Redux Toolkit
import { configureStore, createSlice } from '@reduxjs/toolkit'
const counterSlice = createSlice({
name: 'counter',
initialState: { value: 0 },
reducers: {
increment: state => { state.value += 1 },
decrement: state => { state.value -= 1 },
},
})
const store = configureStore({ reducer: { counter: counterSlice.reducer } })
function Counter() {
const count = useSelector(state => state.counter.value)
const dispatch = useDispatch()
return <button onClick={() => dispatch(counterSlice.actions.increment())}>{count}</button>
}typescript
// Zustand — 极简
import { create } from 'zustand'
const useCounterStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
}))
function Counter() {
const count = useCounterStore((state) => state.count)
const increment = useCounterStore((state) => state.increment)
return <button onClick={increment}>{count}</button>
}typescript
// Jotai — 原子化
import { atom, useAtom } from 'jotai'
const countAtom = atom(0)
const doubleAtom = atom((get) => get(countAtom) * 2) // 派生原子
function Counter() {
const [count, setCount] = useAtom(countAtom)
return <button onClick={() => setCount(c => c + 1)}>{count}</button>
}
function Double() {
const [double] = useAtom(doubleAtom) // 自动追踪依赖
return <div>{double}</div>
}选型建议
项目选型决策树:
├── 大型团队 + 需要严格规范 + 时间旅行调试
│ └── Redux Toolkit
├── 中小型项目 + 全局状态为主 + 简单直接
│ └── Zustand(推荐)
├── 状态细粒度 + 组件级状态为主 + 类似 useState 风格
│ └── Jotai
├── 服务端状态(API 缓存)
│ └── TanStack Query / SWR(不是状态管理!)
└── 能用 props + Context 解决的
└── 不需要状态管理库追问延伸
- Zustand 内部是如何实现 selector 级别的渲染优化的?(useSyncExternalStore)
- Jotai 和 Recoil 有什么区别?(Jotai 更轻量,不需要 Provider)
- 服务端状态和客户端状态该分开管理吗?
13. 什么是 Server Components?和 SSR 有什么区别? ⭐⭐⭐
对比 React Server Components(RSC)和传统 SSR 的本质差异。
考察点:RSC、Next.js
SSR vs RSC
传统 SSR:
服务器 → 生成 HTML 字符串 → 发送到浏览器
浏览器 → 下载 JS → Hydration(注水) → 变成可交互的 React 应用
↑ 所有组件的 JS 都要发送到客户端
React Server Components (RSC):
服务器 → 执行 Server Component → 生成序列化的 React 树(不是 HTML)
浏览器 → 只下载 Client Component 的 JS
↑ Server Component 的 JS 永远不会发送到客户端!核心区别
| 维度 | SSR | RSC |
|---|---|---|
| 目标 | 首屏渲染加速 | 减少客户端 JS 体积 |
| 服务器做什么 | 渲染 HTML 字符串 | 执行组件逻辑,输出序列化树 |
| Hydration | ✅ 需要(全量 JS) | ❌ Server Component 不需要 |
| 交互性 | Hydration 后可交互 | Server Component 不可交互 |
| 数据获取 | 服务器获取 + 序列化到 HTML | 直接在组件中 await |
| JS Bundle | 所有组件的 JS 都发送 | 只发送 Client Component 的 JS |
Next.js App Router 中的实践
tsx
// app/page.tsx — 默认是 Server Component
async function ProductPage({ params }) {
// ✅ 直接在组件中 await,不需要 getServerSideProps
const product = await db.query('SELECT * FROM products WHERE id = ?', [params.id])
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<AddToCartButton productId={product.id} /> {/* Client Component */}
</div>
)
}
// components/AddToCartButton.tsx
'use client' // ← 标记为 Client Component
import { useState } from 'react'
export function AddToCartButton({ productId }) {
const [added, setAdded] = useState(false)
return (
<button onClick={() => setAdded(true)}>
{added ? '已加入' : '加入购物车'}
</button>
)
}Server / Client Component 规则
Server Component(默认):
✅ async/await、直接访问数据库/文件系统
✅ 使用服务器端 API(fs, crypto, db)
✅ 减少客户端 JS 体积
❌ 不能使用 useState, useEffect 等 Hooks
❌ 不能使用浏览器 API(window, document)
❌ 不能使用事件处理器(onClick 等)
Client Component ('use client'):
✅ useState, useEffect, 事件处理器
✅ 浏览器 API
❌ 不能直接访问数据库
❌ 会增加 JS Bundle
组合规则:
Server Component → 可以 import Client Component ✅
Client Component → 不能 import Server Component ❌
Client Component → 可以通过 children 接收 Server Component ✅追问延伸
- RSC 的序列化格式(RSC Payload)是什么样的?
'use client'指令的"边界"如何工作?- RSC 和 SSR 可以共存吗?(可以,Next.js App Router 同时使用两者)
14. React 的 key 为什么重要? ⭐⭐
深入解析 key 对 Reconciliation 的影响。
考察点:Reconciliation、key
key 的作用
key 是 React 在同级元素列表中识别"谁是谁"的唯一标识
没有 key(或用 index):
React 只能按顺序对比 → 增删元素导致大量不必要的更新
有唯一 key:
React 用 Map 快速匹配新旧节点 → 精确复用/移动/删除常见错误和后果
jsx
// ❌ 错误 1: 用 index 做 key + 有内部 state
function TodoList({ todos }) {
return todos.map((todo, index) => (
<TodoItem key={index} todo={todo} />
))
}
// 在列表头部插入新项时:
// 旧: [key=0: A✓] [key=1: B] [key=2: C]
// 新: [key=0: NEW] [key=1: A] [key=2: B] [key=3: C]
// key=0 被复用 → TodoItem 的 state(checked) 留在原地
// 结果: NEW 项显示为已勾选 ← state 错乱!
// ❌ 错误 2: 用 Math.random() 做 key
<Item key={Math.random()} />
// 每次渲染 key 都不同 → 旧节点全部销毁,新节点全部创建
// 完全失去 diff 优化
// ✅ 正确: 用数据中的唯一 ID
<Item key={item.id} />key 的"重置"技巧
jsx
// 利用 key 变化 → 组件销毁重建 → 重置所有 state
function EditProfile({ userId }) {
// userId 变化时,整个表单重置
return <ProfileForm key={userId} userId={userId} />
}
// 对比不用 key 的写法:
function EditProfile({ userId }) {
const [name, setName] = useState('')
const [email, setEmail] = useState('')
// 需要手动 reset...
useEffect(() => {
setName('')
setEmail('')
}, [userId])
}key 在非列表场景的应用
jsx
// 切换动画: 改 key → 旧组件卸载(exit 动画)→ 新组件挂载(enter 动画)
<AnimatePresence>
<motion.div key={selectedTab}>
<TabContent tab={selectedTab} />
</motion.div>
</AnimatePresence>追问延伸
- 什么情况下用 index 做 key 是安全的?(列表不排序/不增删/无内部 state)
- 为什么 React 不自动生成 key?(组件无法自动推断数据的唯一性)
- Vue 的 key 和 React 的 key 有什么行为差异?
15. 如何封装一个好的自定义 Hook? ⭐⭐
给出自定义 Hook 的设计原则和实际案例。
考察点:Hook 设计
设计原则
1. 单一职责 — 一个 Hook 做一件事
2. 命名以 use 开头 — useXxx
3. 返回值清晰 — [value, setter] 或 { data, loading, error }
4. 参数可配置 — 提供合理默认值
5. 清理副作用 — 组件卸载时不留后患
6. TypeScript 类型完善 — 泛型支持案例一:useLocalStorage
typescript
function useLocalStorage<T>(
key: string,
initialValue: T
): [T, (value: T | ((prev: T) => T)) => void] {
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key)
return item ? JSON.parse(item) : initialValue
} catch {
return initialValue
}
})
const setValue = useCallback(
(value: T | ((prev: T) => T)) => {
setStoredValue(prev => {
const nextValue = value instanceof Function ? value(prev) : value
window.localStorage.setItem(key, JSON.stringify(nextValue))
return nextValue
})
},
[key]
)
return [storedValue, setValue]
}
// 使用
const [theme, setTheme] = useLocalStorage('theme', 'light')案例二:useFetch
typescript
interface UseFetchResult<T> {
data: T | null
loading: boolean
error: Error | null
refetch: () => void
}
function useFetch<T>(url: string, options?: RequestInit): UseFetchResult<T> {
const [data, setData] = useState<T | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
const fetchData = useCallback(async () => {
setLoading(true)
setError(null)
try {
const response = await fetch(url, options)
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const json = await response.json()
setData(json)
} catch (err) {
setError(err instanceof Error ? err : new Error(String(err)))
} finally {
setLoading(false)
}
}, [url])
useEffect(() => {
const controller = new AbortController()
fetchData()
return () => controller.abort()
}, [fetchData])
return { data, loading, error, refetch: fetchData }
}
// 使用
const { data: users, loading, error } = useFetch<User[]>('/api/users')案例三:useDebounce
typescript
function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value)
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay)
return () => clearTimeout(timer)
}, [value, delay])
return debouncedValue
}
// 使用
function SearchInput() {
const [query, setQuery] = useState('')
const debouncedQuery = useDebounce(query, 300)
useEffect(() => {
if (debouncedQuery) {
fetchSearchResults(debouncedQuery)
}
}, [debouncedQuery])
return <input value={query} onChange={e => setQuery(e.target.value)} />
}追问延伸
- 自定义 Hook 和普通函数的区别?(内部可以调用其他 Hooks)
- 如何测试自定义 Hook?(
@testing-library/react-hooks/renderHook) - Hook 如何做到"组合优于继承"?
16. React 的并发模式是什么?startTransition 怎么用? ⭐⭐⭐
解释并发模式的核心概念和 API。
考察点:并发特性
什么是并发模式
React 17(同步渲染):
用户输入 → setState → 同步渲染整棵树 → 完成
↑ 如果渲染耗时长,用户输入会卡顿
React 18(并发渲染):
用户输入 → setState → 开始渲染...
↑ 有更高优先级的更新?
→ 暂停当前渲染
→ 处理高优先级更新(如用户输入)
→ 恢复低优先级渲染
核心: 渲染可以被中断和恢复,不同更新有不同优先级startTransition
jsx
import { useState, startTransition } from 'react'
function SearchPage() {
const [input, setInput] = useState('')
const [results, setResults] = useState([])
const handleChange = (e) => {
// 高优先级: 立即更新输入框(用户能看到自己打了什么)
setInput(e.target.value)
// 低优先级: 搜索结果可以延迟更新
startTransition(() => {
setResults(filterLargeList(e.target.value))
})
}
return (
<>
<input value={input} onChange={handleChange} />
<ResultList results={results} />
</>
)
}useTransition
jsx
function TabContainer() {
const [tab, setTab] = useState('home')
const [isPending, startTransition] = useTransition()
const handleTabChange = (nextTab) => {
startTransition(() => {
setTab(nextTab) // 低优先级切换
})
}
return (
<>
<TabBar activeTab={tab} onChange={handleTabChange} />
{isPending && <Spinner />} {/* isPending 可以显示加载状态 */}
<TabContent tab={tab} />
</>
)
}useDeferredValue
jsx
function SearchResults({ query }) {
// query 变化很频繁,但渲染结果很重
const deferredQuery = useDeferredValue(query)
const isStale = query !== deferredQuery
return (
<div style={{ opacity: isStale ? 0.5 : 1 }}>
<HeavyList query={deferredQuery} />
</div>
)
}对比
| API | 用途 | 场景 |
|---|---|---|
startTransition | 标记低优先级更新 | 搜索筛选、Tab 切换 |
useTransition | startTransition + isPending | 需要 loading 状态 |
useDeferredValue | 延迟一个值的更新 | 接收外部 props,延迟渲染 |
追问延伸
startTransition和setTimeout(() => setState(), 0)有什么区别?- Transition 的优先级在 Fiber 的 lanes 模型中是什么位置?
- 并发模式下,为什么组件可能渲染两次但只 commit 一次?
17. forwardRef 和 useImperativeHandle 的使用场景? ⭐⭐
解释 Ref 转发和命令式接口暴露。
考察点:Ref 转发
为什么需要 forwardRef
jsx
// ❌ ref 不能直接传给函数组件
function Input(props) {
return <input {...props} />
}
const ref = useRef()
<Input ref={ref} /> // Warning: Function components cannot be given refs
// ✅ forwardRef 转发 ref
const Input = forwardRef(function Input(props, ref) {
return <input ref={ref} {...props} />
})
const ref = useRef()
<Input ref={ref} /> // 现在 ref.current = <input> DOMuseImperativeHandle — 自定义暴露的接口
tsx
interface InputHandle {
focus: () => void
clear: () => void
getValue: () => string
}
const FancyInput = forwardRef<InputHandle, InputProps>(function FancyInput(props, ref) {
const inputRef = useRef<HTMLInputElement>(null)
useImperativeHandle(ref, () => ({
focus: () => inputRef.current?.focus(),
clear: () => {
if (inputRef.current) inputRef.current.value = ''
},
getValue: () => inputRef.current?.value ?? '',
}), [])
return <input ref={inputRef} {...props} />
})
// 使用
function Form() {
const inputRef = useRef<InputHandle>(null)
const handleSubmit = () => {
const value = inputRef.current?.getValue()
console.log(value)
inputRef.current?.clear()
}
return (
<>
<FancyInput ref={inputRef} />
<button onClick={handleSubmit}>Submit</button>
</>
)
}实战:Modal 组件
tsx
interface ModalHandle {
open: () => void
close: () => void
}
const Modal = forwardRef<ModalHandle, ModalProps>(function Modal({ children }, ref) {
const [visible, setVisible] = useState(false)
useImperativeHandle(ref, () => ({
open: () => setVisible(true),
close: () => setVisible(false),
}), [])
if (!visible) return null
return <div className="modal-overlay"><div className="modal">{children}</div></div>
})
// 命令式调用
function App() {
const modalRef = useRef<ModalHandle>(null)
return (
<>
<button onClick={() => modalRef.current?.open()}>打开弹窗</button>
<Modal ref={modalRef}>
<p>弹窗内容</p>
<button onClick={() => modalRef.current?.close()}>关闭</button>
</Modal>
</>
)
}React 19 的变化
jsx
// React 19: 不再需要 forwardRef!ref 作为普通 prop
function Input({ ref, ...props }) {
return <input ref={ref} {...props} />
}
// 直接使用
<Input ref={myRef} />追问延伸
- 为什么 React 推荐"声明式"而不是"命令式"?什么时候该用命令式?
useImperativeHandle的第三个参数(依赖数组)有什么用?- HOC 和 forwardRef 的关系?(HOC 会阻断 ref 传递)
18. 如何实现一个虚拟列表? ⭐⭐⭐
解释虚拟列表的原理和 React 实现方案。
考察点:长列表优化
为什么需要虚拟列表
10000 个列表项:
全部渲染 → 10000 个 DOM 节点 → 内存占用高 + 渲染慢 + 滚动卡顿
虚拟列表:
只渲染可视区域的 ~20 个节点 → 滚动时动态替换内容核心原理
┌──────────────────────────┐
│ 不可见区域(上方) │ → 用 padding-top 占位
│ │
├──────────────────────────┤ ← scrollTop
│ │
│ 可视区域(渲染) │ → 只渲染这些 DOM
│ Item 15 │
│ Item 16 │
│ Item 17 │
│ ... │
│ Item 30 │
│ │
├──────────────────────────┤ ← scrollTop + clientHeight
│ │
│ 不可见区域(下方) │ → 用 padding-bottom 占位
│ │
└──────────────────────────┘
核心计算:
startIndex = Math.floor(scrollTop / itemHeight)
endIndex = startIndex + Math.ceil(clientHeight / itemHeight)
visibleItems = data.slice(startIndex, endIndex + buffer)简化实现
tsx
function VirtualList({ items, itemHeight, containerHeight }: {
items: any[]
itemHeight: number
containerHeight: number
}) {
const [scrollTop, setScrollTop] = useState(0)
const totalHeight = items.length * itemHeight
const startIndex = Math.floor(scrollTop / itemHeight)
const visibleCount = Math.ceil(containerHeight / itemHeight)
const endIndex = Math.min(startIndex + visibleCount + 2, items.length)
const offsetY = startIndex * itemHeight
const visibleItems = items.slice(startIndex, endIndex)
return (
<div
style={{ height: containerHeight, overflow: 'auto' }}
onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
>
<div style={{ height: totalHeight, position: 'relative' }}>
<div style={{ position: 'absolute', top: offsetY, width: '100%' }}>
{visibleItems.map((item, i) => (
<div key={startIndex + i} style={{ height: itemHeight }}>
{item.content}
</div>
))}
</div>
</div>
</div>
)
}现有方案
| 库 | 特点 |
|---|---|
@tanstack/react-virtual | 轻量,支持横向/纵向/网格 |
react-window | Brian Vaughn(React 团队),简洁稳定 |
react-virtuoso | 自动高度检测,API 友好 |
tsx
// @tanstack/react-virtual 示例
import { useVirtualizer } from '@tanstack/react-virtual'
function VList({ items }) {
const parentRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50,
})
return (
<div ref={parentRef} style={{ height: 400, overflow: 'auto' }}>
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map((virtualItem) => (
<div
key={virtualItem.key}
style={{
position: 'absolute',
top: virtualItem.start,
height: virtualItem.size,
width: '100%',
}}
>
{items[virtualItem.index].text}
</div>
))}
</div>
</div>
)
}追问延伸
- 不定高度的虚拟列表怎么实现?(预估高度 + 动态测量 + 缓存真实高度)
- 虚拟列表和
content-visibility: auto的区别?(CSS 12 题有详解) - 虚拟列表如何处理搜索/滚动到指定位置?
19. React 18 新 API:useId / useSyncExternalStore / useInsertionEffect ⭐⭐
说明 React 18 新增的三个 Hook 的使用场景。
考察点:React 18 新 API
useId — SSR 安全的唯一 ID
jsx
function FormField({ label }) {
const id = useId()
return (
<>
<label htmlFor={id}>{label}</label>
<input id={id} />
</>
)
}
// 为什么不用 Math.random() 或自增 ID?
// SSR 时服务器生成的 ID 和客户端 Hydration 时的 ID 必须一致
// useId 保证服务端和客户端生成相同的 IDuseSyncExternalStore — 订阅外部数据源
typescript
// 用于订阅非 React 管理的外部状态(如浏览器 API、第三方库)
import { useSyncExternalStore } from 'react'
function useOnlineStatus() {
return useSyncExternalStore(
// subscribe: 订阅函数,返回取消订阅的函数
(callback) => {
window.addEventListener('online', callback)
window.addEventListener('offline', callback)
return () => {
window.removeEventListener('online', callback)
window.removeEventListener('offline', callback)
}
},
// getSnapshot: 获取当前值(客户端)
() => navigator.onLine,
// getServerSnapshot: SSR 时的值(可选)
() => true
)
}
function StatusBar() {
const isOnline = useOnlineStatus()
return <div>{isOnline ? '🟢 在线' : '🔴 离线'}</div>
}typescript
// Zustand 内部就使用了 useSyncExternalStore
function useStore(store, selector) {
return useSyncExternalStore(
store.subscribe,
() => selector(store.getState()),
() => selector(store.getInitialState())
)
}useInsertionEffect — CSS-in-JS 库专用
typescript
// 执行时机: DOM 变更后、useLayoutEffect 之前
// 专门用于 CSS-in-JS 库插入 <style> 标签
// 时间线:
// Render → DOM 变更 → useInsertionEffect → useLayoutEffect → Paint → useEffect
// ⚠️ 应用开发者几乎不需要直接使用,这是给库作者用的
// 例如 styled-components、emotion 内部使用
function useCSS(rule) {
useInsertionEffect(() => {
const style = document.createElement('style')
style.textContent = rule
document.head.appendChild(style)
return () => document.head.removeChild(style)
})
}三者对比
| Hook | 目标用户 | 场景 |
|---|---|---|
useId | 所有开发者 | 表单 label/input 关联、无障碍 |
useSyncExternalStore | 库作者 / 高级用法 | 订阅浏览器 API、第三方状态库 |
useInsertionEffect | CSS-in-JS 库作者 | 动态插入样式标签 |
追问延伸
useId生成的 ID 格式是什么样的?(:r1:,:r2:等)- 为什么
useSyncExternalStore需要getSnapshot返回不可变值? useInsertionEffect中能读取 DOM 布局信息吗?(不能,ref 还是 null)
20. 从 Class 到 Hooks,为什么 React 要推 Hooks? ⭐⭐
对比 Class 组件和 Hooks 的设计思想差异。
考察点:设计思想
Class 组件的问题
1. 逻辑复用困难
- HOC (withRouter, connect) → 嵌套地狱 (Wrapper Hell)
- Render Props → 代码嵌套深、可读性差
- Mixins(已废弃)→ 命名冲突、隐式依赖
2. 生命周期导致逻辑碎片化
- 一个功能的逻辑分散在多个生命周期中
- componentDidMount + componentDidUpdate + componentWillUnmount
3. this 指向问题
- bind(this) / 箭头函数 / 方法声明方式不统一
- 新手容易出错
4. 难以优化
- Class 组件难以被编译器优化(tree-shaking、压缩)
- Class 的方法在原型链上,不利于热重载Hooks 如何解决
问题 1: 逻辑复用 → 自定义 Hook
Class: withAuth(withRouter(withTheme(MyComponent)))
Hook: function MyComponent() {
const auth = useAuth()
const router = useRouter()
const theme = useTheme()
}
问题 2: 逻辑碎片化 → useEffect 聚合相关逻辑
Class:
componentDidMount() { subscribe(); fetchData(); }
componentDidUpdate() { if (id changed) fetchData(); }
componentWillUnmount() { unsubscribe(); }
Hook:
useEffect(() => {
fetchData() // mount + update
}, [id])
useEffect(() => {
subscribe()
return () => unsubscribe() // mount + unmount
}, [])
问题 3: this → 闭包
没有 this,函数组件中直接访问 props 和 state
问题 4: 编译优化
函数更容易 tree-shake 和压缩
React Compiler 可以自动优化 Hooks 代码生命周期映射
| Class | Hooks |
|---|---|
constructor | useState(initialValue) / useRef(initialValue) |
componentDidMount | useEffect(() => {}, []) |
componentDidUpdate | useEffect(() => {}, [deps]) |
componentWillUnmount | useEffect(() => () => cleanup, []) |
shouldComponentUpdate | React.memo |
getDerivedStateFromProps | 渲染期间直接计算 |
componentDidCatch | 无直接替代(仍需 Class) |
getSnapshotBeforeUpdate | 无直接替代 |
Hooks 的心智模型
Class 组件 = 面向对象
→ 实例 + 生命周期 + this
→ 思考"在什么时候做什么"
函数组件 + Hooks = 函数式 + 同步快照
→ 每次渲染是一次函数调用
→ 每次渲染都有自己的 props、state、闭包
→ 思考"这次渲染该展示什么"jsx
// Class: this.state 总是指向最新状态
// Hooks: 每次渲染的 state 是当时的"快照"
function Timer() {
const [count, setCount] = useState(0)
const showAlert = () => {
setTimeout(() => {
alert(count) // 总是 alert 点击时的 count,不是 3 秒后的
}, 3000)
}
return (
<>
<p>{count}</p>
<button onClick={() => setCount(count + 1)}>+1</button>
<button onClick={showAlert}>Alert</button>
</>
)
}追问延伸
- Hooks 的闭包陷阱是什么?如何避免?(useRef 保存最新值)
- React Compiler 能否让我们不用再手动写 useMemo/useCallback?
- 是否还有必要学 Class 组件?(ErrorBoundary、遗留代码维护)
21. SSR / SSG / ISR 的区别?Next.js App Router vs Pages Router? ⭐⭐⭐
全面对比服务端渲染策略和 Next.js 两种路由架构。
考察点:服务端渲染、Next.js
三种渲染策略
CSR (Client-Side Rendering):
浏览器下载空 HTML → 下载 JS → 执行 JS 渲染页面
首屏: 白屏 → loading → 内容
SEO: ❌
SSR (Server-Side Rendering):
每次请求 → 服务器执行 React → 生成 HTML → 返回给浏览器
首屏: 有内容的 HTML(但不可交互)→ Hydration → 可交互
SEO: ✅
SSG (Static Site Generation):
构建时 → 预生成所有页面的 HTML → 部署为静态文件
首屏: 极快(CDN 直接返回静态 HTML)
SEO: ✅
ISR (Incremental Static Regeneration):
首次请求 → 返回静态 HTML
超过 revalidate 时间 → 下次请求触发后台重新生成
兼顾 SSG 的速度和 SSR 的实时性对比
| 维度 | CSR | SSR | SSG | ISR |
|---|---|---|---|---|
| 首屏速度 | 🔴 慢 | 🟡 中 | 🟢 快 | 🟢 快 |
| SEO | ❌ | ✅ | ✅ | ✅ |
| 服务器压力 | 无 | 🔴 高(每次请求都渲染) | 无 | 🟢 低 |
| 数据实时性 | ✅ 实时 | ✅ 实时 | ❌ 构建时快照 | 🟡 准实时 |
| 适用场景 | 后台管理、SPA | 动态内容、个性化页面 | 博客、文档、营销页 | 电商列表、新闻 |
Pages Router(传统)
tsx
// pages/products/[id].tsx
// SSR — 每次请求执行
export async function getServerSideProps(context) {
const product = await fetchProduct(context.params.id)
return { props: { product } }
}
// SSG — 构建时执行
export async function getStaticProps({ params }) {
const product = await fetchProduct(params.id)
return {
props: { product },
revalidate: 60, // ISR: 60 秒后重新生成
}
}
export async function getStaticPaths() {
const products = await fetchAllProducts()
return {
paths: products.map(p => ({ params: { id: p.id } })),
fallback: 'blocking', // 未预生成的路径,首次访问时 SSR
}
}
export default function ProductPage({ product }) {
return <div>{product.name}</div>
}App Router(Next.js 13+,推荐)
tsx
// app/products/[id]/page.tsx — 默认 Server Component
// 动态渲染(相当于 SSR)
async function ProductPage({ params }: { params: { id: string } }) {
const product = await fetch(`/api/products/${params.id}`, {
cache: 'no-store', // 不缓存 → 每次请求都获取最新数据
})
return <div>{product.name}</div>
}
// 静态渲染(相当于 SSG)
async function ProductPage({ params }: { params: { id: string } }) {
const product = await fetch(`/api/products/${params.id}`, {
cache: 'force-cache', // 默认行为,构建时缓存
})
return <div>{product.name}</div>
}
// ISR
async function ProductPage({ params }: { params: { id: string } }) {
const product = await fetch(`/api/products/${params.id}`, {
next: { revalidate: 60 }, // 60 秒后重新验证
})
return <div>{product.name}</div>
}
// 生成静态路径(替代 getStaticPaths)
export async function generateStaticParams() {
const products = await fetchAllProducts()
return products.map(p => ({ id: p.id }))
}Pages Router vs App Router 核心差异
| 维度 | Pages Router | App Router |
|---|---|---|
| 数据获取 | getServerSideProps / getStaticProps | 组件内 fetch + 缓存策略 |
| 组件模型 | 全部是 Client Component | 默认 Server Component |
| 布局 | _app.tsx / _document.tsx | layout.tsx(嵌套布局) |
| Loading | 手动实现 | loading.tsx 自动与 Suspense |
| Error | _error.tsx 全局 | error.tsx 路由级别 |
| 路由 | 文件系统路由 | 文件系统路由(更强大) |
| 流式渲染 | ❌ | ✅ 内置 |
追问延伸
fetch在 Next.js App Router 中被增强了什么?(自动去重、缓存)- Streaming SSR 和传统 SSR 的区别?(边渲染边发送 HTML)
generateMetadata和Head有什么区别?
22. React Server Components 的数据流?如何实现"零 JS"组件? ⭐⭐⭐
深入 RSC 的数据传递方式和序列化协议。
考察点:RSC 原理、流式渲染
RSC 的数据流
传统 React(CSR/SSR):
组件获取数据 → props drilling / context / 状态库
所有逻辑都在客户端执行(或 SSR 后 hydrate)
RSC 数据流:
Server Component → 直接访问数据库/文件系统/API
→ 将数据作为 props 传给 Client Component
→ Client Component 收到的是已序列化的数据
Server Component ─── 数据(props)──→ Client Component
↑ 直接访问后端 ↑ 只负责交互tsx
// Server Component: 获取数据
async function ProductList() {
// 直接查数据库,这段代码永远不会出现在客户端 JS 中
const products = await db.query('SELECT * FROM products ORDER BY created_at DESC')
return (
<div>
{products.map(product => (
// 将数据通过 props 传给 Client Component
<ProductCard key={product.id} product={product} />
))}
</div>
)
}
// Client Component: 处理交互
'use client'
function ProductCard({ product }) {
const [liked, setLiked] = useState(false)
return (
<div>
<h3>{product.name}</h3>
<p>{product.price}</p>
<button onClick={() => setLiked(!liked)}>
{liked ? '❤️' : '🤍'}
</button>
</div>
)
}RSC Payload(序列化协议)
浏览器收到的不是 HTML,而是 RSC Payload(类 JSON 流):
0: ["$", "div", null, {"children": [
["$", "h1", null, {"children": "Products"}],
["$", "@1", null, {"product": {"id": 1, "name": "MacBook"}}],
["$", "@1", null, {"product": {"id": 2, "name": "iPhone"}}]
]}]
"@1" 指向 Client Component 的引用
浏览器只需下载 @1 对应的 JS(ProductCard)
Server Component 的代码(数据库查询等)不在 payload 中"零 JS Bundle" 组件
tsx
// 这个组件完全在服务器执行,客户端不需要任何 JS
async function BlogPost({ slug }) {
const post = await getPostBySlug(slug)
const html = await markdownToHtml(post.content) // 可以用重量级库
return (
<article>
<h1>{post.title}</h1>
<time>{post.date}</time>
<div dangerouslySetInnerHTML={{ __html: html }} />
{/* 即使用了 markdown 解析库,也不会增加客户端 JS */}
</article>
)
}"零 JS" 的含义:
Server Component 可以:
✅ import 大型库(如 markdown 解析器、日期库)
✅ 这些库的代码不会被打包到客户端
✅ 只有最终渲染结果(HTML/RSC Payload)发送到浏览器
对比传统方式:
import remarkGfm from 'remark-gfm' // ~50KB
import rehypeHighlight from 'rehype-highlight' // ~30KB
// 这些全部要下载到客户端
RSC 方式:
同样的 import,但代码留在服务器
客户端 JS bundle = 0(如果没有交互)流式渲染(Streaming)
tsx
// Server Component + Suspense = 流式渲染
async function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
{/* 快速数据,立即渲染 */}
<UserInfo />
{/* 慢数据,用 Suspense 包裹,边加载边显示 */}
<Suspense fallback={<ChartSkeleton />}>
<AnalyticsChart /> {/* async Server Component, 可能耗时 2s */}
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<RecentOrders /> {/* async Server Component, 可能耗时 3s */}
</Suspense>
</div>
)
}流式渲染过程:
时间 0ms:
浏览器收到: <h1>Dashboard</h1> + <UserInfo> + <ChartSkeleton> + <TableSkeleton>
用户立即看到页面框架 ✅
时间 2000ms:
服务器完成 AnalyticsChart → 流式发送 HTML 片段
浏览器替换 <ChartSkeleton> → 显示真实图表 ✅
时间 3000ms:
服务器完成 RecentOrders → 流式发送 HTML 片段
浏览器替换 <TableSkeleton> → 显示真实表格 ✅
对比传统 SSR:
必须等所有数据都准备好(3s)→ 才能返回完整 HTML
用户前 3s 看到白屏Server Actions
tsx
// Server Action: 在 Server Component 中定义服务端逻辑
async function TodoList() {
const todos = await db.query('SELECT * FROM todos')
// 定义 Server Action
async function addTodo(formData: FormData) {
'use server'
const text = formData.get('text')
await db.query('INSERT INTO todos (text) VALUES (?)', [text])
revalidatePath('/todos')
}
return (
<div>
<form action={addTodo}>
<input name="text" />
<button type="submit">添加</button>
</form>
<ul>
{todos.map(todo => <li key={todo.id}>{todo.text}</li>)}
</ul>
</div>
)
}Server Action 的工作流程:
1. 用户提交表单
2. 浏览器发送 POST 请求到服务器(不是传统的页面跳转)
3. 服务器执行 addTodo 函数
4. revalidatePath 触发页面数据重新获取
5. 服务器返回新的 RSC Payload
6. 浏览器无刷新更新页面
优势:
- 不需要创建 API 路由
- 类型安全(端到端)
- 渐进增强(JS 禁用时仍可提交表单)追问延伸
- RSC 和 GraphQL 的关系?RSC 能否替代 GraphQL?
'use server'和'use client'的本质区别?- Next.js 的
revalidatePath和revalidateTag分别用于什么场景? - 如何在 RSC 中处理认证/权限?(middleware / server-only 包)