IT博客汇
  • 首页
  • 精华
  • 技术
  • 设计
  • 资讯
  • 扯淡
  • 权利声明
  • 登录 注册

    深入浅出React中的refs

    夕水发表于 2024-09-26 14:35:33
    love 0

    文章概要:

    1. 为什么我们需要在 React 中访问 DOM?
    2. refs 如何帮助我们实现访问 DOM?
    3. 什么是 useRef、forwardRef 和 useImperativeHandle 钩子?
    4. 如何正确使用它们?

    React 的众多优点之一是它抽象了处理真实 DOM 的复杂性。现在,我们无需手动查询元素、绞尽脑汁思考如何为这些元素添加类又或者是添加样式等,也无需为浏览器兼容性而苦恼,只需编写组件并专注于用户体验即可。然而,仍然有一些情况(虽然很少!)我们需要访问实际的 DOM。

    而当涉及到实际的 DOM 时,最重要的是要理解并学习如何正确使用 ref 以及 ref 周围的一切。

    让我们来看看为什么我们首先想要访问 DOM,ref 如何帮助我们做到这一点,什么是 useRef、forwardRef 和 useImperativeHandle,以及如何正确使用它们。

    此外,让我们研究如何避免使用 forwardRef 和 useImperativeHandle,同时仍然保留它们给我们提供的功能。

    如果你曾经尝试弄清楚它们是如何工作的,你就会明白我们为什么想要这样做,另外,我们将学习如何在 React 中实现命令式 API!

    使用 useRef 在 React 中访问 DOM

    假如我想实现一个注册表单,这个注册表单包含用户名和邮箱号,用户名和邮箱号应该是必填项,当用户没有填写这些信息时,我不想只是简单的给输入框添加红色边框,我希望实现一个带有动画的表单,这看起来应该比较炫酷,让我们将焦点关注到用户未填信息上,我们添加一个“摇晃”动画,用来吸引用户的注意力。

    试想一下,如果我们使用原生 js 来做,应该如何实现?

    首先,我们应该获取这个元素。如下所示:

    const element = document.getElementById("xxx");

    然后,我们可以实现关注焦点:

    element.focus();

    又或者是直接滚动它:

    element.scrollIntoView();

    其它的只要我们心中能想到的功能,我们都可以用 js 代码实现。让我们总结一下,在 React 中通常需要用到访问 DOM 的场景。如下:

    • 在元素渲染后手动聚焦元素,例如表单中的输入字段
    • 在显示类似弹出窗口的元素时检测组件外部的点击
    • 在元素出现在屏幕上后手动滚动到元素
    • 计算屏幕上组件的大小和边界以正确定位工具提示之类的东西。

    尽管从技术上讲,即使在今天,也没有什么能阻止我们使用 getElementById,但 React 为我们提供了一种稍微更强大的方法来访问该元素,而不需要我们到处使用 getElementById 或了解底层 DOM 结构:refs。

    ref 只是一个可变对象,是 React 在重新渲染之间保留的引用。它不会触发重新渲染,因此它不是以任何方式声明的替代品。有关这两者之间差异的更多详细信息,请参阅文档。

    它是使用 useRef 钩子创建的:

    const Component = () => {
      // 创建一个默认值是null的ref对象
      const ref = useRef(null);
    
      // ...
    };

    存储在 ref 中的值将在其“current”(也是唯一的)属性中可用。我们实际上可以在其中存储任何值!例如,我们可以存储一个包含来自状态的一些值的对象:

    const Component = () => {
      const ref = useRef(null);
    
      useEffect(() => {
        // 重新赋值ref对象,赋值一个对象,带有一些状态或者是方法
        ref.current = {
          someFunc: () => {
            //...
          },
          someValue: stateValue,
        };
      }, [stateValue]);
    
      // ...
    };

    或者,对于我们的示例更重要的是,我们可以将这个 ref 分配给任何 DOM 元素和一些 React 组件:

    const Component = () => {
      const ref = useRef(null);
    
      // 为输入框元素分配 ref
      return <input ref={ref} />;
    };

    现在,如果我在 useEffect 中打印 ref.current(它仅在组件渲染后可用),将看到 input 元素,这与尝试使用 getElementById 获得元素是一样的:

    const Component = () => {
      const ref = useRef(null);
    
      useEffect(() => {
        // 这将是对输入 DOM 元素的引用!
        // 与使用 getElementById 获取到的元素完全相同
        console.log(ref.current);
      });
    
      return <input ref={ref} />;
    };

    现在,我将注册表单作为一个组件来实现,如下所示:

    const Form = () => {
      const [name, setName] = useState("");
      const inputRef = useRef(null);
    
      const onSubmitClick = () => {
        if (!name) {
          // 如果有人不填用户名,则聚焦输入字段
          ref.current.focus();
        } else {
          // 在这里提交表单数据
        }
      };
    
      return (
        <>
          {/*....*/}
          <input onChange={(e) => setName(e.target.value)} ref={ref} />
          <button onClick={onSubmitClick}>Submit the form!</button>
        </>
      );
    };

    我们将输入的值存储在状态中,为所有输入创建一个 ref 引用,当单击“提交”按钮时,我会检查值是否不为空,如果为空,我们则关注输入的值。

    前往这里查看完整的示例。

    将 ref 从父组件传递给子组件作为 prop

    当然,实际上,我们会更倾向于封装成一个输入框组件:这样它就可以在多个表单中重复使用,并且可以封装和控制自己的样式,甚至可能具有一些附加功能,例如在顶部添加标签或在右侧添加图标。

    const InputField = ({ onChange, label }) => {
      return (
        <>
          {label}
          <br />
          <input type="text" onChange={(e) => onChange(e.target.value)} />
        </>
      );
    };

    但是表单校验和提交功能仍然是在外层表单中,而不是在单个输入框组件中!

    const Form = () => {
      const [name, setName] = useState("");
    
      const onSubmitClick = () => {
        if (!name) {
          // 处理空用户名的情况
        } else {
          // 在这里提交一些数据
        }
      };
    
      return (
        <>
          {/*...*/}
          <InputField label="name" onChange={setName} />
          <button onClick={onSubmitClick}>Submit the form!</button>
        </>
      );
    };

    那么问题来了,我如何才能让 Form 组件的输入框组件“关注自身焦点”呢?在 React 中控制数据和行为的“正常”方式是将 props 传递给组件并监听回调。可以尝试将创建一个 props:focusItself 传递给 InputField,我会将其从 false 切换为 true,但这只能生效一次。

    // 不要这样做!这里只是为了演示它在理论上是如何工作的
    const InputField = ({ onChange, focusItself }) => {
      const inputRef = useRef(null);
    
      useEffect(() => {
        if (focusItself) {
          // 如果 focusItself prop 发生变化,则焦点输入
          // 只会在 false 变为 true 时起作用一次
          ref.current.focus();
        }
      }, [focusItself]);
    
      // 剩余代码
    };

    我可以尝试添加一些“onBlur”回调,并在输入失去焦点时将 focusItself 属性重置为 false,或者尝试使用随机值而不是布尔值,或者是其它方式。

    其实我们不必传 props,而是可以在表单组件(Form)中创建一个 ref,将其传递给子组件 InputField,然后将其附加到那里的底层 input 元素。毕竟,ref 只是一个可变对象。

    然后 Form 将照常创建 ref:

    const Form = () => {
      // 在Form组件中创建一个ref对象
      const inputRef = useRef(null);
      // ...
    };

    将 ref 传给 InputField 组件,而不是在 InputField 组件内部创建一个 ref,如下所示:

    const InputField = ({ inputRef }) => {
      // ...
    
      // 将 ref 从 prop 传递到内部输入框元素
      return <input ref={inputRef} />;
    };

    ref 是一个可变对象,React 就是这样设计的。当我们将它传递给元素时,下面的 React 只会改变它。而要改变的对象是在 Form 组件中声明的。因此,一旦 InputField 被渲染,ref 对象就会改变,我们的 Form 组件将能够通过 inputRef.current 访问到输入框元素:

    const Form = () => {
      // 在Form组件中创建一个ref对象
      const inputRef = useRef(null);
    
      useEffect(() => {
        // input元素
        console.log(inputRef.current);
      }, []);
    
      return (
        <>
          {/* 将 ref 作为 prop 传递给输入框组件 */}
          <InputField inputRef={inputRef} />
        </>
      );
    };

    同样的在提交回调中,也可以调用 inputRef.current.focus(),代码都是一样的。

    前往这里查看以上示例。

    使用 forwardRef 将 ref 从父组件传递给子组件

    如果你想知道为什么我将 prop 命名为 inputRef,而不是 ref,请继续往下看。

    由于 ref 不是一个真正的 prop,它有点像一个“保留字”名称。在过去,当我们还在编写类组件时,如果我们将 ref 传递给类组件,则该组件的实例将是该 ref 的 current 值。

    但是函数式组件没有实例。

    因此,我们只会在控制台中收到警告Function components cannot be given refs. Attempts to access this ref will fail. Did you mean to use React.forwardRef()?(大概翻译一下就是: “函数式组件无法获得 ref。尝试访问此 ref 将失败。你是想使用 React.forwardRef() 吗?”)。

    const Form = () => {
      const inputRef = useRef(null);
    
      // 如果我们这样做,我们会在控制台中收到警告
      return <InputField ref={inputRef} />;
    };

    为了使其正常工作,我们需要向 React 发出信号,表明这个 ref 实际上是有意的,我们想用它做一些事情。我们可以借助 forwardRef 函数来实现这一点:它接受我们的组件并将 ref 属性中的 ref 注入为组件函数的第二个参数,紧接着就是函数组件的 props。

    // 通常,我们在组件当中只有 props
    // 但我们用 forwardRef 包装了组件的函数
    // 它会注入第二个参数 - ref
    // 如果它由其使用者传递给此组件
    const InputField = forwardRef((props, ref) => {
      // 其余代码相同
    
      return <input ref={ref} />;
    });

    我们甚至可以将上述代码拆分为两个变量以提高可读性:

    const InputFieldWithRef = (props, ref) => {
      // 其余代码相同
    };
    
    // 这个将由表单使用
    export const InputField = forwardRef(InputFieldWithRef);

    现在 Form 可以将 ref 传递给 InputField 组件,因为它是一个常规 DOM 元素:

    return <InputField ref={inputRef} />;

    是否应该使用 ForwardRef 或仅将 ref 作为 prop 传递只是个人喜好问题,最终结果是一样的。

    前往这里查看以上示例。

    使用 useImperativeHandle 的命令式 API

    Form 组件聚焦输入框功能已经完成了,但我们还远没有完成我们酷炫的表单。还记得吗,当发生错误时,除了关注焦点之外,我们还想实现"摇晃"输入框?原生 javascript API 中没有 element.shake() 这样的东西,所以访问 DOM 元素在这里没有帮助。

    不过,我们可以很容易地将其实现为 CSS 动画:

    const InputField = () => {
      // 存储我们是否应该在状态中摇动
      const [shouldShake, setShouldShake] = useState(false);
    
      // 只需在需要摇晃时添加类名 - css 会处理它
      const className = shouldShake ? "shake-animation" : "";
    
      // 动画完成后 - 转换状态回到 false,因此我们可以根据需要重新开始
      return (
        <input className={className} onAnimationEnd={() => setShouldShake(false)} />
      );
    };

    但是如何触发它呢?同样,与之前的焦点问题一样——我可以使用 props 想出一些解决方式,但它看起来很奇怪,并且会使 Form 变得过于复杂。

    特别是考虑到我们是通过 ref 来处理焦点的,所以我们会有两个完全相同的问题的解决方案。

    如果我能在这里做类似 InputField.shake() 和 InputField.focus() 的事情就好了!

    说到焦点——为什么我的 Form 组件仍然必须使用 DOM API 来触发它?抽象出这样的复杂性,难道不是 InputField 的责任和重点吗?为什么表单甚至可以访问底层 DOM 元素——它基本上泄露了内部实现细节。Form 组件不应该关心我们正在使用哪个 DOM 元素,或者我们是否使用 DOM 元素或其他东西。

    这就是所谓的关注点分离。

    看起来是时候为我们的 InputField 组件实现一个适当的命令式 API 了,现在,React 是声明性的,并希望我们所有人都相应地编写代码,但有时我们只需要一种命令式触发某些事件或者方法的方法,React 为我们提供了一个 api:useImperativeHandle 钩子函数。

    这个钩子函数有点难以理解,但本质上,我们只需要做两件事:

    1. 决定我们的命令式 API 是什么样子。
    2. 以及将它附加到的 ref。

    对于我们的输入框,这很简单:我们只需要将 focus() 和 shake() 函数作为 API。

    // 我们的 API 看起来应该是这样的
    const InputFieldAPI = {
      focus: () => {
        // 在这里执行关注焦点
      },
      shake: () => {
        // 在这里触发摇晃动画
      },
    };

    useImperativeHandle 钩子函数只是将此对象附加到 ref 对象的“current”属性,仅此而已,它是这样实现的:

    const InputField = () => {
      useImperativeHandle(
        someRef,
        () => ({
          focus: () => {},
          shake: () => {},
        }),
        []
      );
    };

    第一个参数是我们的 ref 对象,它可以在组件本身中创建,也可以从 props 或通过 forwardRef 传递。第二个参数是一个返回对象的函数-这个返回的对象将作为 inputRef.current 的值。第三个参数是一个依赖项数组,与任何其他 React 钩子例如 useEffect 相同。

    对于我们的组件,让我们将 ref 明确作为 apiRef prop 传递。剩下要做的就是实现实际的 API。为此,我们需要另一个 ref - 这次是 InputField 内部的,以便我们可以将其附加到输入框元素并像往常一样触发焦点:

    // 将我们将用作命令式 apiRef 作为 prop 传递
    const InputField = ({ apiRef }) => {
      // 创建另一个 ref - 输入框组件内部
      const inputRef = useRef(null);
      // 将我们的 api 注入到 apiRef
      // 返回的对象将可用作 apiRef.current
      useImperativeHandle(
        apiRef,
        () => ({
          focus: () => {
            // 仅触发附加到 DOM 对象的内部 ref 上的焦点
            inputRef.current.focus();
          },
          shake: () => {},
        }),
        []
      );
    
      return <input ref={inputRef} />;
    };

    对于“摇动”,我们只会触发状态更新:

    // 我们将用作命令式 apiRef 作为 prop 传递
    const InputField = ({ apiRef }) => {
      // 摇动状态
      const [shouldShake, setShouldShake] = useState(false);
    
      useImperativeHandle(
        apiRef,
        () => ({
          focus: () => {},
          shake: () => {
            // 在此处触发状态更新
            setShouldShake(true);
          },
        }),
        []
      );
    
      // ...
    };

    然后我们的 Form 组件只需创建一个 ref,将其传递给 InputField,就可以执行简单的 inputRef.current.focus() 和 inputRef.current.shake(),而不必担心它们的内部实现!

    const Form = () => {
      const inputRef = useRef(null);
      const [name, setName] = useState("");
    
      const onSubmitClick = () => {
        if (!name) {
          // 如果名称为空,则聚焦输入框
          inputRef.current.focus();
          // 摇一摇!
          inputRef.current.shake();
        } else {
          // 在此处提交数据!
        }
      };
    
      return (
        <>
          {/* ... */}
          <InputField label="name" onChange={setName} apiRef={inputRef} />
          <button onClick={onSubmitClick}>提交表单!</button>
        </>
      );
    };

    前往这里查看以上示例。

    无需 useImperativeHandle 的命令式 API

    使用 useImperativeHandle 还是看起来挺麻烦的,而且这个 api 也有点不好记,但我们实际上不必使用它来实现我们刚刚实现的功能。我们已经知道 refs 的工作原理,以及它们是可变的事实。所以我们所需要的只是将我们的 API 对象分配给所需 ref 的 ref.current,如下所示:

    const InputField = ({ apiRef }) => {
      useEffect(() => {
        apiRef.current = {
          focus: () => {},
          shake: () => {},
        };
      }, [apiRef]);
    };

    无论如何,这几乎就是 useImperativeHandle 在幕后所做的,它将像以前一样工作。

    实际上,useLayoutEffect 在这里可能更好,不过这是另一篇文章所要叙述的,现在,让我们使用传统的 useEffect。

    前往这里查看以上示例。

    现在,一个很酷的表单已经准备好了,带有不错的抖动效果,React refs 不再神秘,React 中的命令式 API 实际上就是一个东西。这有多酷?

    总结

    请记住:refs 只是一个“逃生舱口”,它不是状态或带有 props 和回调的正常 React 数据流的替代品。仅在没有“正常”替代方案时使用它们,触发某些东西的命令式方式也是一样-更有可能的是正常的 props/回调流就是你想要的。



沪ICP备19023445号-2号
友情链接