React 入门系列(二)Ref

上一篇博客我大概记录了一下react官方文档中关于核心概念的一些内容和想法,很多内容都是我基于对Vue的理解基础上的猜测。

从这篇博客开始继续去阅读高级指引,本次博客的主题就是React中的Ref。

Ref的作用

Refs 提供了一种方式,允许我们访问 DOM 节点或在 render 方法中创建的 React 元素。

在典型的 React 数据流中,props 是父组件与子组件交互的唯一方式。要修改一个子组件,你需要使用新的 props 来重新渲染它。但是,在某些情况下,你需要在典型数据流之外强制修改子组件。被修改的子组件可能是一个 React 组件的实例,也可能是一个 DOM 元素。对于这两种情况,React 都提供了解决办法。

上面这段话摘自官方文档,简单来说,就是正常情况下,我们只能通过修改state或者prop来触发组件的重新渲染,但是,Ref提供了一种新的方式,可以让你直接去修改自定义组件或者DOM。

何时使用 Refs

下面是几个适合使用 refs 的情况:

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

避免使用 refs 来做任何可以通过声明式实现来完成的事情。

举个例子,避免在 Dialog 组件里暴露 open()close() 方法,最好传递 isOpen 属性。

什么是声明式

上面这段话里面提到了声明式,那么什么是声明式呢?

简单来说,就是一种编程范式,它和命令式变成,函数式编程,面向对象编程是同级的概念。

所谓的声明式,就是表明目的,但是不具体指定怎么做。

就像文档中的这个例子,open()是声明式的编程范式,它只说明了目的,而不具体指定怎么做,如果是isOpen=true,那就是命令式的,就是告诉怎么做了,做法就是把isOpen置为true。

具体的可以去看一下:https://segmentfault.com/a/1190000015924762

勿过度使用 Refs

你可能首先会想到使用 refs 在你的 app 中“让事情发生”。如果是这种情况,请花一点时间,认真再考虑一下 state 属性应该被安排在哪个组件层中。通常你会想明白,让更高的组件层级拥有这个 state,是更恰当的。查看 状态提升 以获取更多有关示例。

如何使用Ref

创建 Refs

Refs 是使用 React.createRef() 创建的,并通过 ref 属性附加到 React 元素。在构造组件时,通常将 Refs 分配给实例属性,以便可以在整个组件中引用它们。

1
2
3
4
5
6
7
8
9
class MyComponent extends React.Component {
constructor(props) {
super(props);
this.myRef = React.createRef();
}
render() {
return <div ref={this.myRef} />;
}
}

访问 Refs

当 ref 被传递给 render 中的元素时,对该节点的引用可以在 ref 的 current 属性中被访问。

1
const node = this.myRef.current;

ref 的值根据节点的类型而有所不同:

  • ref 属性用于 HTML 元素时,构造函数中使用 React.createRef() 创建的 ref 接收底层 DOM 元素作为其 current 属性。
  • ref 属性用于自定义 class 组件时,ref 对象接收组件的挂载实例作为其 current 属性。
  • 你不能在函数组件上使用 ref 属性,因为他们没有实例。

为 DOM 元素添加 ref

以下代码使用 ref 去存储 DOM 节点的引用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class CustomTextInput extends React.Component {
constructor(props) {
super(props);
// 创建一个 ref 来存储 textInput 的 DOM 元素
this.textInput = React.createRef();
this.focusTextInput = this.focusTextInput.bind(this);
}

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>
);
}
}

React 会在组件挂载时给 current 属性传入 DOM 元素,并在组件卸载时传入 null 值。ref 会在 componentDidMountcomponentDidUpdate 生命周期钩子触发前更新。

通过这句话,我们可以大致猜测一下:

  • 在react的源码中,当通过html-parser解析到这里的时候,应该是有一段判断当前组件是否是原生DOM的逻辑,如果是,那么就把这个DOM赋值给this.textInput.current
  • 在react的组件生命周期执行序列之间,应该有一段逻辑是去更新ref的。这样才能保证ref 会在 componentDidMountcomponentDidUpdate 生命周期钩子触发前更新。

为 class 组件添加 Ref

如果我们想包装上面的 CustomTextInput,来模拟它挂载之后立即被点击的操作,我们可以使用 ref 来获取这个自定义的 input 组件并手动调用它的 focusTextInput 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
class AutoFocusTextInput extends React.Component {
constructor(props) {
super(props);
this.textInput = React.createRef(); }

componentDidMount() {
this.textInput.current.focusTextInput(); }

render() {
return (
<CustomTextInput ref={this.textInput} /> );
}
}

请注意,这仅在 CustomTextInput 声明为 class 时才有效:

1
2
class CustomTextInput extends React.Component {  // ...
}

通过这个例子,我们可以回头理解刚才我们说的:当 ref 属性用于自定义 class 组件时,ref 对象接收组件的挂载实例作为其 current 属性。

也就是说在这个AutoFocusTextInput组件中,我们又创建了一个新的ref,虽然还叫作textInput,但是这个时候的textInput.current指向的就是CustomTextInput真正挂载的实例,也就是new CustomTestInput()这个实例(这里我们可以大胆猜测下,背后对应的逻辑,应该是new一个实例出来),那么好,当前我们的textInput.current就是我们刚创建的这个实例,我们就可以调用这个实例上的方法,也就是focusTextInput()

Refs 与函数组件

默认情况下,你不能在函数组件上使用 ref 属性,因为它们没有实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
function MyFunctionComponent() {  return <input />;
}

class Parent extends React.Component {
constructor(props) {
super(props);
this.textInput = React.createRef(); }
render() {
// This will *not* work!
return (
<MyFunctionComponent ref={this.textInput} /> );
}
}

从这段来看,上面所说的自定义组件其实是调用了new的说法可能是有问题的。

以为,如果是直接调用的new,那么只有两种解释:

  • 第一,new Class和new Function不同,这一点我过一段时间回去看看class的源码再回来填坑。
  • 第二,那就是react源码对于Class和Function做了不同的处理,所以Function的声明方式没有创建实例出来。

如果要在函数组件中使用 ref,你可以使用 forwardRef(可与 useImperativeHandle 结合使用),或者可以将该组件转化为 class 组件。

不管怎样,你可以在函数组件内部使用 ref 属性,只要它指向一个 DOM 元素或 class 组件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function CustomTextInput(props) {
// 这里必须声明 textInput,这样 ref 才可以引用它 const textInput = useRef(null);
function handleClick() {
textInput.current.focus(); }

return (
<div>
<input
type="text"
ref={textInput} /> <input
type="button"
value="Focus the text input"
onClick={handleClick}
/>
</div>
);
}

这里的forwardRef等会讲Ref转发的时候再说。

将 DOM Refs 暴露给父组件

在极少数情况下,你可能希望在父组件中引用子节点的 DOM 节点。通常不建议这样做,因为它会打破组件的封装,但它偶尔可用于触发焦点或测量子 DOM 节点的大小或位置。

虽然你可以向子组件添加 ref,但这不是一个理想的解决方案,因为你只能获取组件实例而不是 DOM 节点。并且,它还在函数组件上无效。

如果你使用 16.3 或更高版本的 React, 这种情况下我们推荐使用 ref 转发Ref 转发使组件可以像暴露自己的 ref 一样暴露子组件的 ref。关于怎样对父组件暴露子组件的 DOM 节点,在 ref 转发文档中有一个详细的例子。

如果你使用 16.2 或更低版本的 React,或者你需要比 ref 转发更高的灵活性,你可以使用这个替代方案将 ref 作为特殊名字的 prop 直接传递。

可能的话,我们不建议暴露 DOM 节点,但有时候它会成为救命稻草。注意这个方案需要你在子组件中增加一些代码。如果你对子组件的实现没有控制权的话,你剩下的选择是使用 findDOMNode(),但在严格模式 下已被废弃且不推荐使用。

回调 Refs

React 也支持另一种设置 refs 的方式,称为“回调 refs”。它能助你更精细地控制何时 refs 被设置和解除。

不同于传递 createRef() 创建的 ref 属性,你会传递一个函数。这个函数中接受 React 组件实例或 HTML DOM 元素作为参数,以使它们能在其他地方被存储和访问。

下面的例子描述了一个通用的范例:使用 ref 回调函数,在实例的属性中存储对 DOM 节点的引用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class CustomTextInput extends React.Component {
constructor(props) {
super(props);

this.textInput = null;
this.setTextInputRef = element => { this.textInput = element; };
this.focusTextInput = () => { // 使用原生 DOM API 使 text 输入框获得焦点
if (this.textInput) this.textInput.focus();
};
}

componentDidMount() {
// 组件挂载后,让文本框自动获得焦点
this.focusTextInput();
}

render() {
// 使用 `ref` 的回调函数将 text 输入框 DOM 节点的引用存储到 React
// 实例上(比如 this.textInput)
return (
<div>
<input
type="text"
ref={this.setTextInputRef} />
<input
type="button"
value="Focus the text input"
onClick={this.focusTextInput} />
</div>
);
}
}

React 将在组件挂载时,会调用 ref 回调函数并传入 DOM 元素,当卸载时调用它并传入 null。在 componentDidMountcomponentDidUpdate 触发前,React 会保证 refs 一定是最新的。

这里有小伙伴可能会钻牛角尖,凭什么传入一个函数,就会被调用啊。

答案很简单,React源码中对于ref属性肯定是有特殊处理的,如果它的值是一个函数,回去调用它。

如果这样猜想,那你其实传进去任何函数,他都会执行,不一定要在函数中设置ref,在ref属性传入的函数中设置一个ref,应该只是约定而已。

你可以在组件间传递回调形式的 refs,就像你可以传递通过 React.createRef() 创建的对象 refs 一样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function CustomTextInput(props) {
return (
<div>
<input ref={props.inputRef} /> </div>
);
}

class Parent extends React.Component {
render() {
return (
<CustomTextInput
inputRef={el => this.inputElement = el} />
);
}
}

在上面的例子中,Parent 把它的 refs 回调函数当作 inputRef props 传递给了 CustomTextInput,而且 CustomTextInput 把相同的函数作为特殊的 ref 属性传递给了 <input>。结果是,在 Parent 中的 this.inputElement 会被设置为与 CustomTextInput 中的 input 元素相对应的 DOM 节点。

关于回调 refs 的说明

如果 ref 回调函数是以内联函数的方式定义的,在更新过程中它会被执行两次,第一次传入参数 null,然后第二次会传入参数 DOM 元素。这是因为在每次渲染时会创建一个新的函数实例,所以 React 清空旧的 ref 并且设置新的。通过将 ref 的回调函数定义成 class 的绑定函数的方式可以避免上述问题,但是大多数情况下它是无关紧要的。

Ref转发

Ref 转发是一项将 ref 自动地通过组件传递到其一子组件的技巧。对于大多数应用中的组件来说,这通常不是必需的。但其对某些组件,尤其是可重用的组件库是很有用的。最常见的案例如下所述。

转发 refs 到 DOM 组件

考虑这个渲染原生 DOM 元素 buttonFancyButton 组件:

1
2
3
4
5
6
7
function FancyButton(props) {
return (
<button className="FancyButton">
{props.children}
</button>
);
}

React 组件隐藏其实现细节,包括其渲染结果。其他使用 FancyButton 的组件通常不需要获取内部的 DOM 元素 buttonref。这很好,因为这防止组件过度依赖其他组件的 DOM 结构。

虽然这种封装对类似 FeedStoryComment 这样的应用级组件是理想的,但其对 FancyButtonMyTextInput 这样的高可复用“叶”组件来说可能是不方便的。这些组件倾向于在整个应用中以一种类似常规 DOM buttoninput 的方式被使用,并且访问其 DOM 节点对管理焦点,选中或动画来说是不可避免的。

Ref 转发是一个可选特性,其允许某些组件接收 ref,并将其向下传递(换句话说,“转发”它)给子组件。

在下面的示例中,FancyButton 使用 React.forwardRef 来获取传递给它的 ref,然后转发到它渲染的 DOM button

1
2
3
4
5
6
7
8
9
const FancyButton = React.forwardRef((props, ref) => (  
<button ref={ref} className="FancyButton">
{props.children}
</button>
));

// 你可以直接获取 DOM button 的 ref:
const ref = React.createRef();
<FancyButton ref={ref}>Click me!</FancyButton>;

这样,使用 FancyButton 的组件可以获取底层 DOM 节点 button 的 ref ,并在必要时访问,就像其直接使用 DOM button 一样。

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

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

注意

第二个参数 ref 只在使用 React.forwardRef 定义组件时存在。常规函数和 class 组件不接收 ref 参数,且 props 中也不存在 ref

Ref 转发不仅限于 DOM 组件,你也可以转发 refs 到 class 组件实例中。