简介
在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能被所有的浏览器支持并提供位置更新。现在,恐怕你只能选择你的毒药了。