Skip to content

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)的具体实现?requestIdleCallback vs MessageChannel
  • 为什么 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
    // ✅ 函数式更新,基于最新值
  }
}

追问延伸

  • useReduceruseState 的关系?(useState 内部就是 useReducer)
  • React 用什么机制检测 Hooks 调用顺序?(开发模式的 eslint-plugin-react-hooks)
  • useState 的 initialValue 如果是函数(惰性初始化)有什么好处?

4. useEffectuseLayoutEffect 的区别? ⭐⭐

对比两者的执行时机,以及何时选用 useLayoutEffect

考察点:Effect 生命周期、执行时机

执行时机对比

浏览器渲染流程中的位置:

setState()
  → Render Phase (fiber diff)
  → Commit Phase:
      ① DOM 变更(同步)
      ② useLayoutEffect 回调(同步,在绘制前)  ← 阻塞浏览器绘制
      ③ 浏览器绘制(Paint)
      ④ useEffect 回调(异步,在绘制后)        ← 不阻塞

时间线:
─── Render ──→ DOM 变更 ──→ useLayoutEffect ──→ Paint ──→ useEffect ───
                             (同步,阻塞)                  (异步)

核心区别

维度useEffectuseLayoutEffect
执行时机浏览器绘制之后浏览器绘制之前
同步/异步异步同步
是否阻塞渲染
适用场景数据获取、订阅、日志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. useMemouseCallback 什么时候该用? ⭐⭐

说明两者的区别和正确使用时机。

考察点:记忆化、性能优化、过度优化

本质区别

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.memouseMemo 的区别?(组件级 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 16document所有 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 废除了?
  • onClickCaptureonClick 的区别?

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 17React 18
React 事件处理器✅ 批量✅ 批量
setTimeout / setInterval❌ 逐个✅ 批量
Promise.then❌ 逐个✅ 批量
原生事件监听❌ 逐个✅ 批量
强制同步刷新ReactDOM.flushSyncflushSync

追问延伸

  • createRoot 和旧的 ReactDOM.render 有什么区别?(新 API 才开启并发特性)
  • 批量更新和并发模式(Concurrent Mode)是什么关系?
  • Vue 的 nextTick 和 React 的批量更新有什么异同?

10. Suspenselazy 的原理?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 状态管理方案的设计理念。

考察点:状态管理

设计理念对比

维度ReduxZustandJotai
模型单一 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 永远不会发送到客户端!

核心区别

维度SSRRSC
目标首屏渲染加速减少客户端 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 切换
useTransitionstartTransition + isPending需要 loading 状态
useDeferredValue延迟一个值的更新接收外部 props,延迟渲染

追问延伸

  • startTransitionsetTimeout(() => setState(), 0) 有什么区别?
  • Transition 的优先级在 Fiber 的 lanes 模型中是什么位置?
  • 并发模式下,为什么组件可能渲染两次但只 commit 一次?

17. forwardRefuseImperativeHandle 的使用场景? ⭐⭐

解释 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> DOM

useImperativeHandle — 自定义暴露的接口

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-windowBrian 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 保证服务端和客户端生成相同的 ID

useSyncExternalStore — 订阅外部数据源

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、第三方状态库
useInsertionEffectCSS-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 代码

生命周期映射

ClassHooks
constructoruseState(initialValue) / useRef(initialValue)
componentDidMountuseEffect(() => {}, [])
componentDidUpdateuseEffect(() => {}, [deps])
componentWillUnmountuseEffect(() => () => cleanup, [])
shouldComponentUpdateReact.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 的实时性

对比

维度CSRSSRSSGISR
首屏速度🔴 慢🟡 中🟢 快🟢 快
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 RouterApp Router
数据获取getServerSideProps / getStaticProps组件内 fetch + 缓存策略
组件模型全部是 Client Component默认 Server Component
布局_app.tsx / _document.tsxlayout.tsx(嵌套布局)
Loading手动实现loading.tsx 自动与 Suspense
Error_error.tsx 全局error.tsx 路由级别
路由文件系统路由文件系统路由(更强大)
流式渲染✅ 内置

追问延伸

  • fetch 在 Next.js App Router 中被增强了什么?(自动去重、缓存)
  • Streaming SSR 和传统 SSR 的区别?(边渲染边发送 HTML)
  • generateMetadataHead 有什么区别?

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 的 revalidatePathrevalidateTag 分别用于什么场景?
  • 如何在 RSC 中处理认证/权限?(middleware / server-only 包)

用心学习,用代码说话 💻