useLayoutEffect

useLayoutEffect може погіршити продуктивність. Надавайте перевагу useEffect, якщо це можливо.

useLayoutEffect це версія useEffect, яка спрацьовує до того, як браузер перемалює екран.

useLayoutEffect(setup, dependencies?)

Довідник

useLayoutEffect(setup, dependencies?)

Викличте useLayoutEffect, щоб виконати вимірювання макета перед тим, як браузер перефарбує екран:

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

function Tooltip() {
  const ref = useRef(null);
  const [tooltipHeight, setTooltipHeight] = useState(0);

  useLayoutEffect(() => {
    const { height } = ref.current.getBoundingClientRect();
    setTooltipHeight(height);
  }, []);
  // ...

Дивіться більше прикладів нижче.

Параметри

  • setup: Функція з логікою вашого ефекту. Ваша функція налаштування може також опціонально повертати функцію cleanup. Коли ваш компонент буде додано до DOM, React запустить вашу функцію налаштування. Після кожного повторного рендерингу зі зміненими залежностями React спочатку запустить функцію очищення (якщо ви її надали) зі старими значеннями, а потім запустить вашу функцію налаштування з новими значеннями. Перед тим, як ваш компонент буде видалено з DOM, React запустить вашу функцію очищення.

  • необов'язкові залежності: список усіх реактивних значень, на які посилаються у коді setup. Реактивні значення включають пропси, стан і всі змінні та функції, оголошені безпосередньо у тілі вашого компонента. Якщо ваш лінтер налаштований на React, він буде перевіряти, щоб кожне реактивне значення було правильно вказане як залежність. Список залежностей повинен мати постійну кількість елементів і бути записаний в рядок як [dep1, dep2, dep3]. React буде порівнювати кожну залежність з попереднім значенням за допомогою порівняння Object.is. Якщо ви опустите цей аргумент, ваш ефект буде повторно запускатися після кожного повторного рендерингу компонента.

Повернення

useLayoutEffect повертає undefined.

Застереження

  • useLayoutEffect є хуком, тому ви можете викликати його лише на верхньому рівні вашого компонента або ваших власних хуків. Ви не можете викликати його всередині циклів або умов. Якщо вам це потрібно, витягніть компонент і перемістіть ефект туди.

  • Коли суворий режим увімкнено, React запустить один додатковий цикл налаштування+очищення лише для розробки перед першим реальним налаштуванням. Це стрес-тест, який гарантує, що ваша логіка очищення "віддзеркалює" логіку налаштування, і що вона зупиняє або скасовує все, що робить налаштування. Якщо це спричиняє проблему, застосуйте функцію очищення.

  • Якщо деякі з ваших залежностей є об'єктами або функціями, визначеними всередині компонента, існує ризик, що вони призведуть до повторного запуску ефекту частіше, ніж потрібно. Щоб виправити це, видаліть непотрібні об'єктні та функціональні залежності. Ви також можете витягувати оновлення стану та нереактивну логіку поза межами вашого ефекту.

  • Ефекти працюють лише на клієнті. Вони не працюють під час рендерингу на сервері.

  • Код всередині useLayoutEffect і всі заплановані з нього оновлення стану блокують повторне відображення екрану браузером. При надмірному використанні це уповільнює роботу вашої програми. Якщо можливо, надавайте перевагу useEffect.


Використання

Вимірювання макета перед тим, як браузер перефарбує екран

Більшості компонентів не потрібно знати їхнє положення та розмір на екрані, щоб вирішити, що відображати. Вони лише повертають деякий JSX. Потім браузер обчислює їх розміщення (положення та розмір) і перемальовує екран.

Іноді цього недостатньо. Уявіть собі підказку, яка з'являється поруч з деяким елементом при наведенні. Якщо місця достатньо, підказка має з'явитися над елементом, але якщо місця не вистачає, вона має з'явитися нижче. Для того, щоб відрендерити підказку у правильному кінцевому положенні, потрібно знати її висоту (тобто чи поміститься вона зверху).

Для цього потрібно провести рендеринг у два проходи:

  1. Відображати підказку будь-де (навіть у неправильному положенні).
  2. Виміряйте його висоту і вирішіть, де розмістити підказку.
  3. Відобразити підказку знову у правильному місці.

Усе це має відбутися до того, як браузер перемалює екран. Ви не хочете, щоб користувач бачив, що підказка рухається. Викличте useLayoutEffect, щоб виконати вимірювання макета до того, як браузер перемалює екран:

function Tooltip() {
  const ref = useRef(null);
  const [tooltipHeight, setTooltipHeight] = useState(0); // You don't know real height yet

  useLayoutEffect(() => {
    const { height } = ref.current.getBoundingClientRect();
    setTooltipHeight(height); // Re-render now that you know the real height
  }, []);

  // ...use tooltipHeight in the rendering logic below...
}

Ось як це працює крок за кроком:

  1. Tooltip рендериться з початковим tooltipHeight = 0 (тому підказка може бути неправильно розташована).
  2. React розміщує його в DOM і запускає код в useLayoutEffect.
  3. Ваш useLayoutEffect вимірює висоту вмісту підказки і запускає негайний перерендеринг.
  4. Tooltip рендериться знову з реальним tooltipHeight (щоб підказка була правильно розташована).
  5. React оновлює його у DOM, і браузер нарешті показує підказку.

Наведіть курсор на кнопки нижче і подивіться, як підказка змінює своє положення залежно від того, чи підходить вона:

import ButtonWithTooltip from './ButtonWithTooltip.js';

export default function App() {
  return (
    <div>
      <ButtonWithTooltip
        tooltipContent={
          <div>
            This tooltip does not fit above the button.
            <br />
            This is why it's displayed below instead!
          </div>
        }
      >
        Hover over me (tooltip above)
      </ButtonWithTooltip>
      <div style={{ height: 50 }} />
      <ButtonWithTooltip
        tooltipContent={
          <div>This tooltip fits above the button</div>
        }
      >
        Hover over me (tooltip below)
      </ButtonWithTooltip>
      <div style={{ height: 50 }} />
      <ButtonWithTooltip
        tooltipContent={
          <div>This tooltip fits above the button</div>
        }
      >
        Hover over me (tooltip below)
      </ButtonWithTooltip>
    </div>
  );
}
import { useState, useRef } from 'react';
import Tooltip from './Tooltip.js';

export default function ButtonWithTooltip({ tooltipContent, ...rest }) {
  const [targetRect, setTargetRect] = useState(null);
  const buttonRef = useRef(null);
  return (
    <>
      <button
        {...rest}
        ref={buttonRef}
        onPointerEnter={() => {
          const rect = buttonRef.current.getBoundingClientRect();
          setTargetRect({
            left: rect.left,
            top: rect.top,
            right: rect.right,
            bottom: rect.bottom,
          });
        }}
        onPointerLeave={() => {
          setTargetRect(null);
        }}
      />
      {targetRect !== null && (
        <Tooltip targetRect={targetRect}>
          {tooltipContent}
        </Tooltip>
      )
    }
    </>
  );
}
import { useRef, useLayoutEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import TooltipContainer from './TooltipContainer.js';

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

  useLayoutEffect(() => {
    const { height } = ref.current.getBoundingClientRect();
    setTooltipHeight(height);
    console.log('Measured tooltip height: ' + height);
  }, []);

  let tooltipX = 0;
  let tooltipY = 0;
  if (targetRect !== null) {
    tooltipX = targetRect.left;
    tooltipY = targetRect.top - tooltipHeight;
    if (tooltipY < 0) {
      // It doesn't fit above, so place below.
      tooltipY = targetRect.bottom;
    }
  }

  return createPortal(
    <TooltipContainer x={tooltipX} y={tooltipY} contentRef={ref}>
      {children}
    </TooltipContainer>,
    document.body
  );
}
export default function TooltipContainer({ children, x, y, contentRef }) {
  return (
    <div
      style={{
        position: 'absolute',
        pointerEvents: 'none',
        left: 0,
        top: 0,
        transform: `translate3d(${x}px, ${y}px, 0)`
      }}
    >
      <div ref={contentRef} className="tooltip">
        {children}
      </div>
    </div>
  );
}
.tooltip {
  color: white;
  background: #222;
  border-radius: 4px;
  padding: 4px;
}

Зверніть увагу, що хоча компонент Tooltip рендериться за два проходи (спочатку з tooltipHeight, ініціалізованим до 0, а потім з реальною виміряною висотою), ви бачите лише кінцевий результат. Ось чому для цього прикладу вам знадобиться useLayoutEffect замість useEffect. Нижче ми детально розглянемо різницю.

useLayoutEffect блокує браузер від перефарбовування

React гарантує, що код всередині useLayoutEffect та будь-які заплановані в ньому оновлення стану будуть оброблені до того, як браузер перефарбує екран. Це дозволяє вам відобразити підказку, виміряти її та повторно відобразити підказку так, щоб користувач не помітив першого додаткового відображення. Іншими словами, useLayoutEffect блокує браузер від відображень.

import ButtonWithTooltip from './ButtonWithTooltip.js';

export default function App() {
  return (
    <div>
      <ButtonWithTooltip
        tooltipContent={
          <div>
            This tooltip does not fit above the button.
            <br />
            This is why it's displayed below instead!
          </div>
        }
      >
        Hover over me (tooltip above)
      </ButtonWithTooltip>
      <div style={{ height: 50 }} />
      <ButtonWithTooltip
        tooltipContent={
          <div>This tooltip fits above the button</div>
        }
      >
        Hover over me (tooltip below)
      </ButtonWithTooltip>
      <div style={{ height: 50 }} />
      <ButtonWithTooltip
        tooltipContent={
          <div>This tooltip fits above the button</div>
        }
      >
        Hover over me (tooltip below)
      </ButtonWithTooltip>
    </div>
  );
}
import { useState, useRef } from 'react';
import Tooltip from './Tooltip.js';

export default function ButtonWithTooltip({ tooltipContent, ...rest }) {
  const [targetRect, setTargetRect] = useState(null);
  const buttonRef = useRef(null);
  return (
    <>
      <button
        {...rest}
        ref={buttonRef}
        onPointerEnter={() => {
          const rect = buttonRef.current.getBoundingClientRect();
          setTargetRect({
            left: rect.left,
            top: rect.top,
            right: rect.right,
            bottom: rect.bottom,
          });
        }}
        onPointerLeave={() => {
          setTargetRect(null);
        }}
      />
      {targetRect !== null && (
        <Tooltip targetRect={targetRect}>
          {tooltipContent}
        </Tooltip>
      )
    }
    </>
  );
}
import { useRef, useLayoutEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import TooltipContainer from './TooltipContainer.js';

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

  useLayoutEffect(() => {
    const { height } = ref.current.getBoundingClientRect();
    setTooltipHeight(height);
  }, []);

  let tooltipX = 0;
  let tooltipY = 0;
  if (targetRect !== null) {
    tooltipX = targetRect.left;
    tooltipY = targetRect.top - tooltipHeight;
    if (tooltipY < 0) {
      // It doesn't fit above, so place below.
      tooltipY = targetRect.bottom;
    }
  }

  return createPortal(
    <TooltipContainer x={tooltipX} y={tooltipY} contentRef={ref}>
      {children}
    </TooltipContainer>,
    document.body
  );
}
export default function TooltipContainer({ children, x, y, contentRef }) {
  return (
    <div
      style={{
        position: 'absolute',
        pointerEvents: 'none',
        left: 0,
        top: 0,
        transform: `translate3d(${x}px, ${y}px, 0)`
      }}
    >
      <div ref={contentRef} className="tooltip">
        {children}
      </div>
    </div>
  );
}
.tooltip {
  color: white;
  background: #222;
  border-radius: 4px;
  padding: 4px;
}

useEffect не блокує браузер

Ось той самий приклад, але з useEffect замість useLayoutEffect. Якщо у вас повільний пристрій, ви можете помітити, що іноді підказка "мерехтить", і ви ненадовго бачите її початкове положення перед виправленим.

import ButtonWithTooltip from './ButtonWithTooltip.js';

export default function App() {
  return (
    <div>
      <ButtonWithTooltip
        tooltipContent={
          <div>
            This tooltip does not fit above the button.
            <br />
            This is why it's displayed below instead!
          </div>
        }
      >
        Hover over me (tooltip above)
      </ButtonWithTooltip>
      <div style={{ height: 50 }} />
      <ButtonWithTooltip
        tooltipContent={
          <div>This tooltip fits above the button</div>
        }
      >
        Hover over me (tooltip below)
      </ButtonWithTooltip>
      <div style={{ height: 50 }} />
      <ButtonWithTooltip
        tooltipContent={
          <div>This tooltip fits above the button</div>
        }
      >
        Hover over me (tooltip below)
      </ButtonWithTooltip>
    </div>
  );
}
import { useState, useRef } from 'react';
import Tooltip from './Tooltip.js';

export default function ButtonWithTooltip({ tooltipContent, ...rest }) {
  const [targetRect, setTargetRect] = useState(null);
  const buttonRef = useRef(null);
  return (
    <>
      <button
        {...rest}
        ref={buttonRef}
        onPointerEnter={() => {
          const rect = buttonRef.current.getBoundingClientRect();
          setTargetRect({
            left: rect.left,
            top: rect.top,
            right: rect.right,
            bottom: rect.bottom,
          });
        }}
        onPointerLeave={() => {
          setTargetRect(null);
        }}
      />
      {targetRect !== null && (
        <Tooltip targetRect={targetRect}>
          {tooltipContent}
        </Tooltip>
      )
    }
    </>
  );
}
import { useRef, useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import TooltipContainer from './TooltipContainer.js';

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

  useEffect(() => {
    const { height } = ref.current.getBoundingClientRect();
    setTooltipHeight(height);
  }, []);

  let tooltipX = 0;
  let tooltipY = 0;
  if (targetRect !== null) {
    tooltipX = targetRect.left;
    tooltipY = targetRect.top - tooltipHeight;
    if (tooltipY < 0) {
      // It doesn't fit above, so place below.
      tooltipY = targetRect.bottom;
    }
  }

  return createPortal(
    <TooltipContainer x={tooltipX} y={tooltipY} contentRef={ref}>
      {children}
    </TooltipContainer>,
    document.body
  );
}
export default function TooltipContainer({ children, x, y, contentRef }) {
  return (
    <div
      style={{
        position: 'absolute',
        pointerEvents: 'none',
        left: 0,
        top: 0,
        transform: `translate3d(${x}px, ${y}px, 0)`
      }}
    >
      <div ref={contentRef} className="tooltip">
        {children}
      </div>
    </div>
  );
}
.tooltip {
  color: white;
  background: #222;
  border-radius: 4px;
  padding: 4px;
}

Щоб полегшити відтворення проблеми, у цій версії додано штучну затримку під час рендерингу. React дозволяє браузеру зафарбувати екран до того, як він обробить оновлення стану всередині useEffect. В результаті, підказка мерехтить:

import ButtonWithTooltip from './ButtonWithTooltip.js';

export default function App() {
  return (
    <div>
      <ButtonWithTooltip
        tooltipContent={
          <div>
            This tooltip does not fit above the button.
            <br />
            This is why it's displayed below instead!
          </div>
        }
      >
        Hover over me (tooltip above)
      </ButtonWithTooltip>
      <div style={{ height: 50 }} />
      <ButtonWithTooltip
        tooltipContent={
          <div>This tooltip fits above the button</div>
        }
      >
        Hover over me (tooltip below)
      </ButtonWithTooltip>
      <div style={{ height: 50 }} />
      <ButtonWithTooltip
        tooltipContent={
          <div>This tooltip fits above the button</div>
        }
      >
        Hover over me (tooltip below)
      </ButtonWithTooltip>
    </div>
  );
}
import { useState, useRef } from 'react';
import Tooltip from './Tooltip.js';

export default function ButtonWithTooltip({ tooltipContent, ...rest }) {
  const [targetRect, setTargetRect] = useState(null);
  const buttonRef = useRef(null);
  return (
    <>
      <button
        {...rest}
        ref={buttonRef}
        onPointerEnter={() => {
          const rect = buttonRef.current.getBoundingClientRect();
          setTargetRect({
            left: rect.left,
            top: rect.top,
            right: rect.right,
            bottom: rect.bottom,
          });
        }}
        onPointerLeave={() => {
          setTargetRect(null);
        }}
      />
      {targetRect !== null && (
        <Tooltip targetRect={targetRect}>
          {tooltipContent}
        </Tooltip>
      )
    }
    </>
  );
}
import { useRef, useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import TooltipContainer from './TooltipContainer.js';

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

  // This artificially slows down rendering
  let now = performance.now();
  while (performance.now() - now < 100) {
    // Do nothing for a bit...
  }

  useEffect(() => {
    const { height } = ref.current.getBoundingClientRect();
    setTooltipHeight(height);
  }, []);

  let tooltipX = 0;
  let tooltipY = 0;
  if (targetRect !== null) {
    tooltipX = targetRect.left;
    tooltipY = targetRect.top - tooltipHeight;
    if (tooltipY < 0) {
      // It doesn't fit above, so place below.
      tooltipY = targetRect.bottom;
    }
  }

  return createPortal(
    <TooltipContainer x={tooltipX} y={tooltipY} contentRef={ref}>
      {children}
    </TooltipContainer>,
    document.body
  );
}
export default function TooltipContainer({ children, x, y, contentRef }) {
  return (
    <div
      style={{
        position: 'absolute',
        pointerEvents: 'none',
        left: 0,
        top: 0,
        transform: `translate3d(${x}px, ${y}px, 0)`
      }}
    >
      <div ref={contentRef} className="tooltip">
        {children}
      </div>
    </div>
  );
}
.tooltip {
  color: white;
  background: #222;
  border-radius: 4px;
  padding: 4px;
}

Відредагуйте цей приклад до useLayoutEffect і помітьте, що він блокує фарбу, навіть якщо рендеринг сповільнено.

Зображування у два проходи і блокування браузера погіршує продуктивність. Намагайтеся уникати цього, коли це можливо.


Налагодження

Я отримую помилку: "useLayoutEffect нічого не робить на сервері"

Призначення useLayoutEffect - дозволити вашому компоненту використовувати інформацію про макет для рендерингу:

  1. Відрендерити початковий вміст.
  2. Виміряйте макет перед тим, як браузер перефарбує екран.
  3. Зобразіть остаточний вміст за допомогою прочитаної інформації про макет.

Коли ви або ваш фреймворк використовуєте серверний рендеринг, ваш React-застосунок рендерить в HTML на сервері для початкового рендерингу. Це дозволяє вам показати початковий HTML до завантаження JavaScript коду.

Проблема у тому, що на сервері відсутня інформація про макет.

У попередньому прикладі виклик useLayoutEffect у компоненті Tooltip дозволяє йому правильно розташуватися (над або під вмістом) залежно від висоти вмісту. Якби ви спробували відрендерити Tooltip як частину початкового серверного HTML, це було б неможливо визначити. На сервері ще немає ніякої верстки! Отже, навіть якщо ви відрендерили його на сервері, його позиція "стрибне" на клієнті після завантаження і запуску JavaScript.

Зазвичай, компоненти, які покладаються на інформацію про компонування, не потребують рендерингу на сервері. Наприклад, немає сенсу показувати Tooltip під час початкового рендерингу. Він викликається клієнтською взаємодією.

Втім, якщо ви зіткнулися з цією проблемою, у вас є кілька варіантів:

  • Замініть useLayoutEffect на useEffect. Це скаже React, що можна показувати початковий результат рендерингу, не блокуючи фарбу (оскільки оригінальний HTML стане видимим до запуску ефекту).

  • Альтернативно, позначте ваш компонент як клієнтський. Це вкаже React замінити його вміст до найближчої <Suspense> межі на запасний варіант завантаження (наприклад, спінер або гліммер) під час серверного рендерингу.

  • Альтернативно, ви можете рендерити компонент з useLayoutEffect лише після гідратації. Зберігайте булевий стан isMounted, ініціалізований як false, і встановіть його у true у виклику useEffect. Тоді ваша логіка рендерингу буде виглядати як return isMounted ? <RealContent /> : <FallbackContent />. На сервері та під час гідратації користувач побачить FallbackContent, який не повинен викликати useLayoutEffect. Тоді React замінить його на RealContent, який виконується лише на клієнтській стороні і може включати виклики useLayoutEffect.

  • Якщо ви синхронізуєте свій компонент із зовнішнім сховищем даних і покладаєтеся на useLayoutEffect з інших причин, аніж вимірювання компонування, розгляньте замість нього useSyncExternalStore, який підтримує серверний рендеринг.