useRef()
useRef 用于在 react 组件多次渲染中保存同一个引用。该引用可以指向任何类型的 js 值。保存 DOM 节点只是useRef应用的一个特例。要想访问真实的 DOM 节点,我们要结合 react 支持的 ref 属性来完成。该 API 的使用示例代码如下:
import { useRef } from 'react';
function MyComponent() {
const refObject = useRef(null);
function handleClick() {
inputRef.current.focus();
}
return <input ref={refObject} />;
}
ref object 没有任何魔法,它就是一个 plain JavaScript object。它的结构我们可以打印出来看看:
// .......
const refObject = useRef(null);
console.log('refObject:', refObject);
// ......
结果是:
refObject: {
current: null
}
当然,本文是「从源码看 API系列」文章。所以,我们还是得从源码的角度看看ref object 是怎么被创建出来的。
ref object 的创建
在我之前的文章(【react】react hook 运行原理解析)中说过,hook 函数其实是有两个生命周期阶段:
- mount
- update
在useRef 的 mount 阶段,ref object 会被创建出来:
// react/packages/react-reconciler/src/ReactFiberHooks.old.js
function mountRef(initialValue) {
const hook = mountWorkInProgressHook();
{
const ref = {
current: initialValue,
};
hook.memoizedState = ref;
return ref;
}
}
上面的代码可以归结为三个步骤:
- 创建新的 hook 对象
- 创建
ref object - 最后,把
ref object挂载到 hook 对象上的memoizedState属性上。
想更近一步探究
mountWorkInProgressHook()的原理,可以看我的【react】react hook 运行原理解析
可以看出,ref object 就是一个 plain JavaScript object,没有任何魔法,没有任何惊喜。它的数据结构跟我们打印出来的也是一模一样的。
ref object 的传递
在 「render 阶段」,ref object 作为一个对象的引用,它会在创建之后一路传递出去。先是传递给createElement(),再透传给 ReactElement()来创建 react element:
// react/packages/react/src/ReactElement.js
export function createElement(type, config, children) {
// ......
let ref = null;
// .......
if (config != null) {
if (hasValidRef(config)) {
ref = config.ref;
}
// .......
}
// .......
return ReactElement(
type,
key,
ref,
self,
source,
ReactCurrentOwner.current,
props,
);
}
最后,ref object 被挂载到 react element 的ref属性上:
// react/packages/react/src/ReactElement.js
const ReactElement = function(type, key, ref, self, source, owner, props) {
const element = {
// ......
ref: ref,
// ......
};
// ......
return element;
};
到这里,ref object引用被传递的旅程还没有结束。
接下来,react 会进入render 阶段的 work loop。 work loop 又可以分两个子阶段:
- begin work
- complete work
react 会在 beging work 的时候根据 react element 去创建 fiber 节点;在complete work 阶段根据 react element 去创建 DOM 节点,并将 DOM 节点存放在 fiber 节点的 stateNode 属性上。
下面就是 react 创建新 fiber 节点的时候把 ref object引用透传并挂载到 fiber 节点的 ref 属性上的源码:
// react/packages/react-reconciler/src/ReactChildFiber.old.js
function createChild(
returnFiber: Fiber,
newChild: any,
lanes: Lanes,
): Fiber | null {
// ......
if (typeof newChild === 'object' && newChild !== null) {
switch (newChild.$$typeof) {
case REACT_ELEMENT_TYPE: {
const created = createFiberFromElement(
newChild,
returnFiber.mode,
lanes,
);
created.ref = coerceRef(returnFiber, null, newChild);
created.return = returnFiber;
return created;
}
// ......
}
// ......
}
到这里,ref object 对象引用的传递之旅算是结束了。
ref object current 值的填充
接下来,我们得看看ref object 对象引用是在什么时候被填充上具体的值的。本文我们专注于 useRef 应用于 DOM 操作的场景,所以,这个值就是真实的 DOM 节点引用。
走完 render 阶段,react 接着进入同步执行的 commit 阶段。commit 阶段会分为四个子阶段:
- beforeMutation
- mutation
- layout
- passive
ref object 对象current属性的赋值是发生在 layout 子阶段:
// react/packages/react-reconciler/src/ReactFiberCommitWork.old.js
function commitLayoutEffectOnFiber(
finishedRoot: FiberRoot,
current: Fiber | null,
finishedWork: Fiber,
committedLanes: Lanes,
): void {
const flags = finishedWork.flags;
switch (finishedWork.tag) {
// ......
case ClassComponent: {
// ......
if (flags & Ref) {
safelyAttachRef(finishedWork, finishedWork.return);
}
break;
}
// ......
case HostComponent: {
// ......
if (flags & Ref) {
safelyAttachRef(finishedWork, finishedWork.return);
}
break;
}
// ......
}
}
function safelyAttachRef(current: Fiber, nearestMountedAncestor: Fiber | null) {
try {
commitAttachRef(current);
} catch (error) {
captureCommitPhaseError(current, nearestMountedAncestor, error);
}
}
function commitAttachRef(finishedWork) {
const ref = finishedWork.ref;
if (ref !== null) {
const instance = finishedWork.stateNode;
let instanceToUse;
switch (finishedWork.tag) {
case HostComponent:
instanceToUse = getPublicInstance(instance);
break;
default:
instanceToUse = instance;
} // Moved outside to ensure DCE works with this flag
if (typeof ref === "function") {
let retVal;
{
retVal = ref(instanceToUse);
}
} else {
ref.current = instanceToUse;
}
}
}
从上面的源码中,我们可以得到下面的认知:
-
只有 hostComponent 和 classComponent 才会通过
ref属性来承接你的ref object对象,最后帮你把 DOM 节点引用挂载到ref object对象上来。 functionComponent 是不接受ref属性的。严谨来说,functionComponent 默认不接受
ref属性。当然,通过React.forwardRef()是能解决这个问题的。关于这一点,官方文档也是有提及的,可以查看: I can’t get a ref to a custom component。 -
react 帮我们填充
ref object对象值的时候,它并不确保一定是填充 DOM 节点的引用。上面代码中,getPublicInstance(instance)在生产上的实现是直接返回instance。因此,我们可以得知,react 统一是用当前 fiber 节点的stateNode属性值来填充ref object对象的值的。而我们知道,
stateNode属性值的类型因 fiber 节点的tag类型不同而不同的。对于 hostComponent 来说,stateNode属性的值是「 DOM 节点引用」,而对于 classComponent 来说,stateNode属性的值是「 classComponent 的一个实例」。 这个实例的自有属性一般有:
·
其中,_reactInterals 属性是进入 react 内部实现的 escape hatch。具体来说,_reactInterals 属性是当前 classComponent 所对应的 fiber 节点。因为 fiber 节点是身处一个由child,sibling,return三个指针所连接起来的链表当中。所以,通过它我们能访问链表上所有的其他 fiber 节点。对于 hostComponent 来说,因为它的 stateNode 属性值是 DOM 节点的引用,因而我们也就能访问到 classComponent 内部的真实的 DOM 节点。
举个例子说,假设某个 classComponent 类型的组件是第三方提供的,它的内部实现如下:
class Test extends Component {
constructor() {
super();
}
render() {
return <div>test class component ref</div>;
}
}
该组件会在 <App> 组件里面消费:
function App() {
const classComponentRef = useRef();
return (
<div className="App">
<Test ref={classComponentRef}></Test>
</div>
);
}
就像上面说的,我们通过 classComponentRef 拿到的是 Test 组件的实例。那如果我们想访问 Test 组件里面的 div DOM 元素呢?如果我现在限制你不能用原生的 DOM 查找 API(document.getElementxxxByxxx()或者document.querySelector()) 来访问,你会有什么其他的方法吗?当前有,这个方法就是上面提到的这个「escape hatch」。具体做法是:
function App() {
const classComponentRef = useRef();
const changeText = ()=> { ref.current._reactInternals.child.stateNode.innerText = "我能访问你,并修改你的 innerText ";}
return (
<div className="App">
<Test ref={classComponentRef}></Test>
<button onClick={changeText}>反模式访问 DOM </button>
</div>
);
}
当然,相对于 react 理念来说,这种做法肯定是一种反模式。
- 在 modern react 中,组件的
ref属性只能接受两种类型的值:- 函数类型 -
<Test ref={(classComponentRef)=> {}}> - ref 对象 -
// ...... const classComponentRef = useRef(); // ...... <Test ref={classComponentRef}></Test>
- 函数类型 -
ref object current 值的读取
当我们会在随后的 render 阶段去读取 ref object的current属性值的时候,其实就是代码执行会走到了 useRef hook 函数的 update 阶段。
在 update 阶段,我们先是找到对应的 hook 对象,然后原封不动地从 hook 对象的memoizedState 属性取出来,没有任何多余的其他操作:
// react/packages/react-reconciler/src/ReactFiberHooks.old.js
function updateRef(initialValue) {
const hook = updateWorkInProgressHook();
return hook.memoizedState;
}
从源码可以看出,在 update 阶段,
initialValue是被忽略的。
从引用的角度来看,无论是 mount 阶段还是 update 阶段,ref object和hook.memoizedState指向的是同一个引用。
ref 的一个反模式
// 代码来自于 react@19.3.0
function commitAttachRef(finishedWork: Fiber) {
const ref = finishedWork.ref;
if (ref !== null) {
let instanceToUse;
switch (finishedWork.tag) {
case HostHoistable:
case HostSingleton:
case HostComponent:
instanceToUse = getPublicInstance(finishedWork.stateNode);
break;
case ViewTransitionComponent: {
if (enableViewTransition) {
const instance: ViewTransitionState = finishedWork.stateNode;
const props: ViewTransitionProps = finishedWork.memoizedProps;
const name = getViewTransitionName(props, instance);
if (instance.ref === null || instance.ref.name !== name) {
instance.ref = createViewTransitionInstance(name);
}
instanceToUse = instance.ref;
break;
}
instanceToUse = finishedWork.stateNode;
break;
}
case Fragment:
if (enableFragmentRefs) {
const instance: null | FragmentInstanceType = finishedWork.stateNode;
if (instance === null) {
finishedWork.stateNode = createFragmentInstance(finishedWork);
}
instanceToUse = finishedWork.stateNode;
break;
}
// Fallthrough
default:
instanceToUse = finishedWork.stateNode;
}
if (typeof ref === 'function') {
if (shouldProfile(finishedWork)) {
try {
startEffectTimer();
finishedWork.refCleanup = ref(instanceToUse);
} finally {
recordEffectDuration(finishedWork);
}
} else {
finishedWork.refCleanup = ref(instanceToUse);
}
} else {
if (__DEV__) {
// TODO: We should move these warnings to happen during the render
// phase (markRef).
if (typeof ref === 'string') {
console.error('String refs are no longer supported.');
} else if (!ref.hasOwnProperty('current')) {
console.error(
'Unexpected ref object provided for %s. ' +
'Use either a ref-setter function or React.createRef().',
getComponentNameFromFiber(finishedWork),
);
}
}
// $FlowFixMe[incompatible-use] unable to narrow type to the non-function case
ref.current = instanceToUse;
}
}
}
从用户 ref 值的传递路径到最终挂载具体的 instance 给 ref 的实现代码来看,react 内部对 ref 值的校验只有这么一处:
// 代码来自于 react@19.3.0
function commitAttachRef(finishedWork: Fiber) {
// ......
if (__DEV__) {
// TODO: We should move these warnings to happen during the render
// phase (markRef).
if (typeof ref === 'string') {
console.error('String refs are no longer supported.');
} else if (!ref.hasOwnProperty('current')) {
console.error(
'Unexpected ref object provided for %s. ' +
'Use either a ref-setter function or React.createRef().',
getComponentNameFromFiber(finishedWork),
);
}
}
// $FlowFixMe[incompatible-use] unable to narrow type to the non-function case
ref.current = instanceToUse;
}
}
}
校验点有二:
-
ref 不能是字符串了
-
ref 必须是一个有自有属性
current的对象结合 ref 的实现原理,我们完全可以把 ref 的创建提升到函数作用域的外部,而不去使用 react 提供的
createRef()或者useRef()这俩个API,我们照样会实现相同的功能。如下面的演示代码:
import { useState } from 'react';
import reactLogo from './assets/react.svg';
import viteLogo from '/vite.svg';
import './App.css';
import { usePrev } from './hook/usePrev';
const fakeRef = {
current: null,
};
window.fakeRef = fakeRef;
function App() {
const [count, setCount] = useState(0);
return (
<>
<div>
<a href="https://vite.dev" target="_blank">
<img src={viteLogo} className="logo" alt="Vite logo" />
</a>
<a href="https://react.dev" target="_blank">
<img src={reactLogo} className="logo react" alt="React logo" />
</a>
</div>
{count < 10 && <h1 ref={fakeRef}>Vite + React</h1>}
<div className="card">
<button onClick={() => setCount((count) => count + 2)}>
count is {count}
</button>
<p>
Edit <code>src/App.tsx</code> and save to test HMR
</p>
</div>
<p className="read-the-docs">
Click on the Vite and React logos to learn more
</p>
</>
);
}
export default App;
你在 console 去打印 fakeRef.current 一样能拿到 H1 的 dom 对象。当 H1 卸载的时候,react 能正常将 fakeRef.current 设置为 null,以防止内存泄露。
以上,是基于我们对源码研究结果得到的一个 ref 应用的反模式,实际生产中是不应该这么干的。通过这个发模式,我们很容易解开useRef()的面纱,加深对它的理解。
总结
从本质的角度来看,react function component 就是一个 js 函数。这个 js 函数会不断地被重复调用。一旦被调用了,组件内部的变量会被重新声明,引用类型的同名变量也因此拿到的是不同的引用。在多次调用中,让同一个变量名保存的是同一个引用,这就是 useRef() 所提供的功能。
实现这个功能的原理就是利用于类似于「全局变量」的原理:把 ref object保存在组件函数的内部作用域之上的上层作用域中(fiber 节点的 hook 对象上 memoizedState),通过
- 「传递引用」的方式来「读」
- 「mutable」的方式来「写」
来保证了 ref object 在 function component 被多次渲染期间的唯一不变性。