likes
comments
collection
share

精读React hook(六):useLayoutEffect解决了什么问题?

作者站长头像
站长
· 阅读数 12

useEffectuseLayoutEffect的区别仅有一个:

  • useEffect执行时机是在React的渲染和提交阶段之后;
  • useLayoutEffect执行时机是在React的提交阶段之后,但在浏览器实际绘制屏幕之前。

useEffect vs useLayoutEffect

我们通过一个例子来看看阻塞和非阻塞对用户来说有什么区别。

    import React, { useEffect, useLayoutEffect, useState, useRef } from 'react';

    function BoxComparison() {
      const [heightEffect, setHeightEffect] = useState(0);
      const [heightLayoutEffect, setHeightLayoutEffect] = useState(0);
      const refEffect = useRef(null);
      const refLayoutEffect = useRef(null);

      useEffect(() => {
        if (refEffect.current) {
          setHeightEffect(refEffect.current.offsetWidth);
        }
      }, []);

      useLayoutEffect(() => {
        if (refLayoutEffect.current) {
          setHeightLayoutEffect(refLayoutEffect.current.offsetWidth);
        }
      }, []);

      return (
        <div>
          <div>
            <h2>使用 useEffect</h2>
            <div ref={refEffect} style={{ width: '200px', height: '50px', background: 'lightgray' }}>这是一个方块</div>
            <div style={{ width: '100px', height: `${heightEffect}px`, background: 'red', marginTop: '10px' }}>红色方块</div>
          </div>
          
          <div style={{ marginTop: '30px' }}>
            <h2>使用 useLayoutEffect</h2>
            <div ref={refLayoutEffect} style={{ width: '200px', height: '50px', background: 'lightgray' }}>这是一个方块</div>
            <div style={{ width: '100px', height: `${heightLayoutEffect}px`, background: 'blue', marginTop: '10px' }}>蓝色方块</div>
          </div>
        </div>
      );
    }

    export default BoxComparison;

这个例子写了两个方块,分别使用useEffectuseLayoutEffect来更新高度,代码实际效果在我的演示站查看。

当你在性能较差的设备上肉眼可以明显看到区别:

  • useEffect的方块会闪一下
  • useLayoutEffect的方块则不会闪

如果你的电脑性能比较好,可以尝试多次刷新,也有一定几率看到useEffect的闪动。

我们应该这样描述二者的区别:

  • useEffect: 执行时机是在React的渲染和提交阶段之后。这意味着当任何相关DOM更改被应用并且组件已被重新渲染后,useEffect里的代码会执行。但它是异步的,所以可能会在浏览器的下一个绘制周期之后才执行。
  • useLayoutEffect: 执行时机是在React的提交阶段之后,但在浏览器实际绘制屏幕之前。这使得你可以同步地读取或更改DOM,然后让浏览器在下一次绘制时立即体现这些更改,从而避免不必要的闪烁或布局跳动。

useLayoutEffect的作用

我们已经清楚了useLayoutEffect的特性了,那么可以猜想,useLayoutEffect是作用于这样的场景:需要在浏览器绘制前获取 DOM 元素的大小或位置,或者在浏览器绘制前修改 DOM。

这里有一个非常典型的场景——tooltip 组件。我们就来写一个 tooltip 组件,应用useLayoutEffect来自适应设置 tooltip 位置。

我们的需求是:鼠标移入一个按钮,能够判断 tooltip 展示区域,如果按钮上方空间足够,则显示在上方,如果按钮上方空间不够,则自适应显示在按钮下方。

为了保证没有页面抖动,我们要使用useLayoutEffect来更新显示的位置,示例代码如下:

    import React, { useLayoutEffect, useRef, useState } from "react";
    import { createPortal } from "react-dom";

    export default function HoverTooltip() {
      const containerRef = useRef(null);

      return (
        <div
          ref={containerRef}
          className="p-8 bg-gray-100 w-full rounded-xl mt-5 shadow-lg m-4 space-y-4 overflow-hidden"
        >
          <ButtonWithTooltip
            containerRef={containerRef}
            tooltipContent="This tooltip does not fit above the button. This is why it's displayed below instead!"
          >
            Hover over me (tooltip above)
          </ButtonWithTooltip>
          <ButtonWithTooltip
            containerRef={containerRef}
            tooltipContent="This tooltip fits above the button"
          >
            Hover over me (tooltip below)
          </ButtonWithTooltip>
          <ButtonWithTooltip
            containerRef={containerRef}
            tooltipContent="This tooltip fits above the button"
          >
            Hover over me (tooltip below)
          </ButtonWithTooltip>
        </div>
      );
    }

    const ButtonWithTooltip = ({ tooltipContent, containerRef, children }) => {
      const [targetRect, setTargetRect] = useState(null);
      const [containerRect, setContainerRect] = useState(null);
      const buttonRef = useRef(null);

      return (
        <div className="relative">
          <button
            ref={buttonRef}
            className="py-2 px-4 bg-blue-500 text-white rounded hover:bg-blue-600 active:bg-blue-700 focus:outline-none transition"
            onMouseEnter={() => {
              buttonRef.current &&
                setTargetRect(buttonRef.current.getBoundingClientRect());
              containerRef.current &&
                setContainerRect(containerRef.current.getBoundingClientRect());
            }}
            onMouseLeave={() => setTargetRect(null)}
          >
            {children}
          </button>
          {targetRect && containerRect && (
            <Tooltip targetRect={targetRect} containerRect={containerRect}>
              {tooltipContent}
            </Tooltip>
          )}
        </div>
      );
    };

    const Tooltip = ({ children, targetRect, containerRect }) => {
      const ref = useRef(null);
      const [tooltipHeight, setTooltipHeight] = useState(0);

      useLayoutEffect(() => {
        if (ref.current) {
          const { height } = ref.current.getBoundingClientRect();
          setTooltipHeight(height); // 设置高度
        }
      }, [children]);

      let tooltipX = targetRect.left;
      let tooltipY =
        targetRect.top - containerRect.top - tooltipHeight < 0
          ? targetRect.bottom
          : targetRect.top - tooltipHeight; // 计算位置

      return createPortal(
        <div
          ref={ref}
          className="absolute bg-gray-700 text-white py-1 px-2 rounded shadow-md"
          style={{
            left: `${tooltipX}px`,
            top: `${tooltipY}px`,
          }}
        >
          {children}
        </div>,
        document.body
      );
    };

这里示例中,我们写了三个按钮,每次鼠标移入按钮的时候,计算按钮到父级上沿的空间是否可以容纳一个 tooltip,如果足够,tooltip 就在按钮上方展示,如果不够,则在按钮下方展示。实际效果如图:

精读React hook(六):useLayoutEffect解决了什么问题?

你也可以到演示站试一试。

总结

最后,我们再明确一下useEffectuseLayoutEffect分别在何时使用、useLayoutEffect的使用注意事项。

何时使用useEffect

  • 副作用与DOM无关:例如,数据获取、设置订阅、手动更改浏览器的URL等。
  • 不需要立即同步读取或更改DOM:如果你不关心可能的微小布局跳动或闪烁,那么useEffect就足够了。
  • 性能考虑useEffect通常对性能影响较小,因为它不会阻塞浏览器渲染。

何时使用useLayoutEffect

  • 需要同步读取或更改DOM:例如,你需要读取元素的大小或位置并在渲染前进行调整。
  • 防止闪烁:在某些情况下,异步的useEffect可能会导致可见的布局跳动或闪烁。例如,动画的启动或某些可见的快速DOM更改。
  • 模拟生命周期方法:如果你正在将旧的类组件迁移到功能组件,并需要模拟 componentDidMountcomponentDidUpdatecomponentWillUnmount的同步行为。

使用注意事项

  • 避免过度使用useLayoutEffect,因为它是同步的,可能会影响应用的性能。只有当你确实需要同步的DOM操作时才使用它。
  • 如果代码在服务器端渲染(SSR)中出现问题,考虑回退到useEffectuseLayoutEffect在服务器端渲染时不会运行,可能会引发警告或错误。

系列文章列表

未完待续……