Posted in

React 的正确使用方法:ref 篇_AI阅读总结 — 包阅AI

包阅导读总结

1. 关键词:React、useRef、TypeScript、组件库、DOM 元素

2. 总结:本文主要探讨了 React 中 useRef 的正确使用方法,包括在与 TypeScript 一起使用和撰写组件库时的各种场景,如不同的写法、条件渲染、处理 ref 传递等,还介绍了相关的类型和解决方案,以确保组件的兼容性和正确性。

3. 主要内容:

– React 的 useRef 用法

– 多种写法在 TS 中有类型差异

– 可传入函数获取 DOM 元素

– 场景一

– DOM 元素与 useLayoutEffect 的结合

– 条件渲染时需判断 ref.current 是否为空

– 场景二

– useLayoutEffect deps 配置错误

– 正确配置应使用与条件渲染相同的条件

– 非 repaint 前操作推荐用函数写法

– 处理 ref 传递

– 使用 react-merge-refs 处理复杂情况

– 复杂组件与调用者交互时的正确做法

– 组件导出 Props 中 ref 的处理方案及相关类型介绍

思维导图:

文章地址:https://mp.weixin.qq.com/s/KLbn3gFeT3r63_SobLy3eA

文章来源:mp.weixin.qq.com

作者:布谷

发布时间:2024/8/8 8:57

语言:中文

总字数:3842字

预计阅读时间:16分钟

评分:85分

标签:React,TypeScript,useRef,前端开发,技术实践


以下为原文内容

本内容来源于用户推荐转载,旨在分享知识与观点,如有侵权请联系删除 联系邮箱 media@ilingban.com

你真的用对了useRef吗?在与 TypeScript 一起使用、以及撰写组件库的情况下,你的写法能够避开以下所有场景的坑吗?

说到 useRef,相信你一定不会陌生:你可以用它来获取 DOM 元素,也可以多次渲染之间保持引用不变……

然而,你真的用对了 useRef 吗?在与 TypeScript 一起使用、以及撰写组件库的情况下,你的写法能够避开以下所有场景的坑吗?

以下几种写法,哪种是正确的?

function MyComponent() {  const ref = useRef();
const ref = useRef(undefined);
const ref = useRef(null);
useLayoutEffect(() => { const rect = ref.current.getBoundingClientRect(); }, [ref.current]);
return <div ref={ref} />; }

如果只看 JS,几种写法似乎并没有差别,但如果你开启了 TS 的类型提示,就能够发现其中端倪:

function MyComponent() {     const ref = useRef<HTMLDivElement>();
const ref = useRef<HTMLDivElement>(undefined);
const ref = useRef<HTMLDivElement | undefined>(undefined);
const ref = useRef<HTMLDivElement>(null);
useLayoutEffect(() => { const rect = ref.current.getBoundingClientRect(); }, [ref.current]);
return <div ref={ref} />; }

Ref 还可以传入一个函数,会把被 ref 的对象应用作为参数传入,因此我们也可以这样获取 DOM 元素:

function MyComponent() { const [divEl, setDivEl] = useState<HTMLDivElement | null>(null);
useEffect(() => { if (divEl) { divEl.current.getBoundingClientRect(); } }, [divEl]);
return <div ref={setDivEl} />;}

场景二:DOM 元素与 useLayoutEffect

在场景一中,我们留了一个坑,你能看出以下代码有什么问题吗?


function MyComponent({ visible }: { visible: boolean }) { const ref = useRef<HTMLDivElement>(null);
useLayoutEffect(() => { const rect = ref.current.getBoundingClientRect(); }, [ref.current]);
return <>{visible && <div ref={ref}/>}</>;}

useRef<HTMLDivElement>(null) 返回的类型是RefObject<HTMLDivELement>,其ref.current 类型为HTMLDivELement | null。因此单从 TS 类型出发,也应该判断ref.current 是否为空。

你也许会认为,我都在 useLayoutEffect 里了,此时组件 DOM 已经生成,因而理应存在 ref.current,是否可以不用判断呢?(或用 ! 强制设为非空)

上述使用场景中,确实可以这样做,但如果div 是条件渲染的,则无法保证useLayoutEffect 时组件已被渲染,自然也就不一定存在ref.current

2.useLayoutEffectdeps 配置错误

这个问题涉及到useLayoutEffect 更本质的使用目的。

useLayoutEffect 的执行时机是:

VDOM 生成后(所有render 执行完成);
DOM 生成后(createElement 等 DOM 操作完成);

最终提交渲染之前(同步任务返回前)。

由于其执行时机在 repaint 之前,此时对已生成的 DOM 进行更改,用户不会看到「闪一下」。举个例子,你可以计算元素的尺寸,如果太大则修改 CSS 使其自动换行,从而实现溢出检测。

另一个常见场景是在useLayoutEffect 中获取原生组件,用来添加原生 listener、获取底层HTMLMediaElement 实例来控制播放,或添加ResizeObserverIntersectionObserver 等。

这里,由于div 是条件渲染的,我们显然会希望useLayoutEffect 的操作在每次渲染出来之后都执行一遍,因此我们会想把ref.current 写进useLayoutEffectdependencies,但这是完全错误的。

让我们盘一下MyComponent 的渲染过程:

2.useRef 执行,ref.current 还是上一次的值。
3.useLayoutEffect 执行,对比 dependencies 发现没有变化,跳过执行。

5.由于<div ref={ref}>,React 使用新的 DOM 元素更新ref.current

显然,这里并没有再次触发useLayoutEffect,直到下一次渲染中才会发现ref.current 有变化,这背离了我们对于 useLayoutEffect 能让用户看不到「闪一下」的预期。

解决方案是,使用与条件渲染相同的条件作为useLayoutEffect 的 deps:

function MyComponent({ visible }: { visible: boolean }) { const ref = useRef<HTMLDivElement>(null);
useLayoutEffect(() => { if (ref.current) { const rect = ref.current.getBoundingClientRect(); } }, [ visible]);
return <>{visible && <div ref={ref}/>}</>;}

最后,如果并非是要在 repaint 之前对 DOM 元素进行操作,更推荐的写法是用函数写法:

function MyComponent({ visible }: { visible: boolean }) {  const  = useState<Video | null>(null);
const play = useCallback(() => video?.play(), );
useEffect(() => { console.log(video.currentTime); }, );
return <>{visible && <video ref={setVideo}/>}</>;}

——你实现了一个组件,想要将传入的 ref 传给组件中渲染的根元素,听起来很简单!

哦对了,出于某种原因,你的组件中也需要用到根组件的 ref,于是你写出了这样的代码:


const MyComponent = forwardRef( function ( props: MyComponentProps, ref: ForwardedRef<HTMLDivElement> ) { useLayoutEffect(() => { const rect = ref.current.getBoundingClientRect(); }, []); return <div ref={ref}>{}</div>; }});
等等,如果调用者没传ref 怎么办?想到这里,你把代码改成了这样:

const MyComponent = forwardRef( function ( props: MyComponentProps, ref: ForwardedRef<HTMLDivElement>) { const localRef = useRef<HTMLDivElement>(null); useLayoutEffect(() => { const rect = localRef.current.getBoundingClientRect(); }, []);
return <div ref={(el: HTMLDivElement) => { localRef.current = el; if (ref) { ref.current = el; } }}>{}</div>; }});

这样的代码显然是会 TS 报错的,因为ref 可能是个函数,本来你只需要把它直接传给<div> 就好了,因此你需要写一堆代码,处理多种可能的情况……

更好的解决方式是使用 react-merge-refs

import { mergeRefs } from "react-merge-refs";
const MyComponent = forwardRef( function ( props: MyComponentProps, ref: ForwardedRef<HTMLDivElement>) { const localRef = React.useRef<HTMLDivElement>(null);
useLayoutEffect(() => { const rect = localRef.current.getBoundingClientRect(); }, []); return <div ref={mergeRefs([localRef, ref])} />; });

Form 和 Table 这种复杂的组件往往会在组件内维护较多状态,不适合受控操作,当调用者需要控制组件行为时,往往就会采取这样的模式:

function MyPage() { const ref = useRef<FormRef>(null);
return ( <div> <Button onClick={() => { ref.current.reset(); }}>重置表单</Button> <Form actionRef={ref}>{}</Form> </div> );}

这种用法实际上脱胎于 class component 时代,人们使用 ref 来获取 class 实例,通过调用实例方法来控制组件。

现在,你的超级复杂绝绝子组件也希望通过这种方式与调用者交互,于是你写出了以下实现:


interface MySuperDuperComponentAction { reset(): void;}
const MySuperDuperComponent = forwardRef( function ( props: MySuperDuperComponentProps, ref: ForwardedRef<MySuperDuperComponentAction>) { const action = useMemo((): MySuperDuperComponentAction => ({ reset() { } }), []); if (ref) { ref.current = action; }
return <div/>; } );

然而 TS 不会容许这样的代码通过类型检查,因为调用者可以函数作为 ref 来接收 action,这与获取 DOM 元素时类似。

正确的做法是,你应该使用 React 提供的工具函数useImperativeHandle

const MyComponent = forwardRef( function (  props: MyComponentProps,   ref: ForwardedRef<MyComponentRefType>) {      useImperativeHandle(ref, () => ({   refresh: () => {       },     }), []);
const actions = useMemo(() => ({ refresh: () => { }, }), []); useImperativeHandle(ref, () => actions, [actions]); return <div/>; });

如果内部的组件类型正确,forwardRef 会自动检测 ref 类型:

const MyComponent = forwardRef( function (  props: MyComponentProps,  ref: ForwardedRef<MyComponentRefType>) {  return <div/>; }});

这里有一个问题:你的组件导出的 Props 中需要包含 ref 吗?由于forwardRef 会强行改掉你的 ref,这里有两种方法:

1.MyComponentProps 中写上 ref,类型为MyComponentRefType,直接导出它作为最终的 Props;

2.ComponentProps<typeof MyComponent> 取出最终的 Props。

然而,当组件内需要必须层层透传 ref 的时候,如果把 ref 写进 Props 里,就需要每层组件都使用 forwardRef,否则就会出现问题:


interface OtherComponentProps { ref?: Ref<OtherComponentActions>;}
interface MyComponentProps extends OtherComponentProps { myAdditionalProp: string;}
function MyComponent({ myAdditionalProp, ...props }: MyComponentProps) { console.log(myAdditionalProp);
return <OtherComponent {...props} />;}

因此,更推荐的方案是不用 ref 这个名字,比如叫 actionRef 等等,这样也可以毫无痛苦地写进 props 并导出了。

  • PropsWithoutRef<Props>:从 Props 中删除 ref,可用于 HOC 等场景。
  • PropsWithRef<Props>并不会添加 ref,而是保证 ref 里没有 string。这是因为在古代,可以给 ref 传 string 来代替传函数,现代我们一般不这么做。
  • 如果想要添加 ref,可以仿照forwardRef 里的写法:PropsWithoutRef<Props> & RefAttribute<RefType>
  • RefAttribute<RefType>{ ref?: Ref<T> | undefined; }
  • ForwardedRef<RefType>:组件内部拿外部 ref 唯一指定类型。
  • MutableRefObject<RefType>useRefcreateRef 的结果。
  • RefObject<RefType>useRef(null) 的结果。
  • Ref<RefType>:传入的 ref 参数类型,RefTyperef.current 拿到的类型,会自动加上 null。
  • 注注意:这个类型与 ForwardedRef 的区别是,它只需要 RefObject 而非 MutableRefObject,因此可以接受 useRef(null) 的结果并被用于 props。组件内部由于需要修改 ref.current,必须使用 MutableRefObject

这些类型类似于 React 提供的类型接口,为了保证你的组件能够兼容尽可能多的 React 版本,请务必使用最合适的类型。

通过内容安全API提供直播场景的文本检测能力,响应时间短、支持类型多,多维度判断风险行为。

点击阅读原文查看详情。