在React中获取一个元素的大小和位置的方法

3,558 阅读4分钟

简介

在React中获取元素的大小和位置并不是一个好故事。我研究的每个选项都至少有一个骗局。我将分享我想到的最佳方案,并解释每个方案的优点和缺点。首先让我们看看在React中获取元素的大小和位置的基本方法。

获取大小和位置

你可以使用Element.getClientRects()Element.getBoundingClientRect() 来获取一个元素的大小和位置。在React中,你首先需要获得一个对该元素的引用。下面是一个例子,说明你如何做到这一点。

function RectangleComponent() {
  return (
    <div
      ref={el => {
        // el can be null - see https://reactjs.org/docs/refs-and-the-dom.html#caveats-with-callback-refs
        if (!el) return;

        console.log(el.getBoundingClientRect().width); // prints 200px
      }}
      style={{
        display: "inline-block",
        width: "200px",
        height: "100px",
        background: blue
      }}
    />
  );
}

这将把该元素的宽度打印到控制台。这就是我们所期望的,因为我们在样式属性中设置了宽度为200px。

问题是

如果元素的大小或位置是动态的,这种基本的方法就会失败,例如在以下情况下。

  • 该元素包含图像和其他资源,这些资源是异步加载的
  • 动画
  • 动态内容
  • 窗口调整大小

这些都是很明显的,对吗?这里有一个更狡猾的场景。

function ComponentWithTextChild() {
  return (
    <div
      ref={el => {
        if (!el) return;

        console.log(el.getBoundingClientRect().width);
        setTimeout(() => {
          // usually prints a value that is larger than the first console.log
          console.log("later", el.getBoundingClientRect().width);
        });
        setTimeout(() => {
          // usually prints a value that is larger than the second console.log
          console.log("way later", el.getBoundingClientRect().width);
        }, 1000);
      }}
      style={{ display: "inline-block" }}
    >
      <div>Check it out, here is some text in a child element</div>
    </div>
  );
}

这个例子渲染了一个简单的div,它的唯一子节点是一个文本节点。它立即记录了该元素的宽度,然后在事件循环的下一个周期再次记录,一秒钟后第三次记录。由于我们只有静态内容,你可能会认为这三次的宽度都是一样的,但事实并非如此。当我在电脑上运行这个例子时,第一次的宽度是304.21875 ,第二次是353.125 ,第三次是358.078

有趣的是,当我们用vanilla JS进行同样的DOM操作时,这个问题并没有发生。

const div = document.createElement('div')
div.style.display = 'inline-block';
const p = document.createElement('p');
p.innerText = 'Hello world this is some text';
div.appendChild(p);
document.body.appendChild(div);
console.log('width after appending', div.getBoundingClientRect().width);
setTimeout(() => console.log('width after a tick', div.getBoundingClientRect().width));
setTimeout(() => console.log('width after a 100ms', div.getBoundingClientRect().width), 100);

如果你把这个粘贴到控制台,你会看到初始宽度值是正确的。因此,我们的问题是专门针对React的。

解决方案#1:轮询

一个自然的解决方案是简单地轮询尺寸和位置的变化。

function ComponentThatPollsForWidth() {
  return (
    <div
      ref={el => {
        if (!el) return;
        console.log("initial width", el.getBoundingClientRect().width);
        let prevValue = JSON.stringify(el.getBoundingClientRect());
        const start = Date.now();
        const handle = setInterval(() => {
          let nextValue = JSON.stringify(el.getBoundingClientRect());
          if (nextValue === prevValue) {
            clearInterval(handle);
            console.log(
              `width stopped changing in ${Date.now() - start}ms. final width:`,
              el.getBoundingClientRect().width
            );
          } else {
            prevValue = nextValue;
          }
        }, 100);
      }}
      style={{ display: "inline-block" }}
    >
      <div>Check it out, here is some text in a child element</div>
    </div>
  );
}

在这里,我们可以看到数值随着时间的推移而变化,以及需要多长时间才能得到一个最终的数值。在我的环境中,全页面刷新的时间大约是150ms,尽管我是在Storybook中渲染的,这可能会增加一些开销。

优点

  • 简单
  • 涵盖所有的使用情况

缺点

  • 效率低下--可能会耗尽移动设备的电池
  • 更新延迟到轮询间隔的时间。

解决方案#2:ResizeObserver

ResizeObserver是一个新的API,当元素的大小发生变化时,会通知我们。

优点

  • 对支持它的浏览器来说是有效的
  • 当其他浏览器增加支持时,自动获得更好的性能
  • 漂亮的API

缺点

  • 不提供位置更新,只提供大小
  • 必须使用polyfill

资源

建议

  • 拥抱尺寸和位置会改变的事实。不要在componentDidMount ,并期望它们是准确的。
  • 在你的状态上存储元素的大小和位置。然后根据你的需要,通过ResizeObserver或轮询来检查变化。
  • 如果你使用轮询,请记住,即使你的新值与旧值相同,更新状态也会导致一个渲染周期。因此,在更新你的状态之前,要检查尺寸或位置是否真的发生了变化。

一个使用轮询的实际例子

对于我自己的目的,我不仅需要尺寸,还需要元素的位置。因此,ResizeObserver被排除了,我不得不使用轮询的解决方案。下面是一个更实际的例子,说明你如何实现轮询。

在这个例子中,我们要把一个元素放在一个容器的中心。我称它为工具提示,但它始终是可见的。

class TooltipContainer extends React.Component {
  constructor(props) {
    super(props);

    const defaultRect = { left: 0, width: 0 };

    this.state = {
      containerRect: defaultRect,
      tooltipRect: defaultRect
    };

    this.containerRef = React.createRef();
    this.tooltipRef = React.createRef();
    this.getRectsInterval = undefined;
  }

  componentDidMount() {
    this.getRectsInterval = setInterval(() => {
      this.setState(state => {
        const containerRect = this.containerRef.current.getBoundingClientRect();
        return JSON.stringify(containerRect) !== JSON.stringify(state.containerRect) ? null : { containerRect };
      });
      this.setState(state => {
        const tooltipRect = this.tooltipRef.current.getBoundingClientRect();
        return JSON.stringify(tooltipRect) === JSON.stringify(state.tooltipRect) ? null : { tooltipRect };
      });
    }, 10);
  }

  componentWillUnmount() {
    clearInterval(this.getRectsInterval);
  }

  render() {
    const left = this.state.containerRect.left +
      this.state.containerRect.width / 2 -
      this.state.tooltipRect.width / 2 +
      "px";
    
    return (
      <div
        ref={this.containerRef}
        style={{ display: "inline-block", position: "relative" }}
      >
        <span>Here is some text that will make the parent expand</span>
        <img src="https://www.telegraph.co.uk/content/dam/pets/2017/01/06/1-JS117202740-yana-two-face-cat-news_trans_NvBQzQNjv4BqJNqHJA5DVIMqgv_1zKR2kxRY9bnFVTp4QZlQjJfe6H0.jpg?imwidth=450" />
        <div
          ref={this.tooltipRef}
          style={{
            background: "blue",
            position: "absolute",
            top: 0,
            left
          }}
        >
          Tooltip
        </div>
      </div>
    );
  }
}

总结

我希望对这个问题有一个完美的解决方案。我希望ResizeObserver能被所有的浏览器支持并提供位置更新。现在,恐怕你只能选择你的毒药了。