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) 时:
- React 调用
dispatchSetState。 - React 把这个更新推入
fiber.memoizedState里的queue。 - React 调用
scheduleUpdateOnFiber,告诉调度器:“嘿,有个活儿要干!” - 调度器开始工作,进入
render阶段,生成新的 Fiber 树,然后进入commit阶段,把新的 DOM 扔到屏幕上。 - 结果: 组件重渲染。
2. Ref 的更新流程(主角)
当你写 ref.current = 123 时:
- React 调用
dispatchRef(或者更底层的逻辑)。 - 关键点来了:
dispatchRef拿到当前的 Fiber 节点。 - 它直接修改了
fiber.memoizedState.current的值。 - 它没有调用
scheduleUpdateOnFiber! - 它没有生成新的 Fiber 树!
- 它没有执行
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 时:
- 当前 Fiber 的
memoizedState.current变了。 - 工作 Fiber 的
memoizedState.current也会在渲染过程中被同步更新。 - 结果:
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 里的过程:
mountRef创建{ current: null }。- 组件渲染,React 创建
<input>节点。 - React 发现
refprop,把 DOM 节点赋值给inputRef.current。 useEffect执行,调用 DOM API。- 关键: 这个过程没有任何 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 渲染过程中会被使用。
- 如果是
functionref:ref(currentNode)。 - 如果是
objectref: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.current 和 workInProgressFiber.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 的底层机制。
让我们回顾一下核心要点:
- 存储位置: Ref 存储在
FiberNode.memoizedState中,具体是一个{ current: value }对象。 - 不触发渲染: 修改
ref.current不会调用scheduleUpdateOnFiber,不经过渲染管道,只修改内存数据。 - Fiber 链表: 每个 Fiber 节点维护自己的 Ref 链表,互不干扰。
- 双缓冲机制: 在更新过程中,Ref 的值会被同步到
workInProgressFiber 节点上,这保证了useEffect能感知到变化(前提是依赖数组正确)。 - Ref vs State: State 是给 UI 用的,Ref 是给 JS 逻辑用的(DOM 访问、计时器、存储缓存)。
最后,送给大家几个面试题(也是坑):
-
Q: 在
useEffect里修改ref.current,然后在下一次useEffect里读ref.current,需要把ref加到依赖数组吗?
A: 不需要!因为ref对象本身不会变,只有它指向的值会变。依赖数组里放ref是无效的(除非你每次都重新创建 ref 对象,那是另一种用法)。 -
Q: 为什么不能用 Ref 存 State?
A: 因为 Ref 不触发重渲染。如果你在 Ref 里存了一个对象,修改了对象属性,UI 不会变,你也无法通过 UI 反馈来更新 Ref。State 和 UI 是强绑定的,Ref 和 UI 是解绑的。 -
Q:
useRef和createRef有什么区别?
A:createRef只能用在类组件里,或者函数组件里手动传递。useRef是 Hook,每次渲染都会返回同一个 ref 对象(除了初始挂载时)。如果你在循环里用useRef,一定要用useCallback包裹或者用useRef(prev => prev)的技巧,否则会导致 ref 指向最后一个元素。
讲师结语:
React 的 Ref 机制就像是一个没有监听器的变量。它安静、隐秘,直接操作 DOM 或内存。它不参与 React 的“渲染派对”,所以它永远不会让派对变得混乱。
希望这篇长文能让你对 React 的 Fiber 架构有更深的理解。记住,理解了 Fiber,你就理解了 React 的“心”。下次当你修改 ref.current 却发现 UI 没变时,别慌,那是 React 在对你眨眼呢。
下课!如果有问题,我们在评论区(虽然这里没有评论区)继续探讨!