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

1,521 阅读13分钟

关于作者

作者:NADIA MAKAREVICH

原文:Refs in React: from access to DOM to imperative 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 的世界里,我们会这样做:

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

然后 focus 它:

element.focus();

或者 scroll 它:

element.scrollIntoView();

或其他任何我们想要的东西...

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

  • 在元素渲染后手动 focus 该元素,如表单中的输入框
  • 在显示弹出式元素时,检测组件外部的点击
  • 在元素出现在屏幕上后,手动滚动到该元素
  • 计算屏幕上组件的尺寸和边界,以正确定位工具提示等内容

虽然从技术上讲,即使在今天也没有什么可以阻止我们使用 getElementById,但 React 为我们提供了一种更强大的方法来访问该元素,而无需到处传播 id 或了解底层 DOM 结构,那就是: refs

Ref 只是一个可变对象,React 会在重新渲染时保留它的引用。它不会触发重新渲染,因此无论如何都不能替代状态,不要试图用它来替代 state。关于这两者之间区别的更多详情,请参阅文档

它通过 useRef hook 创建:

const Component = () => {
  // 用默认的 null 值来创建一个 ref
  const ref = useRef(null);

  return ...
}

存储在 Ref 中的值将在它的 "current"(也是唯一的)属性中可用。实际上,我们可以在其中存储任何内容!例如,我们可以将来自 state 的一些值存储到一个对象中:

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


  useEffect(() => {
    // 用一个新的 object 来重写 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 的时候,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 从父组件传递给子组件

当然,在现实生活中,我不会把所有东西都放在一个巨大的组件中。更有可能的情况是,我想把输入内容提取到自己的组件中:这样它就可以在多个表单中重复使用,并可以封装和控制自己的样式,甚至还可以有一些附加功能,比如在顶部有一个标签或在右侧有一个图标。

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

但错误处理和提交功能仍将存在于 "Form"中,而不是 input 中!

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,但问题是这只能奏效一次。

// 别在实际编码中这么干!我们只是在理论层面上举例示意
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:

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

  ...
}

InputField 组件将有一个接受 Ref 的 prop,并将像往常一样有一个接受 Ref 的 input 字段。只是 Ref 将不再在 InputField 中创建,而是从 prop 中获得:

const InputField = ({ inputRef }) => {
  // 其余代码保持一致

  // 从 prop 中获取 ref,传递到 input 上
  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 组件中的 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()?

const Form = () => {
  const inputRef = useRef(null);

  // 如何我们这么干,我们就会在控制台获得一条 warning
  return <InputField ref={inputRef} />
}

为了实现这一目标(译者注:即使用 ref 关键字而非上文中的 inputRef 等其他自定义的 key),我们需要向 React 发出信号:这个 ref 实际上是有意的,我们想用它做一些事情。

我们可以借助 forwardRef 函数来做到这一点:它接受我们的函数组件,并将 ref 属性作为组件函数的第二个参数注入,位置就在 prop 之后。

// 通常情况下,组件只接受 props
// 但现在我们用 forwardRef 函数包裹了组件,并且将第二个入参 ——ref,注入
// 这个参数就是由这个组件的使用者传入的
const InputField = forwardRef((props, ref) => {
  // 剩下的代码不变

  return <input ref={ref} />
})

我们甚至可以将上述代码拆分成两个变量,以提高可读性:

const InputFieldWithRef = (props, ref) => {
  // 剩下的代码不变
}

// 这个 InputField 将被 Form 使用
export const InputField = forwardRef(InputFieldWithRef);

现在,我们就可以在 Form 中像对待普通 DOM 元素一样将 ref 传递给 InputField 组件了:

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 动画来实现:

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 的知识。

// 这就是我们的 API 大致的样子
const InputFieldAPI = {
  focus: () => {
    // 做聚焦的工作
  },
  shake: () => {
    // 触发抖动
  }
}

useImperativeHandle hook 只是将上面的对象绑定到 Ref 对象的 current 属性,仅此而已。它是这样做的:

const InputField = () => {

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

第一个参数是我们的 Ref,它可以在组件中创建,也可以通过 prop 或 forwardRef 传递。第二个参数是一个返回对象的函数 —— 这就是可以通过 inputRef.current 访问的对象。第三个参数是依赖关系数组,与其他的 React hook 相同。

对于我们的组件,让我们以 apiRef prop 的形式显式传递 ref。剩下要做的就是实现实际的 API。为此,我们需要另一个 ref,这次是 InputField 的内部 ref,这样我们就可以将其附加到 input DOM 元素上,并像往常一样触发焦点:

// 传递我们将要使用的指令式 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 的更新

// 传递我们将要使用的指令式 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() 操作,而无需关心其内部实现!

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 即可,就像这样:

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

这几乎就是 useImperativeHandle 在引擎盖下所做的事情。它可以和使用 useImperativeHandle 的效果一模一样。

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

请看这里的最终示例


没错,一个带有抖动效果的炫酷表单已经准备就绪,React refs 不再神秘,React 中的指令式 API 也是一样。这很棒吧?

请记住 Refs 是一个 "逃生舱",它不能取代 state,也不能取代带有 prop 和回调的正常 React 数据流。只有在没有 「正常」 选择的情况下才使用它们。这与触发某事的指令式方法是一样的——您想要的更可能是正常的 prop/回调流程。