React useRef 机制:为什么 ref.current 的修改不会触发组件重渲染?它在 Fiber 节点中是如何存储的?

React Refs 深度解析:为什么你的组件像个“哑巴”,而 Ref 却是个“忍者”?

大家好,欢迎来到今天的 React 内部机制深度解剖课。我是你们的老朋友,那个总是试图在代码里找 Bug 的“资深专家”。

今天我们不聊业务逻辑,不聊组件拆分,我们要聊聊 React 里最神秘、最像“黑魔法”的 Hook —— useRef

你是不是经常遇到这种情况:你想要一个变量,它得记着上次的状态,但是你又不希望它让 React 疯了一样地重新渲染整个屏幕?于是你祭出了 useRef。然后你发现,你修改了 ref.current,UI 却纹丝不动。你挠了挠头,心想:“这玩意儿是不是坏了?”

不,它没坏,它只是个“哑巴”。而 useState 才是个“话痨”。

今天,我们就来扒开 React 的裤裆(比喻),看看 useRef 到底藏在哪里,为什么它修改了数据却像没修改一样,以及它在 Fiber 节点里到底长什么样。


第一章:State vs Ref —— 婚姻的隐喻

在讲 Fiber 之前,我们得先搞清楚这两个家伙的关系。这就像婚姻。

useState 是个多愁善感的妻子。
当你修改它的值时,你得告诉她。她一听到消息,就会哭天抢地,大喊大叫(触发重渲染),要求全家(组件)重新装修一下房子(渲染 UI)。她非常敏感,非常在乎外界的反馈。

useRef 是个冷血的室友。
你修改它的值,就像你悄悄把室友床底下的可乐换成了牛奶,室友根本不知道,也不会大惊小怪。你甚至可以在室友睡觉的时候(组件渲染的时候)偷偷摸摸地改它,室友醒来(组件重渲染)的时候,完全不知道刚才发生了什么。

所以,核心问题来了:为什么 ref.current = '新值' 不会触发重渲染?

因为 React 的调度器压根没收到 ref 变化的通知。ref 变化只影响内存里的一个对象,不影响 React 需要渲染的“虚拟 DOM 树”。


第二章:Fiber 节点 —— React 的神经中枢

要理解 Ref 的存储,我们必须先理解 Fiber

你可以把 Fiber 看作 React 的“神经细胞”。每一个组件在 React 内部,都是一个 FiberNode。它负责管理组件的生命周期、任务调度和状态。

让我们来看看这个 FiberNode 类到底长什么样(为了方便理解,我简化了部分属性,但保留了核心):

// 模拟 React FiberNode 的核心结构
class FiberNode {
  // 组件类型
  type: any; 
  // 当前组件的 props
  pendingProps: any;
  // 更新后的 props(用于渲染)
  memoizedProps: any;

  // 状态管理:这里是关键!
  memoizedState: any; // 对于 State,它是队列;对于 Ref,它是个对象。

  // alternate:这是 React 的秘密武器,用于并发渲染
  alternate: FiberNode | null;

  // ... 其他属性:tag, effectTag, child, sibling, return...
}

注意看 memoizedState。这个属性是所有 Hooks 的家。

  • 如果是 useState,这里存的是 queue(更新队列)。
  • 如果是 useRef,这里存的是一个 RefObject

第三章:Ref 在 Fiber 里是长什么样的?

当你在组件里写 const myRef = useRef(null) 时,React 做了什么?

React 会调用 mountRef 函数。这个函数会创建一个 { current: null } 的普通对象。

然后,这个对象会被塞进当前 Fiber 节点的 memoizedState 里。

代码模拟:

function mountRef(initialValue) {
  // 1. 创建一个 Ref 对象
  const refObj = {
    current: initialValue
  };

  // 2. 创建一个 Hook 对象,用来占位
  // 注意:这里用 effectTag 里的特殊标记来区分是 ref 还是 state
  const hook = {
    memoizedState: refObj, // 核心!Ref 对象被挂载到了 memoizedState 上
  };

  // 3. 把这个 hook 放到链表里
  // ...省略 hook 链表操作代码...

  return refObj;
}

图解存储结构:

想象一下,你的组件树是这样的:
App (Fiber A) -> Header (Fiber B) -> Footer (Fiber C)

  • Fiber A (App): memoizedState -> [hookState1, hookRef1]
  • Fiber B (Header): memoizedState -> [hookRef2]
  • Fiber C (Footer): memoizedState -> [hookState2, hookRef3]

这里有个坑:每个 Fiber 节点都有自己的 memoizedState

这意味着,如果你在 App 里用 useRef,在 Header 里也用 useRef,它们是完全独立的。App 的 Ref 变了,不会影响 Header 的 Ref。它们就像住在不同楼层的室友,互不干扰。


第四章:为什么修改 Ref 不会重渲染?(核心机制)

现在,让我们来看看 ref.current = 123 到底发生了什么。为什么 React 就像瞎了一样?

1. State 的更新流程(对比组)

当你写 setState(123) 时:

  1. React 调用 dispatchSetState
  2. React 把这个更新推入 fiber.memoizedState 里的 queue
  3. React 调用 scheduleUpdateOnFiber,告诉调度器:“嘿,有个活儿要干!”
  4. 调度器开始工作,进入 render 阶段,生成新的 Fiber 树,然后进入 commit 阶段,把新的 DOM 扔到屏幕上。
  5. 结果: 组件重渲染。

2. Ref 的更新流程(主角)

当你写 ref.current = 123 时:

  1. React 调用 dispatchRef(或者更底层的逻辑)。
  2. 关键点来了: dispatchRef 拿到当前的 Fiber 节点。
  3. 它直接修改了 fiber.memoizedState.current 的值。
  4. 它没有调用 scheduleUpdateOnFiber
  5. 它没有生成新的 Fiber 树!
  6. 它没有执行 render 函数!

结论:
因为 Ref 的更新不经过调度器,不经过 render 阶段,直接在内存里改了值。所以,组件根本不知道自己变了,UI 自然不会重绘。


第五章:Fiber 的“克隆”机制与 Alternate

讲到这里,你可能会有个疑问:既然 Ref 不触发重渲染,那我在 useEffect 里改 Ref,然后依赖数组里写了 Ref,useEffect 会执行吗?

答案是:会执行。

这就涉及到 React 更新周期里的另一个概念:Alternate Fiber(双缓冲)

当 React 开始处理一个组件的更新时,它不会直接在“当前 Fiber”上改。它会先克隆一个“工作 Fiber”。

// 模拟更新过程
function updateFunctionComponent(fiber) {
  // 1. 克隆当前的 Fiber 节点,创建一个 workInProgress 节点
  const workInProgress = fiber.alternate || createFiber(fiber);

  // 2. 把当前 Fiber 指向 workInProgress
  fiber.alternate = workInProgress;
  workInProgress.alternate = fiber;

  // 3. 开始渲染逻辑
  const children = fiber.type(fiber.pendingProps);

  // 4. 同步 Ref
  // 注意!这里同步的是 workInProgress 的 ref,而不是当前 fiber 的 ref
  syncRef(workInProgress, fiber);
}

这里的 syncRef 是什么?

这是 React 内部的一个函数,它负责把“当前 Fiber”的 Ref 同步到“工作 Fiber”上。

为什么这么做?

因为 useEffect 的依赖数组检测的是 workInProgress 的 Ref 状态,而不是 current Fiber 的状态。虽然对外界(开发者)来说,Ref 是同一个对象,但在 React 内部,为了并发渲染的稳定性,它维护了两份引用。

代码示例:

// 模拟 React 内部 syncRef 的逻辑
function syncRef(workInProgress, current) {
  // 如果 workInProgress 有 ref,就把 current 的 ref.current 复制过来
  if (workInProgress.ref !== null) {
    // 注意:这里的逻辑比这复杂,涉及到 ref 的创建和销毁
    // 但核心思想是:Ref 的值被同步到了新的 Fiber 节点上
    const currentRef = current.memoizedState?.current;
    if (currentRef) {
        workInProgress.memoizedState = {
            current: currentRef
        };
    }
  }
}

所以,当你修改 ref.current 时:

  1. 当前 FibermemoizedState.current 变了。
  2. 工作 FibermemoizedState.current 也会在渲染过程中被同步更新。
  3. 结果: useEffect 发现依赖变了,于是触发回调。

第六章:实战演练 —— Ref 的那些事儿

光说不练假把式。我们来写几个代码场景,看看 Ref 在 Fiber 里是如何操作的。

场景 1:聚焦输入框

这是 Ref 最经典的用途。我们不想让用户输入时屏幕闪烁,只想在组件挂载后,让输入框自动获得焦点。

function FocusInput() {
  const inputRef = useRef(null);

  useEffect(() => {
    // 这里 inputRef.current 指向真实的 DOM 节点
    if (inputRef.current) {
      inputRef.current.focus();
    }
  }, []);

  return <input ref={inputRef} type="text" />;
}

Fiber 里的过程:

  1. mountRef 创建 { current: null }
  2. 组件渲染,React 创建 <input> 节点。
  3. React 发现 ref prop,把 DOM 节点赋值给 inputRef.current
  4. useEffect 执行,调用 DOM API。
  5. 关键: 这个过程没有任何 State 更新,所以没有重渲染。

场景 2:存储状态

有时候你需要一个变量,它得跨渲染周期存在,但不能触发重渲染。比如一个计时器,或者一个复杂的对象缓存。

function Timer() {
  const timerRef = useRef(null);

  const start = () => {
    if (timerRef.current) return; // 防止重复点击

    timerRef.current = setInterval(() => {
      console.log("Tick Tock...");
      // 这里我们修改了 ref.current
      // 但是组件不会重渲染,console.log 也不会被 React 拦截
    }, 1000);
  };

  return <button onClick={start}>Start</button>;
}

场景 3:Ref 里的 State(大坑!)

这是新手最容易犯的错。如果你在 Ref 里存了一个对象,并且修改了这个对象的属性,这个变化不会触发重渲染

function Counter() {
  const [count, setCount] = useState(0);

  // 错误示范:试图在 ref 里存 state
  const stateRef = useRef({ count }); 

  useEffect(() => {
    // 当 count 变化时,这里不会更新!
    // 因为 stateRef.current.count 永远是初始值
    console.log(stateRef.current.count); 
  }, [count]);

  return <button onClick={() => setCount(count + 1)}>Count: {count}</button>;
}

为什么?
因为 useEffect 的依赖数组里是 count,而 count 是从 useState 来的。useEffect 只在 count 变化时运行。此时,React 会把 count 的值传进去,但 stateRef 本身并没有变,它指向的内存地址没变。


第七章:Fiber 节点的内存布局深度剖析

让我们再深入一点,看看 memoizedState 在 Fiber 节点里到底是个什么结构。

在 React 源码中,memoizedState 实际上是一个链表。

FiberNode
  └── memoizedState: {
         next: { ... }, // 下一个 Hook
         memoizedState: { current: value } // 当前 Hook 的值
       }
  • State Hook: memoizedState 存储的是 updateQueue 对象。
  • Ref Hook: memoizedState 存储的是 { current: value } 对象。

当你执行 ref.current = newValue 时,实际上就是修改了这个 { current: value } 对象的 .current 属性。

// 源码级别的伪代码
function dispatchRef(fiber, newRefValue) {
  // 1. 找到当前的 ref hook
  const hook = fiber.memoizedState;

  // 2. 直接修改 current 属性
  hook.memoizedState.current = newRefValue;

  // 3. 注意!这里没有调用 scheduleUpdateOnFiber(fiber);
  //    也没有调用 enqueueUpdate(hook, ...);
}

这就好比你在图书馆看书(React 渲染),你用笔在书页上划了个重点(修改 Ref)。你划了重点,但你并没有告诉管理员“这本书更新了”,管理员不会重新发新书给你,你继续看你的书就行。


第八章:Ref 的“副作用”属性

在 Fiber 节点中,Ref 还有一个隐藏的属性 ref(注意不要和 ref prop 混淆,这里指 FiberNode 的属性)。

FiberNode 有一个 ref 属性,它通常为 null,除非你在 FiberNode 上挂载了特殊的 ref。

但是,当你在 JSX 里写 <div ref={myRef}> 时,React 会把这个 ref 函数(或对象)挂载到对应的 DOM Fiber 节点上。

// React 处理 ref prop 的伪代码
function reconcileChildren(currentFiber, workInProgressFiber) {
  // 假设我们正在处理一个 div
  const domFiber = createFiber(workInProgressFiber.type);

  // 如果有 ref prop
  if (workInProgressFiber.ref !== null) {
    // 把 ref 保存到 domFiber 的 ref 属性上
    domFiber.ref = workInProgressFiber.ref;
  }

  return domFiber;
}

作用:
这个 ref 属性在 React 渲染过程中会被使用。

  • 如果是 function ref: ref(currentNode)
  • 如果是 object ref: ref.current = currentNode

这是 Ref 与 DOM 建立连接的唯一桥梁。


第九章:useEffect 和 Ref 的爱恨情仇

我们回到之前的问题:为什么 useEffect 能感知到 Ref 的变化?

因为 useEffect 的执行依赖于依赖数组。

当组件更新时(比如点击按钮),React 会计算新的依赖数组。

function MyComponent() {
  const [count, setCount] = useState(0);
  const myRef = useRef(0);

  useEffect(() => {
    // 依赖数组:[count, myRef]
    console.log(count, myRef.current);
  }, [count, myRef]);

  return <button onClick={() => setCount(count + 1)}>Count</button>;
}

这里有个巨大的陷阱!

如果你在依赖数组里写了 myRef,React 会怎么比较它?

React 会比较 currentFiber.memoizedState.currentworkInProgressFiber.memoizedState.current 的值。

但是! 这里的比较是浅比较

// 如果 myRef.current 是一个对象
const objRef = useRef({ a: 1 });

useEffect(() => {
  console.log("Effect runs");
}, [objRef]); // 错误!永远只会在第一次运行

原因:
objRef 对象的引用地址在组件生命周期内是不变的!
虽然 objRef.current.a 变了,但 objRef 这个变量本身指向的内存地址没变。
所以,React 认为依赖没变,useEffect 不会执行。

正确的做法:
要么依赖 count,在 useEffect 里读 objRef.current.a
要么使用 useRef 的特殊技巧(比如在 useEffect 里修改 ref,或者用 useLayoutEffect 配合 ref 的变化),但通常建议不要把 ref 放在 useEffect 依赖数组里,除非你明确知道你在做什么。


第十章:总结与灵魂拷问

好了,家人们,我们终于讲完了 useRef 的底层机制。

让我们回顾一下核心要点:

  1. 存储位置: Ref 存储在 FiberNode.memoizedState 中,具体是一个 { current: value } 对象。
  2. 不触发渲染: 修改 ref.current 不会调用 scheduleUpdateOnFiber,不经过渲染管道,只修改内存数据。
  3. Fiber 链表: 每个 Fiber 节点维护自己的 Ref 链表,互不干扰。
  4. 双缓冲机制: 在更新过程中,Ref 的值会被同步到 workInProgress Fiber 节点上,这保证了 useEffect 能感知到变化(前提是依赖数组正确)。
  5. Ref vs State: State 是给 UI 用的,Ref 是给 JS 逻辑用的(DOM 访问、计时器、存储缓存)。

最后,送给大家几个面试题(也是坑):

  1. Q:useEffect 里修改 ref.current,然后在下一次 useEffect 里读 ref.current,需要把 ref 加到依赖数组吗?
    A: 不需要!因为 ref 对象本身不会变,只有它指向的值会变。依赖数组里放 ref 是无效的(除非你每次都重新创建 ref 对象,那是另一种用法)。

  2. Q: 为什么不能用 Ref 存 State?
    A: 因为 Ref 不触发重渲染。如果你在 Ref 里存了一个对象,修改了对象属性,UI 不会变,你也无法通过 UI 反馈来更新 Ref。State 和 UI 是强绑定的,Ref 和 UI 是解绑的。

  3. Q: useRefcreateRef 有什么区别?
    A: createRef 只能用在类组件里,或者函数组件里手动传递。useRef 是 Hook,每次渲染都会返回同一个 ref 对象(除了初始挂载时)。如果你在循环里用 useRef,一定要用 useCallback 包裹或者用 useRef(prev => prev) 的技巧,否则会导致 ref 指向最后一个元素。

讲师结语:
React 的 Ref 机制就像是一个没有监听器的变量。它安静、隐秘,直接操作 DOM 或内存。它不参与 React 的“渲染派对”,所以它永远不会让派对变得混乱。

希望这篇长文能让你对 React 的 Fiber 架构有更深的理解。记住,理解了 Fiber,你就理解了 React 的“心”。下次当你修改 ref.current 却发现 UI 没变时,别慌,那是 React 在对你眨眼呢。

下课!如果有问题,我们在评论区(虽然这里没有评论区)继续探讨!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注