深入浅出 React 中的 refs
文章概要:
为什么我们需要在 React 中访问 DOM?
refs 如何帮助我们实现访问 DOM?
什么是 useRef、forwardRef 和 useImperativeHandle 钩子?
如何正确使用它们?
React 的众多优点之一是它抽象了处理真实 DOM 的复杂性。现在,我们无需手动查询元素、绞尽脑汁思考如何为这些元素添加类又或者是添加样式等,也无需为浏览器兼容性而苦恼,只需编写组件并专注于用户体验即可。然而,仍然有一些情况(虽然很少!)我们需要访问实际的 DOM。
而当涉及到实际的 DOM 时,最重要的是要理解并学习如何正确使用 ref 以及 ref 周围的一切。
让我们来看看为什么我们首先想要访问 DOM,ref 如何帮助我们做到这一点,什么是 useRef、forwardRef 和 useImperativeHandle,以及如何正确使用它们。
此外,让我们研究如何避免使用 forwardRef 和 useImperativeHandle,同时仍然保留它们给我们提供的功能。
如果你曾经尝试弄清楚它们是如何工作的,你就会明白我们为什么想要这样做,另外,我们将学习如何在 React 中实现命令式 API!
使用 useRef 在 React 中访问 DOM
假如我想实现一个注册表单,这个注册表单包含用户名和邮箱号,用户名和邮箱号应该是必填项,当用户没有填写这些信息时,我不想只是简单的给输入框添加红色边框,我希望实现一个带有动画的表单,这看起来应该比较炫酷,让我们将焦点关注到用户未填信息上,我们添加一个“摇晃”动画,用来吸引用户的注意力。
试想一下,如果我们使用原生 js 来做,应该如何实现?
首先,我们应该获取这个元素。如下所示:
然后,我们可以实现关注焦点:
又或者是直接滚动它:
其它的只要我们心中能想到的功能,我们都可以用 js 代码实现。让我们总结一下,在 React 中通常需要用到访问 DOM 的场景。如下:
在元素渲染后手动聚焦元素,例如表单中的输入字段
在显示类似弹出窗口的元素时检测组件外部的点击
在元素出现在屏幕上后手动滚动到元素
计算屏幕上组件的大小和边界以正确定位工具提示之类的东西。
尽管从技术上讲,即使在今天,也没有什么能阻止我们使用 getElementById,但 React 为我们提供了一种稍微更强大的方法来访问该元素,而不需要我们到处使用 getElementById 或了解底层 DOM 结构:refs。
ref 只是一个可变对象,是 React 在重新渲染之间保留的引用。它不会触发重新渲染,因此它不是以任何方式声明的替代品。有关这两者之间差异的更多详细信息,请参阅文档。
它是使用 useRef 钩子创建的:
存储在 ref 中的值将在其“current”(也是唯一的)属性中可用。我们实际上可以在其中存储任何值!例如,我们可以存储一个包含来自状态的一些值的对象:
或者,对于我们的示例更重要的是,我们可以将这个 ref 分配给任何 DOM 元素和一些 React 组件:
现在,如果我在 useEffect 中打印 ref.current
(它仅在组件渲染后可用),将看到 input 元素,这与尝试使用 getElementById 获得元素是一样的:
现在,我将注册表单作为一个组件来实现,如下所示:
我们将输入的值存储在状态中,为所有输入创建一个 ref 引用,当单击“提交”按钮时,我会检查值是否不为空,如果为空,我们则关注输入的值。
前往这里查看完整的示例。
将 ref 从父组件传递给子组件作为 prop
当然,实际上,我们会更倾向于封装成一个输入框组件:这样它就可以在多个表单中重复使用,并且可以封装和控制自己的样式,甚至可能具有一些附加功能,例如在顶部添加标签或在右侧添加图标。
但是表单校验和提交功能仍然是在外层表单中,而不是在单个输入框组件中!
那么问题来了,我如何才能让 Form 组件的输入框组件“关注自身焦点”呢?在 React 中控制数据和行为的“正常”方式是将 props 传递给组件并监听回调。可以尝试将创建一个 props:focusItself 传递给 InputField,我会将其从 false 切换为 true,但这只能生效一次。
我可以尝试添加一些“onBlur”回调,并在输入失去焦点时将 focusItself 属性重置为 false,或者尝试使用随机值而不是布尔值,或者是其它方式。
其实我们不必传 props,而是可以在表单组件(Form)中创建一个 ref,将其传递给子组件 InputField,然后将其附加到那里的底层 input 元素。毕竟,ref 只是一个可变对象。
然后 Form 将照常创建 ref:
将 ref 传给 InputField 组件,而不是在 InputField 组件内部创建一个 ref,如下所示:
ref 是一个可变对象,React 就是这样设计的。当我们将它传递给元素时,下面的 React 只会改变它。而要改变的对象是在 Form 组件中声明的。因此,一旦 InputField 被渲染,ref 对象就会改变,我们的 Form 组件将能够通过 inputRef.current 访问到输入框元素:
同样的在提交回调中,也可以调用 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() 吗?”)。
为了使其正常工作,我们需要向 React 发出信号,表明这个 ref 实际上是有意的,我们想用它做一些事情。我们可以借助 forwardRef 函数来实现这一点:它接受我们的组件并将 ref 属性中的 ref 注入为组件函数的第二个参数,紧接着就是函数组件的 props。
我们甚至可以将上述代码拆分为两个变量以提高可读性:
现在 Form 可以将 ref 传递给 InputField 组件,因为它是一个常规 DOM 元素:
是否应该使用 ForwardRef 或仅将 ref 作为 prop 传递只是个人喜好问题,最终结果是一样的。
前往这里查看以上示例。
使用 useImperativeHandle 的命令式 API
Form 组件聚焦输入框功能已经完成了,但我们还远没有完成我们酷炫的表单。还记得吗,当发生错误时,除了关注焦点之外,我们还想实现"摇晃"输入框?原生 javascript API 中没有 element.shake()
这样的东西,所以访问 DOM 元素在这里没有帮助。
不过,我们可以很容易地将其实现为 CSS 动画:
但是如何触发它呢?同样,与之前的焦点问题一样——我可以使用 props 想出一些解决方式,但它看起来很奇怪,并且会使 Form 变得过于复杂。
特别是考虑到我们是通过 ref 来处理焦点的,所以我们会有两个完全相同的问题的解决方案。
如果我能在这里做类似 InputField.shake()
和 InputField.focus()
的事情就好了!
说到焦点——为什么我的 Form 组件仍然必须使用 DOM API 来触发它?抽象出这样的复杂性,难道不是 InputField 的责任和重点吗?为什么表单甚至可以访问底层 DOM 元素——它基本上泄露了内部实现细节。Form 组件不应该关心我们正在使用哪个 DOM 元素,或者我们是否使用 DOM 元素或其他东西。
这就是所谓的关注点分离。
看起来是时候为我们的 InputField 组件实现一个适当的命令式 API 了,现在,React 是声明性的,并希望我们所有人都相应地编写代码,但有时我们只需要一种命令式触发某些事件或者方法的方法,React 为我们提供了一个 api:useImperativeHandle 钩子函数。
这个钩子函数有点难以理解,但本质上,我们只需要做两件事:
决定我们的命令式 API 是什么样子。
以及将它附加到的 ref。
对于我们的输入框,这很简单:我们只需要将 focus()
和 shake()
函数作为 API。
useImperativeHandle 钩子函数只是将此对象附加到 ref 对象的“current”属性,仅此而已,它是这样实现的:
第一个参数是我们的 ref 对象,它可以在组件本身中创建,也可以从 props 或通过 forwardRef 传递。第二个参数是一个返回对象的函数-这个返回的对象将作为 inputRef.current 的值。第三个参数是一个依赖项数组,与任何其他 React 钩子例如 useEffect 相同。
对于我们的组件,让我们将 ref 明确作为 apiRef prop 传递。剩下要做的就是实现实际的 API。为此,我们需要另一个 ref - 这次是 InputField 内部的,以便我们可以将其附加到输入框元素并像往常一样触发焦点:
对于“摇动”,我们只会触发状态更新:
然后我们的 Form 组件只需创建一个 ref,将其传递给 InputField,就可以执行简单的 inputRef.current.focus() 和 inputRef.current.shake(),而不必担心它们的内部实现!
前往这里查看以上示例。
无需 useImperativeHandle 的命令式 API
使用 useImperativeHandle 还是看起来挺麻烦的,而且这个 api 也有点不好记,但我们实际上不必使用它来实现我们刚刚实现的功能。我们已经知道 refs 的工作原理,以及它们是可变的事实。所以我们所需要的只是将我们的 API 对象分配给所需 ref 的 ref.current,如下所示:
无论如何,这几乎就是 useImperativeHandle 在幕后所做的,它将像以前一样工作。
实际上,useLayoutEffect 在这里可能更好,不过这是另一篇文章所要叙述的,现在,让我们使用传统的 useEffect。
前往这里查看以上示例。
现在,一个很酷的表单已经准备好了,带有不错的抖动效果,React refs 不再神秘,React 中的命令式 API 实际上就是一个东西。这有多酷?
总结
请记住:refs 只是一个“逃生舱口”,它不是状态或带有 props 和回调的正常 React 数据流的替代品。仅在没有“正常”替代方案时使用它们,触发某些东西的命令式方式也是一样-更有可能的是正常的 props/回调流就是你想要的。
作为程序员,持续学习和充电非常重要,作为开发者,我们需要保持好奇心和学习热情,不断探索新的技术,只有这样,我们才能在这个快速发展的时代中立于不败之地。低代码也是一个值得我们深入探索的领域,让我们拭目以待,它将给前端世界带来怎样的变革,推荐一个低代码工具。
开发语言:Java/.net
这是一个基于 Flowable 引擎(支持 java、.NET),已支持 MySQL、SqlServer、Oracle、PostgreSQL、DM(达梦)、 KingbaseES(人大金仓)6 个数据库,支持私有化部署,前后端封装了上千个常用类,方便扩展,框架集成了表单、报表、图表、大屏等各种常用的 Demo 方便直接使用。
至少包含表单建模、流程设计、报表可视化、代码生成器、系统管理、前端 UI 等组件,这种情况下我们避免了重复造轮子,已内置大量的成熟组件,选择合适的组件进行集成或二次开发复杂功能,即可自主开发一个属于自己的应用系统。
评论