实战中了解useRef

在 React 数据流中,props 是父子组件交互的唯一方式,要修改一个子组件只能通过改变 props 重新渲染。而 refs 提供了另一种方式,允许我们操作 DOM 元素和组件实例,及 Refs 提供了一种获取在渲染过程中生成的 DOM 节点和 React 元素的新方式。

DOM 和 Refs

React 中有两种创建 ref 的方式createRef()useRef(),本文只针对后者进行介绍。

const ref = useRef(initValue)

上面这一行就是 useRef 的用法啦,别看他简单,里面可有不少玄机~

按照 React 官方文档的介绍,useRef 返回了一个可变对象,其.current 属性被入参 initValue 初始化,返回的值将保存在组件的完整生命周期内。

useRef()的几种用法

  • 存储值,类似实例变量
  • 结合 forwardRef 和 useImperativeHandle hook 实现函数组件 ref 转发
  • 连接 DOM,这里可有坑喔~

用法一:值存储

因为 useRef 在组件每次 render 后返回值都是同一个,所以它可以用来存储一些在组件生命周期内都不会变化的值。可以把它理解成全局的一个变量。

定义:const myRef = useRef(initValue)

使用:console.log(myRef.current)

用法二:转发

使用 forwardRef 包裹函数组件并获取实例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const RefGuideChild: React.FC = React.memo(
forwardRef((props, ref) => {
return (
<button
ref={ref}
onClick={() => {
console.log('触发点击!');
}}
/>
);
})
);

const RefGuideParent: React.FC = React.memo(() => {
const childRef = useRef(null);

useEffect(() => {
childRef.current.click();
}, []);

return <RefGuideChild ref={childRef} />;
});

在上述例子中 RefGuideParent 组件拿到了 RefGuideChild 的实例并使用了 click 方法,当然也可以使用其他 DOM 方法,但是仔细的人发现,在这种情况下父组件将可以毫无限制的操纵子组件 DOM,这是不推荐的,应该用 useImperativeHandle 来限制暴露给父组件的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
const RefGuideChild: React.FC = React.memo(
forwardRef((props, ref) => {
const btnRef = useRef(null);
useImperativeHandle(ref, () => ({
click: () => {
btnRef.current.click();
},
customerFn: () => {
console.log('自定义方法');
},
}));
return (
<button
ref={btnRef}
style={{ color: 'red' }}
onClick={() => {
console.log('触发点击!');
}}
onBlur={() => {
console.log('触发blur!');
}}
>
按钮
</button>
);
})
);

const RefGuideParent: React.FC = React.memo(() => {
const childRef = useRef(null);

useEffect(() => {
childRef.current.click();
childRef.current.customerFn();
}, []);

return <RefGuideChild ref={childRef} />;
});

改造后子组件可以将方法暴露给父组件调用,同时又可以在内部维护自己的 ref,父子组件可以实现完美通信。

用法三:连接 DOM

其实在用法二中就已经体现了,使用 useRef 定义完后赋值给组件节点的 ref 属性。这里我想讲一下在实际场景中我遇到的坑供大家在未来参考。

先表述一下笔者的场景,笔者项目的某一子菜单要从父菜单 A 移入菜单 B,并且需要针对 B 菜单生成蒙层提示用户,蒙层组件的实现需要依据菜单 B 节点定位。

蒙层组件的使用,会依据 content 的内容生成弹出气泡,定位依照 element 属性的节点定位,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<Link to={href} ref={menuRef}>菜单B</Link>
<Guide
className={styles['guide-wrap']}
visible={visible}
onVisibleChange={(v) => {
setVisible(v);
setDone('done');
}}
content={[
{
description: (
<span style={{ whiteSpace: 'pre-wrap' }}>
相关功能已迁移至此
</span>
),
element: menuRef.current!,
placement: 'right',
},
]}
/>

添加生成逻辑,利用 useLocalStorageState 中存储的'GUIDE'值实现浏览器只需展示一次,后续进入不展示蒙层的功能:

1
2
3
4
5
6
7
8
9
const menuRef = useRef(null);
const [visible, setVisible] = useState(false);
const [done, setDone] = useLocalStorageState('GUIDE', '');
/** 启动蒙层 */
useEffect(() => {
if (!done) {
setVisible(true);
}
}, [done]);

至此刷新页面,蒙层展示,点击确定后再次刷新,蒙层不展示,功能实现达到预期!

然后当笔者退出登录后清除 localStorage 数据,重新登录项目,发现首次并没有展示蒙层,反而是点击其他菜单后才展示,但是刷新页面又正常。为了验证是否是功能问题,笔者新起了一个简单页面就只有一个蒙层组件和指向节点,发现不管如何蒙层展示都没问题。为什么会出现这诡异的现象呢?

推测:在蒙层组件渲染展示的时候,ref 未完成挂载。

蒙层组件 Guide 明明已经设置了 visible 属性为 true 为什么还不展示呢,笔者试着将 menuRef.current 通过 console 打印发现,首次进入时 menuRef.current 值为 null,后续每次 render 的值都是我们要的菜单节点,罪魁祸首找到了!现在去看看官网的文档:useRef 不会告诉你什么时候它的内容改变,.current 属性改变也不会引起重新渲染!!

再仔细读了useEffect 官方文档,有这么两句话 You might find it easier to think that effects happen “after render”. React guarantees the DOM has been updated by the time it runs the effects. 就是说我们可以认为 useEffect 是在 render 后执行的,react 替我们保证了 DOM 更新完成前 useEffect 均已执行。

总结一下原因:结合 useEffect 的执行顺序:组件更新挂载完成->浏览器 DOM 绘制完成 -> 执行 useEffect 回调,且 useRef 的改变不会引起组件的重新渲染,造成此现象的原因是 ref 挂载晚于组件渲染完成,即渲染蒙层组件时 ref 还未成功挂载至节点,后续挂载成功后由于.current 变化不会引起重新渲染所以导致蒙层组件不会展示。

解决整体思路:在 ref 挂载完成后,触发组件能够重新渲染即可。

解决方式一:设置 setTimeout,即修改蒙层是否展示逻辑

1
2
3
4
5
6
7
8
/** 启动蒙层 */
useEffect(() => {
if (!done) {
setTimeout(() => {
setVisible(true);
}, 100);
}
}, [done]);

解决方式二:使用 callback ref

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
const targetNode = useRef(null);

const menuNodeRef = useCallback((node) => {
if (node !== null) {
targetNode.current = node;
}
}, []);

// 同时改造目标节点ref和蒙层组件content中的element属性
<Link to={href} ref={menuNodeRef}>菜单B</Link>
<Guide
className={styles['guide-wrap']}
visible={visible}
onVisibleChange={(v) => {
setVisible(v);
setDone('done');
}}
content={[
{
description: (
<span style={{ whiteSpace: 'pre-wrap' }}>
相关功能已迁移至此
</span>
),
element: targetNode!,
placement: 'right',
},
]}
/>

callback ref 能够实现在 ref 挂载后再进行逻辑处理,也保证了 ref 必有值。也推荐使用这种方式处理类似的问题。

至此,问题也解决啦~

总结

每次渲染 useRef 的返回值都相同(相同引用),所以尽量不要用 myRef.current 作为依赖项,可以将其视为全局的一个变量。

我们可以用 useRef 获取真实 DOM 元素,但是要注意.current 改变不会引起组件重新渲染,要想在 ref 挂载完成后调用其他方法,需要使用官方给出的callback ref

参考

React useRef 官方文档