一个由Tooltip引发的 React Ref 讨论

别说谁变了你拦得住时间么 提交于 2020-12-24 15:03:57

Tooltip 大家应该都知道,就是我们常见的 当用户移动鼠标悬浮在 按钮 时会跳出显示的小文字框,比如知乎网页编辑器上的各类图标按钮都设置了 Tooltip

70e44b17648d9bcb3b012c40e4c58b7a.jpg

最近在使用 Material-UI Tooltip 时遇到到了个小问题,牵扯出了一系列关于 React Ref 的问题和思考,在本文分享给读者们。



出问题的代码如下

<Tooltip title="hi zhihu">
    <FunctionComponent/>
</Tooltip>

原意是希望在一个函数组件的周围显示一个简单的Tooltip

但是不仅没有成功显示,还在 console 里报出了以下warning log

Warning: Function components cannot be given refs. Attempts to access this ref will fail. 
Did you mean to use React.forwardRef()?

里面显示了2个信息:

  1. Function components cannot be given refs 函数组件不能使用 ref
  2. React.forwardRef() 提示使用这个方法


可是,这几行代码里并没有看到 ref 啊

de55148ba1d42645fb4e52ac90b6b257.jpg



在直接看 Tooltip 源码前,我先去看了文档

https://material-ui.com/components/tooltips/

好家伙,原来 Material-UI Tooltip 底层实现是在DOM节点上设置了事件监听

The tooltip needs to apply DOM event listeners to its child element.

那么底层使用 ref 也就再正常不过了 —— Tooltip 普通使用场景如下

<Tooltip title="Hello World Tooltip">
  <span>Hello World</span>
</Tooltip>


问题零:什么是 React Ref

首先,React Ref 主要有两类用处

  1. 持有 底层的 HTML 元素 的引用
  2. 持有自定义React组件的引用

第一点比较常见,比如下面这个React组件中,点击下面的按钮就会使上面的 input 获得鼠标焦点

class CustomTextInput extends React.Component {
  constructor(props) {
    super(props);
    // create a ref to store the textInput DOM element
    this.textInput = React.createRef();
    this.focusTextInput = this.focusTextInput.bind(this);
  }

  focusTextInput() {
    // Explicitly focus the text input using the raw DOM API
    // Note: we're accessing "current" to get the DOM node
    this.textInput.current.focus();
  }

  render() {
    // tell React that we want to associate the <input> ref
    // with the `textInput` that we created in the constructor
    return (
      <div>
        <input
          type="text"
          ref={this.textInput} />
        <button onClick={this.focusTextInput}>
          Focus the text input
        </button>
      </div>
    );
  }
}

041c2748a59e1b7dcad496d6ba14d20f.jpeg

在类组件中,通常使用 React.createRef()创建一个可以存放 React Ref的”容器“。

而在函数组件中,则使用 React.useRef() —— 下面的代码是上面的函数组件版本

function CustomTextInput2(props) {
  // create a ref to store the textInput DOM element
  const textInput = React.useRef();

  const focusTextInput = () => {
    // Explicitly focus the text input using the raw DOM API
    // Note: we're accessing "current" to get the DOM node
    textInput.current.focus();
  };

  // tell React that we want to associate the <input> ref
  // with the `textInput` that we created in the constructor
  return (
    <div>
      <input type="text" ref={textInput} />

      <button onClick={focusTextInput}>Focus the text input</button>
    </div>
  );
}

使用的要点就是将 React Ref的”容器“ 传给子组件(html元素)的 ref 属性。


React Ref 的第二种用法用的较少,也不推荐使用。

使用方法与第一种几乎一样,唯一不同的是 将 React Ref的”容器“ 传给自定义React组件的 ref 属性。注意这里的 React组件 只支持类组件,不支持函数组件,因为在运行过程中只有类组件有实例(instance),函数组件没有实例

如果给函数组件传 ref 属性,就会报上面的警告信息

Warning: Function components cannot be given refs. Attempts to access this ref will fail.

获取类组件的引用,通常出现在父组件调用子组件方法的场景中,参考我的另一篇文章

FreewheelLee:React调用子组件方法与命令式编程误区zhuanlan.zhihu.com



还有一种特殊的场景是父组件希望持有子组件的 DOM元素的引用,这种情况就需要使用 React.forwardRef() 方法

class ChildComponent extends React.Component {
  render() {
    const { forwardedRef } = this.props;
    return (
      <input
        ref={forwardedRef}
        name="name"
        placeholder="child component input"
      />
    );
  }
}

// forward the ref to child component
const ChildComponentWrapper = React.forwardRef((props, ref) => {
  return <ChildComponent forwardedRef={ref} />;
});

function ParentComponent(props) {
  const textInput = React.useRef();

  const focusTextInput = () => {
    textInput.current.focus();
  };

  return (
    <div>
      <p>This is parent component</p>

      <ChildComponentWrapper ref={textInput} />

      <button onClick={focusTextInput}>Focus the text input</button>
    </div>
  );
}

0d99d00100a88bfd89f2c0433de93e83.gif

上述代码发生了什么事呢?

  1. 父组件 ParentComponent 给 ChildComponentWrapper 传了个 ref 属性
  2. ChildComponentWrapper 中的 React.forwardRef 方法将传入的 ref 属性转发给回调函数中的 ChildComponent 的 forwardedRef (自定义)属性
  3. ChildComponent 将 props 中的forwardedRef 作为值传给 input 元素的 ref 属性
  4. 如此一来,ParentComponent 就获得了子组件 ChildComponent 中 input DOM 元素的引用


Bonus: 此外,仔细看 React.forwardRef 方法的参数,是不是其实就是个 函数组件 呢?换句话说,给 函数组件 传ref其实是”可行“的,前提是需要用 React.forwardRef 包装一层。


p.s. 更多关于React Ref 细节的内容可以参考官方文档 Refs and the DOM – React 和 Forwarding Refs – React


问题一:没有显示使用 ref 属性的情况下,Tooltip怎么获得子组件/元素的ref

只能看源码了 https://github.com/mui-org/material-ui/blob/next/packages/material-ui/src/Tooltip/Tooltip.js

源码有点复杂,这边就不做细致的讲解,只描述核心部分

首先,Tooltip内部有个叫 childNode 的 state (将用于保存子组件引用)

const [childNode, setChildNode] = React.useState();

通过一个工具hook useForkRef,将 setChildNode 与其他几个 ref (或 ref handler)组合成一个名为 handleRef —— 这个函数的作用就是接收子组件的引用并保存起来

  const handleUseRef = useForkRef(setChildNode, ref);
  const handleFocusRef = useForkRef(focusVisibleRef, handleUseRef);
  // can be removed once we drop support for non ref forwarding class components
  const handleOwnRef = React.useCallback(
    (instance) => {
      // #StrictMode ready
      setRef(handleFocusRef, ReactDOM.findDOMNode(instance));
    },
    [handleFocusRef],
  );

  const handleRef = useForkRef(children.ref, handleOwnRef);

将 handleRef 作为 childrenProps 中 ref 属性的值 (这个对象随后就会用到)

  const childrenProps = {
    'aria-describedby': open ? id : null,
    title: shouldShowNativeTitle && typeof title === 'string' ? title : null,
    ...other,
    ...children.props,
    className: clsx(other.className, children.props.className),
    ref: handleRef,
  };

除此之外,还看到给 childrenProps 赋值多个事件监听回调函数

  if (!disableTouchListener) {
    childrenProps.onTouchStart = handleTouchStart;
    childrenProps.onTouchEnd = handleTouchEnd;
  }

  if (!disableHoverListener) {
    childrenProps.onMouseOver = handleEnter();
    childrenProps.onMouseLeave = handleLeave();

    //...
  }

  if (!disableFocusListener) {
    childrenProps.onFocus = handleFocus();
    childrenProps.onBlur = handleLeave();

    //...
  }


在 return JSX 部分, 发现 childrenProps 是 React.cloneElement 的第二个参数

  return (
    <React.Fragment>
      {React.cloneElement(children, childrenProps)}
      // ...
    </React.Fragment>
  );

整理一下逻辑:

  1. childrenProps 属性ref 的值(handleRef)是自定义的一个函数,可以接收子组件的引用并保存在内部状态 childNode 里
  2. childrenProps 还添加了 title 属性——值就是 Tooltip 的 title
  3. childrenProps 中还添加了多种鼠标事件回调函数
  4. Tooltip 通过 React.cloneElement 方法 使用 自定义 childrenProps 克隆了 children (子组件)
  5. Tooltip 由此达到了获得 子组件引用,还给子组件设置事件回调函数 的目的


一个验证我们这个结论的细节就是被 Tooltip 修饰后的html元素都会多出一个 title 属性

1.png


这个看似简单的结论却让我们发现了一个强大的工具 —— 通过 React.cloneElement 我们可以自定义修饰/增强子组件!(欢迎读者头脑风暴,思考使用场景)



问题二:如何在自定义组件外使用Tooltip呢

Material-ui Tooltip 的文档中是这么回答的

If the child is a custom React element, you need to make sure that it spreads its properties to the underlying DOM element.

结合问题零中的答案,其实这个问题的答案就呼之欲出了

class ClassButton extends React.Component {
  render() {
    const { forwardedRef, ...rest } = this.props;
    return (
      <div {...rest} ref={forwardedRef}>
        <Button variant="outlined">Class Button</Button>
      </div>
    );
  }
}

const ClassButtonWrapper = React.forwardRef((props, ref) => {
  return <ClassButton {...props} forwardedRef={ref} />;
});

function App() {
  return (
    <div className="App">
      <Tooltip title="class button">
        <ClassButtonWrapper />
      </Tooltip>
    </div>
  );
}

上面代码跟问题零中的代码大部分相似,稍有不同的是对 props 的处理

  1. React.forwardRef 的参数(回调函数)中的 props 一定要传递给 ClassButton
  2. 在 ClassButton 中,除了forwardedRef,剩余的 props 一定要传递给 div

为什么呢?读者可以自行思考 10 秒


c0ac5beafa5bbb91cb13d0814d77ddd8.jpg


8dad6bba78a0c691f1492bb4d172c527.jpg



答案就是 Tooltip 的文档中提到的

The tooltip needs to apply DOM event listeners to its child element.

以及我们在问题一中读源码得知 —— Tooltip 中定义了onFocus, onBlur,onMouseLeave,onMouseOver,onTouchEnd,onTouchStart 等事件监听回调函数都以 props 的形式传递下来,我们代码需要做的就是把这些回调函数安排到合适的HTML DOM 元素上。


9eeacb27e640b337b606b049283a7612.jpg


本周的文章就到此结束了,是否让你对 React Ref, React.forwardRef 有更深理解呢?

有没有从 Tooltip 的源码获得启示呢?

如果觉得本文对你有帮助有启发,欢迎点赞、喜欢、收藏!

咱们下周再见!



文章中的测试代码可以在我的sandbox中查看 React Ref And Tooltip - CodeSandbox



参考链接:

Refs and the DOM – React

React Tooltip component - Material-UI

https://github.com/mui-org/material-ui/blob/next/packages/material-ui/src/Tooltip/Tooltip.js

https://github.com/mui-org/material-ui/blob/next/packages/material-ui-utils/src/useForkRef.js


FreewheelLee:React调用子组件方法与命令式编程误区zhuanlan.zhihu.comFreewheelLee:畅谈React material-ui的样式方案zhuanlan.zhihu.com


易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!