3
头图

文章概要:

  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/回调流就是你想要的。


夕水
5.2k 声望5.7k 粉丝

问之以是非而观其志,穷之以辞辩而观其变,资之以计谋而观其识,告知以祸难而观其勇,醉之以酒而观其性,临之以利而观其廉,期之以事而观其信。