文章概要:
- 为什么我们需要在 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 来做,应该如何实现?
首先,我们应该获取这个元素。如下所示:
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 钩子函数。
这个钩子函数有点难以理解,但本质上,我们只需要做两件事:
- 决定我们的命令式 API 是什么样子。
- 以及将它附加到的 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/回调流就是你想要的。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。