利用ReactDOM.createPortal 实现一个tooltip组件

该博客详细介绍了如何使用React创建一个Tooltip组件,包括获取按钮btn的几何属性,根据按钮位置动态定位tooltip,以及实现点击事件控制tooltip的显示和隐藏。组件支持在页面滚动时重新定位,并可接受自定义内容和控制显示状态。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

  • 一、创建btn和tooltip组件
  • 二、获取当前btn的几何属性和全局定位属性
  • 三、tooltip需要得到btn的几何属性和全局定位属性
  • 四、tooltip根据btn的位子几何信息进行定位(当滚轮滑动时重新定位tooltip)
  • 五、创建点击事件、点击btn反转display状态、点击不发生在tooltip中 隐藏tooltip
// index.js
const domContainer = document.querySelector('#app');
const body = document.querySelector('body');

const useState = React.useState;
const useEffect = React.useEffect;
const useRef = React.useRef;
const forwardRef = React.forwardRef;
const useImperativeHandle = React.useImperativeHandle;
const useLayoutEffect = React.useLayoutEffect;


/**
 * Tooltip 组件
 */
function RCTooltip(props) {
  return (
    <div>
      {props.message}
    </div>
  )
}

/**
 * 
 * @param {*} e 事件源
 * @param {*} className 被搜索class
 * @returns boolean 
 */
function findClassNameFromParent(e, className) {
  let i = true;
  let parentNode = e.target.parentNode || null;
  let res = false;
  while (i) {
    if (parentNode) {

      if (parentNode.className && parentNode.className.includes(className)) {
        res = true;
        i = false
      } else {
        parentNode = parentNode.parentNode;
      }
    } else {
      res = false;
      i = false;
    }
  }
  return res
}

/**
 * 将弹窗的控制权交给容器
 */
function TooltipWrap(props) {
  const [isShow, setIsShow] = useState(undefined);
  useEffect(() => {
    setIsShow(props.isOpen)
    console.log('change isOpen', props.isOpen)
  }, [props.isOpen]);

  useEffect(() => {
    const bodyClick = (e) => {
      if (!(e.target.className.includes('Tooltip') || findClassNameFromParent(e, 'Tooltip'))) {
        setIsShow(false)
        props.onVisibleChange && props.onVisibleChange(false)
      }
    }
    window.document.addEventListener('click', bodyClick)
    return () => { window.document.removeEventListener('click', bodyClick) }
  }, []);

  useEffect(() => {
    if (isShow === undefined) {
      setIsShow(props.defaultDisplayStatus);
      props.onVisibleChange && props.onVisibleChange(props.defaultDisplayStatus)
      return
    }
    setIsShow(!isShow)
    props.onVisibleChange && props.onVisibleChange(!isShow)
  }, [props.change]);

  return (
    <div
      className={`Tooltip ${isShow ? 'show' : 'hidden'}`}
      ref={props.RCTooltipRef}
    >
      <RCTooltip {...props}></RCTooltip>
    </div>
  )
}


/**
 * 将 Tooltip 插入到页面特定位置
 */
function Tooltip(props) {
  const el = <TooltipWrap {...props} ></TooltipWrap>
  return (
    props.DomNode && ReactDOM.createPortal(
      el,
      props.DomNode
    )
  )
}


/**
 * Tooltip 的包裹组件
 */
function TooltipContent(props, ref) {
  const RCBtnRef = useRef(null)
  useImperativeHandle(ref, () => ({
    el: RCBtnRef.current,
    // el: ReactDOM.findDOMNode(RCBtnRef),
  }));
  const onclick = (e) => {
    e.nativeEvent.stopImmediatePropagation()
    props.changeTooltip()
  }
  return (
    // React.cloneElement(props.children,{ref:RCBtnRef,onClick:onclick})
    <div ref={RCBtnRef} onClick={onclick} id="RCBtn">{props.children}</div>
  )
}
TooltipContent = forwardRef(TooltipContent);

/**
 * 导出 Tooltip  
 */
function MyTooltip(props) {
  const RCBtnRef = useRef(null);
  const RCTooltipRef = useRef(null);
  const [change, setChange] = useState(0);
  const [el, setEl] = useState(null);

  const init = () => {
    selectPosition(RCBtnRef, RCTooltipRef, props)
  }

  useEffect(() => {
    if (el === null) return
    const content = document.createElement('div')
    body.appendChild(content)
    content.appendChild(el);
    init();
    window && window.addEventListener('scroll', (e) => {
      selectPosition(RCBtnRef, RCTooltipRef, props)
    });
    window.onresize = function () {
      init();
    }
    return () => {
      content.removeChild(el)
    }
  }, [el]);

  const changeTooltip = () => {
    // 单例模式
    if (!el) {
      setEl(document.createElement('div'))
    }
    setChange(change + 1);
  }

  // 如果设置了isOpen就需要提前创建tooltip的容器
  useEffect(() => {
    // 单例模式
    if (!el && props.isOpen) {
      setEl(document.createElement('div'))
    }
  }, [props.isOpen])

  return (
    <div>
      <TooltipContent ref={RCBtnRef} changeTooltip={changeTooltip}>
        {
          props.children
        }
      </TooltipContent>
      <Tooltip
        {...props}
        change={change}
        RCTooltipRef={RCTooltipRef}
        DomNode={el}
        defaultDisplayStatus={true}
      ></Tooltip>
    </div>
  );
}

/**
 * @param {*} position 
 */
function selectPosition(RCBtnRef, RCTooltipRef, props) {
  // 当页面的dom和所有微任务执行完成后 在获取Dom节点的各种属性
  setTimeout(() => {
    const RCBtnDom = RCBtnRef.current.el.children[0];// 当前RCBtn实例
    const RCTooltipDom = RCTooltipRef.current;// 当前RCTooltip实例
    const RCBtnDomStyles = RCBtnDom.getBoundingClientRect();
    const RCTooltipDomStyles = RCTooltipDom.getBoundingClientRect();
    const { width, left, top } = RCBtnDomStyles;
    const { width: RCTooltipWidth, height: RCTooltipHeight } = RCTooltipDomStyles;
    switch (props.position) {
      case 'top':
        RCTooltipDom.style.top = top - RCTooltipHeight + document.documentElement.scrollTop + 'px'
        RCTooltipDom.style.left = left + 'px'
        break;
      case 'right':
        RCTooltipDom.style.top = top + document.documentElement.scrollTop + 'px'
        RCTooltipDom.style.left = left + width + 'px'
        break;
      case 'top-center':
        RCTooltipDom.style.top = top - RCTooltipHeight + document.documentElement.scrollTop + 'px'
        RCTooltipDom.style.left = left - (RCTooltipWidth - width) / 2 + 'px'
        break;
      default:
        break;
    }
  }, 0)
}

function Btn(props) {
  return <div className="Btn">{props.children}</div>
}

function Tip(props) {

  return (
    <div>
      <div className="tip">{props.children}</div>
      <div onClick={props.onclick} className="close">X</div>
    </div>
  )
}

function app() {
  const [isOpen, setIsOpen] = useState(false);

  const onclick = () => {
    console.log('close')
    setIsOpen(false)
  }

  const handleVisibleChange = (visible) => {
    setIsOpen(visible);
  }
  return (
    <React.Fragment>
      <div className="container">
        {/* 被提示的内容可以是组件或者元素 */}
        <MyTooltip position="top" message="giao giao!">
          <Btn></Btn>
        </MyTooltip>

        {/* 提示信息也可以是一个自定义的组件 */}
        <MyTooltip position="right" message={<Btn>giao giao!</Btn>}>
          <Btn></Btn>
        </MyTooltip>

        {/* 提供 isOpen 和 onVisibleChange 来控制提示窗的现实隐藏*/}
        <MyTooltip position="top-center" isOpen={isOpen} onVisibleChange={handleVisibleChange} message={<Tip onclick={onclick}>giao giao!</Tip>}>
          <Btn></Btn>
        </MyTooltip>
      </div>

    </React.Fragment>
  )
}

ReactDOM.render(React.createElement(app), domContainer);
//index.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <link rel="stylesheet" type="text/css" href="./index.css"/>
  <title>Document</title>
</head>
<body>
  <div id="app"></div>
</body>
<script src="https://unpkg.com/react@17/umd/react.development.js" crossorigin></script>
<script src="https://unpkg.com/react-dom@17/umd/react-dom.development.js" crossorigin></script>
<script src="https://unpkg.com/babel-standalone@6/babel.min.js"></script>
<script type="text/babel" src="like_button.jsx"></script>
</html>
// index.css

*{
  margin: 0;
  padding: 0;
}

.Tooltip{
  position: absolute;
  padding:10px 15px;
  border-radius: 10px;
  background-color: wheat;
  box-shadow: 0px 0px 5px rgba(0, 0, 0, 0.3);
}

#RCBtn{
  display: inline-block;
}

.TooltipWrap{
  display: inline-block;
}

.show {
  transform: translateX(0px);
}

.hidden {
  transform: translateX(-9999px);
}

.Btn{
  cursor:help;
  height: 35px;
  padding: 5px 10px;
  background-color: chocolate;
  border-radius: 5px;
  text-align: center;
  line-height: 40px;
  color: white;
}

body{
  width: 100%;
  padding-top: 100px;
}

.area{
  width: 100px;
 margin: 0 auto;
}

.container{
  display: flex;
  align-items: center;
  justify-content: center;
  flex-direction: column;
}


在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值