深入浅出React中的refs

文章概要:

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

作为程序员,持续学习和充电非常重要,作为开发者,我们需要保持好奇心和学习热情,不断探索新的技术,只有这样,我们才能在这个快速发展的时代中立于不败之地。低代码也是一个值得我们深入探索的领域,让我们拭目以待,它将给前端世界带来怎样的变革,推荐一个低代码工具。

应用地址: https://www.jnpfsoft.com
开发语言:Java/.net

这是一个基于Flowable引擎(支持java、.NET),已支持MySQL、SqlServer、Oracle、PostgreSQL、DM(达梦)、 KingbaseES(人大金仓)6个数据库,支持私有化部署,前后端封装了上千个常用类,方便扩展,框架集成了表单、报表、图表、大屏等各种常用的 Demo 方便直接使用。

至少包含表单建模、流程设计、报表可视化、代码生成器、系统管理、前端 UI 等组件,这种情况下我们避免了重复造轮子,已内置大量的成熟组件,选择合适的组件进行集成或二次开发复杂功能,即可自主开发一个属于自己的应用系统。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值