Портал рендерит часть компонента в другое место DOM, обычно в document.body, при этом она остаётся на том же месте в дереве React. Его создают через createPortal(children, domNode) из react-dom. С помощью порталов модальные окна, подсказки и выпадающие меню вырываются из родителя, у которого есть overflow: hidden или собственный контекст наложения.
Блок ниже обрезает всё, что выходит за его пределы. Откройте обе подсказки.
Обычная подсказка обрезается по пунктирной рамке. Подсказка в портале видна целиком, потому что её узел DOM является потомком <body>, а не блока. Удалите overflow: 'hidden' у блока, и обычная подсказка больше не будет обрезаться.
Синтаксис
import { createPortal } from 'react-dom';
createPortal(children, domNode, key?)
childrenэто любой JSX: элемент, фрагмент, компонент.domNodeэто существующий элемент DOM, напримерdocument.bodyилиdocument.getElementById('modal-root'). Он должен существовать, когда портал рендерится.keyнеобязателен, он нужен, когда вы рендерите список порталов.
createPortal возвращает то, что вы помещаете в JSX, как любой элемент. В собственной позиции родителя в DOM он ничего не рендерит.
Модальное окно в document.body
Модальное окно это классический случай. Внутри карточки с transform оверлей с position: fixed позиционируется относительно этой карточки, а не окна, а затем overflow: hidden карточки его обрезает. Отрендеренный в document.body, он покрывает всю область просмотра.
Откройте модальное окно, затем нажмите Escape или кликните по тёмному оверлею, чтобы закрыть его. При открытии фокус переходит на кнопку Close. Теперь верните оверлей без createPortal (уберите вызов и его аргумент document.body): оверлей сожмётся до размера карточки, потому что transform делает карточку содержащим блоком для фиксированных элементов.
События всплывают по дереву React
Портал меняет то, где живёт узел DOM, а не то, где живёт компонент. События React всплывают по дереву React, поэтому клик внутри портала доходит до onClick компонента, который его отрендерил, хотя в DOM кнопка является потомком <body>.
С нативными обработчиками иначе. Обработчик, добавленный через addEventListener, следует дереву DOM и клика не видит.
Нажмите «Inside the wrapper»: выводят сообщение и обработчик React, и нативный обработчик. Нажмите «In a portal»: выводит только обработчик React. Кнопка из портала на экране находится вне пунктирной обёртки, но React всё равно считает её дочерней.
Обычно это то, что нужно: onClick у родителя меню видит клики по пунктам меню в портале, а провайдеры контекста над компонентом действуют и внутри портала. Но это может удивить в логике «клик снаружи закрывает». onClick на обёртке, который закрывает меню, срабатывает и при кликах внутри портала меню, поэтому останавливайте всплытие внутри меню (e.stopPropagation(), разобрано на странице о событиях) или используйте нативный обработчик на document и проверяйте, находится ли цель внутри узла DOM портала.
Порталы в собственный контейнер
document.body это самая простая цель. Некоторые приложения добавляют в index.html отдельный контейнер, чтобы все оверлеи делили одно место и один порядок наложения:
<body>
<div id="root"></div>
<div id="modal-root"></div>
</body>
createPortal(<Modal />, document.getElementById('modal-root'));
При серверном рендеринге (Next.js и другие фреймворки) document на сервере не существует. Рендерите портал только после того, как компонент смонтирован, например под состоянием mounted, которое эффект устанавливает в true.
Когда портал нужно расположить рядом с его триггером, сначала измерьте триггер. Первый пример измеряет его в обработчике клика; для подсказки, которая не должна мерцать, измеряйте в useLayoutEffect, который выполняется до отрисовки браузером.
Доступные модальные окна
Перенос разметки в document.body сам по себе ничего не даёт пользователям клавиатуры и экранных дикторов. Модальному окну также нужно:
role="dialog"иaria-modal="true", аaria-labelledbyдолжен указывать на его заголовок.- Перенос фокуса в диалог при открытии (пример фокусирует Close) и обратно на кнопку, которая его открыла, при закрытии.
- Закрытие по Escape.
- Удержание фокуса внутри, пока он открыт, чтобы Tab не уходил на страницу позади. Атрибут
inertна корне приложения, пока открыто модальное окно, блокирует там фокус и клики.
Нативный элемент <dialog> берёт на себя большую часть этого. Открытый через dialogRef.current.showModal(), он рисуется в верхнем слое браузера поверх любого z-index, делает остальную страницу инертной и закрывается по Escape. Портал ему не нужен, поэтому это хороший выбор по умолчанию для простых диалогов подтверждения; порталы остаются инструментом для подсказок, меню и собственных оверлеев.
Почему одного z-index мало
Разработчики часто берутся за портал после того, как большой z-index не смог поднять меню над остальной страницей. Причина в контекстах наложения. Элемент с position и z-index, с opacity меньше 1, с transform, filter или isolation: isolate начинает новый контекст наложения, и значения z-index его детей соревнуются только друг с другом внутри него. Дочерний элемент с z-index: 9999 внутри карточки с z-index: 1 всё равно лежит ниже соседней карточки с z-index: 2.
Портал выводит элемент из всех таких предков. Как потомок <body>, его z-index сравнивается с элементами верхнего уровня страницы, поэтому для оверлеев достаточно скромного значения вроде 1000.
Частые ошибки
Портал в узел, которого ещё нет. document.getElementById('modal-root') возвращает null, если элемента нет, и createPortal выбрасывает "Target container is not a DOM element". Проверьте HTML или используйте портал в document.body.
Создание целевого узла во время рендера. document.createElement('div') в теле компонента создаёт новый узел при каждом рендере. Создайте его один раз в эффекте или используйте фиксированный контейнер.
Всплывающие элементы, которые не следуют за триггером. Подсказка, расположенная по getBoundingClientRect(), не следует за своим триггером, когда страница прокручивается или меняет размер. Пересчитывайте позицию на scroll и resize (и удаляйте эти обработчики в очистке эффекта) или закрывайте подсказку при прокрутке страницы.
Часто задаваемые вопросы
Что такое портал в React?
Способ отрендерить детей в узел DOM вне элемента DOM родительского компонента. Его создают через createPortal(children, domNode) из react-dom. Дети сохраняют своё место в дереве React, поэтому пропсы, состояние и контекст работают как обычно.
Когда использовать портал?
Когда что-то должно появиться поверх своего контейнера или вне его: модальные окна, подсказки, выпадающие меню, уведомления. Иначе родитель с overflow: hidden, transform или собственным контекстом наложения обрежет или скроет это.
Всплывают ли события из портала?
Да, по дереву React. Клик внутри портала доходит до обработчиков onClick у родителей в React, хотя узел DOM живёт в document.body. Нативные обработчики, добавленные через addEventListener, следуют дереву DOM.
Работает ли контекст внутри портала?
Да. Контекст, как и события, следует дереву React. Модальное окно, отрендеренное в document.body, всё равно читает тему или пользователя из провайдеров над компонентом, который его создал.
Нужен ли портал для модального окна?
Не всегда. Нативный элемент <dialog>, открытый через showModal(), рисуется в верхнем слое браузера поверх любого z-index и со встроенной работой с фокусом. Портал это обычный выбор для собственных модальных окон, а также подсказок и меню.