第十二章 利用 useLayoutEffect 避免 UI 闪烁 【上】

268 阅读5分钟

让我探讨在React中访问DOM的问题。在前面的章节,我们知道了如何使用Ref来访问DOM,但是,还有一个很重要但比较少见的场景需要讨论:依据一个真实DOM的大小或者位置来改变一个元素。

那么,这背后的问题是什么?为什么常规的解法不够好?我们会在代码示例中学到:

  • 关于useLayoutEffect的必要知识。
  • 何时以及为什么,我们要用useEffect取代useLayoutEffect
  • 浏览器如何渲染我们的React代码。
  • 什么是绘图,以及其重要性。

useEffect的问题

现在是编码时间!我们来做些酷炫的:实现一个响应式导航栏组件。这个组件可以渲染一行的链接,而这些链接可以依据浏览宽度调整链接的个数。

image.png

如果一些链接溢出了屏幕,我们可以通过点击“更多”按钮来查看它们。

image.png

那么,这个组件要接受一个数组,并渲染这个组件内的数据:

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的宽度进行比较,再找到最后一个可见的链接。

但是,我们忘了一个事情:“更多”按钮。我们需要考虑它的宽度。否则,我们将看不到“更多”按钮。

image.png

所以,我们需要在组件里添加“更多”按钮的代码:

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。