前言
React 的众多优点之一就是它抽象化了处理真实 DOM 的复杂性。现在,我们不必手动查询元素,不必为如何为这些元素添加类而伤透脑筋,也不必为浏览器的不一致性而苦恼,我们只需编写组件,专注于用户体验即可。不过,在某些情况下(虽然很少!),我们仍然需要访问实际的 DOM。
而说到实际的 DOM,我们最需要了解和学习如何正确使用的,就是 Ref 以及围绕 Ref 的一切。因此,今天让我们来看看为什么我们首先要访问 DOM,Ref 如何帮助我们访问 DOM,什么是 useRef
、forwardRef
和 useImperativeHandle
,并且如何正确使用它们。此外,让我们研究一下如何避免 使用 forwardRef
和 useImperativeHandle
而又能获得它们所提供的功能。如果你曾尝试弄清它们的工作原理,你就会明白我们为什么要这样做 😅
作为奖励,我们还将学习如何在 React 中实现指令式 API!
通过使用 useRef
在 React 中访问 DOM
举个栗子,我想为我正在组织的一次会议设置一个注册表单。我希望人们在我向他们发送详细信息之前,先告诉我他们的姓名、电子邮件和 Twitter 账号。「Name」和 「Email」字段是必填项。但我不想在人们尝试提交空输入时,在这些输入周围显示一些恼人的红色边框,我希望表单很酷。因此,我想 focus 到空的表单项,并稍微晃动一下,以吸引人们的注意,这只是为了好玩。
现在,React 给了我们很多,但它并没有给我们所有的东西。像 「手动 focus 元素」这样的功能并不在其中。为此,我们需要掸去那些早已生锈的原生 javascript API 技能上的陈年老灰。为此,我们需要访问实际的 DOM 元素!
在一个没有 React 的世界里,我们会这样做:
JavaScript
const element = document.getElementById("bla");
然后 focus 它:
JavaScript
element.focus();
或者 scroll 它:
JavaScript
element.scrollIntoView();
或其他任何我们想要的东西...
在 React 世界中使用原生 DOM API 的一些典型用例包括:
- 在元素渲染后手动 focus 该元素,如表单中的输入框
- 在显示弹出式元素时,检测组件外部的点击
- 在元素出现在屏幕上后,手动滚动到该元素
- 计算屏幕上组件的尺寸和边界,以正确定位工具提示等内容
虽然从技术上讲,即使在今天也没有什么可以阻止我们使用 getElementById
,但 React 为我们提供了一种更强大的方法来访问该元素,而无需到处传播 id 或了解底层 DOM 结构,那就是: refs。
Ref 只是一个可变对象,React 会在重新渲染时保留它的引用。它不会触发重新渲染,因此无论如何都不能替代状态,不要试图用它来替代 state。关于这两者之间区别的更多详情,请参阅文档。
它通过 useRef
hook 创建:
JavaScript
const Component = () => {
// 用默认的 null 值来创建一个 ref
const ref = useRef(null);
return ...
}
存储在 Ref 中的值将在它的 "current"(也是唯一的)属性中可用。实际上,我们可以在其中存储任何内容!例如,我们可以将来自 state 的一些值存储到一个对象中:
JavaScript
const Component = () => {
const ref = useRef(null);
useEffect(() => {
// 用一个新的 object 来重写 ref 的默认值
ref.current = {
someFunc: () => {...},
someValue: stateValue,
}
}, [stateValue])
return ...
}
或者,对于我们的用例来说更重要的是,我们可以将此 Ref 分配给任何 DOM 元素和一些 React 组件:
JavaScript
const Component = () => {
const ref = useRef(null);
// 把 ref 分配给一个 input 元素
return <input ref={ref} />
}
现在,如果我在 useEffect
中把 ref.current
打印出来(它只在组件渲染后可用),我就会看到,打印的结果与在该 input 上执行 getElementById
完全相同:
JavaScript
const Component = () => {
const ref = useRef(null);
useEffect(() => {
// 这将会得到一个 input DOM 元素的引用!
// 和用 getElementById 查询到的完全一样
console.log(ref.current);
});
return <input ref={ref} />
}
现在,如果我把注册表单作为一个巨大的组件来实现,我就可以做类似这样的事情:
JavaScript
const Form = () => {
const [name, setName] = useState('');
const inputRef = useRef(null);
const onSubmitClick = () => {
if (!name) {
// 当有人尝试提交空 name 的时候,focus该个 input 表单项
ref.current.focus();
} else {
// 在这里做真正的提交
}
}
return <>
...
<input onChange={(e) => setName(e.target.value)} ref={ref} />
<button onClick={onSubmitClick}>Submit the form!</button>
</>
}
将 input 的值存储在 state 中,为所有的 input 创建 ref,当点击 "提交 "按钮时,我会检查输入值是否为空,如果为空,则 focus 对应的输入。
请查看此 codesandbox 中的表单实现:
把 ref 作为 prop 从父组件传递给子组件
当然,在现实生活中,我不会把所有东西都放在一个巨大的组件中。更有可能的情况是,我想把输入内容提取到自己的组件中:这样它就可以在多个表单中重复使用,并可以封装和控制自己的样式,甚至还可以有一些附加功能,比如在顶部有一个标签或在右侧有一个图标。
JavaScript
const InputField = ({ onChange, label }) => {
return <>
{label}
<br />
<input type="text" onChange={(e) => onChange(e.target.value)} />
</>
}
但错误处理和提交功能仍将存在于 "Form"中,而不是 input 中!
JavaScript
const Form = () => {
const [name, setName] = useState('');
const onSubmitClick = () => {
if (!name) {
// 处理空 name
} else {
// 提交表单
}
}
return <>
...
<InputField label="name" onChange={setName} />
<button onClick={onSubmitClick}>Submit the form!</button>
</>
}
如何让 input 从表单组件中 「focus 自己」?在 React 中控制数据和行为的常规"方法是向组件传递 props 并监听回调。我可以尝试将 prop focusItself
传递给 InputField
,然后将其从 false
切换为 true
,但问题是这只能奏效一次。
JavaScript
// 别在实际编码中这么干!我们只是在理论层面上举例示意
const InputField = ({ onChange, focusItself }) => {
const inputRef = useRef(null);
useEffect(() => {
if (focusItself) {
// 当 focusItself prop 变化时,让 input focus 自身。
// 这只会当该值从 false 转换为 true 时奏效一次!
ref.current.focus();
}
}, [focusItself])
// 剩下的没变化
}
我可以尝试添加一些 onBlur
回调,并在输入失去焦点时将 focusItself
prop 重置为 false
,或者使用随机值代替布尔值,或者想出一些其他创造性的解决方案。
不过,我们其实还有另一种方法。我们可以在一个组件(Form
)中创建 Ref,将其传递到另一个组件(InputField
),并将其附加到那里的底层 DOM 元素上,而不是在 prop 上做文章。毕竟 Ref 只是一个可变对象。
Form
会像平常一样创建 Ref:
JavaScript
const Form = () => {
// 在 Form 组件中创建 Ref
const inputRef = useRef(null);
...
}
而 InputField
组件将有一个接受 Ref 的 prop,并将像往常一样有一个接受 Ref 的 input
字段。只是 Ref 将不再在 InputField
中创建,而是从 prop 中获得:
JavaScript
const InputField = ({ inputRef }) => {
// 其余代码保持一致
// 从 prop 中获取 ref,传递到 input 上
return <input ref={inputRef} ... />
}
Ref 是一个可变 对象,设计之初就是如此。当我们将其传递给一个元素时,React 会在底层改变它。要被改变的对象是在 Form
组件中声明的。因此,只要 InputField
被渲染,Ref 对象就会发生变化,我们的 Form
就可以访问 inputRef.current
中的 input
DOM 元素:
JavaScript
const Form = () => {
// 在 Form 组件中创建 Ref
const inputRef = useRef(null);
useEffect(() => {
// 在 InputField 中渲染的"input"元素将在这里打印出来
console.log(inputRef.current);
}, []);
return (
<>
{/* 将 ref 作为 prop 传递给 InputField 组件中的 input 元素 */}
<InputField inputRef={inputRef} />
</>
)
}
你也可以在提交回调中调用 inputRef.current.focus()
,代码与之前的完全相同。
点击此处查看示例:
使用 forwardRef 将 ref 从父组件传递给子组件
你是否想知道为什么我把这个 prop 命名为 inputRef
而不是 ref
,这个问题其实不那么简单。ref
并不是一个真正的 prop,它是一个类似保留字 的东西。在过去,当我们还在编写类组件(class components)时,如果我们将 ref 传递给一个类组件,那么这个组件的实例就是该 Ref 的 .current
值。
但函数组件是没有实例的。因此,我们只会在控制台中得到一个 Warning: Function components cannot be given refs. Attempts to access this ref will fail. Did you mean to use React.forwardRef()?
JavaScript
const Form = () => {
const inputRef = useRef(null);
// 如何我们这么干,我们就会在控制台获得一条 warning
return <InputField ref={inputRef} />
}
为了实现这一目标(译者注:即使用 ref 关键字而非上文中的 inputRef 等其他自定义的 key),我们需要向 React 发出信号:这个 ref
实际上是有意的,我们想用它做一些事情。
我们可以借助 forwardRef
函数来做到这一点:它接受我们的函数组件,并将 ref
属性作为组件函数的第二个参数注入,位置就在 prop 之后。
JavaScript
// 通常情况下,组件只接受 props
// 但现在我们用 forwardRef 函数包裹了组件,并且将第二个入参 ------ref,注入
// 这个参数就是由这个组件的使用者传入的
const InputField = forwardRef((props, ref) => {
// 剩下的代码不变
return <input ref={ref} />
})
我们甚至可以将上述代码拆分成两个变量,以提高可读性:
JavaScript
const InputFieldWithRef = (props, ref) => {
// 剩下的代码不变
}
// 这个 InputField 将被 Form 使用
export const InputField = forwardRef(InputFieldWithRef);
现在,我们就可以在 Form
中像对待普通 DOM 元素一样将 ref
传递给 InputField
组件了:
JavaScript
return <InputField ref={inputRef} />
至于是使用 forwardRef
还是只将 ref
作为 prop 传递给子组件,这只是个人喜好的问题:最终结果都是一样的。
请看这里的示例:
译者补充总结:
使用
forwardRef
的唯一目的就是在把 ref 传递给子组件的时候可以使用ref
的 prop 名,否则就要使用类似inputRef
这样的自定义名了。换句话说,使用forwardRef
可以帮你突破ref
保留字不能作为 prop 用于函数组件的限制。可以发现这个需求并不算刚需(使用自定义名并没有什么大不了的),所以可以看到,后面作者也没有执泥于使用
forwardRef
以 useImperativeHandle 为代表的指令式 API
好了,聚焦Form
组件 input 的功能已经差不多搞定了。但我们的炫酷表单还没有完成。还记得吗?在出错时,我们除了想聚焦 input,还想给它增加抖动的动画效果。在原生的 javascript API 中并没有 element.shake()
这样的东西,所以访问 DOM 元素在这里没有用 😢 。
不过我们可以很容易地将其作为 CSS 动画来实现:
JavaScript
const InputField = () => {
// 在 state 中存储是否需要 shake 的状态值
const [shouldShake, setShouldShake] = useState(false);
// 当你想要 shake 的时候,给元素增加类名,剩下的 css 会帮你搞定
const className = shouldShake ? "shake-animation" : '';
// 当动画效果结束后,把状态置为 false,这样下次我们就能在想要的时候重新启动动画了
return <input className={className} onAnimationEnd={() => setShouldShake(false)} />
}
问题是如何触发动画呢?还是和之前聚焦的问题一样------我可以利用 prop 想出一些创造性的解决方案,但这样看起来会很奇怪,而且会大大增加表单的复杂性。特别是考虑到,我们是通过 ref 来处理聚焦问题的,如果我们再引入什么奇技淫巧,就会有两个方案来解决几乎完全相同的问题了。如果我能像 InputField.shake()
和 InputField.focus()
那样做就好了!
说回聚焦------为什么我的 Form
组件仍然要使用原生 DOM API 来触发聚焦呢?InputField
的职责和全部意义,不就是抽象掉这样的复杂问题吗?如果 Form
还要访问 InputField
下的原生 DOM 元素,这基本上是在泄露内部实现细节。Form
组件不应该关心我们使用了哪个 DOM 元素,甚至不应该关心我们到底有没有使用 DOM 元素。这就是所谓的 「关注点分离」。
看来是时候为我们的 InputField
组件实现适当的 imperative API (指令式 API)了。到目前为止,React 都是声明式的,React 官方希望我们都能用声明式的风格编写代码。但有时我们需要一种方法来强制触发某些东西。React 给了我们一个逃生通道: useImperativeHandle hook。
这个 hook 理解起来有点匪夷所思,我不得不苦读两遍文档,试了好几次,并在实际的 React 代码中实现了它,才真正理解了它的作用。但从本质上讲,我们只需要两样东西:决定我们的指令 API 是什么,以及将其附加到一个 Ref 上。对于我们的需求来说,这很简单:我们只需要 .focus()
和 .shake()
函数作为 API,而且我们之前已经了解了所有关于 Ref 的知识。
JavaScript
// 这就是我们的 API 大致的样子
const InputFieldAPI = {
focus: () => {
// 做聚焦的工作
},
shake: () => {
// 触发抖动
}
}
useImperativeHandle
hook 只是将上面的对象绑定到 Ref 对象的 current
属性,仅此而已。它是这样做的:
JavaScript
const InputField = () => {
useImperativeHandle(someRef, () => ({
focus: () => {},
shake: () => {},
}), [])
}
第一个参数是我们的 Ref,它可以在组件中创建,也可以通过 prop 或 forwardRef 传递。第二个参数是一个返回对象的函数 ------ 这就是可以通过 inputRef.current
访问的对象。第三个参数是依赖关系数组,与其他的 React hook 相同。
对于我们的组件,让我们以 apiRef
prop 的形式显式传递 ref。剩下要做的就是实现实际的 API。为此,我们需要另一个 ref,这次是 InputField
的内部 ref,这样我们就可以将其附加到 input
DOM 元素上,并像往常一样触发焦点:
JavaScript
// 传递我们将要使用的指令式 API
const InputField = ({ apiRef }) => {
// 创建另一个 ref------这个 ref 是 Input 组件内部的
const inputRef = useRef(null);
// 把我们的 API 和 apiRef merge 到一起
// 返回的对象可以被 apiRef.current 使用
useImperativeHandle(apiRef, () => ({
focus: () => {
// 仅在绑定了 DOM 对象的内部 ref 上触发 focus(内部 ref 不再对外部暴露了)
inputRef.current.focus()
},
shake: () => {},
}), [])
return <input ref={inputRef} />
}
为了抖动效果,我们只需要触发 state 的更新
JavaScript
// 传递我们将要使用的指令式 API
const InputField = ({ apiRef }) => {
// 还记得我们用于指示抖动动画效果的 state 吗?
const [shouldShake, setShouldShake] = useState(false);
useImperativeHandle(apiRef, () => ({
focus: () => {},
shake: () => {
// 触发 state 更新
setShouldShake(true);
},
}), [])
return ...
}
然后就大功告成了!我们的 Form
只需创建一个 ref
,将其传递给 InputField
,就能执行简单的 inputRef.current.focus()
和 inputRef.current.shake()
操作,而无需关心其内部实现!
JavaScript
const Form = () => {
const inputRef = useRef(null);
const [name, setName] = useState('');
const onSubmitClick = () => {
if (!name) {
// 当 name 为空时聚焦 input
inputRef.current.focus();
// 并且,抖它!
inputRef.current.shake();
} else {
// 提交表单
}
}
return <>
...
<InputField label="name" onChange={setName} apiRef={inputRef} />
<button onClick={onSubmitClick}>Submit the form!</button>
</>
}
可以在这里玩一下完整的工作表单示例:
不使用 useImperativeHandle 的指令式 API
如果 useImperativeHandle
钩子仍然让你的眼角抽搐--别担心,我的眼角也在抽搐!但实际上,我们不必使用它来实现刚才的功能。我们已经知道 ref 是如何工作的,也知道它们是可变的。因此,我们只需将我们的 API 对象赋值给所需 Ref 的 ref.current
即可,就像这样:
JavaScript
const InputField = ({ apiRef }) => {
useEffect(() => {
apiRef.current = {
focus: () => {},
shake: () => {},
}
}, [apiRef])
}
这几乎就是 useImperativeHandle
在引擎盖下所做的事情。它可以和使用 useImperativeHandle
的效果一模一样。
实际上,使用 useLayoutEffect
可能更好,但这是另一篇文章的主题。现在,让我们使用传统的 useEffect
。
请看这里的最终示例
没错,一个带有抖动效果的炫酷表单已经准备就绪,React refs 不再神秘,React 中的指令式 API 也是一样。这很棒吧?
请记住 Refs 是一个 "逃生舱",它不能取代 state,也不能取代带有 prop 和回调的正常 React 数据流。只有在没有 「正常」 选择的情况下才使用它们。这与触发某事的指令式方法是一样的------您想要的更可能是正常的 prop/回调流程。