React 深度指南:从 Fiber 到 Hooks,万字长文带你吃透原理

0 阅读4分钟

React 深度指南:从 Fiber 到 Hooks,万字长文带你吃透原理

上周有个读者私信我:"React 用了两年,setState 都会写,但面试官问 Fiber 是什么,脑子一片空白。" 这不是他一个人的问题。


写在前面

面试的时候,你有没有遇到过这种情况:

  • 问 React 生命周期,能答个七七八八
  • 问 Hooks 原理,只能说"闭包+链表"
  • 问 Fiber 架构,直接懵了

我花了两周时间,把 React 从哲学到源码梳理了一遍,做成了一个结构化的知识图谱。今天把核心内容分享出来,万字长文,建议收藏。

GitHub 项目地址:react-deep-dive


一、React 的核心哲学:声明式 UI

命令式 vs 声明式

jQuery 时代(命令式):

// 告诉浏览器"怎么做"
$('#list').empty();
data.forEach(item => {
  $('#list').append(`<li>${item.name}</li>`);
});

React 时代(声明式):

// 告诉 React"想要什么"
function List({ data }) {
  return (
    <ul>
      {data.map(item => (
        <li key={item.id}>{item.name}</li>
      ))}
    </ul>
  );
}

核心区别:

维度命令式声明式
思维方式过程导向结果导向
代码量
维护性
Bug 率

React 的哲学可以用一句话概括:

UI = f(State)

给定相同的 State,永远得到相同的 UI。


二、Virtual DOM:不是为了快,而是为了声明式

很多人以为 Virtual DOM 是为了性能,其实不是。

Virtual DOM 解决了什么问题?

  1. 跨平台:一套代码可以跑 Web、Native、VR
  2. 声明式:开发者只需要描述 UI,React 负责更新
  3. 可中断:为并发模式打下基础

Diff 算法的三个假设

React 的 diff 算法基于三个假设:

  1. 只比较同层:不跨层级移动
  2. 类型不同直接替换divspan 就销毁重建
  3. Key 标识:通过 key 识别哪些节点是相同的

这三个假设让 diff 的时间复杂度从 O(n³) 降到 O(n)。

Diff 过程:

新树节点 vs 旧树节点
    ↓
类型相同? → 是 → 更新属性,递归比较 children
    ↓ 否
直接替换(销毁旧的,创建新的)

"理解 Diff 算法,是理解 Fiber 的前提。"


三、Fiber 架构:React 的灵魂

这是面试高频考点,也是理解 React 的关键。

为什么需要 Fiber?

React 15 用递归遍历 Virtual DOM 树,问题是不可中断

// React 15:递归,一旦开始无法停止
function reconcile(node) {
  updateDOM(node);
  node.children.forEach(child => {
    reconcile(child);  // 卡住了!
  });
}

如果树很大,主线程被占用几百毫秒,用户点击、输入就会卡顿。

问题场景:

用户点击按钮
    ↓
React 开始渲染 1000 个列表项
    ↓
主线程被占用 500ms
    ↓
用户的点击/输入完全无响应 → 页面卡顿!

Fiber 的解决方案

Fiber 把递归变成了可中断的循环

// React 16+:链表遍历,可以中断
function workLoop(deadline) {
  while (nextUnitOfWork && deadline.timeRemaining() > 1) {
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
  }
  
  if (nextUnitOfWork) {
    // 还有工作,让出主线程,等下一帧继续
    requestIdleCallback(workLoop);
  } else {
    // 渲染完成,提交到 DOM
    commitRoot();
  }
}

优化后:

用户点击按钮
    ↓
React 开始渲染列表
    ↓
渲染了 100 个,时间用完了
    ↓
让出主线程,处理用户输入 ✓
    ↓
下一帧继续渲染剩下的 900 个
    ↓
用户交互流畅,列表也渲染完了

一句话总结

Fiber 就是把递归 diff 变成了可中断的链表遍历。

Fiber 节点结构

每个 React 元素对应一个 Fiber 节点:

interface Fiber {
  type: string | Function;      // 'div' 或组件函数
  child: Fiber | null;          // 第一个子节点
  sibling: Fiber | null;        // 下一个兄弟节点
  return: Fiber | null;         // 父节点
  pendingProps: any;            // 新 props
  memoizedState: any;           // 上次渲染的 state
  flags: number;                // 标记需要执行的操作
  alternate: Fiber | null;      // 双缓冲指针
}

三种指针的遍历顺序:

parent
  ↓ child
child → sibling → sibling → null
                ↓ child
                grandchild → sibling → null

双缓冲机制

React 维护两棵 Fiber 树:

  • current tree:当前屏幕显示的
  • workInProgress tree:正在内存中构建的
current tree (屏幕上显示的)
    ↕ alternate 指针
workInProgress tree (正在构建的)
    ↓ 构建完成后,原子切换
新的 current tree

构建完成后,通过一个原子操作将其变为 current 树。好处是构建过程中如果被中断,current 树不受影响。


四、Hooks 原理:闭包 + 链表

"Fiber 解决了'能不能中断'的问题,那 Hooks 解决了'逻辑能不能复用'的问题。"

为什么 Hooks 不能在条件语句中调用?

Hooks 是按调用顺序存储在链表中的:

// 第一次渲染
useState('A');  // hooks[0]
useState('B');  // hooks[1]

// 第二次渲染(条件变化)
useState('A');  // hooks[0] ✓
// useState('B') 被跳过了
useState('C');  // hooks[1] ✗ 错误!应该是 hooks[2]

useState 的简化实现

let hooks: any[] = [];
let hookIndex = 0;

function useState<T>(initialValue: T): [T, (newValue: T) => void] {
  const idx = hookIndex;
  hooks[idx] = hooks[idx] ?? initialValue;
  
  function setState(newValue: T) {
    hooks[idx] = newValue;
    render();  // 触发重新渲染
  }
  
  hookIndex++;
  return [hooks[idx], setState];
}

闭包陷阱

function Counter() {
  const [count, setCount] = useState(0);
  
  function handleClick() {
    setTimeout(() => {
      alert(`当前值: ${count}`);  // 永远是 0!
    }, 3000);
  }
  
  return <button onClick={handleClick}>3秒后弹出</button>;
}

原因: 闭包捕获的是渲染时的值,不是最新值。

解决方案:useRef 或函数式更新。

// 方案1:函数式更新
setCount(prev => {
  alert(`当前值: ${prev}`);
  return prev;
});

// 方案2:useRef
const countRef = useRef(count);
countRef.current = count;
setTimeout(() => alert(countRef.current), 3000);

五、性能优化:什么时候该用 memo?

React.memo

// 只有 props 变化时才重新渲染
const Child = React.memo(function Child({ data }) {
  return <div>{data}</div>;
});

useMemo / useCallback

// 缓存计算结果
const value = useMemo(() => computeExpensive(a, b), [a, b]);

// 缓存函数引用
const handleClick = useCallback(() => doSomething(), []);

什么时候该用?

场景使用
昂贵的计算useMemo
传给 memo 组件的回调useCallback
普通计算不用
普通函数不用

真实案例:列表渲染优化

// 优化前:1000 条数据,每次输入都重新渲染
function App() {
  const [input, setInput] = useState('')
  const [list] = useState(generateLargeList()) // 1000 条
  
  return (
    <div>
      <input value={input} onChange={e => setInput(e.target.value)} />
      {list.map((item, i) => (
        <ExpensiveItem key={i} data={item} />
      ))}
    </div>
  )
}
// 每次输入,1000 个 ExpensiveItem 全部重新渲染 → 卡顿
// 优化后:只渲染变化的部分
const ExpensiveItem = React.memo(({ data }) => (
  <div>{data.name}</div>
))

function App() {
  const [input, setInput] = useState('')
  const [list] = useState(generateLargeList())
  
  return (
    <div>
      <input value={input} onChange={e => setInput(e.target.value)} />
      {list.map((item, i) => (
        <ExpensiveItem key={i} data={item} />
      ))}
    </div>
  )
}
// 输入时,ExpensiveItem 的 props 没变,跳过渲染 → 流畅

优化效果:

指标优化前优化后
输入响应延迟200ms5ms
渲染耗时150ms10ms
重新渲染组件数1000 个1 个

记住:过早优化是万恶之源。先测量,再优化。


六、React 19 新特性

React 19 是当前的流量密码,也是面试的新热点。

1. Actions:简化异步状态更新

React 18 的痛点:

// React 18:需要手动管理 loading 状态
const [isPending, setIsPending] = useState(false)

async function handleSubmit() {
  setIsPending(true)
  try {
    await updateName(name)
  } finally {
    setIsPending(false)
  }
}

React 19 的改进:

// React 19:Actions 自动管理
function UpdateName() {
  const [name, setName] = useState('')
  const [isPending, startTransition] = useTransition()
  
  async function handleSubmit() {
    startTransition(async () => {
      await updateName(name)
    })
  }
  
  return (
    <form onSubmit={handleSubmit}>
      <input value={name} onChange={e => setName(e.target.value)} />
      <button disabled={isPending}>
        {isPending ? '更新中...' : '更新'}
      </button>
    </form>
  )
}

解决的问题:

  • 不再需要手动管理 loading 状态
  • 异步操作和状态更新统一处理
  • 代码更简洁,不容易出错

2. useOptimistic:乐观更新

什么是乐观更新?

用户点击"点赞",先立即显示已点赞状态(乐观),后台再发请求。如果请求失败再回滚。

实际场景:

function TodoList({ todos, addTodo }) {
  const [optimisticTodos, addOptimisticTodo] = useOptimistic(
    todos,
    (state, newTodo) => [...state, { ...newTodo, sending: true }]
  )
  
  async function handleAdd(formData) {
    const newTodo = { text: formData.get('text') }
    // 先乐观更新,让用户立即看到结果
    addOptimisticTodo(newTodo)
    // 再发真实请求
    await addTodo(newTodo)
  }
  
  return (
    <form action={handleAdd}>
      {optimisticTodos.map(todo => (
        <div key={todo.id} style={{ opacity: todo.sending ? 0.5 : 1 }}>
          {todo.text}
          {todo.sending && ' (发送中...)'}
        </div>
      ))}
    </form>
  )
}

使用场景:

  • 点赞/收藏按钮
  • 评论发送
  • 待办事项添加
  • 任何需要"先显示结果,后确认"的场景

3. React Compiler:告别 useMemo

现在的痛点:

// 开发者需要手动写一堆优化代码
const MemoizedChild = React.memo(Child)
const memoizedValue = useMemo(() => compute(a, b), [a, b])
const memoizedCallback = useCallback(() => doSomething(), [])

React Compiler 的愿景:

// 以后可能只需要这样写
function App({ a, b }) {
  const value = compute(a, b)
  const handleClick = () => doSomething()
  return <Child value={value} onClick={handleClick} />
}
// 编译器自动分析依赖,自动添加记忆化

这意味着什么?

  • 不再需要手动写 useMemo/useCallback
  • 代码更简洁,心智负担更低
  • 性能优化由编译器自动完成

面试高频题

Q1: 为什么 React 16 要重写渲染引擎?

递归渲染不可中断,无法优先级调度。Fiber 通过链表结构实现了可中断和优先级。

Q2: useEffect 和 useLayoutEffect 的区别?

useEffect 在浏览器绘制后执行,useLayoutEffect 在 DOM 更新后、绘制前执行。

Q3: React.memo 和 useMemo 的区别?

React.memo 避免组件重渲染,useMemo 缓存计算结果。

Q4: 什么是闭包陷阱?如何解决?

闭包捕获旧值的问题。用 useRef 或函数式更新解决。


一起参与

这篇文章只覆盖了 React 知识图谱的一部分。完整项目包含 7 个章节、50+ 图解、30+ 面试题, 全部开源在 GitHub:

👉 React 深度知识图谱

下一步计划:

  • 状态管理全景(Context vs Zustand vs Redux)
  • React 19 完全解读
  • 大厂面试 100 题

如果你有想补充的内容,欢迎提 PR 或 Issue 一起完善。 觉得有帮助的话,点个 ⭐ 就是最大的支持 🙏


延伸阅读: