React Hooks 详解:useImperativeHandle

2025-06-04

React Hooks 详解:useImperativeHandle

目录


作者注

我见过一些关于如何使用 React useImperativeHandlehooks 的不错的解释——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>
    </>
  );
};


Enter fullscreen mode Exit fullscreen mode

此组件需要传入两个 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>
  );
};


Enter fullscreen mode Exit fullscreen mode

如上所示,父组件一旦挂载,就会加载初始的评论集。评论列表存储在comments状态变量中,并向下传递给Comments作为父组件子组件挂载的组件。addComment()函数被赋值给onSubmitCommentprop 的值。当用户点击“提交”按钮时,组件实际上是通过propComments调用父组件的函数addComment()onSubmitComment

这是一个非常基础的示例,用于在不违反单向流的情况下协调父节点和子节点的行为。新评论表单中的值、提交按钮及其任何交互都与父组件无关。父组件不会直接“介入”并获取存储在子组件中的信息。相反,父组件会为子组件提供一个回调函数,并期望子组件在每次添加新评论时都调用该函数。父组件无法调用在组件handleSubmit()内部声明的函数Comments


添加命令式逻辑

如果您在 React 应用中广泛使用过表单,您可能熟悉input元素如何公开诸如blurfocus和 之类的函数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>
...


Enter fullscreen mode Exit fullscreen mode

我们希望第一个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>
    ...
  );
};


Enter fullscreen mode Exit fullscreen mode

focus()现在可以通过 访问该函数nameInputRef.current。借助钩子useEffect,我们可以在Comments组件首次挂载和渲染后调用此函数。



...
  const nameInputRef = useRef();
  useEffect(() => {
    if (nameInputRef.current) {
      nameInputRef.current.focus();
    }
  }, []);
...


Enter fullscreen mode Exit fullscreen mode

命令式处理和函数组件

假设我们的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, []);
...


Enter fullscreen mode Exit fullscreen mode

到目前为止,我们实际上所做的就是将之前在useEffect钩子中定义的所有逻辑移到它自己的单独函数中。我们现在在 中调用该函数useEffect

还记得我们需要input通过 引用特定元素ref才能访问其focus()函数吗?为了允许父组件访问组件focusOnForm()内部的函数,我们需要做类似的事情Comments

步骤2:在父组件中定义一个ref并将其传递给子组件

现在让我们回到父级。首先,我们定义一个新的 ref,名为。然后,我们将通过propcommentsFeedRef将 ref 赋值给组件,就像我们对元素所做的那样:Commentsrefinput



const Article = () => {
  ...
  const commentsFeedRef = useRef();
  ...
  return (
    ...
    <Comments
      comments={comments}
      onSubmitComment={addComment}
      ref={commentsFeedRef}
    />
  );
};


Enter fullscreen mode Exit fullscreen mode

如果这是 2018 年,并且我们的Comments组件是类组件,那么这完全没问题,我们也能顺利上手。但这是未来啊,伙计——这个Comments组件是函数组件。与类组件不同,函数组件在挂载时没有关联的组件实例。换句话说,我们无法通过默认属性访问函数组件的某个“实例” ref。我们还需要先做一些工作。

顺便说一句,简单地向 Comments 组件上现有的 props 添加一个ref属性也不起作用,因此以下方法也是不正确的:



const Comments = (props: {
  comments: [];
  onSubmitComment: (name: string, newComment: string) => void;
  ref,
}) => ...


Enter fullscreen mode Exit fullscreen mode

相反,我们必须使用forwardRefReact 提供的功能来将 ref 传递给我们的函数组件。

步骤 3:使用 forwardRef 允许将 ref 传递给子级

实现这一点有几种不同的方法,但我通常更喜欢这种方法,因为它非常简洁易懂。首先,我们需要将组件定义为命名函数,而不是赋值给常量的匿名函数:



function Comments (
  props: {
    comments: [];
    onSubmitComment: (name: string, newComment: string) => void;
  }
) {
  ...
  function focusOnForm () { ... }
  ...
}


Enter fullscreen mode Exit fullscreen mode

假设我们之前将此组件导出为模块级默认导出:



export default Comments;


Enter fullscreen mode Exit fullscreen mode

我们现在需要先将Comments组件传递给forwardRef高阶组件,然后导出结果:



export default React.forwardRef(Comments);


Enter fullscreen mode Exit fullscreen mode

接下来,我们将ref属性添加到Comments组件。但请注意,该ref属性与主要组件 props 是分开的:



function Comments (
  props: {
    comments: [];
    onSubmitComment: (name: string, newComment: string) => void;
  },
  ref
) {
  ...
  function focusOnForm () { ... }
  ...
}


Enter fullscreen mode Exit fullscreen mode

父组件现在可以将 ref 传递给Comments组件,并使用它来调用该focusOnForm()函数。当我们调用它时,我们可能会执行以下操作:



...
commentsFeedRef.current.focusOnForm();
...


Enter fullscreen mode Exit fullscreen mode

但这仍然行不通。怎么回事?

好吧,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,
      }
    }
  );
  ...
}


Enter fullscreen mode Exit fullscreen mode

我们向 传递了两个参数useImperativeHandle。第一个参数只是指示要暴露给父级的引用。

在第二个参数中,我们传递一个函数,该函数返回一个对象,该对象包含我们试图向父级公开的各种函数和属性。当父级访问作为第一个参数传入的 ref 的属性useImperativeHandle时,将返回此对象。current

我们可以简化它,像这样:



useImperativeHandle(
  ref,
  () => ({
    focusOnForm,
  })
);


Enter fullscreen mode Exit fullscreen mode

实际上还有第三个可选参数。你可以传入一个依赖项数组,useImperativeHandle当任何依赖项发生变化时,都会重新计算要返回的内容。如果你返回的内容受到子组件状态的影响,这将非常有用;例如:



const [someValue, setSomeValue] = useState<number>(...);
...
useImperativeHandle(
  ref,
  () => ({
    someFunction: (value) => value * someValue,
  }),
  [someValue]
);


Enter fullscreen mode Exit fullscreen mode

但目前我们不需要它。

现在,当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}
    />
  );
};


Enter fullscreen mode Exit fullscreen mode

这样,开发人员现在可以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>
  );
};


Enter fullscreen mode Exit fullscreen mode

太棒了!现在,无论何时再次显示评论提要,新评论表单中的“您的姓名”字段都将始终重新聚焦,即使该Comments组件尚未卸载并重新安装。


明智地使用它

归根结底,useImperativeHandle它并不常用,而且有充分的理由——它是一个逃生舱口、一个消防通道,是当其他选择失败或根本不可行时绝对的最后手段。

我偶尔遇到的情况useImperativeHandle是,当组件中有一个可滚动区域和一个按钮,让用户滚动回到顶部时。这很简单,只需获取相关元素(通过 ref 或查询document.querySelector),然后调用scrollTop = 0即可。但你肯定不希望开发人员每次实现相关组件时都必须编写这套逻辑——组件应该公开一些属性,并传入一个可以触发效果的值,对吧?

但你很快就会发现,对于命令式操作来说,传递值并没有多大意义。你会传递什么呢?一个onRequestScrollToTop带有值的布尔变量 ( ) true?这个变量会被重新设置为 吗false?父级组件会先短暂延迟,再将其重新设置为 吗falsesetTimeout或者,是否有一个回调函数 ( onScrollToTop),在滚动到顶部完成后执行,此时将相关变量设置为false?所有这些听起来都同样糟糕且没有必要。

像这样特殊且罕见的情况才useImperativeHandle真正值得重视,值得认真考虑。相反,如果你没有问过这类问题,那么你可能不用 也能完成你想要做的事情useImperativeHandle

还有一件事需要考虑:当你为他人创建组件并将其发布为开源工具时,你不可能提前预测它们的所有使用方式。以最大化其灵活性的方式构建组件有着明显的优势。例如,评论区里说:并没有规定组件必须用于折叠面板。或许,在某些罕见的情况下,添加组件useImperativeHandle可以让开发人员在特定情况下使用特定功能,而无需每次出现新的特殊情况时都被迫彻底修改原始组件。


补充阅读

文章来源:https://dev.to/anikcreative/react-hooks-explained-useimperativehandle-5g44
PREV
使用 Mergify 像专业人士一样合并拉取请求
NEXT
使用新的 Angular Clipboard CDK 与剪贴板进行交互