React refs使用指南

2023 review:

  • ref 相对于 state就像CSS的 NF,Position 相对 于Flexbox。Flexbox 对完成一常规任务(UI 动态布局)实在太好用了,但也有不适用的地方,至少 Position会更合适。同样,React state V 也很好用,但也不宜用来模仿所有的 交互操作。
  • 前端 UI交互 在技术上 都是 DOM(BOM)编程 ——用JS 命令操作 文档对象模型,只是有相当一部分(或一类交互)功能适用 状态(双向绑定)自动实现。
  • 理解「交互功能」——分交互计算和交互辅助——是关键,交互计算几乎都适用 **状态响应(双向绑定)**表达,交互辅助则比较适用 直接操作DOM 实现(而像BOM根本不能用状态表达)
  • 使用ref 的准则:“当你的交互功能需要(以命令式)执行一个React没有直接抽象的DOM API函数时”

(本文略译自《A guide to React refs: useRef and createRef》)

本文,我们将探究React的一个设计——全面抽象封装了DOM的操作,同时,又开了一个小门给开发者直接操作DOM。

作为新的第二代UI库,React将DOM的操作进行抽象封装,引入了一种新的开发模式——SPA由一个个带状态的 View组件 组成,开发者将 交互功能 理解成 View的状态的变更 的结果,这个观念改变是突变的。

当我们习惯了这个新模式后会发现,过往的同样的任务(开发交互功能),使用View组件在 分析设计和实现 上都比较原来 DOM命中模式 更好。

不过,React团队非常的有远见,就像其它代码库作者一样,想到了:为特殊情况开一后门(逃生门),这扇“逃生门”就是 Refs。

如何创建 refs

Refs 是React提供的特殊API,通过它,你可以绕过VDOM抽象,引用到DOM节点,按需要对这些节点进行修改(包括改变某个属性值,方法,或者节点树结构)。

要注意的是,“逃生门”不是正门,能不用尽量不要用,因为它可能会与 React 的自动机制产生冲突,包括diff算法。

误用refs会产生反模式,后面我们会介绍 一些反模式,现在我们先看看如何使用refs去获取一个R组件的DOM节点。

1 类组件与createRef()

import React from 'react'

class ActionButton extends React.Component {

  render() {
    const { label, action } = this.props
    return (
      <button onClick={action}>{label}</button>
    )
  }
}

这个JSX定义里,< button > 只是一个V node,而不是真正的DOM node。想访问button对应的DOM node可以这样:

import React, { createRef } from 'react'

class ActionButton extends React.Component {

  constructor() {
    super()
    this.buttonRef = createRef()
  }

  render() {
    const { label, action } = this.props
    return (
      <button onClick={action} ref={this.buttonRef}>{label}</button>
    )
  }
}

分两步,第一,在类构造器创建一个refs对象实例;第二,在JSX的V node上添加这个refs属性; 使用上,你可在任意的生命事件勾子里 通过 this.buttonRef.current 来访问此DOM node。

2 Hooks 与 useRef

createRef()必须是用在「React 类组件」,对于 「hook组件」 则有对应的一个hook :useRef

import React, { useRef } from 'react'

function ActionButton({ label, action }) {
    const buttonRef = useRef(null)

    return (
      <button onClick={action} ref={buttonRef}>{label}</button>
    )
  }
}

现在我们已经掌握了 refs 的基础使用,我们接着看看几个 refs 常见使用情景。

React refs常见实例

React 带给前端社区最大的一股潮流,就是 **以声明的方式创建 V 组件 **;而在这之前,是命令过程式的时代——交互功能通过DOM指令(或包装成函数)直接“变更”对应的DOM节点属性或结构。

正如前面所提到的,我们将交互功能包装一个个V对象,而V对象以是「内部状态的编程」来实现 交互功能的效果 的,它不会触及到物理的DOM结构,React 帮我们决定如何以及何时修改DOM节点产生相对应的交互效果,我们活在一个“牢笼”里。

在实际开发中,有一些交互效果不容易或者不方便表达为 V对象形式或「内部状态的编程」,原始的DOM操作会更有效。我们看看最常见的几种这样的场景。

1 输入控件的聚焦

第一常见例子是表单控件的聚焦(Focus control)。

假设我们有一个订单列表,每一个订单项都有可编辑数量(Quantity)的功能,当我们点击“编辑”按键会弹一个模式编辑对话框(modal)。

react refs1

对话框是一个独立了V组件(InputModal),代码如下:


import React, { createRef } from "react";
    
    class InputModal extends React.Component {
      constructor(props) {
        super(props);
        this.inputRef = createRef();//// 1
    
        this.state = { value: props.initialValue };
      }
    
      componentDidMount() {
        this.inputRef.current.focus();//// 3
      }
    
      onChange = e => {
        this.setState({ value: e.target.value });
      };
    
      onSubmit = e => {
        e.preventDefault();
        const { value } = this.state;
        const { onSubmit, onClose } = this.props;
        onSubmit(value);
        onClose();
      };
    
      render() {
        const { value } = this.state;
    
        return (
          <div className="modal--overlay">
            <div className="modal">
              <h1>Insert a new value</h1>
              <form action="?" onSubmit={this.onSubmit}>
                <input				 
                  ref={this.inputRef} <!--## 2 ### -->
                  type="text"
                  onChange={this.onChange}
                  value={value}
                />
                <button>Save new value</button>
              </form>
            </div>
          </div>
        );
      }
    }
    
    export default InputModal;

这里,当对话框渲染出来后,如果输入框能立即获得编辑焦点(focus),用户不必使用鼠标手动聚焦,则这个用户体验会非常好。

由于节点的聚焦是通过节点的 focus()函数 实现的,所以最好的实现方式是使用refs,获取节点的引用,再在适当的时机(InputModal 完成渲染后)执行这个函数即可。

2 检测节点是否被包含

第二个常见例子,是事件联动。

事件联动 是指交互页某个(VV的)事件会触发页面另一部分的响应这个事件。例如上面的模式对话框,当用户点击 对话框边以外的区域 时,我们希望会关闭这个对话框。对话框边以外的区域 严格上不属于 对框InputModal的,但是这个逻辑与它相关,最好写入它的实现上。

具体实现是:


import React, { createRef } from "react";

class InputModal extends React.Component {
  constructor(props) {
    super(props);
    this.inputRef = createRef();
    this.modalRef = createRef(); //// 1
...
  }

  componentDidMount() {
    this.inputRef.current.focus();

    document.body.addEventListener("click", this.onClickOutside);
  }

  componentWillUnmount() {
    document.removeEventListener("click", this.onClickOutside);
  }

  onClickOutside = e => {
    const { onClose } = this.props;
    const element = e.target;

    if (this.modalRef.current
      && !this.modalRef.current.contains(element)) { //// 3
      e.preventDefault();
      e.stopPropagation();
      onClose();
    }
  };
    ...

  render() {
    const { value } = this.state;
    return (
      <div className="modal--overlay">
        <div className="modal" ref={this.modalRef}> //// 2
          <h1>Insert a new value</h1>
          <form action="?" onSubmit={this.onSubmit}>
            <input
              ref={this.inputRef}
              type="text"
              onChange={this.onChange}
              value={value}
            />
            <button>Save new value</button>
          </form>
        </div>
      </div>
    );
  }
}

export default InputModal;
  • 第一,创建一个InputModal边界节点的refs:modalRef;
  • 第二,为文档安装一个全局的点击事件处理函数(onClickOutside);
  • 第三,在onClickOutside中,我们进行判断(例如对 modalRef 进行比较)并执行相应的处理。

这里特别注意两点:

  • 第一,在使用modalRef前先检查它的可用性,因为React动态性很强的;
  • 第二,记得卸载安装的事件处理函数。

3 集成通用代码库

第三个例子,是集成基于DOM的第三方代码库。例如,业界演化了很多成熟的动画库可用,此例子略。

refs 的使用准则

“当你的交互功能需要(以命令式)执行一个React没有直接抽象的DOM API函数,可用 ref”

学会 refs 后会发现,实现同一项交互功 能既可用View ,也可以用 refs,这样很容易误用或滥用 refs,造成写出很反模式(anti-pattern)的代码。因为同一个交互功能,直观上好像使用 refs更方便。

我的一条经验准则就是:当你的交互功能需要(以命令式)执行一个API函数,而这个函数没有React对应的API时。

我们看一个很常见的 反模式例子(甚至在面试中也常看到)。


import React, { createRef } from 'react';

class Form extends React.Component {
  constructor(props) {
    super(props)
    this.inputRef = createRef()

    this.state = { storedValue: '' }
  }

  onSubmit = (e) => {
    e.preventDefault()
    this.setState({ storedValue: this.inputRef.current.value })
  }  

  render() {

    return (
      <div className="modal">
        <form action="?" onSubmit={this.onSubmit}>
          <input
            ref={this.inputRef}
            type="text"
          />
          <button>Submit</button>
        </form>
      </div>
    )
  }  
}

这是一个典型的使用 refs 访问 非受控组件 的状态值的例子。这个代码是可以运行的。但是,React V抽象API已经实现了对DOM节点的状态(values)和形式属性(properties)的访问,没必要通过refs,正门可入,不必从后门。例如:

render() {
  const { value } = this.state

  return (
    <input
      type="text"
      onChange={e => this.setState({ value: e.target.value })}
      value={value}
    />
  )
}

我们再回顾刚才提到的准则:“当你的交互功能需要(以命令式)执行一个React没有直接抽象的API函数”,再看上面那 非受控组件 的例子,我们创建 了 ref,但并没有 以命令式 执行一个函数来使用这个refs,不像之前的 focus的例子就有。

refs的传递

直到目前为止,我们认识到,refs 对于实现 某种特殊的交互操作 是非常有用的。但是,上面举的例子,相对于实际生产中的代码,则过于简单化。

产品级的V 组件要复杂得多,几乎不会直接用HTML ,都是包装封装结构的自定义组件。例如如下的一个 LabelInput:

import React from 'react'

const LabelledInput = (props) => {
  const { id, label, value, onChange } = props

  return (
    <div class="labelled--input">
      <label for={id}>{label}</label>
      <input id={id} onChange={onChange} value={value} />
    </div>
  )
}

export default LabelledInput

当我们在JSX上给LabelInput定义一个ref,引用到的是这个自定义V的实例,而不是它内部的节点。那么上面的focus的功能就实现不了。

还好 React 提供了另一个特殊的API forwardRef ,我们可以将ref 传入自定义组件的内部:

import React from "react";

const LabelledInput = (props, ref) => {
  const { id, label, value, onChange } = props;

  return (
    <div class="labelled--input">
      <label for={id}>{label}</label>
      <input id={id} onChange={onChange} value={value} ref={ref} />
    </div>
  );
};

export default React.forwardRef(LabelledInput);

参考

裸男
Nakeman.cn 2023 Build by Gatsby and Tailwind, Deploy on Netlify.