一文搞定 React 的 ref

创建一个 ref

类组件的 ref

函数


当 ref 是一个函数时,入参就是当前的 DOM 节点,在函数内将它赋给别的值,就可以通过这个值进行访问。
输出结果

字符串


ref 可以绑定一个字符串,它会被保存到 this.refs 上。
输出结果

另外当判断 ref 绑定的是字符串时,其实走的是函数的处理逻辑,只不过字符串都被绑定在了 this.refs 上。

createRef()


通过 React 提供的 createRef 来创建一个 ref 对象,ref 对象有一个 current 属性,绑定的值就会保存在这里。
输出结果

createRef 源码

1
2
3
4
5
6
export function createRef(): RefObject {
const refObject = {
current: null,
};
return refObject;
}

createRef 的本质就是创建一个带有 current 属性的对象,然后把这个对象返回。

函数组件创建 ref 方式

useRef

输出结果

和类组件不同的是,函数式组件没有实例,没办法在实例上保存一些东西,所以 React 提供了一系列的 hooks
来帮助函数式组件能够像类组件一样有自己的状态等功能,关于 ref 可以通过使用 useRef 来创建一个 ref 对象。

useRef 源码

初始化 ref

1
2
3
4
5
6
function mountRef<T>(initialValue: T): { current: T } {
const hook = mountWorkInProgressHook();
const ref = {current: initialValue};
hook.memoizedState = ref;
return ref;
}

更新 ref

1
2
3
4
function updateRef<T>(initialValue: T): { current: T } {
const hook = updateWorkInProgressHook();
return hook.memoizedState;
}

ref 值被保存在 memoizedState 中。

为什么函数式组件不能用 createRef?

看过 createRef 就知道他只是返回了一个带有 current 属性的对象,而函数式组件每次更新时都会重新执行,也就相当于说我们又重新创建了一个 ref,这么做没什么意义。

高阶用法

useRef 缓存数据

在函数式组件内部每次更新状态都会触发视图更新,当有一些数据更新并不需要视图更新的时候,使用 state 难免造成多余的渲染。
这时候使用 useRef 应该是更好的选择,useRef 保存的值更新时不会触发视图更新,
并且 useRef 由于始终指向同一个地址,拿到的永远是最新值,因此 useRef 可以起到一个缓存数据的功能。

ahooks 中的 useLatest

1
2
3
4
5
6
function useLatest<T>(value: T) {
const ref = useRef(value);
ref.current = value;

return ref;
}

在 ahooks 中的 useLatest 就使用 useRef 来保存值,确保每次都可以拿到最新的值,从而避免闭包问题。

forwardRef 转发 ref

在子组件是类组件时可以通过 ref 直接获取到子组件的实例
类组件通过ref通信
输出
但是对于函数式组件来说,不存在实例,也就无法像类组件一样通过 ref 直接通信,针对这个问题 React 提供了 forwardRef 来帮助我们在函数式组件通过 ref 通信。
使用forwardRef转发ref
输出

配合 useImperativeHandle 实现通信

仅仅使用 forwardRef 只能是将 ref 转发出去,但还不足以实现组件间的通信,如果父组件需要调用子组件中的属性/方法,那子组件就需要用 useImperativeHandle 暴漏出去

使用useImperativeHandle

这里我们将子组件改造一下,使用 useImperativeHandle 暴露出一些属性,再看父组件的输出:

可以看到父组件接收到了子组件暴露出来的属性,这也就完成了通信。

ref 的更新流程


以上代码会发生奇怪的输出情况:
输出结果
根据输出结果发现每次更新都会有两次输出,一次为 null,一次才是真正的 DOM。在 React 中一次更新分为 render 和 commit 两个阶段,替换真实 DOM 发生在 commit 阶段,而我们的 ref 就是获取真实 DOM 的,所以对于 ref 的处理都在 commit 阶段。至于会变更两次,分别发生在 DOM 更新前和 DOM 更新后,在 DOM 更新前会重置 ref 的值,所以会输出 null,然后 DOM 进行更新,更新后拿到新的 DOM 再次更新 ref,这次输出的是真实 DOM 节点

流程

  1. 首先在 commit 的 mutation 阶段(也就是 DOM 更新前),执行 commitDetachRef,将 ref 值置为 null
  2. 然后 DOM 进行更新
  3. 在 commit 的 layout 阶段(也就是 DOM 更新后),更新真实节点,执行 commitAttachRef,重新设置 ref 的值

markRef 标记

ref 更新前会进行判断,如果存在 tag 才会进行更新,给 ref 打 tag 的操作就是 markRef

1
2
3
4
5
6
7
function markRef(current: Fiber | null, workInProgress: Fiber) {
const ref = workInProgress.ref;
if (
(current === null && ref !== null) || // 初始化
(current !== null && current.ref !== ref) // 更新
) { /*...*/}
}

标记的时机就是初始化的时候ref 指向发生改变,在上文输出中的 null 就是因为 current.ref !== ref 认为发生了更新,打上了 tag ,存在 tag 在执行 commitDetachRef 时把 ref 赋值为 null。

总结

在 react 的日常开发中,ref 的使用是很常见的,本文从创建 ref 开始,分析类组件和函数组件的 ref 区别,以及一些使用 ref 需要注意的地方,在通过例子讲述了 ref 的一些高阶用法,希望能够帮助读者更好的进行开发。


一文搞定 React 的 ref
https://l1ushun.github.io/2024/04/29/react-ref/
作者
liu shun
发布于
2024年4月29日