拥抱React-Hooks(二)- useRef

Why & When Refs


在典型的 React 数据流中,DOM 元素的修改都是通过 M-V 的形式重新渲染。M 主要是组件的本地 state ,以及父组件传递给子组件的 props
但是,在某些情况下,你需要在典型数据流之外,手动使用/操作 DOM

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

这个时候,就不要用类似 document.getElementById() 这种原生方式了。React 提供了 Refs 这样一种方式,来访问 DOM 节点或在 render 方法中创建的 React 元素。

另外,refs 还可用于创建一个可变对象(mutable object),并且修改它不会触发组件更新。

但是,一定不能滥用 Refs。避免使用 refs 来做任何可以通过声明式渲染(Declarative,DOM随状态(数据)更新而更新)来完成的事情。

Class 组件中的 Refs


在类组件中,目前有三种方式来创建和使用Refs:

  • String类Refs: 官方表示会弃用的,过时的 API,还不支持函数组件。所以别再用了
  • 回调Refs:将 回调函数 作为 ref 的一种方式。
  • React.createRef: 这是 React@16.3 版本引入的 顶层 API

String类refs

命名一个 string 作为元素的ref属性,然后通过 this.refs[string] 的形式访问。我们来看一个媒体播放的示例:

import React from 'react';
class VideoPlay extends React.Component {
  constructor(props) {
    super(props);
  }
  componentDidMount () {
    // 使用refs
    this.refs.myVideo.play()
  }
  render() {
    // 创建refs
    return (
      <video 
        ref='myVideo' 
        src="https://media.w3.org/2010/05/sintel/trailer.mp4" 
        playsinline="" 
      />
    );
  }
}

过时 API,了解一下即可。再次强调:不要再这样使用

备注:本篇文章的示例验证,都是使用 React 16.12.0 版本。

回调Refs

你可以传递一个函数作为 ref。这个函数中接受 React 组件实例或 DOM 元素作为参数,以便它们能在其他地方被存储和访问。
React 在组件挂载时,会调用 ref 回调函数并传入 DOM 元素,当卸载时调用它并传入 null。在 componentDidMountcomponentDidUpdate 触发前,React 会保证 ref 一定是最新的。

上面的例子用 回调Refs 实现如下:

import React from 'react';
class VideoPlay extends React.Component {
  constructor(props) {
    super(props);
  }
  componentDidMount () {
    // 使用refs
    this.myVideo.play()
  }
  render() {
    // 创建refs
    return (
      <video 
        ref={ (elem) => { this.myVideo = elem } }
        src="https://media.w3.org/2010/05/sintel/trailer.mp4" 
        playsinline="" 
      />
    );
  }
}

React.createRef

React 16.3 版本新增的这个顶层 API,用于创建 ref。组件挂载时,会把 DOM 元素或组件实例传给 refcurrent 属性,并在组件卸载时置为 nullref 会在 componentDidMountcomponentDidUpdate 生命周期钩子触发前更新。

ref 的值根据它所赋予的节点的类型有所不同:

  • ref 属性用于 DOM 元素时,ref 接收底层 DOM 元素作为其 current 属性 :
import React from 'react';
class APP extends React.Component {
  constructor(props) {
    super(props);
    // 创建一个 ref 来存储 textInput 的 DOM 元素
    this.textInput = React.createRef();
  }

  focusTextInput = () => {
    // 直接使用原生 API 使 text 输入框获得焦点
    // 注意:我们通过 "current" 来访问 DOM 节点
    this.textInput.current.focus();
  }

  render() {
    // 告诉 React 我们想把 <input> ref 关联到
    // 构造器里创建的 `textInput` 上
    return (
      <div>
        <input
          type="text"
          ref={this.textInput} />
        <input
          type="button"
          value="Focus the text input"
          onClick={this.focusTextInput}
        />
      </div>
    );
  }
}
export default APP;
  • ref 属性用于自定义 class 子组件时,ref 对象接收 组件的挂载实例 作为其 current 属性。
import React from "react";
class APP extends React.Component {
  constructor(props) {
    super(props);
    // 创建一个 ref 来存储 子组件的实例
    this.textInput = React.createRef();
  }

  focusTextInput = () => {
    // 注意:这里 “ref” 的 "current" 是子组件的实例 
    // 所以可以调用实例的 “setValue” 方法
    this.textInput.current.setValue("hello");
  };

  render() {
    // 告诉 React 我们想把 <input> ref 关联到
    // 构造器里创建的 `textInput` 上
    return (
      <div>
        <InputText ref={this.textInput} />
        <input
          type="button"
          value="Focus the text input"
          onClick={this.focusTextInput}
        />
      </div>
    );
  }
}
// 注意 仅在 InputText 组件声明为 class 的时候有用
class InputText extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      value: ""
    };
  }
  // 演示用的实例方法,没现实意义
  setValue = value => {
    this.setState({ value });
  };
  render() {
    return <input type="text" value={this.state.value} />;
  }
}
export default APP;

如果子组件通过 import InputText from "./InputText" 的方式引入,InputText 就是子组件的实例。那这种 refs 作用于子组件的使用方式意义何在?可能用 refs 显得更语义化,规范化。比如使用了多个 InputText 的情况。

注意: 你不能在函数组件上像这样使用 ref 属性,因为函数组件没有实例。不过可以通过forwardRef转发refs使用,这个后面会讲到。

函数组件中的 Refs


首先,我们思考这样一个问题:类组件中三种 refs 的使用方式能否在函数组件中应用。我们来逐个分析一下。

首先,String类refs。不能!官方明确表示不支持函数组件,也是过时 API, Just forget about it。

其次,我们在函数组件中应用一下 回调refs,比如写一个自动聚焦的 input组件:

import React, { useState, useEffect } from "react";

function App() {
  const [value, setValue] = useState("hellow hooks");
  let inputRef = null;

  console.log(inputRef);// 日志A
  useEffect(() => {
    console.log(inputRef); //日志B
    inputRef.focus();
  });

  function inputHandler(val) {
    setValue(val.target.value);
  }

  return (
    <>
      <input
        type="text"
        value={value}
        ref={elem => {
          inputRef = elem;
        }}
        onChange={inputHandler}
      />
    </>
  );
}
export default App;
  • 上面是一个简单的 input 输入组件。

  • 通过 回调refs 创建一个 ref( 对应 input元素) 赋值给 inputRef 变量。

  • 使用 useEffect 实现自动 focus功能。这里没有加依赖项 [] 是想看看组件重新渲染以后,refs的变化。

  • 发现功能一切正常。每次渲染,日志A 为 null,日志B 为 <input type="text" value="hellow hooks"></input>(其中 value 是当前最新值)。

以上,我们完全可以得出一个结论:至少在函数组件内部,回调refs 完全可用。但是从日志A日志B看出,每次渲染都会通过回调refs 的方式重新创建一个 ref 赋值给 inputRef 变量。明显存在性能问题。

让我们再试试createRef,还是上面的例子:

import React, { useState, useEffect } from "react";

function App() {
  const [value, setValue] = useState("hellow hooks");
  //创建 refs
  const inputRef = React.createRef();
  // 日志A
  console.log(inputRef.current || null) 
  useEffect(() => {
    // 日志B
    console.log(inputRef.current);
    inputRef.current.focus();
  });

  function inputHandler(val) {
    setValue(val.target.value);
  }

  return (
    <>
      <input type="text" value={value} ref={inputRef} onChange={inputHandler} />
    </>
  );
}
export default App;
  • 使用 createRef 创建一个 inputRef,直接赋予 inputref 属性。比 回调refs 优美一些。
  • 功能正常。日志跟回调refs 一样。

结论:和 回调refs 一样 react.createRef 同样可以在函数组件内部使用。当然也有同样的性能问题。

网上有看到文章说在函数组件内部,使用 react.createRef 永远获取不到 refs 。这和我的验证不符,可能是由于 React 版本差异。

这里有一点需要说明,上面的两个示例之所以有性能问题,是因为我们在函数组件中使用了 hooks。而我们使用的两种 refs 并没有 hooks 特性。所以,我们需要更好的选择。

useRef hooks


更好的选择那就是 useRef hooks,我们将要学习的新的 React Hooks,也是今天的主角。
createRef相比,就是改用 useRef 这个 hooks API 来创建 ref,使用上并无差别。

还是同样的示例:

import React, { useState, useEffect, useRef } from "react";

function App() {
  const [value, setValue] = useState("hellow hooks");
  //创建 refs 只有这里有修改
  const inputRef = useRef();

  // 日志A
  console.log(inputRef.current) 
  useEffect(() => {
    // 日志B
    console.log(inputRef.current);
    inputRef.current.focus();
  });

  function inputHandler(val) {
    setValue(val.target.value);
  }

  return (
    <>
      <input type="text" value={value} ref={inputRef} onChange={inputHandler} />
    </>
  );
}
export default App;
  • API:和 createRef 非常类似。useRef 也可以接受一个参数作为 refcurrent值。有个细微区别是 createRefcurrent 默认值是 null,而 useRef 中默认是 undefined
  • 性能:重新渲染时,inputRef 不会再被初始化。日志A日志B 都打印 <input type="text" value="Hello Reac"></input>,并且其中 value 是当前最新值。
  • 原理:和 useState useEffect 一样,每个组件都有一个 “内存单元”,首次渲染的时候初始化,把Hooks的数据按顺序存入各个内存单元格,重新渲染的时候再按顺序依次从单元格读取。

暴露 Ref 给父组件

在实际复杂业务场景中,你可能希望在父组件中引用子节点的 DOM 节点。也就是我们不光需要 Ref,还要把 Ref 的控制权也交给父组件。

官方不建议如此,因为它会打破组件的封装,逻辑分散。所以我们要慎用,然后该用还得用。

当你决定如此,这里有三种办法:

  • ref属性传递: 支持所有 React 版本,不支持函数子组件。不推荐
  • props传递: 支持所有 React 版本,也支持函数子组件。
  • 转发refs: 支持 React16.3及以上版本。也支持函数子组件。

父组件是类组件还是函数组件,只决定你是用createRef 还是 useRef 创建 refs

ref属性传递

前面在介绍 createRef 的时候有提到:当 ref 属性用于自定义 class 子组件时,ref 对象接收组件的挂载实例 作为其 current 属性。

注意了,我们获取到的是子组件的实例,而不是具体的 DOM 元素。这个时候,你想操作子组件,还需要在子组件也创建 ref, 并把相关操作封装成一个方法供父组件调用。这也是不推荐这种方式的原因。

比如在某些场景,我们想在父组件让子组件中的 input 输入框聚焦:

import React from "react";

class APP extends React.Component {
  constructor(props) {
    super(props);
    // 创建一个 ref 来存储 子组件的实例
    this.inputText = React.createRef();
  }
  // 按钮事件处理中,实现聚焦。
  // 你也可以在 componentDidMount 中处理,实现自动聚焦。
  focusInputText = () => {
    // 注意:这里 “ref” 的 "current" 是子组件的实例 
    // 只能通过调用实例的 doFocus 方法达到控制目的
    this.inputText.current.doFocus();
    // 更不优雅的方式如下
    this.inputText.current.inputRef.current.focus();
  };

  render() {
    return (
      <>
        <InputText ref={this.inputText} />
        <input
          type="button"
          value="Focus the text input"
          onClick={this.focusInputText}
        />
      </>
    );
  }
}
// 注意 仅在 InputText 组件声明为 class 的时候有用
class InputText extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      value: ""
    };
    // 子组件也需要创建一个ref
    this.inputRef = React.createRef();
  }
  // 提供一个给父组件调用的方法
  doFocus = () => {
    this.inputRef.current.focus();
  };
  render() {
    return <input type="text" value={this.state.value} ref={this.inputRef} />;
  }
}
export default APP;

缺点很明显:

  • 不支持函数子组件,很致命。
  • 父子组件都需要创建 refs,性能不佳。

props传递

顾名思义,既然 ref 属性作用于子组件,是返回整个组件实例。那么我们可以通过 props 传递 refs。你只需要给它起一个独一无二的名字。

上面的示例修改后是这样的:

import React from "react";
class APP extends React.Component {
  constructor(props) {
    super(props);
    // 创建一个 ref 来存储 子组件的实例
    this.inputRef = React.createRef();
  }
  // 按钮事件处理中,实现聚焦。
  focusInputText = () => {
    // 注意:这里 “ref” 的 "current" 在子组件挂载的时候 已经指向 input 元素
    this.inputRef.current.focus();
  };

  render() {
    return (
      <>
        <InputText inputRef={this.inputRef} />
        <input
          type="button"
          value="Focus the text input"
          onClick={this.focusInputText}
        />
      </>
    );
  }
}
// 注意 这里用函数组件也同样可以
class InputText extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      value: ""
    };
    // ref 通过参数获取
    this.inputRef = props.inputRef;
  }
  render() {
    return <input type="text" value={this.state.value} ref={this.inputRef} />;
  }
}
export default APP;

复制上述代码到 codesandbox 验证,功能ok。发现没有,代码相对于 “ref属性”,精简优雅了许多。而且,也支持函数组件。

我们用 函数组件 + hooks 来实现上述示例,并且使用 useEffect 实现自动focus

import React, { useState, useEffect, useRef } from 'react';
const App = () => {
  const [greeting, setGreeting] = useState('Hello React!');
  const ref = useRef();
  useEffect(() => ref.current.focus(), []);
  const handleChange = event => setGreeting(event.target.value);
  return (
    <div>
      <h1>{greeting}</h1>
      <Input value={greeting} handleChange={handleChange} inputRef={ref} />
    </div>
  );
};
const Input = ({ value, handleChange, inputRef }) => (
  <input
    type="text"
    value={value}
    onChange={handleChange}
    ref={inputRef}
  />
);
export default App;

转发refs - forwardRef

  • Ref转发 是通过 React 提供的一个顶层 API - forwardRef 来实现。

  • forwardRef 通过包装子组件的形式,允许子组件能接收 ref 作为第二个参数,并将其向下传递(换句话说,“转发” 它)给子组件。

  • 前面提到过,将 ref 作为子组件的 JSX 属性,是没法把 ref 传递下去。函数组件不支持,类组件也只能获取到子组件的实例。现在通过 forwardRef 包装即可实现。

    请看示例:

import React, {
  useState,
  useEffect,
  useRef,
  forwardRef,
} from 'react';

const App = () => {
  const [greeting, setGreeting] = useState('Hello React!');
  const handleChange = event => setGreeting(event.target.value);
  const ref = useRef();
  useEffect(() => ref.current.focus(), []);
  return (
    <div>
      <h1>{greeting}</h1>
      <Input value={greeting} handleChange={handleChange} ref={ref} />
    </div>
  );
};

const Input = forwardRef(({ value, handleChange }, ref) => (
  <input
    type="text"
    value={value}
    onChange={handleChange}
    ref={ref}
  />
));

export default App;

以下是对上述示例发生情况的逐步解释:

  • 我们通过调用 React.useRef 创建了一个 ref 并将其赋值给 ref 变量。
  • 我们通过指定 refJSX 属性,将其向下传递给 <Input ref={ref}>
  • React 传递 refforwardRef 内函数 (props, ref) => ...,作为其第二个参数。
  • 我们向下转发该 ref 参数到 <input ref={ref}>,将其指定为 JSX 属性
  • ref 挂载完成,ref.current 将指向 <input/> DOM 节点。

forwardRef + useImperativeHandle

我们通过 forwardRef 进行 refs转发,并配合 useImperativeHandle hooks,可以将函数子组件的指定元素和方法暴露给父组件使用。这在很多稍复杂的业务场景非常有用。

API 可抽象为: useImperativeHandle(refParam, arrowFunction, [depsArr])

  • refParam: 必须。通过 forwardRef转发的父组件传递的 ref,也就是forwardRef里函数的第二个参数。
  • arrowFunction: 必须。回调函数,该函数返回的对象将暴露给父组件访问。
  • depsArr: 非必须。一个依赖项数组。当依赖项改变的时候会重新调用 arrowFunction

我们改一下上面的示例,让父组件可以直接调用子组件的方法来聚焦子组件中的 input

import React, {
  useState,
  useEffect,
  useRef,
  forwardRef,
  useImperativeHandle
} from 'react';

const App = () => {
  const [greeting, setGreeting] = useState('Hello React!');
  const handleChange = event => setGreeting(event.target.value);
  const ref = useRef();

  useEffect(() => ref.current.doFocus(), []);

  return (
    <div>
      <h1>{greeting}</h1>
      <Input value={greeting} handleChange={handleChange} ref={ref} />
    </div>
  );
};

const Input = forwardRef( ({ value, handleChange }, ref) => {
  const inputRef = useRef();

  useImperativeHandle(ref, () => ({ doFocus }), []);

  const doFocus = () => {
    inputRef.current.focus();
  }

  return (
    <input
      type="text"
      value={value}
      onChange={handleChange}
      ref={inputRef}
    />
  )
});

export default App;

可变对象

前面提到,refs 不光可以用于引用和操作Dom,还可以用于创建可变对象。
我们在函数组件中使用useRef hook 来创建一个看看:

import React, { useState, useRef } from "react";
function App() {
  const valRef = useRef(0);// React.createRef don't work
  let normalVal = 0;
  const [, setChange] = useState();
  return (
    <div style={{ padding: "100px 200px" }}>
      refValue: {valRef.current} | 
      normalValue: {normalVal}
      <button
        onClick={() => {
          valRef.current += 80;
          normalVal += 1;
          setChange({});
        }}
      >
        +
      </button>
    </div>
  );
}
export default App;
  • 可变对象的改变,不会触发组件的重新渲染。所以示例增加调用了 setChange({}) 来触发组件重新渲染,来展示可变对象的变化。
  • 点击 + 号,你会发现 refValue 会不断递增,而 normalValue 始终为0.
  • 这是一个有趣的功能,暂时还不知道会在哪些场景会用到它。了解一下即可。

最佳实践

所谓的最佳实践,也就是对一些规则的总结:

  • 尽量在必要的时候使用refs,除了个别需要手动获取/操作Dom的场景:管理焦点,媒体播放,文本选择,触发强制动画,集成第三方 DOM 库等。
  • 不要再使用String类refs,类似 this.refs.XX 这种形式。
  • 类组件中:React16.3 及以上版本建议使用React.createRef 这个API,低版本使用回调refs
  • 函数组件中:为性能考虑,建议都使用 useRef 这个 hook。即便它无状态,无副作用。
  • 对于 refs 在父子组件间传递的情况,慎用但该用还得用,然后首推 refs转发,其次 props传递

参考资料
官网-refs
官网-refs转发
官网-状态提升
React Function Components