Ref 的妙用

Ref 在组建中可以用来帮助引用一个不需要渲染的内容,包括值或者 React 节点。Ref 是通过 Hook useRef来定义的。组件可以通过 Ref 记住一些信息,但是这些信息可以不触发重新渲染。

在组件中创建一个 Ref 可以直接使用语句:const ref = useRef<T>(initialValue)。要使用 Ref 中保存的值,需要使用 Ref 的.current属性。当 Ref 的.current属性值被改变时,React 不会重新渲染组件。

以下是一个使用 Ref 存放计数器值的简单示例。

import { useRef } from "react";

function Counter() {
  const ref = useRef<number>(0);
  const handleClick = () => {
    ref.current++;
    console.log(`点击了 ${ref.current} 次`);
  };

  return <button onClick={handleClick}>点击</button>;
}

Caution

不要在组件的渲染期间读写Ref的值,这可能会让组件的行为变得难以预测。读写Ref的位置应该是在Effect或者事件处理中。

操作 DOM

使用 Ref 来操作 DOM 也是非常常见的行为,这也是 React 内置支持的功能。要操作 DOM,需要首先声明一个初始值为null的 Ref。

const domRef = useRef(null);

Tip

所有的DOM元素在React中也是有对应的类型的,可以用来声明Ref对象所存放的内容类型。具体可以在@types/react库中寻找名称为HTML*ElementSVG*Elemenet的类型定义,如果不知道具体所要引用的DOM节点类型,可以直接使用HTMLElement或者SVGElement

之后就可以在组件的渲染部分将 Ref 绑定到所要引用的 DOM 节点上。

return <input ref={domRef} />;

接下来就可以通过定义的 Ref 来操作 DOM 节点的一些功能了。以下是这个示例的完整版。

import { useRef } from "react";

function ManuelFocusInput() {
  const inputRef = useRef<HTMLInputElement | null>(null);
  const handleFocusClick = () => {
    inputRef.current?.focus();
  };

  return (
    <div>
      <input ref={inputRef} />
      <button onClick={handleFocusClick}>Focus Input</button>
    </div>
  );
}

获取自定义组件的 Ref

在默认情况下,自定义组件不会暴露他们内部 DOM 节点的 Ref,所以如果需要获取自定义组件的 Ref,就需要使用forwardRef对其进行包装。例如以下示例。

import { forwardRef } from "react";

const CustomInput = forwardRef(({ value, onChange }, ref) => {
  return <input ref={ref} value={value} onChange={onChange} />;
});

在 Typescript 中书写forwarfRef的类型是一个比较苦恼的事情,因为forwardRef所使用到的类型比较复杂,这里提供一个简短的示例来展示一下如何声明forwardRef所使用的类型。

// 首先声明一个自定义组件所接受的props参数的类型
type CustomComponentProps = {};

const CustomComponent: ForwardRefExoticComponent<
  CustomComponentProps & RefAttributes<HTMLDivElement>
> = forwardRef((props: CustomComponentProps, ref: Ref<HTMDivElement>) => {
  return <div ref={ref}></div>;
});

Tip

在未来React 19版本中,forwardRef将会发生更改,引用自定义组件会变得更加简单,所以不要纠结在forwardRef的类型声明上。

暴露组件的可操作接口

一般来说,一个组件是封闭的,是不能允许从外部操作其内部数据的。在父组件中调用其中子组件中的方法,一般会被认为是不推荐行为。这是因为这种调用需要父组件明确知晓子组件内部的具体方法以及调用形式,而这种知晓往往是侵入式的,并且破坏了面向对象变成原则中对于密封的要求。

但是在一些情况下,我们又需要组件能够暴露出一些功能,允许我们进行操作。这种调用往往存在非常实在的意义,比如进行父子组件间的通讯。这单单仅使用 Ref 是不容易做到的,还需要useImperativeHandle Hook 配合一起使用。

useImperativeHandle这个 Hook 提供了这样一个能力,允许在不破坏组件密封性的前提下,让组件对外暴露一定的方法,来允许其他的组件调用。这种形式就相当于组件对外暴露了一套接口,所以这种形式是保持了面向对象的基本原则的。

以下通过一个示例来说明useImperativeHandle对于组件密封性的保护意义。假设有一个组件提供了一个列表界面,这个列表界面上允许用户进行内容的选择,但是选项的清空功能需要从组件外部触发,所以组件需要向外提供一个方法来清空列表当前的已选项。

根据实例中对于列表组件的功能需求,首先定义一个列表组件。

type ListProps = {
  items: string[];
};

const ListComponent = (props: ListProps) => {
  const [selected, setSelected] = useState<number>(-1);
  const listItems = props.items.map((item: string, index: number) => (
    <li className={ 'selected': index === selected } 
        key={index} 
        onClick={() => setSelected(index)}
    >{item}</li>
  ));

  return (
    <ul>
      {listItems}
    </ul>
  );
};

export default ListComponent;

在这个列表组件中,对于列表项只提供了选择功能,而没有提供清除选择功能,这就导致这个组件在使用的时候,其中的列表项一旦被选择,就不能再被取消选择。而本着保持组件只包含最简单的展示逻辑的原则,这个列表组件并不适合再向其中添加其他的控制按钮或者连接来清除列表项的选择状态。

那么一个比较可行的方法就是让这个列表组件提供一个清除选择状态的方法来提供其他的组件调用。为了实现这个目标,就需要再定义一个接口,并配合useImperativeHandle来将其开放出去。这个列表组件经过改造以后,就变成了下面这个样子。

type ListProps = {
  items: string[];
};

export interface ClearSelection {
  clear: () => void;
}

const ListComponent = forwardRef((
  props: ListProps,
  ref: Ref<ClearSelection>
) => {
  const [selected, setSelected] = useState<number>(-1);
  const listItems = props.items.map((item: string, index: number) => (
    <li className={ 'selected': index === selected }
      key={index}
      onClick={() => setSelected(index)}
    >{item}</li>
  ));

  useImperativeHandle(
    ref,
    () => ({
      clear: () => {
        setSelected(-1);
      }
    }),
    []
  );

  return (
    <ul>
      {listItems}
    </ul>
  );
});

export default ListComponent;

forwardRef在 React 中提供的是将ref自动通过组件传递到子组件的方法,也就是ref转发。这里使用forwardRef的原因主要是普通的组件定义是不包含ref参数的,但是在这里我们需要使用ref参数来携带组件开放出去的方法。在这个示例中,需要注意的是,ref参数的类型被定义为了Ref<ClearSelection>,这就说明ref传递的内容是一个符合ClearSelection接口的对象,不能像使用 React 组件一样来使用它,只能按照接口的定义来使用。

所以,根据组件ListComponent的定义,在其父组件中可以这样来使用它。

const ListHost = ({ items }) => {
  const listRef = useRef<ClearSelection>(null);
  const clearSelection = () => {
    listRef.current?.clear();
  };

  return (
    <>
      <button onClick={clearSelection}>Clear</button>
      <ListComponent items={items} ref={listRef} />
    </>
  );
};

在这个示例中需要注意的是,listRef在使用的时候需要做空值判断,在示例中只是利用了 Typescript 的安全访问语法免除了空值判断过程。