「好文翻译」React 中的 Refs,从操作 DOM 到指令式 API

前言

React 的众多优点之一就是它抽象化了处理真实 DOM 的复杂性。现在,我们不必手动查询元素,不必为如何为这些元素添加类而伤透脑筋,也不必为浏览器的不一致性而苦恼,我们只需编写组件,专注于用户体验即可。不过,在某些情况下(虽然很少!),我们仍然需要访问实际的 DOM。

而说到实际的 DOM,我们最需要了解和学习如何正确使用的,就是 Ref 以及围绕 Ref 的一切。因此,今天让我们来看看为什么我们首先要访问 DOM,Ref 如何帮助我们访问 DOM,什么是 useRefforwardRefuseImperativeHandle,并且如何正确使用它们。此外,让我们研究一下如何避免 使用 forwardRefuseImperativeHandle 而又能获得它们所提供的功能。如果你曾尝试弄清它们的工作原理,你就会明白我们为什么要这样做 😅

作为奖励,我们还将学习如何在 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/回调流程。

相关推荐
m0_748256783 分钟前
SpringBoot 依赖之Spring Web
前端·spring boot·spring
web1350858863531 分钟前
前端node.js
前端·node.js·vim
m0_5127446432 分钟前
极客大挑战2024-web-wp(详细)
android·前端
若川41 分钟前
Taro 源码揭秘:10. Taro 到底是怎样转换成小程序文件的?
前端·javascript·react.js
潜意识起点1 小时前
精通 CSS 阴影效果:从基础到高级应用
前端·css
奋斗吧程序媛1 小时前
删除VSCode上 origin/分支名,但GitLab上实际上不存在的分支
前端·vscode
IT女孩儿1 小时前
JavaScript--WebAPI查缺补漏(二)
开发语言·前端·javascript·html·ecmascript
m0_748256563 小时前
如何解决前端发送数据到后端为空的问题
前端
请叫我飞哥@3 小时前
HTML5适配手机
前端·html·html5
@解忧杂货铺5 小时前
前端vue如何实现数字框中通过鼠标滚轮上下滚动增减数字
前端·javascript·vue.js