obsidian-computer
前端识记前端面试

05-react

面向大厂 P6 前端面试的体系化答案。题目来源:快手、飞书、PDD 真实面经。 每题结构:【核心答案】(30 秒口述版)→ 【展开】(原理 + 可运行代码)→ 【追问 & 加分点】。

最后更新 2026年8月29日

React

面向大厂 P6 前端面试的体系化答案。题目来源:快手、飞书、PDD 真实面经。 每题结构:【核心答案】(30 秒口述版)→ 【展开】(原理 + 可运行代码)→ 【追问 & 加分点】。

目录


1. React Hook 的规则,以及为什么有这些规则

【核心答案】

两条规则:只在最顶层调用 Hook(不能放在条件、循环、嵌套函数、return 之后);只在 React 函数组件或自定义 Hook 中调用 Hook(不能在普通函数、类组件、事件回调里调用)。

根本原因是:React 并没有把 Hook 的状态按"名字"存起来,而是把它们按调用顺序串成一条单向链表挂在当前组件对应的 Fiber 节点的 memoizedState 上。每次渲染时 React 只是从头到尾按顺序取链表节点,靠"第几次调用"来对应"第几个状态"。一旦条件分支让某次渲染少调用或多调用了一个 Hook,后面所有 Hook 的下标就整体错位,useState 拿到别人的 state、useEffect 拿到别人的依赖数组,行为完全错乱。

【展开】

1.1 状态存在哪里

函数组件本身没有实例,状态挂在 Fiber 上:

FiberNode (函数组件)
  ├── memoizedState ──> Hook1 ──next──> Hook2 ──next──> Hook3 ──next──> null
  │                      │              │
  │                      │              └── memoizedState: 依赖数组 + effect 对象
  │                      └── memoizedState: state 值
  │                          queue: 更新队列(环形链表)
  └── ...

每个 Hook 节点的简化结构(对应 React 源码 ReactFiberHooks.js):

const hook = {
  memoizedState: null, // useState 存 state;useEffect 存 effect 对象;useMemo 存 [value, deps]
  baseState: null,     // 计算新 state 的基础值
  baseQueue: null,     // 上次渲染被跳过的更新(优先级不足时留下的)
  queue: null,         // 本次的更新队列
  next: null,          // 指向下一个 Hook,构成单向链表
};

1.2 手写一个"迷你 Hook",直观解释为什么顺序不能变

// ===== 极简版实现:只演示"按下标取状态"这一核心机制 =====
let hookStates = []; // 相当于 fiber.memoizedState 链表
let hookIndex = 0;   // 相当于遍历链表的游标

function useState(initial) {
  const currentIndex = hookIndex; // 关键:靠"第几次调用"定位自己的槽位
  hookStates[currentIndex] =
    hookStates[currentIndex] === undefined ? initial : hookStates[currentIndex];

  const setState = (action) => {
    const prev = hookStates[currentIndex];
    hookStates[currentIndex] = typeof action === 'function' ? action(prev) : action;
    render(); // 触发重新渲染
  };

  hookIndex++; // 游标后移,下一个 Hook 用下一个槽位
  return [hookStates[currentIndex], setState];
}

function render() {
  hookIndex = 0; // 每次渲染都从头开始遍历,这就是"顺序必须稳定"的原因
  App();
}

// ❌ 错误写法:条件调用
function App() {
  const [count, setCount] = useState(0);
  if (count > 0) {
    // 第一次渲染 count===0,不执行;第二次 count===1,执行
    // 于是 name 在第一次渲染时占用槽位 1,第二次渲染时槽位 1 被 name 占用、
    // 而原本槽位 1 的 age 被挤到槽位 2 —— 整条链表错位
    const [name] = useState('kwai');
  }
  const [age, setAge] = useState(18);
}

React 真实实现里,更新阶段的 updateWorkInProgressHook 会沿着 currentHook.next 走。如果本次渲染多调用了一个 Hook,走到链表尾部会拿到 null,直接抛出经典报错:

Rendered more hooks than during the previous render.

1.3 第二条规则:只在 React 函数中调用

React 用一个全局变量 ReactCurrentDispatcher.current 指向当前的 dispatcher。只有在 renderWithHooks 执行组件函数期间,它才被赋值为 HooksDispatcherOnMount / HooksDispatcherOnUpdate;其余时间是 ContextOnlyDispatcher,调用任何 Hook 都会报 "Invalid hook call"。

// react-reconciler/src/ReactFiberHooks.js(简化)
function renderWithHooks(current, workInProgress, Component, props) {
  currentlyRenderingFiber = workInProgress;
  workInProgress.memoizedState = null; // 清空,准备重建链表
  ReactCurrentDispatcher.current =
    current === null ? HooksDispatcherOnMount : HooksDispatcherOnUpdate;

  const children = Component(props); // ← 只有这一行执行期间,Hook 才是合法的

  ReactCurrentDispatcher.current = ContextOnlyDispatcher; // 出了这里就报错
  return children;
}

这条规则还有一个副作用是保证 React 知道状态属于哪个组件:currentlyRenderingFiber 是全局的,在事件回调里调用 Hook 时这个变量指向的是上一次渲染的残留或 null,状态无处安放。

1.4 合法的"条件"写法

规则限制的是 Hook 的调用,不是它的行为。把条件挪进 Hook 内部即可:

function Panel({ visible, id }) {
  // ✅ Hook 无条件调用,条件写在内部
  const [detail, setDetail] = useState(null);

  useEffect(() => {
    if (!visible) return; // 条件写在 effect 内部
    fetchDetail(id).then(setDetail);
  }, [visible, id]);

  // ✅ 提前 return 必须放在所有 Hook 之后
  if (!visible) return null;
  return <div>{detail?.name}</div>;
}

如果确实需要"某个分支才有状态",正确做法是抽成子组件,让条件出现在 JSX 层面(组件的挂载/卸载),而不是 Hook 层面。

【追问 & 加分点】

  • 为什么不用 key/名字而用顺序? Dan Abramov 在 RFC 里解释过:如果按名字,每个 Hook 都要传一个唯一字符串,自定义 Hook 组合时会出现命名冲突,且无法自动处理同一个自定义 Hook 被调用两次的情况。按调用顺序的方案让自定义 Hook 天然可组合、可嵌套、可复用,代价就是这两条规则。而这两条规则可以被 eslint-plugin-react-hooks 静态检查覆盖,属于"用工具兜底换 API 简洁"。
  • eslint-plugin-react-hooks 怎么判断"这是不是 React 函数"? 靠命名约定:大写字母开头(组件)或 use 开头(自定义 Hook)。所以自定义 Hook 必须以 use 开头,不是审美要求,而是 lint 规则的判定依据。
  • React 19 的 use() 是个例外:use(promise) / use(context) 可以在条件语句中调用,因为它不依赖 Hook 链表下标(不在链表上分配节点,靠 Suspense 机制和 context 栈工作)。面试时能主动提一句会加分。
  • StrictMode 下组件函数会执行两次(开发环境),就是为了暴露 Hook 里的副作用和非幂等逻辑。

2. useEffect 第二个参数的三种情况与清理函数时机

【核心答案】

  • 不传第二个参数:每次渲染(包括首次)完成后都执行 effect。
  • 传空数组 []:只在组件挂载后执行一次,卸载时执行清理。
  • 传依赖数组 [a, b]:挂载后执行一次;之后每次渲染用 Object.is 逐项浅比较依赖,只要有一项变了就先执行上一次的清理函数、再执行新的 effect。

清理函数的执行时机有两个:下一次 effect 执行之前(先清理旧的再跑新的)和组件卸载时。注意 useEffect 是异步的,在浏览器绘制(paint)之后执行;useLayoutEffect 是同步的,在 DOM 变更后、浏览器绘制之前执行,会阻塞绘制。

【展开】

2.1 三种情况的完整行为表

第二参数首次渲染后续渲染清理函数触发时机
不传(undefined)执行每次都执行每次 effect 前 + 卸载时
[]执行不执行仅卸载时
[a, b]执行任一依赖变化时执行依赖变化的那次 effect 前 + 卸载时

React 内部的比较逻辑就是浅比较:

// react-reconciler/src/ReactFiberHooks.js
function areHookInputsEqual(nextDeps, prevDeps) {
  if (prevDeps === null) return false; // 上次没传依赖 → 永远不相等 → 每次都执行
  for (let i = 0; i < prevDeps.length && i < nextDeps.length; i++) {
    if (is(nextDeps[i], prevDeps[i])) continue; // Object.is
    return false;
  }
  return true;
}

关键点:用的是 Object.is(引用比较),不是深比较。 所以依赖里放对象字面量、数组字面量、内联函数,每次渲染都是新引用,等价于"没传依赖"。

2.2 一个能跑起来的完整验证 Demo

import { useState, useEffect } from 'react';

function EffectDemo() {
  const [count, setCount] = useState(0);
  const [other, setOther] = useState(0);

  // 情况一:不传依赖 —— 每次渲染后都跑
  useEffect(() => {
    console.log('[A] 每次渲染都执行, count =', count);
    return () => console.log('[A] 清理, 上一次的 count =', count);
  });

  // 情况二:空数组 —— 只在挂载后跑一次
  useEffect(() => {
    console.log('[B] 只在挂载时执行一次');
    const timer = setInterval(() => console.log('tick'), 1000);
    // 卸载时清理,必须写,否则组件卸载后定时器还在跑 → 内存泄漏
    return () => {
      console.log('[B] 卸载时清理');
      clearInterval(timer);
    };
  }, []);

  // 情况三:有依赖 —— count 变才跑,other 变不跑
  useEffect(() => {
    console.log('[C] count 变化时执行, count =', count);
    const controller = new AbortController();
    fetch(`/api/item/${count}`, { signal: controller.signal })
      .then((r) => r.json())
      .catch((e) => {
        if (e.name !== 'AbortError') throw e;
      });
    // 清理:取消上一次未完成的请求,避免竞态(老请求后返回覆盖新数据)
    return () => controller.abort();
  }, [count]);

  return (
    <>
      <button onClick={() => setCount((c) => c + 1)}>count: {count}</button>
      <button onClick={() => setOther((o) => o + 1)}>other: {other}</button>
    </>
  );
}

点击 other 按钮时的控制台顺序是:

[A] 清理, 上一次的 count = 0     ← 先清理旧的
[A] 每次渲染都执行, count = 0    ← 再执行新的

清理函数里拿到的是上一次渲染的闭包变量,这点非常容易被追问。

2.3 依赖数组的三类经典坑

坑一:空数组闭包陷阱(stale closure)

// ❌ 定时器里的 count 永远是 0
useEffect(() => {
  const t = setInterval(() => {
    setCount(count + 1); // 闭包捕获的是首次渲染的 count = 0
  }, 1000);
  return () => clearInterval(t);
}, []);

// ✅ 方案一:函数式更新,不依赖外部 count
useEffect(() => {
  const t = setInterval(() => setCount((c) => c + 1), 1000);
  return () => clearInterval(t);
}, []);

// ✅ 方案二:把 count 放进依赖(代价是每秒重建定时器)
useEffect(() => {
  const t = setInterval(() => setCount(count + 1), 1000);
  return () => clearInterval(t);
}, [count]);

// ✅ 方案三:用 ref 存最新值(适合"我要读最新值但不想重跑 effect")
const latest = useRef(count);
latest.current = count;
useEffect(() => {
  const t = setInterval(() => console.log(latest.current), 1000);
  return () => clearInterval(t);
}, []);

坑二:对象/数组/函数依赖导致的无限循环

// ❌ options 每次渲染都是新对象 → effect 每次都执行 → setState → 再渲染 → 死循环
function List({ pageSize }) {
  const options = { pageSize, sort: 'desc' };
  useEffect(() => {
    fetchList(options).then(setData);
  }, [options]);
}

// ✅ 依赖原始值
useEffect(() => {
  fetchList({ pageSize, sort: 'desc' }).then(setData);
}, [pageSize]);

// ✅ 或者用 useMemo 稳定引用
const options = useMemo(() => ({ pageSize, sort: 'desc' }), [pageSize]);

坑三:useCallback 的依赖差异

useCallback(fn, deps) 本质就是 useMemo(() => fn, deps),它缓存的是函数引用,不是执行结果:

// 不传第二参数 —— 等于没优化,每次渲染都返回新函数
const f1 = useCallback(() => doSth(a));        // deps 为 undefined,永远判定"依赖变了"

// 空数组 —— 函数引用永远不变,但闭包里的 a 永远停留在首次渲染的值
const f2 = useCallback(() => doSth(a), []);    // ⚠️ stale closure

// 有依赖 —— a 变了才生成新函数,闭包里的 a 是最新的(推荐)
const f3 = useCallback(() => doSth(a), [a]);

useCallback 空数组和 useEffect 空数组的坑是同源的,都是闭包捕获问题;区别在于 useEffect 的表现是"副作用用了旧值",useCallback 的表现是"传给子组件的回调用了旧值",后者更隐蔽,因为它往往几次点击之后才暴露。

2.4 执行时机:useEffect vs useLayoutEffect

React 渲染完成
  → commit mutation 阶段:真实 DOM 被修改
  → useLayoutEffect 同步执行(DOM 已更新,但屏幕还没画)  ← 可以读布局、改 DOM,不会闪烁
  → 浏览器 paint(用户看到画面)
  → useEffect 异步执行(下一帧或调度空闲时)              ← 不阻塞绘制

判断标准:如果 effect 会同步修改 DOM 并且用户可能看到中间态(比如测量宽高后调整位置),用 useLayoutEffect;其余一律用 useEffect。

【追问 & 加分点】

  • React 18 StrictMode 开发环境下,[] 的 effect 会执行两次(mount → unmount → mount),这是刻意设计,用来检验清理函数是否写全,为将来的 Offscreen / 状态保留特性做准备。生产环境只执行一次。有候选人会误以为是 bug 去加 useRef 做"只跑一次"的标记,这是反模式。
  • 依赖数组长度必须稳定:不能写 useEffect(fn, cond ? [a] : [a, b]),React 会警告并可能比较错位。
  • effect 不是"渲染后的回调",而是"提交后的副作用"。React 官方文档明确表示:如果只是根据 props/state 算出新的渲染数据,应该在渲染期间直接算或者用 useMemo,不要 useEffect + setState,那会多一次渲染并造成闪烁。
  • React 19 新增 useEffectEvent(实验性):把"读最新值但不作为依赖"这件事标准化,解决前面坑一/坑三的场景,不用再手写 ref。
  • 清理函数在并发渲染下的语义:React 只保证 commit 过的 effect 会被清理;被中断丢弃的 render 结果不会产生 effect,所以不用担心"渲染了但没提交"造成的泄漏。

3. React.memo 与 useMemo 的区别,useCallback + memo 的正确姿势

【核心答案】

三者作用对象不同:

  • React.memo(Component) 是高阶组件,包裹一个组件,对新旧 props 做浅比较,相等则跳过这个组件的重新渲染(复用上次的 Fiber 与输出)。
  • useMemo(fn, deps) 是 Hook,缓存一次计算的结果值,依赖不变就不重新计算。
  • useCallback(fn, deps) 是 useMemo(() => fn, deps) 的语法糖,缓存的是函数本身的引用。

组合的正确姿势:React.memo 的浅比较,只要有一个 prop 是每次新建的对象/函数就会失效。所以给被 memo 包裹的子组件传函数时要用 useCallback 稳定引用,传对象/数组时要用 useMemo 稳定引用——三者必须成套使用,缺一个就白干。

不该用的时候:子组件本身很轻(几个 DOM 节点)、props 里必然含有每次都变的值、或者组件本来就很少重渲染。这时 memo 的浅比较开销 + useCallback/useMemo 的缓存开销 + 额外的内存占用,是净亏损。

【展开】

3.1 三者的区别一张表

API类型缓存的东西比较方式典型用途
React.memo高阶组件组件的渲染输出对 props 逐 key 做 Object.is 浅比较阻止子组件被父组件的无关更新带着重渲染
useMemoHook任意计算结果对 deps 浅比较昂贵计算、稳定对象/数组引用
useCallbackHook函数引用对 deps 浅比较稳定回调引用,配合 memo / effect 依赖

3.2 一个完整的、正确的优化示例

import { memo, useCallback, useMemo, useState } from 'react';

// ---------- 子组件:用 memo 包裹 ----------
const TodoItem = memo(function TodoItem({ todo, onToggle, style }) {
  console.log('渲染 TodoItem:', todo.id);
  return (
    <li style={style} onClick={() => onToggle(todo.id)}>
      {todo.done ? '✅' : '⬜'} {todo.text}
    </li>
  );
});

// ---------- 父组件 ----------
export default function TodoList() {
  const [todos, setTodos] = useState(() =>
    Array.from({ length: 1000 }, (_, i) => ({ id: i, text: `任务 ${i}`, done: false }))
  );
  const [keyword, setKeyword] = useState(''); // 与列表项渲染无关的高频状态

  // ✅ 用 useCallback 稳定回调引用
  // 依赖数组为空是安全的:这里用了函数式更新,没有捕获任何外部变量
  const handleToggle = useCallback((id) => {
    setTodos((prev) =>
      prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t))
    );
  }, []);

  // ✅ 用 useMemo 稳定对象引用(否则 style 每次都是新对象,memo 直接失效)
  const itemStyle = useMemo(() => ({ cursor: 'pointer', lineHeight: 1.8 }), []);

  // ✅ 昂贵计算用 useMemo 缓存:keyword 变才重算
  const filtered = useMemo(
    () => todos.filter((t) => t.text.includes(keyword)),
    [todos, keyword]
  );

  return (
    <>
      <input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
      <ul>
        {filtered.map((todo) => (
          <TodoItem
            key={todo.id}
            todo={todo}       // 引用只在这一项被修改时才变(map 只重建被改的那项?不,见下)
            onToggle={handleToggle}
            style={itemStyle}
          />
        ))}
      </ul>
    </>
  );
}

注意上面 setTodos 里的 map 只对被点击的那一项创建了新对象,其他项保持原引用,所以 999 个未变的 TodoItem 的 todo prop 引用不变,memo 生效,只有 1 个重渲染。这就是"不可变更新 + memo"配套的意义:不可变更新保证"变的才变引用",memo 才能利用这一点。

3.3 常见的"白优化"反面教材

// ❌ 反例 1:memo 了,但每次传新函数
<TodoItem onToggle={(id) => handleToggle(id)} />   // 内联箭头函数,每次都是新引用

// ❌ 反例 2:memo 了,但每次传新对象
<TodoItem style={{ color: 'red' }} config={{ a: 1 }} />

// ❌ 反例 3:memo 了,但传了 children
<Card>{<Foo />}</Card>   // children 是 React Element,每次 createElement 都是新对象

// ❌ 反例 4:useCallback 了,但父组件没有 memo 子组件
// useCallback 单独用对渲染性能没有任何帮助,反而多一次依赖比较

3.4 React.memo 的第二个参数

const Row = memo(
  function Row({ data }) {
    return <div>{data.name}</div>;
  },
  // 自定义比较函数:返回 true 表示"相等,跳过渲染"(注意与 shouldComponentUpdate 语义相反)
  (prevProps, nextProps) => prevProps.data.id === nextProps.data.id
);

慎用:写深比较(如 lodash.isEqual)往往比直接重渲染更贵,而且很容易因为漏比某个字段导致 UI 不更新——这是线上非常难查的 bug。

3.5 什么时候不该用

  1. 子组件很轻:渲染一个 <span>{text}</span> 的组件,浅比较 props 的成本和渲染成本在同一量级,memo 是纯负担。
  2. props 必然每次都变:比如子组件接收 Date.now()、每次新建的 children、来自上层 context 的变化值。memo 永远返回 false,只增加了一次比较。
  3. 组件本来就在叶子节点且父组件很少更新。
  4. useMemo 缓存的计算本身很便宜:useMemo(() => a + b, [a, b]) 的缓存开销(创建数组、浅比较、多一个 Hook 链表节点)比 a + b 贵得多。
  5. Context 场景:memo 挡不住 context 更新。消费了某个 Context 的组件,只要 Provider 的 value 变了,即使被 memo 包裹也一定会重渲染。要优化只能拆 Context 或用 useSyncExternalStore 之类的订阅方案。

决策顺序(面试可以答这个方法论,比死记规则高一档):

先用 React DevTools Profiler 定位真实瓶颈
  → 能不能通过"状态下放 / 内容提升(children 作为 props 传入)"从结构上避免重渲染?
    → 能:用结构优化,零运行时成本,优先做
    → 不能:再上 memo + useCallback + useMemo,并用 Profiler 验证确实变快了

"内容提升"这个技巧值得单独展开:

// ❌ keyword 每次变化,整棵子树都重渲染
function Page() {
  const [keyword, setKeyword] = useState('');
  return (
    <>
      <input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
      <HeavyTree />   {/* 被迫跟着重渲染 */}
    </>
  );
}

// ✅ 把高频状态下放到子组件,或把重内容作为 children 传入
function Page() {
  return (
    <SearchBox>
      <HeavyTree />   {/* HeavyTree 的 Element 在 Page 里创建,SearchBox 内部 state 变化不会重建它 */}
    </SearchBox>
  );
}
function SearchBox({ children }) {
  const [keyword, setKeyword] = useState('');
  return (
    <>
      <input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
      {children}      {/* children 引用不变,React 复用整棵子树,无需 memo */}
    </>
  );
}

【追问 & 加分点】

  • React Compiler(原 React Forget):React 19 起提供的编译器会自动插入等价于 useMemo/useCallback/memo 的缓存,目标就是让开发者不再手写这些。能提一句"这些手动优化在编译器普及后会逐步退场,但理解引用相等性依然是前提",是明显的加分项。
  • memo 只做浅比较,跳过的是"这个组件自己的重渲染",不是整棵子树的更新。如果子树里有组件消费了变化的 Context,或者子组件自己有 state 更新,照样会渲染。
  • useMemo 不保证一定缓存:React 文档明确说未来可能为了释放内存丢弃缓存,所以不能把 useMemo 当作语义保证(比如靠它来保证某个副作用只跑一次)。真正需要"引用永远稳定"的场景用 useRef。
  • useCallback 更常见的价值其实不是 memo,而是 effect 依赖:把函数放进 useEffect 依赖数组时,不 useCallback 就会每次重跑 effect。

4. 父子组件传值:props、ref、回调、Context

【核心答案】

React 的数据流是单向的,四种主要通信方式:

  1. 父 → 子:props 下行,最常用,直接传值。
  2. 子 → 父:回调函数上行,父把 setter 或处理函数作为 prop 传下去,子调用它把数据传回来("状态提升")。
  3. 父调用子的方法:ref + useImperativeHandle,命令式地暴露子组件的能力(focus、播放、校验表单等),是逃生舱,不是常规手段。
  4. 跨层级:Context,避免 props 逐层透传(prop drilling),适合主题、语言、当前用户这类"低频变化的全局值"。

兄弟组件通信 = 状态提升到共同父级 + 上面两种的组合;更复杂的场景交给状态管理库(Zustand / Redux Toolkit / Jotai)。

【展开】

4.1 props 下行 + 回调上行(受控组件的标准形态)

import { useState, useCallback } from 'react';

// ---------- 子组件:完全受控,自己不持有状态 ----------
function SearchInput({ value, placeholder, onChange, onSearch }) {
  return (
    <div>
      <input
        value={value}                                   // 父 → 子:props 下行
        placeholder={placeholder}
        onChange={(e) => onChange(e.target.value)}      // 子 → 父:回调上行
        onKeyDown={(e) => e.key === 'Enter' && onSearch(value)}
      />
      <button onClick={() => onSearch(value)}>搜索</button>
    </div>
  );
}

// ---------- 父组件:持有状态(状态提升) ----------
export default function SearchPage() {
  const [keyword, setKeyword] = useState('');
  const [list, setList] = useState([]);

  const handleSearch = useCallback(async (kw) => {
    const res = await fetch(`/api/search?q=${encodeURIComponent(kw)}`);
    setList(await res.json());
  }, []);

  return (
    <>
      <SearchInput
        value={keyword}
        placeholder="请输入关键词"
        onChange={setKeyword}      // 直接把 setter 传下去
        onSearch={handleSearch}
      />
      <ul>{list.map((it) => <li key={it.id}>{it.name}</li>)}</ul>
    </>
  );
}

4.2 ref + useImperativeHandle:父命令式调用子

import { forwardRef, useImperativeHandle, useRef, useState } from 'react';

// React 19 之前需要 forwardRef;React 19 起 ref 可以直接作为 props 传入函数组件
const FormModal = forwardRef(function FormModal(props, ref) {
  const [visible, setVisible] = useState(false);
  const inputRef = useRef(null);

  // 只暴露想给外部的 API,而不是整个 DOM / 整个组件内部
  useImperativeHandle(ref, () => ({
    open() {
      setVisible(true);
      // 等 DOM 更新后聚焦
      requestAnimationFrame(() => inputRef.current?.focus());
    },
    close() {
      setVisible(false);
    },
    validate() {
      return inputRef.current?.value.trim().length > 0;
    },
  }), []); // 依赖数组语义与 useMemo 一致

  if (!visible) return null;
  return (
    <div className="modal">
      <input ref={inputRef} />
      <button onClick={() => setVisible(false)}>关闭</button>
    </div>
  );
});

function Parent() {
  const modalRef = useRef(null);
  return (
    <>
      <button onClick={() => modalRef.current.open()}>打开弹窗</button>
      <button onClick={() => console.log(modalRef.current.validate())}>校验</button>
      <FormModal ref={modalRef} />
    </>
  );
}

什么时候该用 ref:命令式的一次性动作——focus、scrollIntoView、播放/暂停、触发动画、表单校验。什么时候不该用:任何可以用状态表达的 UI(弹窗开关、列表数据)都应该用 state + props,用 ref 会让数据流变得不可追踪。

4.3 Context:跨层级传值

import { createContext, useContext, useMemo, useState, useCallback } from 'react';

// 1. 拆分成"值 Context"和"操作 Context",避免值变化时连带 dispatch 消费者一起重渲染
const ThemeStateContext = createContext(null);
const ThemeActionsContext = createContext(null);

export function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light');

  const toggle = useCallback(() => {
    setTheme((t) => (t === 'light' ? 'dark' : 'light'));
  }, []);

  // 2. actions 引用永远稳定 → 只用 toggle 的组件永远不会因为 theme 变化而重渲染
  const actions = useMemo(() => ({ toggle, setTheme }), [toggle]);

  return (
    <ThemeActionsContext.Provider value={actions}>
      {/* 3. value 直接传原始值,避免 {{ theme }} 这种每次新建对象的写法 */}
      <ThemeStateContext.Provider value={theme}>
        {children}
      </ThemeStateContext.Provider>
    </ThemeActionsContext.Provider>
  );
}

// 4. 封装自定义 Hook,顺带做"是否在 Provider 内"的校验
export function useTheme() {
  const ctx = useContext(ThemeStateContext);
  if (ctx === null) throw new Error('useTheme 必须在 ThemeProvider 内部使用');
  return ctx;
}
export function useThemeActions() {
  const ctx = useContext(ThemeActionsContext);
  if (ctx === null) throw new Error('useThemeActions 必须在 ThemeProvider 内部使用');
  return ctx;
}

// ---------- 深层组件直接消费,无需逐层传 props ----------
function DeepButton() {
  const theme = useTheme();
  const { toggle } = useThemeActions();
  return <button onClick={toggle}>当前主题:{theme}</button>;
}

Context 的性能陷阱:Provider 的 value 一变,所有消费该 Context 的组件都会重渲染,且 React.memo 拦不住(Context 更新走的是 Fiber 上的依赖列表,绕过了 props 比较)。所以:

  • value 必须用 useMemo 稳定,绝不能写 value={{ a, b }};
  • 高频变化的值和低频的值要拆成不同 Context;
  • 状态和 dispatch 拆开(上例做法);
  • 真的需要细粒度订阅时,用 Zustand / Jotai 或 useSyncExternalStore,它们支持 selector 级别的订阅。

4.4 四种方式的选型

场景推荐方式
父传数据给子props
子把用户输入/事件传给父回调函数(状态提升)
父需要触发子的命令式动作ref + useImperativeHandle
兄弟组件共享状态状态提升到最近公共父级
跨 3 层以上、低频变化的全局值Context
跨页面、高频、复杂的共享状态Zustand / Redux Toolkit / Jotai

【追问 & 加分点】

  • React 19 移除了 forwardRef 的必要性:函数组件可以直接从 props 里解构 ref,forwardRef 保留但标记为逐步废弃。
  • useImperativeHandle 的第三个参数是依赖数组,忘了写会导致每次渲染都重建句柄对象(对 ref 本身影响不大,但如果外部对句柄做了 memo 判断就有问题)。
  • 子组件修改父组件传下来的对象是反模式:props 应视为只读。要改就调用父传下来的回调,让父自己用不可变方式更新。
  • "回调上行"传的是事件语义还是数据语义? 好的组件设计是 onSearch(keyword) 这种语义化事件,而不是 setParentState,前者让子组件不依赖父组件的状态结构,可复用性更强。
  • Context 不是状态管理库:它只解决"传递"问题,不解决"更新粒度"和"派生状态"问题。这是面试里区分"用过"和"想过"的关键点。

5. 自定义 Hook 的设计:useCountdown 与 useRequest

【核心答案】

自定义 Hook 的本质是逻辑复用的函数——把若干内置 Hook 组合起来、封装一段有状态的逻辑,对外暴露值和操作方法。它复用的是逻辑,不是状态:两个组件调用同一个自定义 Hook,各自拥有完全独立的状态。

设计原则:

  1. use 开头(lint 依据);
  2. 只在顶层调用其他 Hook;
  3. 返回值设计:单值返回值本身,多值且需要重命名返回数组(如 useState),字段多且语义固定返回对象;
  4. 返回的函数引用要稳定(useCallback),否则会污染使用方的依赖数组;
  5. 必须清理副作用:定时器、订阅、请求都要在卸载时取消;
  6. 关注"竞态"和"卸载后 setState"这两个高频线上问题。

【展开】

5.1 useCountdown:完整实现

需求:给一个目标时间戳或秒数,返回剩余时间、格式化结果,支持开始/暂停/重置,组件卸载自动清理。

import { useState, useRef, useEffect, useCallback, useMemo } from 'react';

/**
 * 倒计时 Hook
 * @param {number} initialSeconds 初始秒数
 * @param {object} options
 * @param {number} options.interval 刷新间隔(毫秒),默认 1000
 * @param {boolean} options.autoStart 是否自动开始,默认 true
 * @param {Function} options.onEnd 倒计时结束回调
 * @returns {{ seconds:number, isRunning:boolean, formatted:string,
 *             start:Function, pause:Function, reset:Function }}
 */
export function useCountdown(initialSeconds, options = {}) {
  const { interval = 1000, autoStart = true, onEnd } = options;

  const [seconds, setSeconds] = useState(initialSeconds);
  const [isRunning, setIsRunning] = useState(autoStart);

  // 用 ref 存 onEnd:这样 onEnd 变化不会导致 effect 重跑(否则使用方传内联函数就每次重建定时器)
  const onEndRef = useRef(onEnd);
  onEndRef.current = onEnd;

  // 用「结束时间戳」而不是「每次减 1」来计算,避免 setInterval 累积误差、
  // 以及页面切到后台被浏览器节流后时间不准的问题
  const endTimeRef = useRef(0);

  useEffect(() => {
    if (!isRunning || seconds <= 0) return;

    // 每次进入运行态,用当前剩余秒数重新锚定结束时间
    endTimeRef.current = Date.now() + seconds * 1000;

    const timer = setInterval(() => {
      const rest = Math.max(0, Math.ceil((endTimeRef.current - Date.now()) / 1000));
      setSeconds(rest);
      if (rest <= 0) {
        clearInterval(timer);
        setIsRunning(false);
        onEndRef.current?.(); // 读 ref 拿到最新回调,规避 stale closure
      }
    }, interval);

    // 清理:暂停、依赖变化、组件卸载时都会走到这里
    return () => clearInterval(timer);
    // 注意:这里故意不把 seconds 放进依赖,否则每秒重建定时器。
    // isRunning 从 false → true 时才需要重新锚定 endTime,这已由 isRunning 依赖覆盖。
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [isRunning, interval]);

  const start = useCallback(() => setIsRunning(true), []);
  const pause = useCallback(() => setIsRunning(false), []);
  const reset = useCallback(
    (next = initialSeconds) => {
      setIsRunning(false);
      setSeconds(next);
    },
    [initialSeconds]
  );

  // 派生数据用 useMemo,而不是再开一个 state + useEffect
  const formatted = useMemo(() => {
    const m = String(Math.floor(seconds / 60)).padStart(2, '0');
    const s = String(seconds % 60).padStart(2, '0');
    return `${m}:${s}`;
  }, [seconds]);

  return { seconds, isRunning, formatted, start, pause, reset };
}

// ---------- 使用:短信验证码按钮 ----------
function SmsButton({ phone }) {
  const { seconds, isRunning, start } = useCountdown(60, { autoStart: false });

  const handleSend = async () => {
    await fetch('/api/sms', { method: 'POST', body: JSON.stringify({ phone }) });
    start();
  };

  return (
    <button disabled={isRunning} onClick={handleSend}>
      {isRunning ? `${seconds} 秒后重发` : '获取验证码'}
    </button>
  );
}

这个实现里值得在面试中主动讲的三个设计点:

  1. 用 Date.now() 锚定结束时间,而不是每次 seconds - 1。因为 setInterval 会漂移,且浏览器在后台标签页会把定时器节流到 ≥1s 甚至更久,纯累减会明显不准。
  2. 用 onEndRef 存回调,避免使用方传内联函数导致定时器被反复销毁重建。
  3. 派生值 formatted 用 useMemo 而不是额外的 state,避免多一次渲染。

5.2 useRequest:带竞态处理、取消、缓存的请求 Hook

import { useState, useRef, useCallback, useEffect } from 'react';

/**
 * 请求 Hook
 * @param {(params:any, signal:AbortSignal) => Promise<any>} service 请求函数
 * @param {object} options
 * @param {boolean} options.manual 是否手动触发,默认 false(挂载即请求)
 * @param {any[]}   options.deps 自动重新请求的依赖
 * @param {any}     options.defaultParams 首次自动请求的参数
 * @param {Function} options.onSuccess / options.onError
 */
export function useRequest(service, options = {}) {
  const {
    manual = false,
    deps = [],
    defaultParams,
    onSuccess,
    onError,
  } = options;

  const [state, setState] = useState({
    data: undefined,
    error: undefined,
    loading: !manual,
  });

  // 只保留最新一次请求的"令牌",用于竞态判定
  const requestIdRef = useRef(0);
  const controllerRef = useRef(null);
  const mountedRef = useRef(true);

  // 把可能变化的外部依赖存进 ref,保证 run 的引用可以稳定
  const serviceRef = useRef(service);
  serviceRef.current = service;
  const onSuccessRef = useRef(onSuccess);
  onSuccessRef.current = onSuccess;
  const onErrorRef = useRef(onError);
  onErrorRef.current = onError;

  useEffect(() => {
    mountedRef.current = true;
    return () => {
      mountedRef.current = false;
      controllerRef.current?.abort(); // 卸载时取消在途请求
    };
  }, []);

  const run = useCallback(async (params) => {
    // 1. 取消上一次未完成的请求(省流量、也防止服务端压力)
    controllerRef.current?.abort();
    const controller = new AbortController();
    controllerRef.current = controller;

    // 2. 自增请求 id,只有 id 最大的那次结果才允许写入 state(解决竞态)
    const id = ++requestIdRef.current;

    setState((s) => ({ ...s, loading: true, error: undefined }));

    try {
      const data = await serviceRef.current(params, controller.signal);
      // 3. 三重防护:组件已卸载 / 已被更新的请求覆盖 / 已被取消
      if (!mountedRef.current || id !== requestIdRef.current) return;
      setState({ data, error: undefined, loading: false });
      onSuccessRef.current?.(data, params);
      return data;
    } catch (error) {
      if (error.name === 'AbortError') return; // 主动取消,不算错误
      if (!mountedRef.current || id !== requestIdRef.current) return;
      setState({ data: undefined, error, loading: false });
      onErrorRef.current?.(error, params);
    }
  }, []); // 依赖为空,run 引用永久稳定,使用方可以安心放进 effect 依赖

  const refresh = useCallback(() => run(defaultParams), [run, defaultParams]);

  const cancel = useCallback(() => {
    controllerRef.current?.abort();
    requestIdRef.current++; // 让在途结果失效
    setState((s) => ({ ...s, loading: false }));
  }, []);

  // 自动请求
  useEffect(() => {
    if (manual) return;
    run(defaultParams);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, deps);

  return { ...state, run, refresh, cancel };
}

// ---------- 使用 ----------
function UserDetail({ userId }) {
  const { data, loading, error, refresh } = useRequest(
    (id, signal) => fetch(`/api/user/${id}`, { signal }).then((r) => r.json()),
    { deps: [userId], defaultParams: userId }
  );

  if (loading) return <Skeleton />;
  if (error) return <Error onRetry={refresh} />;
  return <div>{data.name}</div>;
}

竞态(race condition)是这道题的核心考点:用户快速切换 userId 从 1 到 2,如果请求 1 比请求 2 后返回,不做处理的话页面会显示用户 1 的数据。上面用了两层保护——AbortController 主动取消 + 自增 requestId 丢弃过期结果。只答其中一个也能过,两个都答出来是加分。

5.3 自定义 Hook 的设计 checklist

□ 命名以 use 开头
□ 内部 Hook 只在顶层调用,无条件分支
□ 返回的函数用 useCallback 包裹,引用稳定
□ 派生数据用 useMemo,不用额外 state + useEffect
□ 所有副作用(定时器 / 订阅 / 事件监听 / 请求)都有对应清理
□ 处理竞态:异步结果落地前校验"我还是最新的那次吗"
□ 回调型参数(onSuccess 等)用 ref 存,避免污染依赖数组
□ 对外 API 语义化,隐藏内部实现细节
□ SSR 安全:不在渲染期间访问 window / document

【追问 & 加分点】

  • 自定义 Hook 复用的是逻辑不是状态:两个组件各调一次 useCountdown,两份独立状态。要共享状态得靠 Context 或外部 store。这是一个非常高频的追问点。
  • 为什么不用 isMounted 判断来"防止卸载后 setState"? React 18 起卸载后 setState 已经不再警告(不算内存泄漏),官方推荐的做法是用 AbortController 取消请求,而不是加 mounted 标志位。我上面保留 mountedRef 是为了兼容"取消不了的 Promise",但要能说清这个演变。
  • useSyncExternalStore:如果自定义 Hook 要订阅外部数据源(window.matchMedia、localStorage、WebSocket),并发模式下用 useState + useEffect 会有"撕裂"(tearing)问题,正确 API 是 useSyncExternalStore。
  • 和 ahooks 的对比:能说出 ahooks 的 useRequest 还支持轮询、防抖节流、缓存与 SWR、依赖 ready、loading delay,说明你读过工业级实现。

6. Fiber 架构:动机、数据结构、双缓存、更新流程

【核心答案】

为什么需要 Fiber:React 15 的协调过程是基于虚拟 DOM 树的同步递归(mountComponent 递归调用子组件)。递归一旦开始就不能停,调用栈由 JS 引擎管理,React 无法保存进度、无法中断。组件树一大(几千个节点),一次更新的计算就可能占用几十上百毫秒,超过一帧的 16.6ms,主线程被独占,用户的输入、动画全部卡住(掉帧)。

Fiber 的解法:把递归改成链表遍历 + 循环。用 child / sibling / return 三个指针把树变成可以用循环遍历的结构,把每个节点作为一个"工作单元",React 自己维护遍历进度(workInProgress 指针)。这样就可以在每个工作单元之间检查"这一帧的时间片用完了吗",用完就保存进度、把主线程还给浏览器,下一帧再从断点继续——这就是**时间切片(可中断渲染)**的基础。

Element vs Fiber:Element 是 createElement 的返回值,是描述 UI 长什么样的不可变的、轻量的普通对象(type / props / key / ref),每次渲染都重新创建;Fiber 是可变的工作单元 + 组件实例的持久载体,保存了 stateNode(真实 DOM 或类实例)、Hook 链表、副作用标记、优先级、指针关系,跨渲染复用。

双缓存:内存中同时最多存在两棵 Fiber 树——屏幕上正在显示的 current 树和正在内存中构建的 workInProgress 树,两者的同名节点通过 alternate 互相指向。新树在内存中完整构建好之后,一次性把 fiberRoot.current 指向它完成切换,避免用户看到构建到一半的中间状态。

更新流程:触发更新(setState)→ Render / Reconciler 阶段(构建 workInProgress 树,可中断、可丢弃、可重来,纯计算无副作用)→ Commit 阶段(把标记的副作用一次性刷到真实 DOM,同步不可中断)。

【展开】

6.1 React 15 的问题

// React 15 的 mountComponent 本质是这样的递归(示意)
function reconcile(element, parentInstance) {
  const instance = instantiate(element);
  const dom = instance.mount();          // 创建 DOM
  parentInstance.appendChild(dom);
  element.props.children.forEach((child) =>
    reconcile(child, instance)           // ← 递归,调用栈由 JS 引擎管理,无法中断
  );
}

问题不在"慢",而在"不可中断"。哪怕总耗时 100ms,如果能拆成 20 个 5ms 的片段,中间让浏览器响应用户输入,体验就完全不同。

6.2 Element 与 Fiber 的对比

// ---------- React Element:createElement 的返回值 ----------
// <div className="box"><span key="a">hi</span></div> 编译后
const element = {
  $$typeof: Symbol.for('react.element'), // 防 XSS 的标记,JSON 无法伪造 Symbol
  type: 'div',                            // 'div' | 函数组件 | 类组件 | Symbol(Fragment)
  key: null,
  ref: null,
  props: { className: 'box', children: { /* 又一个 element */ } },
};
// 特点:不可变、轻量、每次 render 都重新创建、只描述"要渲染什么",不含任何状态
// ---------- Fiber Node:工作单元 ----------
function FiberNode(tag, pendingProps, key, mode) {
  // ===== 静态属性:描述"我是什么" =====
  this.tag = tag;                 // FunctionComponent=0 / ClassComponent=1 / HostComponent=5 ...
  this.key = key;
  this.elementType = null;
  this.type = null;               // 对应 element.type
  this.stateNode = null;          // HostComponent → 真实 DOM;ClassComponent → 类实例

  // ===== 链表指针:把树变成可循环遍历的结构 =====
  this.return = null;             // 父 Fiber("归"的时候回到哪里)
  this.child = null;              // 第一个子 Fiber
  this.sibling = null;            // 下一个兄弟 Fiber
  this.index = 0;                 // 在兄弟中的位置,diff 时用来判断是否需要移动

  // ===== 工作单元属性:描述"这次要干什么" =====
  this.pendingProps = pendingProps; // 本次新的 props
  this.memoizedProps = null;        // 上次渲染完成后的 props
  this.updateQueue = null;          // 更新队列(类组件的 setState / 函数组件的 effect 链表)
  this.memoizedState = null;        // 类组件 → state;函数组件 → Hook 单向链表的头节点
  this.dependencies = null;         // 消费的 Context 列表

  // ===== 副作用与调度 =====
  this.flags = NoFlags;           // 本节点的副作用标记:Placement/Update/Deletion/Passive...
  this.subtreeFlags = NoFlags;    // 子树是否有副作用(用于 commit 阶段快速跳过整棵干净子树)
  this.deletions = null;          // 需要删除的子 Fiber
  this.lanes = NoLanes;           // 本节点待处理更新的优先级
  this.childLanes = NoLanes;      // 子树待处理更新的优先级

  // ===== 双缓存 =====
  this.alternate = null;          // 指向另一棵树中的对应节点
}
维度React ElementFiber Node
产生方式createElement()(JSX 编译结果)createFiberFromElement() 或从 alternate 复用
可变性不可变,每次渲染新建可变,跨渲染复用(双缓存两个实例交替)
是否含状态否是(memoizedState、Hook 链表、stateNode)
结构通过 props.children 嵌套通过 child / sibling / return 组成链表
职责描述 UI 的形状工作单元 + 状态载体 + 副作用记录 + 优先级载体

6.3 树 → 链表的遍历

        App
       /   \
    Header  List
             |
          Item ── Item ── Item

Fiber 结构中的指针:

App.child = Header
Header.sibling = List,  Header.return = App
List.child = Item1,     List.return = App
Item1.sibling = Item2,  Item1.return = List

遍历算法是"深度优先 + 递归改循环",分"递"(beginWork)和"归"(completeWork)两个方向:

// 可中断的工作循环:每处理完一个 Fiber 就检查是否该让出主线程
function workLoopConcurrent() {
  while (workInProgress !== null && !shouldYield()) {
    //                                ↑ 关键:Scheduler 判断当前时间片(5ms)是否用完
    performUnitOfWork(workInProgress);
  }
}

// 同步模式没有 shouldYield,一口气做完
function workLoopSync() {
  while (workInProgress !== null) {
    performUnitOfWork(workInProgress);
  }
}

function performUnitOfWork(unitOfWork) {
  const current = unitOfWork.alternate;
  // "递"阶段:根据新 props 计算,创建/复用子 Fiber,返回第一个子节点
  let next = beginWork(current, unitOfWork, renderLanes);
  unitOfWork.memoizedProps = unitOfWork.pendingProps;

  if (next === null) {
    // 没有子节点了 → 进入"归"阶段
    completeUnitOfWork(unitOfWork);
  } else {
    workInProgress = next;
  }
}

function completeUnitOfWork(unitOfWork) {
  let completedWork = unitOfWork;
  do {
    // "归"阶段:创建真实 DOM(离屏)、处理 props、把子树的 flags 向上冒泡
    completeWork(completedWork.alternate, completedWork, renderLanes);

    const siblingFiber = completedWork.sibling;
    if (siblingFiber !== null) {
      workInProgress = siblingFiber; // 有兄弟,转去处理兄弟的"递"
      return;
    }
    completedWork = completedWork.return; // 没兄弟,回到父节点继续"归"
    workInProgress = completedWork;
  } while (completedWork !== null);
}

中断为什么安全:因为 workInProgress 是一个普通变量,中断时它就停在某个 Fiber 上;恢复时从这个变量继续 workLoopConcurrent 即可。而且 Render 阶段是纯计算,构建的是内存中的新树,没有碰真实 DOM,所以即使被高优先级更新打断、整棵 workInProgress 树被丢弃重来,用户也感知不到。

6.4 双缓存机制

首屏(mount):
  fiberRoot.current ──> HostRootFiber(current,空)
                          ↕ alternate
                        HostRootFiber(workInProgress) ──> 构建整棵新树
  commit 后:fiberRoot.current = workInProgress 树

更新(update):
  fiberRoot.current ──> 树 A(屏幕上显示的)
                          ↕ alternate
                        树 B(复用上一次的那棵,作为新的 workInProgress)
  构建完成 commit 后:fiberRoot.current = 树 B
  下一次更新时,树 A 又成为 workInProgress —— 两棵树交替复用,避免每次都新建全部对象
// 创建/复用 workInProgress 节点
function createWorkInProgress(current, pendingProps) {
  let workInProgress = current.alternate;
  if (workInProgress === null) {
    // 首次:新建一个,并与 current 建立 alternate 双向指向
    workInProgress = createFiber(current.tag, pendingProps, current.key, current.mode);
    workInProgress.stateNode = current.stateNode; // 复用真实 DOM,不重新创建
    workInProgress.alternate = current;
    current.alternate = workInProgress;
  } else {
    // 复用上一棵树的节点对象,只重置本次需要重算的字段(对象复用,减少 GC 压力)
    workInProgress.pendingProps = pendingProps;
    workInProgress.type = current.type;
    workInProgress.flags = NoFlags;
    workInProgress.subtreeFlags = NoFlags;
    workInProgress.deletions = null;
  }
  // 从 current 同步不变的状态
  workInProgress.child = current.child;
  workInProgress.memoizedProps = current.memoizedProps;
  workInProgress.memoizedState = current.memoizedState;
  workInProgress.updateQueue = current.updateQueue;
  workInProgress.lanes = current.lanes;
  workInProgress.childLanes = current.childLanes;
  return workInProgress;
}

双缓存的两个价值:一是避免中间态(新树没构建完之前,屏幕上的 current 树完全不受影响;如果被中断丢弃,用户什么都不会看到);二是对象复用(两棵树的 Fiber 节点反复复用,避免每次更新都创建成千上万个对象)。这个思路和图形学的 double buffering、canvas 的离屏渲染是同一个。

6.5 完整更新流程

① 触发更新
   this.setState / useState 的 dispatch / root.render
     → 创建 Update 对象,挂到对应 Fiber 的 updateQueue(环形链表)
     → markUpdateLaneFromFiberToRoot:从触发节点沿 return 指针向上,
        把 lane 记录到每一层的 childLanes,一直到 FiberRoot
     → scheduleUpdateOnFiber → ensureRootIsScheduled

② 调度(Scheduler)
   根据 lane 算出优先级 → Scheduler.scheduleCallback(priority, performConcurrentWorkOnRoot)
   Scheduler 用 MessageChannel 产生宏任务(不是 requestIdleCallback:兼容性差、触发不稳定)
   同步优先级(如离散事件、flushSync)走 performSyncWorkOnRoot,不可中断

③ Render / Reconciler 阶段  ★可中断★
   renderRootConcurrent → workLoopConcurrent
     beginWork(递):
       - 判断能否 bailout(props 全等 && 没有本节点的更新 && 子树无更新 lane)
       - 不能则调用组件函数 / render(),得到新的 children Element
       - reconcileChildren:diff,生成/复用子 Fiber,打上 Placement / Update / Deletion 标记
     completeWork(归):
       - HostComponent:createInstance 创建离屏 DOM,appendAllChildren 拼接子 DOM
       - diffProperties 计算属性差异,存入 updateQueue
       - bubbleProperties:把子树的 flags 冒泡到 subtreeFlags
   结束后得到一棵带完整副作用标记的 workInProgress 树(finishedWork)
   ▲ 这个阶段是纯计算,没有操作真实 DOM,所以中断/丢弃/重跑都安全
   ▲ 也正因为可能重跑,render 阶段的代码必须是纯的(这就是 StrictMode 双调用要暴露的问题)

④ Commit 阶段  ★同步、不可中断★
   commitRoot 分三个子阶段:
   a. before mutation:读取变更前的 DOM 状态
      - 类组件 getSnapshotBeforeUpdate
      - 调度 useEffect 的异步执行(scheduleCallback(NormalPriority, flushPassiveEffects))
   b. mutation:真正操作 DOM
      - Placement:插入/移动节点     - Update:更新属性、文本
      - Deletion:删除节点,同时执行 useLayoutEffect 的清理函数、componentWillUnmount
      - 【这一步之后切换 fiberRoot.current = finishedWork】
        切换点选在 mutation 之后、layout 之前:保证 componentWillUnmount 里读到的是旧树,
        而 componentDidMount/Update 与 useLayoutEffect 里读到的是新树
   c. layout:DOM 已更新但浏览器还没绘制
      - componentDidMount / componentDidUpdate
      - useLayoutEffect 的回调(同步执行,会阻塞绘制)
      - ref 的赋值
   → 浏览器 paint
   → useEffect 的回调此时才被异步执行

为什么 Commit 必须同步不可中断:这一步在直接改真实 DOM,如果中途被打断,用户就会看到一半旧一半新的破碎界面。所以 React 的设计是"把耗时的可中断部分(计算)和快速的不可中断部分(DOM 写入)分离"。

【追问 & 加分点】

  • Fiber 这个名字的含义:借用操作系统"纤程"的概念——比线程更轻量的、由用户态自己调度的执行单元。React 用它表达"一个可以被调度、可以暂停恢复的最小工作单元"。
  • 为什么用 MessageChannel 而不是 requestIdleCallback? requestIdleCallback 存在兼容性问题(Safari 长期不支持)、触发频率不稳定(浏览器认为空闲才调,可能几百毫秒不触发)、且回调优先级太低。React 自己实现了 Scheduler,用 MessageChannel 生成宏任务(比 setTimeout 少 4ms 的最小延迟钳制),并固定 5ms 的时间切片。
  • shouldYield() 判断的不只是时间:新版本还会检查是否有待处理的输入事件(isInputPending,Chrome 支持)。
  • React 17 移除了 effectList(副作用链表),改为 subtreeFlags 冒泡:老版本用一条链表串起所有有副作用的 Fiber,commit 时遍历链表;新版本改为在 completeWork 时把子树 flags 冒泡到父节点,commit 时递归遍历但可以靠 subtreeFlags === NoFlags 整棵跳过。原因是可中断渲染下维护单链表更容易出错。
  • "Fiber 让 React 变快了吗":严格说单次更新的总计算量没变,甚至因为调度开销略有增加。Fiber 提升的是响应性/交互流畅度(perceived performance),不是吞吐量。能澄清这一点是很强的信号。
  • bailout 的条件:oldProps === newProps(引用相等)且 !includesSomeLane(renderLanes, workInProgress.lanes) 且 context 没变,则可以复用整棵子树,直接 bailoutOnAlreadyFinishedWork。这解释了为什么"内容提升"(把 children 作为 props 传入)能优化性能——children 的 Element 引用没变,触发了 bailout。

7. Concurrent 并发特性与 RSC 服务端组件

【核心答案】

Concurrent(并发渲染) 不是一个开关或一个 API,而是 React 18 起由 createRoot 启用的一套可中断渲染能力,包含三块:

  1. 时间切片:Render 阶段每处理若干工作单元就检查时间片(5ms),超时就把主线程还给浏览器,下一个宏任务继续,保证用户输入和动画不被长渲染阻塞。
  2. 优先级(Lane 模型):每个更新被赋予一条 lane(31 位位掩码),离散事件(点击、输入)优先级高于连续事件(滚动、拖动),高于 transition,高于 offscreen。高优先级更新可以打断正在进行的低优先级渲染,做完之后低优先级的再重头来一遍。
  3. 暴露给开发者的 API:useTransition(把更新标记为"非紧急")、useDeferredValue(延迟某个值的更新)、<Suspense>(声明式加载态)、startTransition。

RSC(React Server Components) 是"组件在服务端执行"的模型:服务端组件在服务器上渲染成一段序列化的 RSC Payload(不是 HTML 字符串,而是描述 UI 树的特殊格式),流式发送到客户端由 React 渲染。价值有三:组件代码不进客户端 bundle(用 markdown 解析、日期库这类重依赖时体积收益巨大);可以直接 async/await 访问数据库/文件系统,数据获取贴近数据源,消除客户端的请求瀑布;与 Suspense 配合可以流式渲染,先出骨架再逐块填充。代价是服务端组件不能有状态、不能用 effect 和浏览器 API,需要通过 'use client' 显式划出客户端边界。

【展开】

7.1 从 Legacy 到 Concurrent

// React 17 / React 18 legacy 模式:同步渲染,无并发能力
ReactDOM.render(<App />, container);

// React 18 concurrent 模式:开启可中断渲染 + 自动批处理
import { createRoot } from 'react-dom/client';
createRoot(container).render(<App />);

仅仅换成 createRoot 就会得到的两个变化:

  • 自动批处理(Automatic Batching):React 17 只在 React 事件处理函数内批处理 setState;setTimeout、Promise、原生事件里的多次 setState 会各触发一次渲染。React 18 concurrent 模式下所有场景都批处理。需要立刻同步刷新时用 flushSync。
  • 渲染可被中断,为下面的 API 提供基础。

7.2 Lane 优先级模型

// react-reconciler/src/ReactFiberLane.js(节选)
export const SyncLane            = 0b0000000000000000000000000000010;
export const InputContinuousLane = 0b0000000000000000000000000001000;
export const DefaultLane         = 0b0000000000000000000000000100000;
export const TransitionLane1     = 0b0000000000000000000000001000000;
// ... TransitionLane1..16
export const RetryLane1          = 0b0000000010000000000000000000000;
export const IdleLane            = 0b0100000000000000000000000000000;
export const OffscreenLane       = 0b1000000000000000000000000000000;

用位掩码而不是数字优先级的好处:一个 Fiber 可以同时携带多条 lane(多个不同优先级的待处理更新),用位运算 & | 就能高效地做批量判断和合并,这是 React 16 的 expirationTime(单一数字)做不到的。

高优先级打断低优先级的过程:

低优先级 transition 渲染进行到一半(workInProgress 停在某个节点)
  → 用户点击按钮,产生 SyncLane 更新
  → ensureRootIsScheduled 发现新 lane 优先级更高
  → 丢弃当前的 workInProgress 树(不是保存,是丢弃!因为状态可能已经不一致)
  → 从根节点重新开始,以高优先级 lane 渲染
  → 高优先级 commit 完成
  → 剩余的 transition lane 重新调度,从头再渲染一次

注意"重头再来"这个细节:这就是为什么 render 阶段的函数必须是纯的——它可能被执行任意多次。

7.3 useTransition:标记非紧急更新

经典场景:输入框实时过滤一个上万条的大列表。输入必须立刻响应,列表可以慢一点。

import { useState, useTransition, memo } from 'react';

const HugeList = memo(function HugeList({ keyword, items }) {
  // 故意做一个昂贵的渲染
  const filtered = items.filter((it) => it.name.includes(keyword));
  return (
    <ul>
      {filtered.map((it) => (
        <li key={it.id}>{it.name}</li>
      ))}
    </ul>
  );
});

export default function SearchPage({ items }) {
  const [input, setInput] = useState('');       // 紧急状态:输入框的值
  const [keyword, setKeyword] = useState('');   // 非紧急状态:用于过滤列表
  const [isPending, startTransition] = useTransition();

  const handleChange = (e) => {
    const value = e.target.value;
    setInput(value);                            // 紧急更新:立刻反映到输入框,绝不能卡

    startTransition(() => {
      setKeyword(value);                        // 非紧急更新:可被打断、可被丢弃
    });
  };

  return (
    <>
      <input value={input} onChange={handleChange} />
      {/* isPending 让我们能给一个"正在更新"的视觉反馈,而不是白屏或卡死 */}
      <div style={{ opacity: isPending ? 0.5 : 1 }}>
        <HugeList keyword={keyword} items={items} />
      </div>
    </>
  );
}

useTransition 和防抖的区别(高频追问):

防抖(debounce)useTransition
机制延迟触发计算,等用户停下来立刻开始计算,但可被更高优先级打断
快速输入时中间的输入完全不计算中间的渲染会开始、被打断、丢弃
用户停止输入后还要等一个防抖时长立刻拿到结果(可能已经算了一部分)
适用请求节流(省网络)纯渲染开销(省不了,只能让出主线程)

结论:如果昂贵的是网络请求,用防抖;如果昂贵的是渲染,用 transition;两者可以叠加。

7.4 useDeferredValue

useTransition 需要你能控制 setState 的调用处;如果值是从 props 传下来的,用 useDeferredValue:

function Results({ keyword }) {
  // deferredKeyword 会"滞后"于 keyword:紧急渲染用旧值,空闲时再用新值渲染一次
  const deferredKeyword = useDeferredValue(keyword);
  const isStale = keyword !== deferredKeyword;

  return (
    <div style={{ opacity: isStale ? 0.6 : 1 }}>
      <HugeList keyword={deferredKeyword} />
    </div>
  );
}

可以理解为:useDeferredValue 是"接收端"的 transition,useTransition 是"发送端"的 transition。

7.5 Suspense 与流式 SSR

<Suspense fallback={<Skeleton />}>
  <Comments postId={id} />
</Suspense>

React 18 的 SSR 配合 Suspense 提供两个能力:

  • Streaming SSR(renderToPipeableStream):服务端不必等全部数据就绪才发 HTML,可以先发出骨架,慢的部分后续通过 <script> 补丁流式插入。
  • Selective Hydration:客户端不必等全部 JS 加载完才能交互;某个 Suspense 边界的代码到了就先水合它。而且如果用户点击了一个尚未水合的区域,React 会提高该区域的水合优先级,插队处理。

7.6 RSC:服务端组件

// app/posts/[id]/page.jsx —— 默认就是服务端组件(Next.js App Router)
// 没有 'use client',所以这段代码永远不会出现在浏览器的 bundle 里
import { db } from '@/lib/db';
import { marked } from 'marked';       // 一个 100KB 的 markdown 库,客户端零负担
import LikeButton from './LikeButton'; // 客户端组件

export default async function PostPage({ params }) {
  // ✅ 服务端组件可以是 async 函数,直接 await
  // ✅ 可以直接访问数据库 / 文件系统 / 内部服务,无需暴露 API 接口
  // ✅ 密钥、连接串留在服务端,不会泄漏
  const post = await db.post.findUnique({ where: { id: params.id } });
  const html = marked(post.content);

  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: html }} />
      {/* 把服务端取到的数据作为 props 传给客户端组件(props 必须可序列化) */}
      <LikeButton postId={post.id} initialCount={post.likes} />
    </article>
  );
}
// app/posts/[id]/LikeButton.jsx
'use client'; // ← 边界指令:这个文件及其 import 的模块会进客户端 bundle

import { useState } from 'react';

export default function LikeButton({ postId, initialCount }) {
  const [count, setCount] = useState(initialCount); // 只有客户端组件能有状态
  return <button onClick={() => setCount((c) => c + 1)}>👍 {count}</button>;
}

服务端组件与客户端组件的能力对照:

能力服务端组件客户端组件
useState / useReducer / useEffect❌✅
事件处理(onClick 等)❌✅
浏览器 API(window / localStorage)❌✅
async/await 直接取数✅⚠️(需 use() 或 effect)
直接访问数据库 / 文件系统 / 密钥✅❌
代码进入客户端 bundle❌(零字节)✅
可以 import 客户端组件✅—
可以 import 服务端组件—❌(只能通过 children 插槽传入)

RSC Payload 不是 HTML:它是一种流式的、类 JSON 的序列化格式,描述"渲染出的元素树 + 客户端组件的占位符和 chunk 引用 + props"。客户端 React 拿到后与已有的客户端树做协调,因此导航时不会丢失客户端组件的状态——这是 RSC 相比传统 SSR 的关键优势。

RSC 的价值总结:

  1. bundle 体积:只在服务端使用的库(markdown、语法高亮、日期格式化、ORM)完全不进客户端。
  2. 数据获取:组件级别的取数,天然并行(同级 async 组件并发执行),消除 useEffect 取数造成的请求瀑布和 loading 闪烁。
  3. 安全:密钥和数据访问逻辑留在服务端。
  4. 首屏:配合流式 SSR,TTFB 和 FCP 更好。

代价:心智模型变复杂(要时刻分清代码跑在哪一侧)、必须有 Node 服务端(不能纯静态部署)、跨边界的 props 必须可序列化(不能传函数,除非是 Server Action)、生态和调试工具还在成熟中。

【追问 & 加分点】

  • "React 18 升级到 createRoot 就自动变快了吗":不会。并发能力只是"可用",真正的收益需要你主动用 useTransition / useDeferredValue / Suspense 去标记优先级。这是一个很好的辨析题。
  • useTransition 里能不能放异步操作? React 18 的 startTransition 回调必须是同步的(await 之后的 setState 不再处于 transition 上下文)。React 19 支持了异步 transition(Actions),useActionState、useOptimistic 就是配套 API。
  • Server Actions:RSC 的另一半,用 'use server' 标记的函数可以从客户端直接调用,React 帮你做 RPC,配合 useActionState 和 useOptimistic 做表单和乐观更新。
  • RSC 与 SSR 的区别:SSR 是"把组件渲染成 HTML 字符串发给客户端,然后客户端再下载同一份 JS 水合一遍";RSC 是"这个组件的 JS 根本不发给客户端"。SSR 解决首屏,RSC 解决 bundle 体积和数据获取。两者正交、可以叠加使用。
  • 撕裂(tearing):并发渲染下,一次渲染可能被中断,中断期间外部 store 的数据变了,导致同一次渲染的不同部分读到不同的值。这就是 useSyncExternalStore 存在的原因,也是 Redux/Zustand 等库必须适配 React 18 的原因。

8. React 的 diff 策略与 key

【核心答案】

完整的树 diff 算法复杂度是 O(n³),不可接受。React 基于三个前提假设把它降到 O(n):

  1. 同层比较(tree diff):只比较同一层级的节点,不做跨层级的移动匹配。一个节点如果跨层级移动了,React 会把它在旧位置删除、在新位置重建,而不是复用。
  2. 不同类型直接重建(component diff):type 不同(div → p,或组件 A → 组件 B)就卸载整棵旧子树、挂载新子树,不再向下比较。
  3. 同层列表用 key 匹配(element diff):同一层的多个子节点,通过 key 来标识"是不是同一个节点",从而实现移动复用而非删除重建。

key 的作用:告诉 React "本次的这个节点和上次的哪个节点是同一个"。有了稳定的 key,React 才能在列表增删改序时复用已有的 DOM 和组件状态。

用数组下标做 key 的问题:下标是"位置"而不是"身份"。在列表头部插入、删除中间项、排序、过滤时,同一个下标在前后两次渲染中对应的是不同的数据项,导致:(a) 性能退化——本可以只插一个节点,变成从插入点开始逐个更新所有节点的内容;(b) 状态错位——组件内部的 state、非受控输入框的值、ref、CSS 过渡动画会跟着"位置"走而不是跟着"数据"走,出现输入框内容串行、勾选状态跑到别的行这类真实的 UI bug。

【展开】

8.1 三大策略的具体表现

// ① 同层比较:跨层级移动不会复用
// 更新前
<div>
  <Counter />   {/* 有内部 state */}
</div>
// 更新后
<div>
  <span>
    <Counter />  {/* 层级变了 → 旧的 Counter 被卸载(state 丢失),新的重新挂载 */}
  </span>
</div>

// ② 类型不同直接重建
<div><Counter /></div>  →  <p><Counter /></p>
// div 和 p 的 type 不同 → 整棵子树销毁重建,Counter 的 state 丢失

// ③ 类型相同则复用 DOM,只更新变化的属性
<div className="a" title="x" />  →  <div className="b" title="x" />
// 复用同一个 DOM 节点,只执行 dom.className = 'b'

顺带一个高频陷阱:

// ❌ 在组件内部定义组件
function Parent() {
  function Child() { return <input />; }  // 每次 Parent 渲染,Child 都是一个全新的函数
  return <Child />;                       // type 每次都不同 → 每次都卸载重建 → input 失焦、state 丢失
}

8.2 React 的列表 diff 实现(reconcileChildrenArray)

React 的实现和 Vue 的双端 diff 不同,是从左到右单向遍历 + 两轮:

function reconcileChildrenArray(returnFiber, currentFirstChild, newChildren, lanes) {
  let resultingFirstChild = null;
  let previousNewFiber = null;
  let oldFiber = currentFirstChild;
  let lastPlacedIndex = 0;   // 「最后一个可以保持不动的旧节点」在旧列表中的下标
  let newIdx = 0;

  // ===== 第一轮:从左到右逐个比较同下标的新旧节点 =====
  for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
    const newFiber = updateSlot(returnFiber, oldFiber, newChildren[newIdx], lanes);
    if (newFiber === null) break;   // key 不同 → 无法就地复用,跳出第一轮
    // ... 挂接链表
    lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
    previousNewFiber = newFiber;
    oldFiber = oldFiber.sibling;
  }

  // ===== 第二轮的三种收尾情况 =====
  // 情况 A:新节点遍历完了 → 剩下的旧节点全部删除
  if (newIdx === newChildren.length) {
    deleteRemainingChildren(returnFiber, oldFiber);
    return resultingFirstChild;
  }

  // 情况 B:旧节点遍历完了 → 剩下的新节点全部插入
  if (oldFiber === null) {
    for (; newIdx < newChildren.length; newIdx++) {
      const newFiber = createChild(returnFiber, newChildren[newIdx], lanes);
      lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
      // ... 挂接
    }
    return resultingFirstChild;
  }

  // 情况 C:新旧都有剩余(说明发生了移动/乱序)
  //   把剩余旧节点放进 Map<key ?? index, fiber>,然后遍历剩余新节点去 Map 里捞
  const existingChildren = mapRemainingChildren(returnFiber, oldFiber);
  for (; newIdx < newChildren.length; newIdx++) {
    const newFiber = updateFromMap(existingChildren, returnFiber, newIdx, newChildren[newIdx], lanes);
    if (newFiber !== null) {
      if (newFiber.alternate !== null) {
        existingChildren.delete(newFiber.key === null ? newIdx : newFiber.key);
      }
      lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
    }
    // ... 挂接
  }
  // Map 里没被复用的旧节点全部删除
  existingChildren.forEach((child) => deleteChild(returnFiber, child));
  return resultingFirstChild;
}

// 判断是否需要移动:React 只标记"向右移动"
function placeChild(newFiber, lastPlacedIndex, newIndex) {
  newFiber.index = newIndex;
  const current = newFiber.alternate;
  if (current !== null) {
    const oldIndex = current.index;
    if (oldIndex < lastPlacedIndex) {
      // 这个节点在旧列表中的位置,比"已确定不动的最右边界"还靠左 → 需要移动
      newFiber.flags |= Placement;
      return lastPlacedIndex;
    }
    return oldIndex;              // 不需要移动,更新右边界
  }
  newFiber.flags |= Placement;    // 全新节点,需要插入
  return lastPlacedIndex;
}

关键点:React 的移动判定是单向的——只处理"把节点往右挪",不做"往左挪"的优化。所以 [A,B,C,D] → [D,A,B,C] 这种把最后一个移到最前的情况,React 会移动 A、B、C 三个(3 次移动),而 Vue3 用 LIS 只需移动 D(1 次)。这是 React diff 相对 Vue diff 的一个已知取舍:实现更简单、常数更小,但极端乱序场景移动次数更多。

8.3 下标 key 的问题,用具体例子说清

场景一:头部插入(性能退化)

// 旧:['B', 'C']            新:['A', 'B', 'C']
// 用 index 作 key:
//   key=0: 'B' → 'A'   复用节点,改文本
//   key=1: 'C' → 'B'   复用节点,改文本
//   key=2: 无  → 'C'   新建节点
//   结果:2 次文本更新 + 1 次插入

// 用 id 作 key:
//   'B'、'C' 完全复用,只在头部插入一个 'A'
//   结果:1 次插入

场景二:状态错位(真正的 bug)

function TodoList() {
  const [todos, setTodos] = useState([
    { id: 1, text: '写周报' },
    { id: 2, text: '改 bug' },
  ]);

  const addFirst = () =>
    setTodos((prev) => [{ id: 3, text: '开会' }, ...prev]); // 插到最前面

  return (
    <>
      <button onClick={addFirst}>头部插入</button>
      {todos.map((todo, index) => (
        // ❌ 用 index 做 key
        <div key={index}>
          {todo.text}
          {/* 非受控输入框,值存在真实 DOM 里,不受 React 数据流管理 */}
          <input defaultValue="" placeholder="备注" />
          {/* 子组件有自己的 state */}
          <Checkbox />
        </div>
      ))}
    </>
  );
}

操作步骤:在"写周报"那行的备注框里输入"周五前",然后点"头部插入"。

结果:新插入的"开会"这一行,备注框里显示的是"周五前"。原因是 index=0 这个 key 前后两次渲染都存在,React 认为是同一个节点,于是复用了那个 DOM <input>(浏览器保存的用户输入值跟着 DOM 走),只把兄弟节点的文本改成了"开会"。同理,<Checkbox /> 的内部 state 也留在了原来的位置上。

结论:只要列表项内部持有"不由 props 决定的状态",下标 key 就会造成真正的渲染错乱,而不只是性能问题。 这类状态包括:

  • 子组件的 useState / useReducer state
  • 非受控表单元素的用户输入(<input defaultValue>、<textarea>、文件选择)
  • 焦点、光标位置、滚动位置
  • ref 指向的 DOM
  • CSS transition / animation 的进行状态
  • <video> / <audio> 的播放进度

场景三:删除中间项

// 旧:[{id:1,'A'}, {id:2,'B'}, {id:3,'C'}]   删除 id=2
// 新:[{id:1,'A'}, {id:3,'C'}]
// 用 index:key=0 'A'→'A'(不变);key=1 'B'→'C'(复用了 B 的 DOM 和状态,只改文本!);key=2 删除
//   → B 的 input 内容、勾选状态跑到了 C 这一行
// 用 id:  精确删除 id=2 的节点,A、C 完全不动

场景四:排序 / 过滤

// 点击表头排序:[A,B,C] → [C,B,A]
// 用 index:三行的 DOM 全部原地复用,只把文本换了一遍
//   → 每行的展开状态、勾选状态、动画完全错乱;有过渡动画的话看不到"移动"效果,只看到内容突变
// 过滤同理:筛选条件一变,下标与数据的对应关系整体错位

什么时候用 index 是安全的(要能说出边界,才显得是理解而不是背诵):

同时满足以下全部条件时,index 作 key 没有问题:

  1. 列表是静态的,永远不会增删;
  2. 列表项永远不重新排序或过滤;
  3. 列表项内部没有任何本地状态(纯展示,所有内容都来自 props);
  4. 列表项没有非受控输入、没有 transition 动画。

典型场景:渲染一个固定的表头、一个静态的 tab 配置数组。

为什么不能用 Math.random() 做 key:那比 index 更糟。每次渲染 key 都不同 → React 认为所有节点都是新的 → 全部销毁重建 → 性能最差,且所有状态、焦点、动画每次都丢。

8.4 key 的正确选取

// ✅ 最好:后端返回的稳定唯一 id
{list.map((item) => <Row key={item.id} data={item} />)}

// ✅ 次选:能唯一标识业务实体的字段组合
{list.map((item) => <Row key={`${item.type}-${item.code}`} data={item} />)}

// ✅ 数据本身没有 id 时:在数据进入 state 的那一刻生成并固化 id,而不是在渲染时生成
const [list, setList] = useState(() => raw.map((it) => ({ ...it, _key: crypto.randomUUID() })));

// ⚠️ key 只需在「同一父节点的兄弟之间」唯一,不需要全局唯一

8.5 key 的另一个用法:主动重置组件状态

// 切换用户时,希望表单内部的所有状态完全重置
<ProfileForm key={userId} userId={userId} />
// userId 变化 → key 变化 → React 认为是不同的组件 → 卸载旧的、挂载新的 → state 全部重置
// 这比在组件内写 useEffect(() => resetAllStates(), [userId]) 干净得多,也是官方推荐做法

【追问 & 加分点】

  • React diff 与 Vue3 diff 的差异:React 单向遍历 + lastPlacedIndex 判定右移;Vue3 是双端预处理(先同步头部、再同步尾部)+ 中间乱序段用 newIndexToOldIndexMap + 最长递增子序列求最少移动。Vue 的移动次数更优,React 的实现更简单且更契合其可中断渲染的需求。
  • 为什么 React 不做跨层级的移动优化? 因为 DOM 跨层级移动在真实业务里极其罕见,为它付出 O(n³) 的匹配成本不划算。这是一个典型的"用假设换复杂度"的工程决策。
  • key 变化会触发什么? 完整的卸载 + 挂载:useEffect 清理函数执行、componentWillUnmount、ref 置 null、DOM 销毁重建。所以用 key 重置状态虽然干净,但成本不低,不要在高频变化的值上用。
  • <Fragment key={...}>:<>...</> 短语法不支持 key,需要 key 时必须写成 <React.Fragment key={id}>。
  • key 不会作为 props 传给组件:props.key 是 undefined(读取时 React 还会警告),需要在组件内用到这个值时必须额外传一个 id prop。注意 React 19 把 ref 变成了普通 prop,但 key 依然是 React 保留的、不下传的。

反向链接(0)

还没有笔记链接到这里。在 Obsidian 里用 [[笔记名]] 引用它,这里就会出现,关系图谱上也会多出一条连线。

评论

关系图谱

01-js-core02-browser03-network04-css-html05-react06-vue07-engineering08-scenarios09-handwritten-js10-algorithms11-ai-frontend前端识记前端面试

On this page

React目录1. React Hook 的规则,以及为什么有这些规则【核心答案】【展开】1.1 状态存在哪里1.2 手写一个"迷你 Hook",直观解释为什么顺序不能变1.3 第二条规则:只在 React 函数中调用1.4 合法的"条件"写法【追问 & 加分点】2. useEffect 第二个参数的三种情况与清理函数时机【核心答案】【展开】2.1 三种情况的完整行为表2.2 一个能跑起来的完整验证 Demo2.3 依赖数组的三类经典坑2.4 执行时机:useEffect vs useLayoutEffect【追问 & 加分点】3. React.memo 与 useMemo 的区别,useCallback + memo 的正确姿势【核心答案】【展开】3.1 三者的区别一张表3.2 一个完整的、正确的优化示例3.3 常见的"白优化"反面教材3.4 React.memo 的第二个参数3.5 什么时候不该用【追问 & 加分点】4. 父子组件传值:props、ref、回调、Context【核心答案】【展开】4.1 props 下行 + 回调上行(受控组件的标准形态)4.2 ref + useImperativeHandle:父命令式调用子4.3 Context:跨层级传值4.4 四种方式的选型【追问 & 加分点】5. 自定义 Hook 的设计:useCountdown 与 useRequest【核心答案】【展开】5.1 useCountdown:完整实现5.2 useRequest:带竞态处理、取消、缓存的请求 Hook5.3 自定义 Hook 的设计 checklist【追问 & 加分点】6. Fiber 架构:动机、数据结构、双缓存、更新流程【核心答案】【展开】6.1 React 15 的问题6.2 Element 与 Fiber 的对比6.3 树 → 链表的遍历6.4 双缓存机制6.5 完整更新流程【追问 & 加分点】7. Concurrent 并发特性与 RSC 服务端组件【核心答案】【展开】7.1 从 Legacy 到 Concurrent7.2 Lane 优先级模型7.3 useTransition:标记非紧急更新7.4 useDeferredValue7.5 Suspense 与流式 SSR7.6 RSC:服务端组件【追问 & 加分点】8. React 的 diff 策略与 key【核心答案】【展开】8.1 三大策略的具体表现8.2 React 的列表 diff 实现(reconcileChildrenArray)8.3 下标 key 的问题,用具体例子说清8.4 key 的正确选取8.5 key 的另一个用法:主动重置组件状态【追问 & 加分点】