likes
comments
collection
share

React Refs: 从访问 DOM 到命令式 API

作者站长头像
站长
· 阅读数 18

本文翻译自 Refs in React: from access to DOM to imperative API,主要介绍用 React Refs 访问 DOM 元素以及与其相关的内容。

React 的一个美妙之处在于它通过抽象降低了处理真实 DOM 的复杂程度。这让我们不必手动去查询元素,也不必思考如何向这些元素添加类,也不必再苦于浏览器的不一致性,而是让我们专心编写组件并专注于用户体验。然而,仍然会有一些场景(虽然很少!)需要我们访问 DOM。

而当涉及到 DOM 时,最重要的是理解并学习如何正确使用 Ref 以及它的周边。因此在本文中,我们要弄清楚为什么需要访问 DOM、Ref 如何帮助我们实现这一点、useRef、forwardRef 和 useImperativeHandle 是什么,以及如何正确使用它们。此外,我们还要讨论如何避免使用 forwardRef 和 useImperativeHandle,但是同时还要拥有它们提供的能力。

除了上述内容之外,我们还将学习如何在 React 中实现命令式 API!

在 React 中使用 useRef 访问 DOM

假设我想为我组织的会议实现一个注册表单。我希望在给人们发送详细信息之前,他们要先提供自己的姓名、电子邮件和 Twitter 账号。我想要“姓名”和“电子邮件”字段是必填的。但我不想在人们尝试提交空字段时显示一些恼人的红色边框,我希望表单很酷。因此,我希望聚焦空字段并将其稍微晃动一下以吸引注意,当然这些只是为了好玩。

虽然,React 给我们提供了很多东西,但它并没有提供一切。像“手动聚焦一个元素”这样的东西,并不是它的一部分。为此,需要使用原生 JavaScript API 技能,来访问实际的 DOM 元素。

在没有 React 的世界中,我们会做这样的事情:

const element = document.getElementById("bla");

然后我们进行手动聚焦:

element.focus();

或者进行滚动:

element.scrollIntoView();

在 React 世界中使用原生 DOM API 的一些典型场景包括:

  • 在渲染后手动聚焦元素,例如表单中的输入字段。
  • 在显示类似弹出窗口的元素时,检测组件外部的点击。
  • 在出现在屏幕上的元素后手动滚动到一个元素。
  • 计算屏幕上组件的大小和边界,以便在正确的位置展示一些内容,比如 tooltip。

尽管从技术上讲,我们现在仍然可以使用 getElementById。但是为了让我们既不需要在各个角落里设置 id,也不需要了解到底层 DOM 结构,React 提供了一种更强大的访问元素的方式:refs

Ref 只是一个可变对象,React 只在重新渲染期间保留对其的引用。它并不会触发重新渲染,因此它并不能替代 state,当然也不要试图用它来替代 state。关于这两者之间的区别的更多细节可以在文档中找到。

它是使用 useRef 钩子创建的:

const Component = () => {
  // 创建一个 ref,并将其默认值设置为 null
  const ref = useRef(null);

  return ...
}

而存储在 Ref 中的值只能通过 “current” 属性来访问。实际上,我们可以在其中存储任何东西!例如,我们可以存储一个包含一些来自 state 的值的对象:

const Component = () => {
  const ref = useRef(null);

  useEffect(() => {
    // 为 ref 赋值,并覆盖默认值
    ref.current = {
      someFunc: () => {...},
      someValue: stateValue,
    }
  }, [stateValue])


  return ...
}

或者,可以将这个 Ref 分配给任何 DOM 元素和一些 React 组件:

const Component = () => {
  const ref = useRef(null);

  // 将 ref 分配给一个 input 元素
  return <input ref={ref} />
}

现在,如果我在 useEffect 中打印 ref.current(它只有在组件呈现后才可用),它将输出一个 input 元素,其效果与 getElementById 一样:

const Component = () => {
  const ref = useRef(null);

  useEffect(() => {
    // 这将是一个指向 input DOM 元素的引用!
    // 它与我对其执行 getElementById 时得到的元素完全相同
    console.log(ref.current);
  });

  return <input ref={ref} />
}

现在,为了完全实现注册表单组件,我们可以这样做:

const Form = () => {
  const [name, setName] = useState('');
  const inputRef = useRef(null);

  const onSubmitClick = () => {
    if (!name) {
      // 如果有人尝试提交空 name,则聚焦该 input 字段
      ref.current.focus();
    } else {
      // 在此处提交数据!
    }
  }

  return <>
    ...
    <input onChange={(e) => setName(e.target.value)} ref={inputRef} />
    <button onClick={onSubmitClick}>提交表单!</button>
  </>
}

将 input 的值存储在 state 中,为所有 input 创建 Ref,在单击“提交”按钮时,将检查它们的值是否为空,如果为空,则聚焦所对应的 input。

将 ref 作为 prop 从父组件传递到子组件

在实际应用中,当然不会只创建一个上面那样的组件。更有可能的是,我们会将输入框提取出来成为一个单独的组件,以便在多个表单中重复使用;它可以封装和控制其自己的样式,甚至可以具有一些其他功能,例如在顶部添加标签或在右侧添加图标。

const InputField = ({ onChange, label }) => {
  return <>
    {label}<br />
    <input type="text" onChange={(e) => onChange(e.target.value)} />
  </>
}

但是,错误处理和提交功能仍将在 Form 组件中,而不是在 InputField 组件中。

const Form = () => {
  const [name, setName] = useState('');

  const onSubmitClick = () => {
    if (!name) {
      // 处理空名称
    } else {
      // 在此提交数据!
    }
  }

  return <>
    ...
    <InputField label="name" onChange={setName} />
    <button onClick={onSubmitClick}>提交表单!</button>
  </>
}

怎么才能告知 Form 组件中的输入框进行 “自我聚焦”?在 React 中控制数据和行为的 “正常” 方式是将 props 传递给组件并监听回调。我可以尝试将“focusItself” prop 传递给 InputField,我可以从 false 更改为true,但这只能工作一次。

// 这里只是为了方便演示,实际应用中不要这样做!
const InputField = ({ onChange, focusItself }) => {
  const inputRef = useRef(null);

  useEffect(() => {
    if (focusItself) {
      // 如果 focusItself prop 更改,将聚焦于 input
      // 仅在 false 更改为 true 时有效
      ref.current.focus();
    }
  }, [focusItself])

  // 其余代码相同
}

我可以添加一个 “onBlur” 回调,并在输入框失去焦点时将 focusItself prop 重置为 false。当然方法有很多种,接下来介绍另外一种方法。

我们可以在一个组件(Form)中创建 Ref,将其作为 prop 传递到另一个组件(InputField)中,并在那里将其附加到底层 DOM 元素上。在这里,Ref 只是一个可变对象。Form 中正常创建 Ref:

const Form = () => {
  // 在 Form 组件中创建 Ref
  const inputRef = useRef(null);

  ...
}

InputField 组件将具有一个接受 ref 的 prop,然后输入框 input 接受 ref。只是 Ref 不在 InputField 中创建的,而是来自的 props:

const InputField = ({ inputRef }) => {
  // 其余代码相同

  // 将prop中的ref传递给内部的输入组
  return <input ref={inputRef} ... />
}

Ref 是一种可变对象,正是为了这种场景而设计的。当我们将其传递给一个元素时,React 在底层会对其进行更改。要被更改的对象是在 Form 组件中声明的。因此,一旦 InputField 被渲染,Ref 对象就会被更改,我们的 Form 将能够访问 inputRef.current 中的 input DOM 元素:

const Form = () => {
  // 在 Form 组件中创建 Ref
  const inputRef = useRef(null);

  useEffect(() => {
    // 渲染在 InputField 内部的 “input” 元素将出现在此处
    console.log(inputRef.current);
  }, []);

  return (
    <>
      {/* 将 ref 作为 prop 传递给输入字段组件 */}
      <InputField inputRef={inputRef} />
    </>
  )
}

或者在 submit 回调中,我们可以调用 inputRef.current.focus(),完全与之前的代码相同。

使用 forwardRef 将 ref 从父组件传递给子组件

如果你想知道为什么我将 prop 命名为 inputRef 而不仅仅是 ref:这并不是那么简单。ref 不是一个真正的属性,它是一种“保留”名称。在旧的时代,当我们编写类组件时,如果我们将 ref 传递给一个类组件,这个组件的实例将是该 Ref 的 .current 值。

但是函数组件没有实例。所以,我们只会在控制台中得到一个警告:“函数组件无法被给予 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);

现在,表单可以像将 ref 传递给常规 DOM 元素一样,将 ref 传递给 InputField 组件:

return <InputField ref={inputRef} />

使用 forwardRef 还是只传递 ref 作为属性,只是个人偏好的问题:最终的结果是一样的。

用 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 的创造性解决方案,但它看起来很奇怪,会极大地复杂化表单。特别是考虑到我们通过 ref 处理聚焦,所以我们也有两个解决方案,用于处理完全相同的问题。如果我能在这里做些像 InputField.shake() 和 InputField.focus() 这样的事情就好了!

说到聚焦,为什么 Form 组件仍然需要处理 DOM API 才能触发它?难道这不应该是 InputField 的责任的吗?为什么表单甚至可以访问底层的 DOM 元素 - 它基本上泄漏了内部实现细节。表单组件不应该关心我们使用哪些 DOM 元素,或者我们是否甚至使用 DOM 元素或完全不同的东西。分离关注点,你懂的。

看起来是时候为我们的 InputField 组件实现一个合适的命令式 API 了。现在,React 是声明式的,并希望我们按照规定的方式编写代码。但有时我们需要以命令式的方式触发某些操作。React 可能会为此提供一个出路:useImperativeHandle 钩子。

这个钩子略微令人费解,我不得不两次阅读文档,并且尝试在实际的 React 代码中查看其实现,才真正理解它在做什么。但本质上,我们只需要两件事:决定我们的命令式 API 应该如何实现,以及要如何附加到的 Ref。对于我们的 input,它很简单:只需要 .focus() 和 .shake() 函数作为 API,我们已经了解所有关于 refs 的知识。

// 这是我们的 API 可能的样子
const InputFieldAPI = {
  focus: () => {
    // 在这里执行焦点魔法
  },
  shake: () => {
    // 触发抖动
  }
}

这个 useImperativeHandle 钩子只是将这个对象附加到 Ref 对象的 “current” 属性上,这是它的实现方式:

const InputField = () => {

  useImperativeHandle(someRef, () => ({
    focus: () => {},
    shake: () => {},
  }), [])

}

第一个参数 - 是我们的 Ref,它可以在组件自身中创建,通过 props 传递或通过 forwardRef 传递。第二个参数是一个返回对象的函数 - 这是将作为 inputRef.current 可用的对象。第三个参数与任何其他 React 钩子一样是依赖项数组。

对于我们的组件,让我们将 ref 作为 apiRef 属性明确传递。唯一剩下的事情就是实现实际的 API。为此,我们需要另一个 ref - 这次是 InputField 内部的,以便我们可以将其附加到输入 DOM 元素并像往常一样触发焦点:

// 将我们用作命令式 API 的 Ref 作为属性传递
const InputField = ({ apiRef }) => {
  // 创建另一个ref - 内部的 Input 组件
  const inputRef = useRef(null);

  // 将我们的API“合并”到 apiRef 中
  // 返回的对象将可用于作为 apiRef.current 使用
  useImperativeHandle(apiRef, () => ({
    focus: () => {
      // 仅在附加到 DOM 对象的内部 ref 上触发焦点
      inputRef.current.focus()
    },
    shake: () => {},
  }), [])

  return <input ref={inputRef} />
}

至于“抖动”,我们只需要触发状态更新:

// 将我们用作命令式 API 的 Ref 作为属性传递
const InputField = ({ apiRef }) => {
  // 记住我们用于抖动的状态吗?
  const [shouldShake, setShouldShake] = useState(false);

  useImperativeHandle(apiRef, () => ({
    focus: () => {},
    shake: () => {
      // 在这里触发状态更新
      setShouldShake(true);
    },
  }), [])

  return ...
}

最后一步!我们的表单只需创建一个引用,将其传递给 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}>Submit the form!</button>
  </>
}

不使用 useImperativeHandle 实现命令式 API

如果使用 useImperativeHandle hook 让你感到头痛,别担心,我也一样!但是我们实际上不必使用它来实现刚刚实现的功能。我们已经知道了 Refs 如何工作以及它们是可变的事实。因此,我们只需要将我们的 API 对象分配给所需 Ref 的 ref.current,就像这样:

const InputField = ({ apiRef }) => {
  useEffect(() => {
    apiRef.current = {
      focus: () => {},
      shake: () => {},
    }
  }, [apiRef])
}

这几乎与 useImperativeHandle 在底层执行的操作完全相同。而且它将像之前一样正常工作。

实际上,在这里使用 useLayoutEffect 也许更好,但这是另一篇文章的主题。现在,让我们使用传统的 useEffect。


太棒了,现在我们拥有了一个带有抖动效果的酷炫表单,React 的 refs 不再是一个神秘的问题,而且在 React 中使用命令式 API 真的是件很棒的事情。

但是请记住:Refs 只是一种“应急通道”,它并不能替代 state 或通过 props 和回调函数进行的正常 React 数据流。只有在没有“正常”替代方案的情况下才使用它们。同样,触发某些行为的命令式方式也是如此 —— 更多的场景是使用正常的 props/callbacks 流程来实现。