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

🧑‍💻
推荐全栈学习资源:
  • Next.js 中文文档:样式和官网一样的中文文档,创造沉浸式Next.js中文学习体验。
  • 《Chrome插件全栈开发》:真实出海项目的实战教学课,讲解Chrome插件和Next.js端的全栈开发,帮助你半个月内成为全栈出海工程师。
  • 如果你会用useEffect精读React hook(五):useEffect使用细节知多少?),那么你一定也会用useLayoutEffect。因为它们的用法一模一样。

    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 就在按钮上方展示,如果不够,则在按钮下方展示。实际效果如图:

    1.png

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

    总结

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

    何时使用useEffect

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

    何时使用useLayoutEffect

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

    使用注意事项

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

    专栏资源

    专栏博客地址:精读React Hooks

    专栏演示站:React Hooks Demos

    专栏源码仓库:👉Github - Source Code

    交个朋友:👉加入「独立全栈交流群」

    专栏文章列表:

    精读React hooks(一):useState 的几个基础用法和进阶技巧

    精读React hooks(二):React状态管理的强大工具——useReducer

    精读React hooks(三):useContext从基础应用到性能优化

    精读React hooks(四):useRef的多维用途

    精读React hooks(五):useEffect使用细节知多少?

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

    精读React hooks(七):用useMemo来减少性能开销

    精读React hooks(八):我们为什么需要useCallback

    精读React hooks(九):使用useTransition进行非阻塞渲染

    精读React hooks(十):使用useDeferredValue延迟状态更新

    精读React hooks(十一):useInsertionEffect——CSS-in-JS样式注入新方式

    精读React hooks(十二):使用useImperativeHandle能获得什么能力

    精读React hooks(十三):使用useSyncExternalStore获取实时数据

    精读React hooks(十四):总有一天你会需要useId为你生成唯一id

    精读React hooks(十五):把useDebugValue加入你的React调试工具库

    精读React hooks(十六):一个为代码优雅而生的hook——use