消除父元素 overflow:hidden 对子元素的影响

4,655 阅读4分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第 2 天,点击查看活动详情

背景

最近在和其他部门的同事合作处理一个toC的项目,他们负责前端组件库的开发,我处理一些特定业务的组件,然后将业务组件插入组件库中,进行渲染。两边的开发是完全解耦的( 各搞各的 ),就是说,组件库对我来说完全是黑盒子,当我往组件库中插入我的渲染节点时,我的一个设置了定位的元素 消失 了...

  • 计划渲染的效果

image.png

  • 实际渲染的效果

image.png

定位元素被隐藏了

问题原因

在我调试样式的过程中,我发现,父级元素设置了 overflow: 'hidden',测试代码如下

function App() {
  return (
    <div className='wrapper'>
      <div style={{ overflow: 'hidden' }}>
        <TestComponent />
      </div>
    </div>
  );
}

const TestComponent = () => {
  return (
    <div style={{ border: '1px solid red', position: 'relative' }}>
      <div 
        style={{ 
          position: 'absolute', 
          height: '22px', 
          width: '140px', 
          left: 0, 
          top: '-26px',
          border: '1px solid green'
        }}
      >
        定位元素absolute
      </div>
      <div style={{ height: '22px', width: '150px' }}>组件上面有定位元素</div>
    </div>
  )
}

也就是说,组件库中的元素设置了 overflow: 'hidden'。这会导致我插入的元素,如果设置了 position: 'absolute' 都会被隐藏

解决方案

方案一:去除父级 overflow: hidden

既然是父级样式影响的,那去掉不就可以了?如果所有代码都是你写的,你可以发现一处改一处。但是对于一个 多人协作 的项目,这明显不科学(都说了是各搞各的,别人不可能为你改代码)。组件库的代码已经写好了,我们能做的就是 不入侵 别人的代码。所以 此方案不可取

方案二:外层设置position: 'absolute'

既然不能让别人改代码,那就只能从我们自己的代码入手。你可以在你的组件外层,再包裹一层节点,并设置position: 'absolute',可以这样写:

function App() {
  return (
    <div className='wrapper'>
      <div style={{ overflow: 'hidden' }}>
        {/* 加一层节点,并设置 position: 'absolute' */}
        <div style={{ position: 'absolute' }}>
          <TestComponent />
        </div>
      </div>
    </div>
  );
}

image.png

定位元素确实是 显示 了,但是整个业务组件的样式却乱了,测试代码中,应该是要居中展示,现在却靠右了。所以 此方案也不可取

方案三:createPortal + getBoundingClientRect

由于是父元素设置 overflow 引起的问题,且不能去改父元素的样式。那我们能不能给定位元素 重新找一个父元素,比如把 body 作为父元素,再通过top、left值,重新把定位元素定位到目标元素上面。理论上是可行的,我们需要解决两个问题:1. 通过某种方法,把定位元素放到 body 下;2. 确定定位元素的位置:

  • 给定位元素重新找个父元素(body)

ReactDOM.createPortal: 提供了一种将子节点渲染到存在于父组件以外 DOM 节点的方案

我们可以通过 react 提供的 ReactDOM.createPortal,将我们的定位元素传送到 body 去做节点渲染

// 这里默认挂载在 document.body 元素下,你可以自定义挂载在哪
const Portal = ({ children }) =>
  typeof document === 'object' ? ReactDOM.createPortal(children, document.body) : null;
  
const TestComponent = () => {
  return (
    <div style={{ border: '1px solid red', position: 'relative' }}>
        <Portal>
           <div style={{ position: 'absolute', ... }}>
            定位元素absolute
           </div>
        </Portal>

      <div style={{ height: '22px', width: '150px' }}>组件上面有定位元素</div>
    </div>
  )
}

image.png

可以看到,我们的定位元素已经被传送到 body 下面,并且正常渲染了,那接下的事情就是找位置了(设置 top 和 left)

  • 找定位的位置

getBoundingClientRect: 用于获得页面中某个元素的左,上,右和下分别相对浏览器视窗的位置。是DOM元素到浏览器可视范围的距离(不包含文档卷起的部分)

我们首先要找到目标元素的位置,以此来确定我们的定位元素要在哪里显示,这里我们可以通过 getBoundingClientRect 来做

const Portal = ({ children }) =>
  typeof document === 'object' ? ReactDOM.createPortal(children, document.body) : null;

const TestComponent = () => {
  const divRef = useRef()
  const [rect, setRect] = useState({ top: 0, left: 0 })

  useEffect(() => {
    if (divRef.current) {
      // 获取目标元素的位置
      const { left, top } = divRef.current.getBoundingClientRect()
      // 设置定位元素的位置
      setRect({ top: `${top - 26}px`, left: `${left}px` })
    }
  }, [divRef])

  return (
    <div style={{ border: '1px solid red', position: 'relative' }}>
      <Portal>
          <div 
            style={{ 
              position: 'absolute', 
              height: '22px', 
              width: '140px', 
              left: rect.left, 
              top: rect.top,
              border: '1px solid green'
            }}
          >
            定位元素absolute
          </div>
      </Portal>
      <div ref={divRef} style={{ height: '22px', width: '150px' }}>组件上面有定位元素</div>
    </div>
  )
}

image.png

这样就实现了我们想要的效果

结束语

其实这样的方案并不是特例,业内很多组件库在实现 Dialog 等全局组件的时候,都会采用类似的方案,另外一些组件库,为了规避代码入侵,也会这样做。需要注意的是,当目标元素发生了位置的变动,需要重新计算一次 getBoundingClientRect,我们可以监听 resizescroll 事件

如果是 absolute 定位,可以不用考虑 scroll

此方案还要考虑父元素是否用了transformtransform 会导致父元素偏离原来位置,使得 getBoundingClientRect 拿不到正确的值

/************ 监听 dom 滚动 ****************/
// 记得要取消监听
document.getElementById('xxx').addEventListener('scroll'this.handleScroll)

/************ 监听 dom 宽高变化 ****************/
// 新建obsever对象 
const resizeObserver = new ResizeObserver(entries => { 
    for (let entry of entries) { 
        console.log(entry.target.offsetWidth) 
    } 
}); 

// 开始监听,传入dom对象 
resizeObserver.observe(targetNode);