React Hooks 详解:useImperativeHandle
目录
作者注
我见过一些关于如何使用 React useImperativeHandle
hooks 的不错的解释——Kent C. Dodds 的 React 课程里有一个很棒的简短练习,教你如何正确使用这个 hooks。但我仍然觉得关于何时应该使用这个 hooks 还有更多讨论的空间,因为你应该谨慎使用,并且只在特定情况下,当它是最合乎逻辑的(或唯一剩下的)选择时才使用。
这是我在 DEV (✨🥳🎉) 上的第一篇文章,我打算以此作为围绕 React 和 Typescript 的系列短文的第一篇。我大约四年前开始使用 React,很高兴能与大家分享我从那时起学到的一些知识。如果您发现任何错误,请告诉我!
简介
除极少数例外,React 应用中的数据流是单向的。组件由父节点和子节点构成,构成一个层级结构。子节点可以访问信息,并可以通过声明式“props” API 调用从父节点向下传递的函数。另一方面,父节点无法访问子节点的内部状态(也不会受其影响)。父节点通常也不会调用子组件中声明的函数。
当父节点和子节点之间需要更紧密的协调时,回调函数通常就足够了。在涉及多个移动部件和密集组件层次结构的更复杂的情况中,可能需要使用 Redux 或内置的 Context API 之类的工具。即便如此,父节点通常也无法直接控制子节点。
但是,在极少数情况下,回调、上下文等等根本不够用,最简洁、最灵活,或者说唯一的选择就是让父级直接控制子级,并命令子级应该做什么,那该怎么办呢?让我们看看这种情况是如何发生的,以及我们能做些什么。
单向数据流
假设你被委托构建一个“评论提要”组件,该组件将在多个应用程序中的多个不同位置使用。具体用例会有所不同;你只需要遵循以下验收标准:
- 标准#1:评论提要应该接受现有评论列表(数组)作为其道具之一,并应显示它们。
- 标准 #2:评论提要底部应有一个表单,允许用户添加新评论。该表单应包含两个字段:一个用于输入用户名,另一个用于输入新评论本身。表单底部应有一个“提交”按钮,供用户请求添加新评论。
- 标准 #3:当用户点击按钮时,评论 feed 应该将新评论表单中的信息(用户名和新评论)发送到挂载它的父组件。父组件负责处理请求,更新现有评论列表,并将更新后的评论列表提供给评论 feed 进行显示。
以下是评论提要的一个非常基本的实现(我们将此组件命名为Comments
):
const Comments = (props: {
comments: [];
onSubmitComment: (name: string, newComment: string) => void;
}) => {
// State management for form
const [values, setValues] = useState({
name: "",
newComment: "",
});
// Handle changes to form fields
function handleChange (event) {
setValues((values) => {
...values,
[event.target.name]: event.target.value,
});
}
// Function that renders content of each comment
function renderComment (comment) { ... }
// Submit comment
function handleSubmit () {
const { name, newComment } = values;
props.onSubmitComment(name, newComment);
}
return (
<>
<ul>
{props.comments.map(renderComment)}
</ul>
<h4>Add a comment</h4>
<form>
<label for="name">Your Name</label>
<input
name="name"
type="text"
value={values.name}
onChange={handleChange}
/>
<label for="newComment">Your Comment</label>
<textarea
name="newComment"
rows={4}
value={values.newComment}
onChange={handleChange}
/>
</form>
<button onClick={handleSubmit}>Submit</button>
</>
);
};
此组件需要传入两个 props。第一个 propscomments
提供要显示的评论列表。评论将以无序列表中的列表项形式呈现。这满足了条件 1。
表单允许用户输入自己的姓名和新评论。表单底部有一个“提交”按钮,点击即可提交新评论。这满足了条件 2。
提供给此组件的第二个 prop 是一个回调函数onSubmitComment
。此回调函数需要传入两个参数:提交评论的人的姓名和评论本身。当点击“提交”按钮时,handleSubmit
将执行该函数。在函数内部,onSubmitComment
执行回调函数并传入用户在表单中输入的值。这就是Comments
组件将要保存的新评论“发送”给其直接父组件的方式。这满足了第三个也是最后一个验收标准。
现在让我们看看“父”组件如何实现该Comments
组件:
const Article = () => {
// State management
const [comments, setComments] = useState([]);
// Load comments when component mounts
async function loadComments () {
const existingComments = await fetch(...) // API request to get comments
setComments(existingComments); // Store comments in state
}
useEffect(() => {
loadComments();
}, []);
// Event handlers
async function addComment (name: string, newComment: string) {
// API request to persist new comment...
// Optimistic update of comments list...
...
}
return (
<div>
<article>
...
</article>
...
<Comments
comments={comments}
onSubmitComment={addComment}
/>
</div>
);
};
如上所示,父组件一旦挂载,就会加载初始的评论集。评论列表存储在comments
状态变量中,并向下传递给Comments
作为父组件子组件挂载的组件。addComment()
函数被赋值给onSubmitComment
prop 的值。当用户点击“提交”按钮时,组件实际上是通过propComments
调用父组件的函数。addComment()
onSubmitComment
这是一个非常基础的示例,用于在不违反单向流的情况下协调父节点和子节点的行为。新评论表单中的值、提交按钮及其任何交互都与父组件无关。父组件不会直接“介入”并获取存储在子组件中的信息。相反,父组件会为子组件提供一个回调函数,并期望子组件在每次添加新评论时都调用该函数。父组件无法调用在组件handleSubmit()
内部声明的函数Comments
。
添加命令式逻辑
如果您在 React 应用中广泛使用过表单,您可能熟悉input
元素如何公开诸如blur
、focus
和 之类的函数select
,这些函数分别可用于以编程方式模糊或聚焦某个字段,或选择字段内的所有文本。通常,当用户点击某个字段内部时,该字段将被聚焦;而当用户移动到另一个字段或点击字段外部时,之前的字段将被模糊。但有时,有必要在不等待用户输入的情况下执行这些操作。
当用户首次在页面或对话框中加载表单时,将键盘焦点立即置于表单的第一个字段(或用户预期首先输入的任何字段)上,可以提升用户体验。这样做可以节省用户一些时间和动作交互成本,否则用户需要将鼠标光标移动到该字段并点击它。
在其他情况下,你可能需要做类似的事情。如果用户尝试提交表单,但其中一个字段出现错误,那么如果应用程序能够自动将焦点放在出现错误的字段上(并确保该字段已滚动到视图中),那就太好了。
假设我们为新Comments
组件提供了额外的验收标准:
- 验收标准 4:当评论提要被安装并向用户可见时,“您的姓名”字段应立即获得键盘焦点。
再次查看该Comments
组件,我们看到新的评论表单当前如下所示:
...
<form>
<label for="name">Your Name</label>
<input
name="name"
type="text"
value={values.name}
onChange={handleChange}
/>
<label for="newComment">Your Comment</label>
<textarea
name="newComment"
rows={4}
value={values.newComment}
onChange={handleChange}
/>
</form>
...
我们希望第一个input
,即“Your Name” 字段,在组件挂载后立即获得焦点Comments
。我们无法通过更改输入框的值(或其他 prop)来期望输入框再次自动获得焦点。父节点(在本例中为Comments
组件)只需要一种方式来代表子节点( )直接(命令式地input
)调用 focus 函数。
这是命令式逻辑最简单的应用示例之一。我们终于遇到了真正需要它的情况!
但是,为了访问该函数,我们需要一种方法来引用特定的输入元素。在 React 中,我们通过使用ref(我们称之为nameInputRef
)来实现这一点:
const Comments = ...
...
const nameInputRef = useRef();
...
return (
...
<form>
<label for="name">Your Name</label>
<input
name="name"
type="text"
value={values.name}
onChange={handleChange}
ref={nameInputRef}
/>
...
</form>
...
);
};
focus()
现在可以通过 访问该函数nameInputRef.current
。借助钩子useEffect
,我们可以在Comments
组件首次挂载和渲染后调用此函数。
...
const nameInputRef = useRef();
useEffect(() => {
if (nameInputRef.current) {
nameInputRef.current.focus();
}
}, []);
...
命令式处理和函数组件
假设我们的Comments
组件现在被用在众多应用程序中。在某些页面上,它位于底部。在其他页面上,它被放置在侧面。它也出现在一些对话框和工具提示中。在所有这些情况下,它都会立即渲染,并自动聚焦“您的姓名”字段。然而,随着组件使用量的增加,开发人员开始发现“初始挂载时自动聚焦第一个字段”的行为已经不够用了。
有一天,一位开发者接到一个任务,要以一种略有不同的方式实现你的评论推送。在页面底部,有一组可折叠的折叠面板标签,每个标签包含不同的内容。其中一个折叠面板标签包含评论推送。要查看评论推送,用户必须点击“查看评论”来展开折叠面板标签,如下所示:
负责此工作的开发人员被告知,每当评论部分展开时,“您的姓名”字段必须始终处于初始自动聚焦状态。他们通过仅在折叠式选项卡展开时挂载评论提要,并在折叠时卸载它来实现这一点。这样,展开折叠式选项卡始终会导致评论提要重新挂载。每当发生这种情况时,useEffect
都会执行副作用,“您的姓名”字段再次自动聚焦。
然而,项目经理和用户体验主管对这个解决办法并不满意。你看,如果用户开始输入评论,然后折叠评论区,那么当评论提要卸载时,他们辛苦输入的一切都会立即消失。再次展开评论区后,他们会沮丧地发现,他们写下的一切都消失在时间的长河中了。
还有一些其他方法可以解决这个问题:您可以临时存储(例如在本地存储中)用户输入的内容。然后,当重新安装组件时,这些存储的值可以作为“初始值”传递到评论提要中。
但为了方便讨论,我们是否可以避免添加更多 props 并对组件进行重大修改,Comments
只需执行类似于之前对input
字段的操作即可。如果组件包含一个专注于“您的姓名”字段的函数,并将该函数暴露给任何实现它的父级,就像元素暴露的函数Comments
一样,会怎么样?这样,任何父级都可以在必要时强制调用该函数。focus()
input
步骤1:在子组件中定义一个函数
我们首先在Comments
组件内部定义上述函数。我们将其命名为focusOnForm()
:
const Comments = ...
...
const nameInputRef = useRef();
function focusOnForm () {
if (nameInputRef.current) {
nameInputRef.current.focus();
}
}
useEffect(focusOnForm, []);
...
到目前为止,我们实际上所做的就是将之前在useEffect
钩子中定义的所有逻辑移到它自己的单独函数中。我们现在在 中调用该函数useEffect
。
还记得我们需要input
通过 引用特定元素ref
才能访问其focus()
函数吗?为了允许父组件访问组件focusOnForm()
内部的函数,我们需要做类似的事情Comments
。
步骤2:在父组件中定义一个ref并将其传递给子组件
现在让我们回到父级。首先,我们定义一个新的 ref,名为。然后,我们将通过propcommentsFeedRef
将 ref 赋值给组件,就像我们对元素所做的那样:Comments
ref
input
const Article = () => {
...
const commentsFeedRef = useRef();
...
return (
...
<Comments
comments={comments}
onSubmitComment={addComment}
ref={commentsFeedRef}
/>
);
};
如果这是 2018 年,并且我们的Comments
组件是类组件,那么这完全没问题,我们也能顺利上手。但这是未来啊,伙计——这个Comments
组件是函数组件。与类组件不同,函数组件在挂载时没有关联的组件实例。换句话说,我们无法通过默认属性访问函数组件的某个“实例” ref
。我们还需要先做一些工作。
顺便说一句,简单地向 Comments 组件上现有的 props 添加一个ref
属性也不起作用,因此以下方法也是不正确的:
const Comments = (props: {
comments: [];
onSubmitComment: (name: string, newComment: string) => void;
ref,
}) => ...
相反,我们必须使用forwardRef
React 提供的功能来将 ref 传递给我们的函数组件。
步骤 3:使用 forwardRef 允许将 ref 传递给子级
实现这一点有几种不同的方法,但我通常更喜欢这种方法,因为它非常简洁易懂。首先,我们需要将组件定义为命名函数,而不是赋值给常量的匿名函数:
function Comments (
props: {
comments: [];
onSubmitComment: (name: string, newComment: string) => void;
}
) {
...
function focusOnForm () { ... }
...
}
假设我们之前将此组件导出为模块级默认导出:
export default Comments;
我们现在需要先将Comments
组件传递给forwardRef
高阶组件,然后导出结果:
export default React.forwardRef(Comments);
接下来,我们将ref
属性添加到Comments
组件。但请注意,该ref
属性与主要组件 props 是分开的:
function Comments (
props: {
comments: [];
onSubmitComment: (name: string, newComment: string) => void;
},
ref
) {
...
function focusOnForm () { ... }
...
}
父组件现在可以将 ref 传递给Comments
组件,并使用它来调用该focusOnForm()
函数。当我们调用它时,我们可能会执行以下操作:
...
commentsFeedRef.current.focusOnForm();
...
但这仍然行不通。怎么回事?
好吧,ref 的current
属性实际上还没有focusOnForm
函数。我们首先需要明确定义通过该属性暴露的内容current
。
步骤 4:使用 useImperativeHandle 通过传递的 ref 公开函数
我们将通过以下方式实现这一目标useImperativeHandle
:
function Comments (
props: {
comments: [];
onSubmitComment: (name: string, newComment: string) => void;
},
ref
) {
...
function focusOnForm () { ... }
useImperativeHandle(
// Parameter 1: the ref that is exposed to the parent
ref,
// Parameter 2: a function that returns the value of the ref's current property,
// an object containing the things we're trying to expose (in this case, just
// one function)
() => {
return {
focusOnForm: focusOnForm,
}
}
);
...
}
我们向 传递了两个参数useImperativeHandle
。第一个参数只是指示要暴露给父级的引用。
在第二个参数中,我们传递一个函数,该函数返回一个对象,该对象包含我们试图向父级公开的各种函数和属性。当父级访问作为第一个参数传入的 ref 的属性useImperativeHandle
时,将返回此对象。current
我们可以简化它,像这样:
useImperativeHandle(
ref,
() => ({
focusOnForm,
})
);
实际上还有第三个可选参数。你可以传入一个依赖项数组,useImperativeHandle
当任何依赖项发生变化时,都会重新计算要返回的内容。如果你返回的内容受到子组件状态的影响,这将非常有用;例如:
const [someValue, setSomeValue] = useState<number>(...);
...
useImperativeHandle(
ref,
() => ({
someFunction: (value) => value * someValue,
}),
[someValue]
);
但目前我们不需要它。
现在,当Comments
组件传递一个 ref 时,它会立即将一个对象赋值给 ref 的属性值current
。目前,这个对象只包含focusOnForm()
函数。
步骤 5:通过传递给子级的 ref 调用子级公开的函数
回到父组件,我们可以看到如何focusOnForm()
在子组件内部调用父组件内部定义的函数:
const Article = () => {
...
const commentsFeedRef = useRef();
...
function focusOnNewCommentForm () {
if (commentsFeedRef.current) {
commentsFeedRef.current.focusOnForm();
}
}
...
return (
...
<Comments
comments={comments}
onSubmitComment={addComment}
ref={commentsFeedRef}
/>
);
};
这样,开发人员现在可以focusOnForm()
在必要时轻松调用,而无需卸载并重新挂载Comments
组件。showComments
下面显示的变量控制评论部分的展开/折叠状态。一个useEffect
钩子监视其值的变化。每当其值变为 时true
,我们就会调用focusOnForm()
。
const Article = () => {
...
const [showComments, setShowComments] = useState(false);
useEffect(() => {
if (showComments && commentsFeedRef.current) {
commentsFeedRef.current.focusOnForm();
}
}, [showComments]);
...
return (
...
<Accordion ...>
<Accordion.Tab show={showComments}>
<Comments
comments={comments}
onSubmitComment={addComment}
ref={commentsFeedRef}
/>
</Accordion.Tab />
</Accordion>
);
};
太棒了!现在,无论何时再次显示评论提要,新评论表单中的“您的姓名”字段都将始终重新聚焦,即使该Comments
组件尚未卸载并重新安装。
明智地使用它
归根结底,useImperativeHandle
它并不常用,而且有充分的理由——它是一个逃生舱口、一个消防通道,是当其他选择失败或根本不可行时绝对的最后手段。
我偶尔遇到的情况useImperativeHandle
是,当组件中有一个可滚动区域和一个按钮,让用户滚动回到顶部时。这很简单,只需获取相关元素(通过 ref 或查询document.querySelector
),然后调用scrollTop = 0
即可。但你肯定不希望开发人员每次实现相关组件时都必须编写这套逻辑——组件应该公开一些属性,并传入一个可以触发效果的值,对吧?
但你很快就会发现,对于命令式操作来说,传递值并没有多大意义。你会传递什么呢?一个onRequestScrollToTop
带有值的布尔变量 ( ) true
?这个变量会被重新设置为 吗false
?父级组件会先短暂延迟,再将其重新设置为 吗false
?setTimeout
或者,是否有一个回调函数 ( onScrollToTop
),在滚动到顶部完成后执行,此时将相关变量设置为false
?所有这些听起来都同样糟糕且没有必要。
像这样特殊且罕见的情况才useImperativeHandle
真正值得重视,值得认真考虑。相反,如果你没有问过这类问题,那么你可能不用 也能完成你想要做的事情useImperativeHandle
。
还有一件事需要考虑:当你为他人创建组件并将其发布为开源工具时,你不可能提前预测它们的所有使用方式。以最大化其灵活性的方式构建组件有着明显的优势。例如,评论区里说:并没有规定组件必须用于折叠面板。或许,在某些罕见的情况下,添加组件useImperativeHandle
可以让开发人员在特定情况下使用特定功能,而无需每次出现新的特殊情况时都被迫彻底修改原始组件。