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。在 componentDidMount 或 componentDidUpdate 触发前,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 元素或组件实例传给 ref 的 current 属性,并在组件卸载时置为 null 。ref 会在 componentDidMount 或 componentDidUpdate 生命周期钩子触发前更新。
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,直接赋予input的ref属性。比回调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也可以接受一个参数作为ref的current值。有个细微区别是createRef的current默认值是null,而useRef中默认是undefined。 - 性能:重新渲染时,
inputRef不会再被初始化。日志A和日志B都打印<input type="text" value="Hello Reac"></input>,并且其中value是当前最新值。 - 原理:和
useStateuseEffect一样,每个组件都有一个 “内存单元”,首次渲染的时候初始化,把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变量。 - 我们通过指定
ref为JSX属性,将其向下传递给<Input ref={ref}>。 React传递ref给forwardRef内函数(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传递。