深入理解 Ref 机制
useRef/createRef/callback ref 原理、forwardRef、ref 在 Commit 阶段的附着与清理、React 19 ref as prop
什么是 Ref?
定义:Ref 是 React 提供的逃生舱机制,用于在渲染周期之外持有可变引用。最常见的用途是获取 DOM 节点引用,但 ref 也可以存储任何不需要触发重渲染的可变值(如定时器 ID、上一次的值等)。
一、三种 Ref 用法
useRef(函数组件,推荐)
jsx
import { useRef, useEffect } from 'react';
function TextInput() {
const inputRef = useRef(null);
const renderCount = useRef(0);
// 获取 DOM
useEffect(() => {
inputRef.current.focus();
}, []);
// 存储可变值(不触发重渲染)
renderCount.current++;
console.log('渲染次数:', renderCount.current);
return <input ref={inputRef} />;
}createRef(类组件)
jsx
class TextInput extends React.Component {
constructor(props) {
super(props);
this.inputRef = React.createRef(); // 每次调用创建新对象 { current: null }
}
componentDidMount() {
this.inputRef.current.focus();
}
render() {
return <input ref={this.inputRef} />;
}
}Callback Ref(最灵活)
jsx
function MeasuredBox() {
const [height, setHeight] = useState(0);
// callback ref:节点挂载时调用(参数为 DOM 节点),卸载时调用(参数为 null)
const measuredRef = useCallback((node) => {
if (node !== null) {
setHeight(node.getBoundingClientRect().height);
}
}, []);
return (
<div ref={measuredRef}>
<p>这个盒子的高度是: {height}px</p>
</div>
);
}
// callback ref 的优势:
// 1. 可以在 ref 附着时立即执行逻辑(不需要 useEffect)
// 2. 可以处理条件渲染的节点(节点出现/消失时自动通知)
// 3. 可以同时传给多个元素二、useRef 的内部实现
javascript
// Mount 阶段
function mountRef(initialValue) {
const hook = mountWorkInProgressHook(); // 创建 Hook 节点
const ref = { current: initialValue };
hook.memoizedState = ref;
return ref;
}
// Update 阶段
function updateRef() {
const hook = updateWorkInProgressHook();
return hook.memoizedState; // 直接返回同一个对象引用
}
// 关键:
// 1. useRef 返回的对象在整个组件生命周期中保持不变(引用稳定)
// 2. 修改 .current 不会触发重渲染
// 3. .current 的变化在下次渲染时可读(但不触发渲染)useRef vs useState
jsx
// useState:值变化 → 触发重渲染
const [count, setCount] = useState(0);
setCount(1); // 触发重渲染
// useRef:值变化 → 不触发重渲染
const countRef = useRef(0);
countRef.current = 1; // 不触发重渲染
// 使用场景:
// useState → 需要渲染到 UI 的值
// useRef → 不需要渲染的值(DOM 引用、定时器 ID、上一次的 props/state)三、Ref 在 Commit 阶段的处理
Ref 的附着与清理时机
Render 阶段:
beginWork 中发现 ref 属性变化
→ 标记 Fiber 的 flags |= Ref
Commit 阶段 - Mutation 子阶段(DOM 操作后):
1. 清理旧 ref(detach)
if (current.flags & Ref) {
safelyDetachRef(current);
// object ref → ref.current = null
// callback ref → ref(null)
}
Commit 阶段 - Layout 子阶段(DOM 已更新):
2. 附着新 ref(attach)
if (finishedWork.flags & Ref) {
safelyAttachRef(finishedWork);
// object ref → ref.current = DOM 节点
// callback ref → ref(DOM 节点)
}
顺序保证:
先清理旧 ref → 操作 DOM → 附着新 ref
→ ref.current 在 useLayoutEffect 回调中已经是新的 DOM源码简化
javascript
function commitAttachRef(finishedWork) {
const ref = finishedWork.ref;
const instance = finishedWork.stateNode; // DOM 节点
if (typeof ref === 'function') {
// Callback ref
ref(instance);
} else if (ref !== null) {
// Object ref (useRef / createRef)
ref.current = instance;
}
}
function commitDetachRef(current) {
const ref = current.ref;
if (typeof ref === 'function') {
ref(null); // 通知 callback ref 清理
} else if (ref !== null) {
ref.current = null;
}
}Ref 何时标记更新
javascript
// beginWork 中,对比新旧 ref
function markRef(current, workInProgress) {
const ref = workInProgress.ref;
if (
(current === null && ref !== null) || // 首次挂载且有 ref
(current !== null && current.ref !== ref) // ref 引用变了
) {
workInProgress.flags |= Ref;
}
}
// 这就是为什么 callback ref 用 useCallback 包裹很重要:
// ❌ 每次渲染创建新函数 → ref 引用每次都变 → 每次都清理+重新附着
<div ref={(node) => { ... }} />
// ✅ useCallback 稳定引用 → ref 引用不变 → 不重复清理附着
const callbackRef = useCallback((node) => { ... }, []);
<div ref={callbackRef} />四、forwardRef
为什么需要 forwardRef?
jsx
// ❌ ref 不会作为 props 传递(React 16-18)
function MyInput(props) {
// props.ref 是 undefined!
return <input ref={props.ref} />;
}
// ref 是 React 的特殊属性,和 key 一样会被 React 内部消费,不传给组件
// ✅ forwardRef 将 ref 转发给子组件
const MyInput = forwardRef(function MyInput(props, ref) {
return <input ref={ref} className="custom-input" {...props} />;
});
// 使用
function Form() {
const inputRef = useRef(null);
return <MyInput ref={inputRef} placeholder="请输入" />;
// inputRef.current → <input> DOM 节点
}forwardRef 内部原理
javascript
function forwardRef(render) {
return {
$$typeof: REACT_FORWARD_REF_TYPE,
render, // 原始的渲染函数 (props, ref) => JSX
};
}
// beginWork 中处理 ForwardRef
function updateForwardRef(current, workInProgress, renderLanes) {
const render = workInProgress.type.render;
const props = workInProgress.pendingProps;
const ref = workInProgress.ref; // React 从 JSX 中提取的 ref
// 将 ref 作为第二个参数传给 render 函数
const children = render(props, ref);
reconcileChildren(current, workInProgress, children, renderLanes);
return workInProgress.child;
}五、React 19:ref as prop
不再需要 forwardRef
jsx
// React 19+:ref 直接作为 props 传递
function MyInput({ ref, ...props }) {
return <input ref={ref} className="custom-input" {...props} />;
}
// 直接使用,无需 forwardRef 包裹
function Form() {
const inputRef = useRef(null);
return <MyInput ref={inputRef} placeholder="请输入" />;
}内部变化
javascript
// React 19 之前:
// ref 从 JSX 中提取,存在 fiber.ref 上,不传入 props
// React 19:
// ref 仍然存在 fiber.ref 上(用于 Commit 阶段的附着/清理)
// 但同时也作为 props.ref 传给函数组件
// → 函数组件可以直接从 props 中解构 ref
// → forwardRef 变成可选的(保持向后兼容)六、useImperativeHandle
jsx
// 自定义暴露给父组件的 ref 接口
const FancyInput = forwardRef(function FancyInput(props, ref) {
const inputRef = useRef(null);
// 不暴露完整的 DOM 节点,只暴露需要的方法
useImperativeHandle(ref, () => ({
focus: () => inputRef.current.focus(),
clear: () => { inputRef.current.value = ''; },
getValue: () => inputRef.current.value,
}), []);
return <input ref={inputRef} {...props} />;
});
// 父组件
function Form() {
const inputRef = useRef(null);
const handleSubmit = () => {
console.log(inputRef.current.getValue()); // 自定义方法
inputRef.current.clear();
inputRef.current.focus();
// inputRef.current.style → undefined(未暴露)
};
return <FancyInput ref={inputRef} />;
}useImperativeHandle 原理
javascript
function useImperativeHandle(ref, create, deps) {
// 本质是一个 useLayoutEffect
useLayoutEffect(() => {
const instance = create(); // 执行工厂函数获取暴露的接口
if (typeof ref === 'function') {
ref(instance);
return () => ref(null);
} else if (ref !== null) {
ref.current = instance;
return () => { ref.current = null; };
}
}, deps);
}
// 在 Layout 阶段执行 → DOM 已更新 → 内部 ref 已附着
// 所以 create 函数中可以安全地访问内部的 DOM ref七、常见模式
获取上一次的值
jsx
function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value; // 渲染后更新
});
return ref.current; // 返回渲染前的值(上一次的)
}
function Counter() {
const [count, setCount] = useState(0);
const prevCount = usePrevious(count);
return <p>当前: {count},上次: {prevCount}</p>;
}Ref 回调实现列表 DOM 收集
jsx
function ItemList({ items }) {
const itemRefs = useRef(new Map());
const scrollToItem = (id) => {
const node = itemRefs.current.get(id);
node?.scrollIntoView({ behavior: 'smooth' });
};
return (
<ul>
{items.map(item => (
<li
key={item.id}
ref={(node) => {
if (node) {
itemRefs.current.set(item.id, node);
} else {
itemRefs.current.delete(item.id);
}
}}
>
{item.name}
</li>
))}
</ul>
);
}面试高频题
Q: useRef 和 useState 的区别?
useState:值变化触发重渲染,值在渲染闭包中是不可变的useRef:值变化不触发重渲染,.current是可变引用,随时可读写useRef返回的对象引用在整个组件生命周期中保持不变
Q: Ref 是在什么时机被赋值的?
在 Commit 阶段的 Layout 子阶段,DOM 已经更新到屏幕后。先在 Mutation 阶段清理旧 ref(设为 null),再在 Layout 阶段附着新 ref(设为 DOM 节点)。因此在 useLayoutEffect 中可以安全地通过 ref 访问 DOM。
Q: React 19 的 ref as prop 和 forwardRef 有什么区别?
React 19 允许函数组件直接从 props 中接收 ref,无需 forwardRef 包裹。forwardRef 保持向后兼容但不再必需。内部实现上,React 仍然将 ref 存在 fiber.ref 上用于 Commit 阶段处理,但同时也将其作为 props 传给组件函数。
Q: 为什么 callback ref 要用 useCallback 包裹?
不用 useCallback 的话,每次渲染都创建新函数,ref 引用变化会导致 React 在 Commit 阶段先调用旧的 callback ref(传 null 清理),再调用新的(传 DOM 附着)。用 useCallback 保持引用稳定,避免不必要的清理-附着循环。