Tooltip 大家应该都知道,就是我们常见的 当用户移动鼠标悬浮在 按钮 时会跳出显示的小文字框,比如知乎网页编辑器上的各类图标按钮都设置了 Tooltip
最近在使用 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个信息:
- Function components cannot be given refs 函数组件不能使用 ref
- React.forwardRef() 提示使用这个方法
可是,这几行代码里并没有看到 ref 啊
在直接看 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 主要有两类用处
- 持有 底层的 HTML 元素 的引用
- 持有自定义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>
);
}
}
在类组件中,通常使用 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>
);
}
上述代码发生了什么事呢?
- 父组件 ParentComponent 给 ChildComponentWrapper 传了个 ref 属性
- ChildComponentWrapper 中的 React.forwardRef 方法将传入的 ref 属性转发给回调函数中的 ChildComponent 的 forwardedRef (自定义)属性
- ChildComponent 将 props 中的forwardedRef 作为值传给 input 元素的 ref 属性
- 如此一来,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>
);
整理一下逻辑:
- childrenProps 属性ref 的值(handleRef)是自定义的一个函数,可以接收子组件的引用并保存在内部状态 childNode 里
- childrenProps 还添加了 title 属性——值就是 Tooltip 的 title
- childrenProps 中还添加了多种鼠标事件回调函数
- Tooltip 通过 React.cloneElement 方法 使用 自定义 childrenProps 克隆了 children (子组件)
- Tooltip 由此达到了获得 子组件引用,还给子组件设置事件回调函数 的目的
一个验证我们这个结论的细节就是被 Tooltip 修饰后的html元素都会多出一个 title 属性
这个看似简单的结论却让我们发现了一个强大的工具 —— 通过 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 的处理
- React.forwardRef 的参数(回调函数)中的 props 一定要传递给 ClassButton
- 在 ClassButton 中,除了forwardedRef,剩余的 props 一定要传递给 div
为什么呢?读者可以自行思考 10 秒
答案就是 Tooltip 的文档中提到的
The tooltip needs to apply DOM event listeners to its child element.
以及我们在问题一中读源码得知 —— Tooltip 中定义了onFocus, onBlur,onMouseLeave,onMouseOver,onTouchEnd,onTouchStart 等事件监听回调函数都以 props 的形式传递下来,我们代码需要做的就是把这些回调函数安排到合适的HTML DOM 元素上。
本周的文章就到此结束了,是否让你对 React Ref, React.forwardRef 有更深理解呢?
有没有从 Tooltip 的源码获得启示呢?
如果觉得本文对你有帮助有启发,欢迎点赞、喜欢、收藏!
咱们下周再见!
文章中的测试代码可以在我的sandbox中查看 React Ref And Tooltip - CodeSandbox
参考链接:
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
来源:oschina
链接:https://my.oschina.net/u/4389900/blog/4837739