← React/Порталы: рендеринг вне иерархии DOM#277 из 383← ПредыдущийСледующий →+20 XP
Полезно по теме:Гайд: React или VueПрактика: React setТермин: React HooksМаршрут: старт с нуля

Порталы: рендеринг вне иерархии DOM

Проблема: ловушка overflow и z-index

Представьте такую ситуацию: у вас есть карточка товара с overflow: hidden и position: relative. Внутри этой карточки вы хотите показать всплывающую подсказку или модальное окно. Но CSS-свойства родителя обрезают всё, что выходит за его границы.

// Проблема: tooltip обрезается родителем
<div style={{ overflow: 'hidden', position: 'relative', height: '50px' }}>
  <ProductCard>
    <Tooltip>Это подсказка — она будет обрезана!</Tooltip>
  </ProductCard>
</div>

Решение — порталы (portals). Portal позволяет рендерить компонент в другом узле DOM (например, прямо в document.body), при этом сохраняя его в React-дереве на прежнем месте.

ReactDOM.createPortal()

import ReactDOM from 'react-dom'

function Modal({ isOpen, onClose, children }) {
  if (!isOpen) return null

  // Рендерим в document.body, но компонент остаётся в React-дереве
  return ReactDOM.createPortal(
    <div className="modal-overlay" onClick={onClose}>
      <div className="modal-content" onClick={e => e.stopPropagation()}>
        {children}
      </div>
    </div>,
    document.body  // <- второй аргумент: DOM-узел назначения
  )
}

// Использование как обычного компонента:
function App() {
  const [isOpen, setIsOpen] = useState(false)

  return (
    <div style={{ overflow: 'hidden' }}>
      <button onClick={() => setIsOpen(true)}>Открыть</button>
      <Modal isOpen={isOpen} onClose={() => setIsOpen(false)}>
        <h2>Я рендерюсь в document.body!</h2>
      </Modal>
    </div>
  )
}

Зачем нужны порталы

Три главных сценария:

1. Модальные окна и диалоги

Должны перекрывать весь интерфейс. Рендеринг в document.body гарантирует, что z-index: 9999 сработает правильно.

2. Тултипы и выпадающие меню

Родительский элемент часто имеет overflow: hidden или overflow: auto. Portal позволяет тултипу выйти за эти ограничения.

3. Уведомления (Toast)

Система уведомлений независима от текущего компонента. Удобно рендерить в отдельный контейнер #notifications всегда присутствующий в DOM.

// Подготовка контейнера для уведомлений (index.html):
// <div id="notifications"></div>

function Toast({ message }) {
  return ReactDOM.createPortal(
    <div className="toast">{message}</div>,
    document.getElementById('notifications')
  )
}

Важно: события всплывают через React-дерево

Это ключевая особенность порталов: хотя компонент находится в DOM в другом месте, события всплывают по React-дереву (там, где компонент объявлен).

function Parent() {
  const handleClick = () => console.log('Клик поймал Parent!')

  return (
    <div onClick={handleClick}>
      <Modal>
        {/* Клик на кнопку всплывёт до Parent — несмотря на то,
            что в DOM Modal находится в document.body */}
        <button>Нажми меня</button>
      </Modal>
    </div>
  )
}

Это поведение — намеренное. Оно позволяет компонентам-родителям обрабатывать события из порталов, как если бы портал был вложен обычным образом.

Создание контейнера для портала

Лучшая практика — создавать и очищать DOM-контейнер через useEffect:

function usePortal(id = 'portal-root') {
  const [container] = useState(() => {
    let el = document.getElementById(id)
    if (!el) {
      el = document.createElement('div')
      el.id = id
      document.body.appendChild(el)
    }
    return el
  })

  return container
}

function Modal({ children }) {
  const container = usePortal('modal-root')
  return ReactDOM.createPortal(children, container)
}

z-index стратегия

При использовании порталов следует придерживаться системы z-index:

/* Рекомендуемые уровни: */
.dropdown     { z-index: 100; }
.sticky-header { z-index: 200; }
.tooltip      { z-index: 300; }
.modal-overlay { z-index: 1000; }
.notification  { z-index: 1100; }
.debug-panel  { z-index: 9999; }

Примеры

Реализация паттерна портала на JavaScript: аппендим элемент к document.body, управляем z-index, делегируем события через родителя

// Демонстрируем концепцию порталов через DOM API.
// В React это ReactDOM.createPortal(), здесь — прямая работа с DOM.

// --- Создаём "компонент" Modal ---

function createModal() {
  let overlay = null
  let isOpen = false
  let onCloseCallback = null

  function mount(content, onClose) {
    if (isOpen) return
    isOpen = true
    onCloseCallback = onClose

    // Создаём overlay в document.body — как портал в React
    overlay = document.createElement('div')
    overlay.style.cssText = [
      'position: fixed',
      'inset: 0',
      'background: rgba(0,0,0,0.5)',
      'display: flex',
      'align-items: center',
      'justify-content: center',
      'z-index: 1000',  // перекрываем всё
    ].join(';')

    const dialog = document.createElement('div')
    dialog.style.cssText = [
      'background: white',
      'padding: 24px',
      'border-radius: 8px',
      'min-width: 300px',
      'position: relative',
    ].join(';')
    dialog.setAttribute('role', 'dialog')
    dialog.setAttribute('aria-modal', 'true')

    dialog.innerHTML = content

    const closeBtn = document.createElement('button')
    closeBtn.textContent = '✕ Закрыть'
    closeBtn.style.cssText = 'margin-top: 16px; cursor: pointer;'
    closeBtn.addEventListener('click', unmount)

    dialog.appendChild(closeBtn)

    // Клик по overlay закрывает модал
    overlay.addEventListener('click', (e) => {
      if (e.target === overlay) unmount()
    })

    overlay.appendChild(dialog)

    // Ключевой момент: добавляем в document.body, а не в родительский контейнер
    document.body.appendChild(overlay)

    console.log('Модал открыт. Добавлен в:', overlay.parentElement.tagName)
    console.log('Модал находится вне родительского контейнера? Да!')
  }

  function unmount() {
    if (!isOpen || !overlay) return
    isOpen = false
    overlay.remove()
    overlay = null
    if (onCloseCallback) onCloseCallback()
    console.log('Модал закрыт и удалён из DOM')
  }

  return { mount, unmount, isOpen: () => isOpen }
}

// --- Демонстрация проблемы с overflow:hidden ---

// Создаём родительский контейнер с overflow:hidden
const container = document.createElement('div')
container.style.cssText = 'overflow: hidden; height: 60px; border: 2px solid red; padding: 10px;'
container.textContent = 'Родительский контейнер (overflow: hidden)'
document.body.appendChild(container)

// Обычный элемент внутри — будет обрезан
const overflowingDiv = document.createElement('div')
overflowingDiv.style.cssText = 'position: absolute; top: 80px; background: orange; padding: 8px;'
overflowingDiv.textContent = 'Обычный div: виден только если не обрезан'
container.appendChild(overflowingDiv)

console.log('=== Демонстрация порталов ===')
console.log('Контейнер имеет overflow:hidden')
console.log('Без портала: дочерние элементы обрезаются')
console.log('С порталом: рендерим в document.body — обрезания нет')

// --- Демонстрация всплытия событий ---

function createEventDemo() {
  let parentClickCount = 0

  // В React: событие из портала всплывает по React-дереву (к Parent)
  // Здесь симулируем это через кастомные события

  function simulatePortalEvent(source) {
    console.log('\n--- Клик из компонента:', source, '---')
    console.log('Событие всплывает по React-дереву:')
    console.log('  ' + source + ' → Modal → Parent (поймано!)')
    parentClickCount++
    console.log('Parent получил событий:', parentClickCount)
  }

  return { simulatePortalEvent }
}

const eventDemo = createEventDemo()
eventDemo.simulatePortalEvent('Button внутри Modal')

// --- Создаём и открываем модал ---

const modal = createModal()

if (typeof document !== 'undefined') {
  const openBtn = document.createElement('button')
  openBtn.textContent = 'Открыть модал (через портал)'
  openBtn.style.cssText = 'margin: 20px; padding: 10px 20px; cursor: pointer;'
  openBtn.addEventListener('click', () => {
    modal.mount(
      '<h2>Модал через портал</h2>' +
      '<p>Я рендерюсь в document.body,</p>' +
      '<p>но события всплывают к родителю!</p>',
      () => console.log('Callback onClose вызван')
    )
  })
  document.body.appendChild(openBtn)
}

// --- z-index приоритеты ---

const zIndexLevels = {
  'Обычный контент': 1,
  'Выпадающее меню': 100,
  'Липкая шапка': 200,
  'Тултип': 300,
  'Модальное окно': 1000,
  'Уведомления (Toast)': 1100,
  'Dev инструменты': 9999,
}

console.log('\n=== Рекомендуемые z-index уровни ===')
Object.entries(zIndexLevels).forEach(([name, z]) => {
  console.log('z-index ' + String(z).padStart(5) + ': ' + name)
})

Порталы: рендеринг вне иерархии DOM

Проблема: ловушка overflow и z-index

Представьте такую ситуацию: у вас есть карточка товара с overflow: hidden и position: relative. Внутри этой карточки вы хотите показать всплывающую подсказку или модальное окно. Но CSS-свойства родителя обрезают всё, что выходит за его границы.

// Проблема: tooltip обрезается родителем
<div style={{ overflow: 'hidden', position: 'relative', height: '50px' }}>
  <ProductCard>
    <Tooltip>Это подсказка — она будет обрезана!</Tooltip>
  </ProductCard>
</div>

Решение — порталы (portals). Portal позволяет рендерить компонент в другом узле DOM (например, прямо в document.body), при этом сохраняя его в React-дереве на прежнем месте.

ReactDOM.createPortal()

import ReactDOM from 'react-dom'

function Modal({ isOpen, onClose, children }) {
  if (!isOpen) return null

  // Рендерим в document.body, но компонент остаётся в React-дереве
  return ReactDOM.createPortal(
    <div className="modal-overlay" onClick={onClose}>
      <div className="modal-content" onClick={e => e.stopPropagation()}>
        {children}
      </div>
    </div>,
    document.body  // <- второй аргумент: DOM-узел назначения
  )
}

// Использование как обычного компонента:
function App() {
  const [isOpen, setIsOpen] = useState(false)

  return (
    <div style={{ overflow: 'hidden' }}>
      <button onClick={() => setIsOpen(true)}>Открыть</button>
      <Modal isOpen={isOpen} onClose={() => setIsOpen(false)}>
        <h2>Я рендерюсь в document.body!</h2>
      </Modal>
    </div>
  )
}

Зачем нужны порталы

Три главных сценария:

1. Модальные окна и диалоги

Должны перекрывать весь интерфейс. Рендеринг в document.body гарантирует, что z-index: 9999 сработает правильно.

2. Тултипы и выпадающие меню

Родительский элемент часто имеет overflow: hidden или overflow: auto. Portal позволяет тултипу выйти за эти ограничения.

3. Уведомления (Toast)

Система уведомлений независима от текущего компонента. Удобно рендерить в отдельный контейнер #notifications всегда присутствующий в DOM.

// Подготовка контейнера для уведомлений (index.html):
// <div id="notifications"></div>

function Toast({ message }) {
  return ReactDOM.createPortal(
    <div className="toast">{message}</div>,
    document.getElementById('notifications')
  )
}

Важно: события всплывают через React-дерево

Это ключевая особенность порталов: хотя компонент находится в DOM в другом месте, события всплывают по React-дереву (там, где компонент объявлен).

function Parent() {
  const handleClick = () => console.log('Клик поймал Parent!')

  return (
    <div onClick={handleClick}>
      <Modal>
        {/* Клик на кнопку всплывёт до Parent — несмотря на то,
            что в DOM Modal находится в document.body */}
        <button>Нажми меня</button>
      </Modal>
    </div>
  )
}

Это поведение — намеренное. Оно позволяет компонентам-родителям обрабатывать события из порталов, как если бы портал был вложен обычным образом.

Создание контейнера для портала

Лучшая практика — создавать и очищать DOM-контейнер через useEffect:

function usePortal(id = 'portal-root') {
  const [container] = useState(() => {
    let el = document.getElementById(id)
    if (!el) {
      el = document.createElement('div')
      el.id = id
      document.body.appendChild(el)
    }
    return el
  })

  return container
}

function Modal({ children }) {
  const container = usePortal('modal-root')
  return ReactDOM.createPortal(children, container)
}

z-index стратегия

При использовании порталов следует придерживаться системы z-index:

/* Рекомендуемые уровни: */
.dropdown     { z-index: 100; }
.sticky-header { z-index: 200; }
.tooltip      { z-index: 300; }
.modal-overlay { z-index: 1000; }
.notification  { z-index: 1100; }
.debug-panel  { z-index: 9999; }

Примеры

Реализация паттерна портала на JavaScript: аппендим элемент к document.body, управляем z-index, делегируем события через родителя

// Демонстрируем концепцию порталов через DOM API.
// В React это ReactDOM.createPortal(), здесь — прямая работа с DOM.

// --- Создаём "компонент" Modal ---

function createModal() {
  let overlay = null
  let isOpen = false
  let onCloseCallback = null

  function mount(content, onClose) {
    if (isOpen) return
    isOpen = true
    onCloseCallback = onClose

    // Создаём overlay в document.body — как портал в React
    overlay = document.createElement('div')
    overlay.style.cssText = [
      'position: fixed',
      'inset: 0',
      'background: rgba(0,0,0,0.5)',
      'display: flex',
      'align-items: center',
      'justify-content: center',
      'z-index: 1000',  // перекрываем всё
    ].join(';')

    const dialog = document.createElement('div')
    dialog.style.cssText = [
      'background: white',
      'padding: 24px',
      'border-radius: 8px',
      'min-width: 300px',
      'position: relative',
    ].join(';')
    dialog.setAttribute('role', 'dialog')
    dialog.setAttribute('aria-modal', 'true')

    dialog.innerHTML = content

    const closeBtn = document.createElement('button')
    closeBtn.textContent = '✕ Закрыть'
    closeBtn.style.cssText = 'margin-top: 16px; cursor: pointer;'
    closeBtn.addEventListener('click', unmount)

    dialog.appendChild(closeBtn)

    // Клик по overlay закрывает модал
    overlay.addEventListener('click', (e) => {
      if (e.target === overlay) unmount()
    })

    overlay.appendChild(dialog)

    // Ключевой момент: добавляем в document.body, а не в родительский контейнер
    document.body.appendChild(overlay)

    console.log('Модал открыт. Добавлен в:', overlay.parentElement.tagName)
    console.log('Модал находится вне родительского контейнера? Да!')
  }

  function unmount() {
    if (!isOpen || !overlay) return
    isOpen = false
    overlay.remove()
    overlay = null
    if (onCloseCallback) onCloseCallback()
    console.log('Модал закрыт и удалён из DOM')
  }

  return { mount, unmount, isOpen: () => isOpen }
}

// --- Демонстрация проблемы с overflow:hidden ---

// Создаём родительский контейнер с overflow:hidden
const container = document.createElement('div')
container.style.cssText = 'overflow: hidden; height: 60px; border: 2px solid red; padding: 10px;'
container.textContent = 'Родительский контейнер (overflow: hidden)'
document.body.appendChild(container)

// Обычный элемент внутри — будет обрезан
const overflowingDiv = document.createElement('div')
overflowingDiv.style.cssText = 'position: absolute; top: 80px; background: orange; padding: 8px;'
overflowingDiv.textContent = 'Обычный div: виден только если не обрезан'
container.appendChild(overflowingDiv)

console.log('=== Демонстрация порталов ===')
console.log('Контейнер имеет overflow:hidden')
console.log('Без портала: дочерние элементы обрезаются')
console.log('С порталом: рендерим в document.body — обрезания нет')

// --- Демонстрация всплытия событий ---

function createEventDemo() {
  let parentClickCount = 0

  // В React: событие из портала всплывает по React-дереву (к Parent)
  // Здесь симулируем это через кастомные события

  function simulatePortalEvent(source) {
    console.log('\n--- Клик из компонента:', source, '---')
    console.log('Событие всплывает по React-дереву:')
    console.log('  ' + source + ' → Modal → Parent (поймано!)')
    parentClickCount++
    console.log('Parent получил событий:', parentClickCount)
  }

  return { simulatePortalEvent }
}

const eventDemo = createEventDemo()
eventDemo.simulatePortalEvent('Button внутри Modal')

// --- Создаём и открываем модал ---

const modal = createModal()

if (typeof document !== 'undefined') {
  const openBtn = document.createElement('button')
  openBtn.textContent = 'Открыть модал (через портал)'
  openBtn.style.cssText = 'margin: 20px; padding: 10px 20px; cursor: pointer;'
  openBtn.addEventListener('click', () => {
    modal.mount(
      '<h2>Модал через портал</h2>' +
      '<p>Я рендерюсь в document.body,</p>' +
      '<p>но события всплывают к родителю!</p>',
      () => console.log('Callback onClose вызван')
    )
  })
  document.body.appendChild(openBtn)
}

// --- z-index приоритеты ---

const zIndexLevels = {
  'Обычный контент': 1,
  'Выпадающее меню': 100,
  'Липкая шапка': 200,
  'Тултип': 300,
  'Модальное окно': 1000,
  'Уведомления (Toast)': 1100,
  'Dev инструменты': 9999,
}

console.log('\n=== Рекомендуемые z-index уровни ===')
Object.entries(zIndexLevels).forEach(([name, z]) => {
  console.log('z-index ' + String(z).padStart(5) + ': ' + name)
})

Задание

Создай компонент Modal с использованием ReactDOM.createPortal. Модал должен рендериться в отдельный div вне основного дерева. Добавь кнопки для открытия/закрытия модала.

Подсказка

Используй ReactDOM.createPortal(overlay, document.body). Первый аргумент — JSX для рендеринга, второй — DOM-узел куда рендерить. Модал будет физически в document.body, но события будут всплывать по React-дереву.

Загружаем среду выполнения...
Загружаем AI-помощника...