React 的正确使用方法:ref 篇

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

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


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


场景一:获取 DOM 元素

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


function MyComponent() {
  // 写法 1
  const ref = useRef();

  // 写法 2
  const ref = useRef(undefined);

  // 写法 3
  const ref = useRef(null);

  // 通过 ref 计算 DOM 元素尺寸
  // 🚨 这段代码故意留了坑,坑在哪里?请看下文。
  useLayoutEffect(() => {
    const rect = ref.current.getBoundingClientRect();
  }, [ref.current]);

  return <div ref={ref} />;  
}

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

function MyComponent() {
  // ❌ 写法 1
  // 你会得到一个 MutableRefObject<HTMLDivElement | undefined>,
  // 即 ref.current 类型是 HTMLDivElement | undefined,
  // 这导致你每次获取 DOM 元素都需要判断是否为 undefined,很是麻烦。
  const ref = useRef<HTMLDivElement>();

  // ❌ 写法 2.1
  // 你可能想得到一个 MutableRefObject<HTMLDivElement>,但初始值传入的
  // undefined 并不是 HTMLDivElement,所以会 TS 报错。
  const ref = useRef<HTMLDivElement>(undefined);

  // ❌ 写法 2.2
  // 等价于写法 1,但需要多打一些字。
  const ref = useRef<HTMLDivElement | undefined>(undefined);

  // ✅ 写法 3
  // 你会得到一个 RefObject<HTMLDivElement>,其中
  // ref.current 类型是 HTMLDivElement | null。
  // 这个 ref 的 current 是不可从外部修改的,更符合使用场景下的语义,
  // 也是 React 推荐的获取 DOM 元素方式。
  // 注意:如果 tsconfig 没开 strictNullCheck,则不会匹配到这个定义,
  // 因此请务必开启 strictNullCheck。
  const ref = useRef<HTMLDivElement>(null);

  // 通过 ref 计算 DOM 元素尺寸
  // 🚨 这段代码故意留了坑,坑在哪里?请看下文。
  useLayoutEffect(() => {
    const rect = ref.current.getBoundingClientRect();
  }, [ref.current]);

  return <div ref={ref} />;  
}

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

function MyComponent() {
  const [divEl, setDivEl] = useState<HTMLDivElement | null>(null);

  // 计算 DOM 元素尺寸
  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}/>}</>;
}

这段代码有两个问题:


1. useLayoutEffect 中没有判空

按照场景一中的分析:

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


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


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


2. useLayoutEffect deps 配置错误


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



useLayoutEffect 的执行时机是:


VDOM 生成后(所有render 执行完成);

DOM 生成后(createElement 等 DOM 操作完成);

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



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



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


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


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


1.visible 变化导致触发 render。

2.useRef 执行,ref.current 还是上一次的值。

3.useLayoutEffect 执行,对比 dependencies 发现没有变化,跳过执行。

4.渲染结果包含div

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 (visible),因为只要这里有 ref.current 那就必然是 visible
    if (ref.current) {
      const rect = ref.current.getBoundingClientRect();
    }
  }, [/* ✅ */ visible]);
  // 这样,在 visible 变化时,就必然会在同一次渲染内触发 useLayoutEffect

  return <>{visible && <div ref={ref}/>}</>;
}

// 或者也可以将 <div> 抽取成一个独立的组件,从而避免上述问题

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

function MyComponent({ visible }: { visible: boolean }) {
  // ✅ 无需使用 ref
  const [video, setVideo] = useState<Video | null>(null);

  const play = useCallback(() => video?.play(), [video]);

  // ✅ 使用普通 useEffect 即可
  useEffect(() => {
    console.log(video.currentTime);
  }, [video]);

  return <>{visible && <video ref={setVideo}/>}</>;
}

场景三:组件中同时传递 & 获取 Ref

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

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

/* 🚨 错误案例,请勿照抄 */

const MyComponent = forwardRef(
  function (
    props: MyComponentProps, 
    // type ForwardedRef<T> = 
    //   | ((instance: T | null) => void)
    //   | MutableRefObject<T | null>
    //   | null
    // ✅ 这个工具类型覆盖了传 useRef 和传 setState 的情况,是正确的写法
    ref: ForwardedRef<HTMLDivElement>
  ) {
    useLayoutEffect(() => {
      const rect = ref.current.getBoundingClientRect();
      // 使用 rect 进行计算
    }, []);
    
    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();
      // 使用 rect 进行计算
    }, []);

    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();
      // 使用 rect 进行计算
    }, []);
    
    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/>;
  }  
);   

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

const MyComponent = forwardRef(
  function (
    props: MyComponentProps, 
    ref: ForwardedRef<MyComponentRefType>
) {
    // useImperativeHandle 这个工具函数会自动处理函数 ref 和对象 ref 的情况,
    // 后两个参数基本等于 useMemo
    useImperativeHandle(ref, () => ({
      refresh: () => {
        // ...
      },
      // ...
    }), [/* deps */]);

    // 命令式 + 下传
    // 如果你的组件内部也会用到这个命令式对象,推荐的写法是:
    const actions = useMemo(() => ({
      refresh: () => {
        // ...
      },
    }), [/* deps */]);
    useImperativeHandle(ref, () => actions, [actions]);
    
    return <div/>;
  }
);

场景五:组件 TS 导出如何声明 Ref 类型

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

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

// 其结果类型为:
// const MyComponent: ForwardRefExoticComponent<
//   PropsWithoutRef<MyComponentProps> & RefAttributes<MyComponentRefType>
// >

// 这里最后导出的 PropsWithoutRef<P> & RefAttributes<T> 就是用户侧最终可传的类型,
// 其中 PropsWithoutRef 会无视你 component 中 props 的 ref。

这里有一个问题:你的组件导出的 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;
}

// 这是错误的,props 里根本拿不到 ref!
function MyComponent({ myAdditionalProp, ...props }: MyComponentProps) {
  console.log(myAdditionalProp);

  return <OtherComponent {...props} />;
}

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


Bonus: 与 Ref 相关的 TS 类型


  • 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。
  • 注意:这个类型包括 RefObject 与函数。
  • 注注意:这个类型与 ForwardedRef 的区别是,它只需要 RefObject 而非 MutableRefObject,因此可以接受 useRef(null) 的结果并被用于 props。组件内部由于需要修改 ref.current,必须使用 MutableRefObject
  • ForwardRefExoticComponentforwardRef 的返回类型。

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




来源  |  阿里云开发者公众号
作者  |  布谷


相关文章
|
4月前
|
前端开发 JavaScript API
第八章 react组件实例中三大属性之ref
第八章 react组件实例中三大属性之ref
|
1天前
|
前端开发
react学习(17)回调形式的ref
react学习(17)回调形式的ref
|
1天前
|
存储 前端开发 容器
react学习(18)createRef形式的ref
react学习(18)createRef形式的ref
|
20天前
|
前端开发 知识图谱
[译] React 中的 "最新 Ref 模式"
[译] React 中的 "最新 Ref 模式"
|
2月前
|
JavaScript
react18【系列实用教程】useRef —— 创建 ref 对象,获取 DOM (2024最新版)
react18【系列实用教程】useRef —— 创建 ref 对象,获取 DOM (2024最新版)
36 0
|
4月前
|
前端开发
React Hooks - useState 的使用方法和注意事项(1),web前端开发前景
React Hooks - useState 的使用方法和注意事项(1),web前端开发前景
|
4月前
|
前端开发 JavaScript
深入了解 React 中的 Ref:不是魔法,是超级工具
深入了解 React 中的 Ref:不是魔法,是超级工具
117 0
|
4月前
|
JavaScript 前端开发 Java
React 中的 ref 和 refs:解锁更多可能性(下)
React 中的 ref 和 refs:解锁更多可能性(下)
React 中的 ref 和 refs:解锁更多可能性(下)
|
4月前
|
JavaScript 前端开发 测试技术
React 中的 ref 和 refs:解锁更多可能性(上)
React 中的 ref 和 refs:解锁更多可能性(上)
React 中的 ref 和 refs:解锁更多可能性(上)
|
9月前
|
前端开发
react在ts中提示ref问题
react在ts中提示ref问题
71 0