让我探讨在React中访问DOM的问题。在前面的章节,我们知道了如何使用Ref来访问DOM,但是,还有一个很重要但比较少见的场景需要讨论:依据一个真实DOM的大小或者位置来改变一个元素。
那么,这背后的问题是什么?为什么常规的解法不够好?我们会在代码示例中学到:
- 关于
useLayoutEffect的必要知识。 - 何时以及为什么,我们要用
useEffect取代useLayoutEffect。 - 浏览器如何渲染我们的React代码。
- 什么是绘图,以及其重要性。
useEffect的问题
现在是编码时间!我们来做些酷炫的:实现一个响应式导航栏组件。这个组件可以渲染一行的链接,而这些链接可以依据浏览宽度调整链接的个数。
如果一些链接溢出了屏幕,我们可以通过点击“更多”按钮来查看它们。
那么,这个组件要接受一个数组,并渲染这个组件内的数据:
const Component = ({ items }) => {
return (
<div className="navigation">
{items.map((item) => (
<a href={item.href}>{item.name}</a>
))}
</div>
)
}
接下来,就是让她有响应式了。 我们需要计算有多少个链接要展示在浏览器的空白位置。为此,我们需要计算浏览器的宽度以及每个链接的宽度。
得到真实尺寸的方法是让浏览器渲染这个组件,然后通过JavaScript原生APIgetBoundingClientRect来获得相关的尺寸。
要实现这个,我要分成几个步骤。第一步,我们要访问到元素,并把它赋值给一个Ref:
const Component = ({ items }) => {
const ref = useRef(null);
return (
<div className="navigation" ref={ref}>
{items.map((item) => (
<a href={item.href}>{item.name}</a>
))}
</div>
)
}
第二步,在useEffect中得到div元素并获得其尺寸:
const Component = ({ items }) => {
useEffect(() => {
cosnt div = ref.current;
const { width } = div.getBoundingClientRect();
},[ref])
return ...
}
第三步,遍历div的子元素并提取其子元素的宽度:
const Component = ({ items }) => {
useEffect(() => {
// same code as before
// convert div's children into an array
const children = [...div.childNodes];
// all the widths
const childrenWidths = children.map(child => childe.getBoundingClientRect().width)
},[ref])
return ...
}
现在,我们要做的就是遍历childrenWidths数组,计算其宽度的总和,再将之与父div的宽度进行比较,再找到最后一个可见的链接。
但是,我们忘了一个事情:“更多”按钮。我们需要考虑它的宽度。否则,我们将看不到“更多”按钮。
所以,我们需要在组件里添加“更多”按钮的代码:
const Component = ({ items }) => {
const ref = useRef(null);
return (
<div className="navigation" ref={ref}>
{items.map((item) => (
<a href={item.href}>{item.name}</a>
))}
<button id="more">...</button>
</div>
)
}
如果我们把计算宽度的逻辑抽象到一个函数里面,useEffect的里的代码就简洁多了:
useEffect(() ={
const itemIndex = getLastVisibleItem(ref.current);
}, [ref])
getLastVisibleItem函数负责进行宽度的计算并返回一个数字。这个数字是最后一个可以被渲染的链接的索引。至于这个函数的具体实现和代码,我们可以在示例代码中找到。
真正重要的是我们得到这个数字后,我们该干嘛?如果我们什么都不做,我们会看见所有的链接和“更多”按钮。我们需要添加一个状态,当状态发生变化时,移除不需要展示在浏览器的链接:
const Component = ({ items }) => {
// set the initial value to -1, to indicate that we haven't run the calculations yet
const [lastVisibleMenuItem, setLastVisibleMenuItem] = useState(-1);
useEffect(() => {
const itemIndex = getLastVisibleItem(ref.current);
// update state with the actual number
setLastVisibleMenuItem(itemIndex);
}, [ref]);
};
这是return部分的代码:
const Component = ({ items }) => {
// render everything if it's the first pass and the value is
still the default
if (lastVisibleMenuItem === -1) {
// render all of them here, same as before
return ...
}
// show "more" button if the last visible item is not the last one in the array
const isMoreVisible = lastVisibleMenuItem < items.length - 1;
// filter out those items which index is more than the last visible
const filteredItems = items.filter((item, index) => index <=
lastVisibleMenuItem);
return (
<div className="navigation">
{/*render only visible items*/}
{filteredItems.map(item => <a href={item.href}>{item.name}
</a>)}
{/*render "more" conditionally*/}
{isMoreVisible && <button id="more">...</button>}
</div>
)
}
如此一来,当这个状态更新后,它后触发导航栏的重新渲染,React后隐藏不需要展示的链接。想要做好响应式功能,我们还需要监听resize事件,但这个功能就由读者自己实现吧。
完整的代码在下面。但是这个功能还不是完美的,还是有一个致命缺陷的。
代码示例: advanced-react.com/examples/12…
试着刷新页面几次,尤其是在 CPU 性能受限(降速)的情况下。 遗憾的是,会明显看到内容闪烁。你应该能清晰地看到初始渲染的情况 —— 此时菜单中的所有项目和 “更多” 按钮都能看到。在投入生产环境之前,我们一定要解决这个问题。
使用useLayoutEffect修复这个问题
产生这个闪烁的原因明显:在去除不需要展示的链接前,我们渲染了所有的链接。我们需要渲染全部,不然这个酷炫的响应式功能就无效了。所以,有一个解决方法:还是渲染所有的链接,但是是隐形地渲染,把其设置为透明。之后,在我们得到了尺寸和lastVisibleMenuItem后,让这个部分可视。这是我们以前处理这个问题的方案。
而在16.8版本之后的React中,我们只要用useLayoutEffect来替代useEffect即可。
const Component = ({ items}) => {
// everything is exactly the same, only the hook name is different
useLayoutEffect(() => {
// the code is still the same
}, [ref])
}
代码示例: advanced-react.com/examples/12…
这样做安全吗?为什么我们不用useLayoutEffect全量替代useEffect?因为官方文档明确说,useLayoutEffect会影响应用的性能,所以我们应该少用useLayoutEffect。为什么呢?因为官方文档说useLayoutEffect在浏览器重绘前触发这个钩子。而这也意味着,useEffect是在浏览器重绘后触发。那这在实际开发中,意味着什么?
为了回答这些问题,我们需要先把React放一边,先讨论一下浏览器与旧JavaScript。