React Ref 的使用

1,021 阅读6分钟

React 提供了 refref 属性,让我们可以引用组件的实例或者原生 DOM 元素。使用 refref,可以在父组件中调用子组件暴露出来的方法,或者调用原生 element 的 API。通常,refref 被用作如下几种情况:

  • 管理焦点,文本选择或媒体播放。
  • 触发强制动画。
  • 集成第三方 DOM 库。

在类组件当中使用 ref

在类组件当中使用 ref 主要有两种方式:React.createRefReact.createRef回调函数回调函数。当然还有一种过时的方式,使用 String 类型的 Refs,现在已被弃用

React.createRef

通过 React.createRef() 可以创建一个 ref,然后在需要被引用的元素上面传入该 ref 属性,就能通过该 ref 控制这个元素了。

import React, { Component } from 'react';

class App extends Component {

  // 创建 ref
  inputRef = React.createRef(null);

  // 点击按钮时聚焦 input 框,通过 this.inputRef.current 获取 input 元素
  handleFocus() {
    this.inputRef.current.focus();
  }
  
  render(){
    return (
      <div className="app">
        // 在原生元素上面传入创建的 inputRef
        <input type="text" ref={this.inputRef} />
        <button onClick={() => this.handleFocus()}>聚焦</button>
      </div>
    )
  }
}

export default App

在上面的例子中:

  • 首先使用 React.createRef 创建了一个 inputRef
  • 然后在原生 input 元素上面传入该 inputRef 给 input 元素的 ref 属性
  • 为按钮注册点击事件,通过 this.inputRef.current 获取到 input 元素,并调用其 focus 方法

注意: 我们在获取元素的时候,需要加一个 .current 属性。

这样,当点击按钮时,输入框会自动获取焦点。为什么这样就能通过 inputRef 拿到对应的 element 元素了呢?

因为 React 在创建对应的 DOM 元素之后渲染该元素到页面之前,会将该 DOM 元素赋值给传入进来的 inputRef 的 current 属性。在页面渲染之前,就会将此 DOM 元素赋值给 inputRef 的 current 属性,点击按钮的时候,我们当然可以通过 this.inputRef.current 拿到对应的元素。

这一点怎么验证呢?后面的ref中回调函数的执行时机ref 中回调函数的执行时机中会进行验证。

当然,除了可以给原生元素传入 ref,还可以给类组件传入 ref。

import React, { Component } from 'react';

class App extends Component {
  inputRef = React.createRef(null);
  
  handleFillValue() {
    this.inputRef.current.fillHello();
  }
  
  render(){
    return (
      <div className="app">
        // 给类组件 Input 传入 inputRef
        <Input ref={this.inputRef} />
        <button onClick={() => this.handleFillValue()}>回填</button>
      </div>
    )
  }
}

class Input extends Component {

  state = {
    text: '',
  }

  fillHello() {
    this.setState({ text: 'hello world' });
  }

  render() {
    return (
      <input type="text" value={this.state.text} />
    );
  }
}

export default App

上面的例子中,给类组件传入了 inputRef,React 就会将类实例赋值给 inputRef 的 current 属性,我们就能拿到子组件 Input 的实例,并调用 fillHello 方法。

需要注意的是,对于函数组件,不能直接传入 ref,因为函数组件没有实例,如果要拿到函数子组件中的方法,可以通过 forwardRef 配合 useImperativeHandle 实现。

回调 Refs

除了上述的 React.createRef,还可以通过回调函数的形式给类组件或者原生元素传递 ref。

import { Component } from 'react';
import './App.css';

class App extends Component {
  state = { text: '' };
  inputRef = null;
  componentDidMount() {
    console.log('componentDidMount:', this.inputRef);
  }
  componentDidUpdate() {
    console.log('componentDidUpdate:', this.inputRef);
  }
  onFocus() {
    this.inputRef.focus();
  }
  render() {
    return (
      <div className='app'>
        <input
          // 通过回调函数的形式传递 ref
          ref={ e => {
            this.inputRef = e;
            alert(e);
          } }
          text={ this.state.text }
          onChange={ e => {
            this.setState({text: e.target.value});
          }}
        />
        <button onClick={() => this.onFocus()}>聚焦</button>
      </div>
    )
  }
}

export default App;

上面例子中,通过回调函数的形式拿到了 input 元素。我们将一个回调函数传给了 input 元素的 ref 属性,然后由 React 决定什么时候调用这个回调函数。当 input 元素被创建之后页面渲染之前,React 会调用该回调函数,并将创建的 input 元素当作参数。

只要我们在回调函数里,将参数 e 赋值给函数外部的变量 inputRef,就能通过 inputRef 获取到 input 元素了。当点击按钮时,调用 inputRef 的 focus 方法,实际上就是调用 input 元素的 focus 方法,就能聚焦于 input 框。

ref 中回调函数的执行时机

上面的代码中,我们在 ref 的回调函数中 alert 传入的元素 e,并分别在 componentDidMountcomponentDidUpdate 中打印了一些内容。

下面是首次进入页面时的截图:

可以看到,是先执行的 ref 回调函数中的 alert,并没有执行 componentDidMount 生命周期钩子中的 console,也没有渲染页面。当点击确定后,才执行 componentDidMount 生命周期钩子并渲染页面。

当点击聚焦按钮并输入字符之后,会调用 setState 触发组件更新。此时会先调用两次 ref 回调函数,弹出对话框的内容分别为 null[object HTMLInputElement]

这个时候并没有执行 componentDidUpdate 生命周期钩子,也没有渲染最新的页面。当点击确定后,才执行 componentDidUpdate 生命周期钩子,并渲染最新的页面。

所以可以得到结论:ref 回调函数的调用时机在页面渲染和 componentDidMount/componentDidUpdate 生命周期钩子之前,当然是在对应的元素创建之后,这样才能在回调函数中拿到最新的元素。

创建 element 元素 -> 执行 ref 回调函数 -> 执行 componentDidMount/componentDidUpdate 生命周期钩子 -> 渲染页面

实际上,ref 回调函数的调用时机在 getSnapshotBeforeUpdate 生命周期钩子之后,在 componentDidMount/componentDidUpdate 生命周期钩子之前。

image.png

那为什么在更新组件的时候,会调用两次 ref 回调函数呢?

这是因为当我们向 ref 属性传递内联函数时,更新前后两次的函数引用不一样,React 需要清空旧的 ref 并设置新的 ref。官方文档上面也有说明。

如果我不想它执行两次怎么办?不使用内联函数就行了。

在函数组件中使用 ref

函数组件没有实例,一般在函数组件中使用 ref:

  • 可以将 ref 转发到函数组件返回的类组件或者原生元素上面。
  • 可以与 useImperativeHandle 配合将函数组件中的方法暴露给父组件。

Refs 转发

与 class 组件不同的是,function 组件内部使用 useRef 来创建 ref,如果要给函数组件传 ref 属性,必须要使用 forwardRef 包裹这个函数组件。被 forwardRef 包裹的函数组件有两个参数,第一个是与普通函数组件一样的 props 属性,第二个是传给函数组件的 ref。

import { useRef, forwardRef } from 'react';
import './App.css';

function App() {
  const inputRef = useRef(null);
  const onFocus = () => {
    inputRef.current.focus();
  }
  return (
    <div className="app">
      <Input ref={inputRef} />
      <button onClick={() => onFocus()}>聚焦</button>
    </div>
  )
}

const Input = forwardRef((_props, ref) => {
  return (
    <>
      <input type="text" ref={ref} />
      <input type="text" defaultValue="without ref input" />
    </>
  )
});

export default App;

上面的例子中,将传给函数组件 Input 的 inputRef 转发给了其内部第一个原生组件 input,这样就能在父组件 App 中通过 inputRef 拿到第一个 input 元素,并调用其 focus 方法。

其实直接将 input 元素暴露给父组件并不明智,因为在这里父组件 App 只需要用到 input 元素的 focus 方法,而我却把整个 input 元素给暴露出去了,这样对于子组件 Input 来说,是不安全的,因为 Input 组件并不能保证它的父组件 App 会如何使用原生 input 元素,App 组件可能会直接删除原生 input 元素,即使 App 不删除,也并不能保证其他使用 Input 组件的组件不对其内部的原生 input 元素做一些破坏性行为

所以我们可以使用下面的方式暴露一些必要的方法给父组件,而非暴露整个 input 元素。

暴露子组件方法

使用 forwardRef 能够让函数组件接收 ref 参数,并配合 useImperativeHandle 将子组件的某些方法暴露给传进来的 ref,这样父组件 App 就不能直接拿到原生 input 元素了,只能拿到通过 useImperativeHandle 暴露出来的方法。

import { useRef, forwardRef, useImperativeHandle } from 'react';
import './App.css';

function App() {
  const inputRef = useRef(null);
  const onFocus = () => {
    inputRef.current.focus();
  }
  return (
    <div className="app">
      <Input ref={inputRef} />
      <button onClick={() => onFocus()}>聚焦</button>
    </div>
  )
}

const Input = forwardRef((_props, ref) => {
  const realRef = useRef(null);
  useImperativeHandle(ref, () => ({
    focus() {
      realRef.current.focus();
    }
  }));
  return (
    <>
      <input type="text" ref={realRef} />
      <input type="text" defaultValue="without ref input" />
    </>
  )
});

export default App;

上面的实现中,在 Input 函数组件中新创建了一个 realRef,使用这个 realRef 来获取对原生 input 元素的引用,这样就能将对原生 input 元素的管理权集中在 Input 函数组件中,而外部的父组件是拿不到原生 input 元素的。