Что такое порталы (Portals) в React?

👨‍💻 Frontend Developer 🟠 Может встретиться 🎚️ Средний
#React

Краткий ответ

Порталы (Portals) — это механизм в React, который позволяет рендерить дочерние элементы в DOM-узел, который находится вне иерархии DOM-компонента родителя.

Основные особенности порталов:

  • Позволяют рендерить элементы вне родительского компонента
  • События всплывают через React-дерево, а не DOM-дерево
  • Полезны для модальных окон, тултипов, выпадающих меню
  • Не нарушают работу контекста React

Частые ошибки:

  • Неправильное использование порталов для всех компонентов
  • Игнорирование доступности (a11y)
  • Неправильная очистка порталов

Ключевое правило: Используйте порталы только тогда, когда нужно вывести элемент вне обычного потока DOM! 🎯


Полный ответ

Представьте, что вы играете в прятки. Обычно дети прячутся в своей комнате, но порталы — это как дверь в другую комнату, где можно спрятаться, но при этом оставаться частью игры! 🚪

Что такое порталы

Порталы позволяют рендерить дочерние элементы в любом месте DOM-дерева:

// Создание портала
import { createPortal } from 'react-dom';
 
function Modal({ children, isOpen }) {
  if (!isOpen) return null;
  
  // Рендерим children в document.body, а не в родительский компонент
  return createPortal(
    <div className="modal">
      {children}
    </div>,
    document.body
  );
}

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

Порталы решают проблему CSS-контекста и z-index:

// ❌ Проблема без порталов
function App() {
  return (
    <div style={{ position: 'relative', zIndex: 1 }}>
      <div style={{ overflow: 'hidden' }}>
        {/* Модальное окно обрезается контейнером! */}
        <Modal>
          <h1>Модальное окно</h1>
        </Modal>
      </div>
    </div>
  );
}
 
// ✅ Решение с порталами
function Modal({ children }) {
  return createPortal(
    <div className="modal-overlay">
      <div className="modal-content">
        {children}
      </div>
    </div>,
    document.body
  );
}

Как работают порталы

События и всплытие

Порталы не изменяют поведение событий в React:

function ParentComponent() {
  const handleClick = () => {
    console.log('Клик по родителю');
  };
  
  return (
    <div onClick={handleClick}>
      <h1>Родительский компонент</h1>
      
      {/* Событие будет всплывать к ParentComponent, 
          даже если кнопка в портале! */}
      {createPortal(
        <button>Кнопка в портале</button>,
        document.body
      )}
    </div>
  );
}

Работа с контекстом

Порталы сохраняют доступ к контексту:

const ThemeContext = createContext('light');
 
function App() {
  return (
    <ThemeContext.Provider value="dark">
      <div>
        <Header />
        {/* Портал получает доступ к теме через контекст */}
        <PortalComponent />
      </div>
    </ThemeContext.Provider>
  );
}
 
function PortalComponent() {
  const theme = useContext(ThemeContext);
  
  return createPortal(
    <div className={`modal ${theme}`}>
      Модальное окно в теме: {theme}
    </div>,
    document.body
  );
}

Практические примеры

1. Модальное окно

import { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
 
function Modal({ isOpen, onClose, children }) {
  const [modalRoot, setModalRoot] = useState(null);
  
  useEffect(() => {
    // Создаем контейнер для модального окна
    const modalContainer = document.createElement('div');
    modalContainer.className = 'modal-root';
    document.body.appendChild(modalContainer);
    setModalRoot(modalContainer);
    
    // Очистка
    return () => {
      document.body.removeChild(modalContainer);
    };
  }, []);
  
  if (!isOpen || !modalRoot) return null;
  
  return createPortal(
    <div className="modal-overlay" onClick={onClose}>
      <div className="modal-content" onClick={e => e.stopPropagation()}>
        <button className="modal-close" onClick={onClose}>×</button>
        {children}
      </div>
    </div>,
    modalRoot
  );
}
 
// Использование
function App() {
  const [isModalOpen, setIsModalOpen] = useState(false);
  
  return (
    <div>
      <button onClick={() => setIsModalOpen(true)}>
        Открыть модальное окно
      </button>
      
      <Modal 
        isOpen={isModalOpen} 
        onClose={() => setIsModalOpen(false)}
      >
        <h2>Содержимое модального окна</h2>
        <p>Это модальное окно рендерится вне основного DOM-дерева!</p>
      </Modal>
    </div>
  );
}

2. Тултип

import { useState, useRef } from 'react';
import { createPortal } from 'react-dom';
 
function Tooltip({ children, content }) {
  const [isVisible, setIsVisible] = useState(false);
  const targetRef = useRef(null);
  const tooltipRef = useRef(null);
  
  const showTooltip = () => setIsVisible(true);
  const hideTooltip = () => setIsVisible(false);
  
  if (!isVisible) return (
    <span 
      ref={targetRef}
      onMouseEnter={showTooltip}
      onMouseLeave={hideTooltip}
    >
      {children}
    </span>
  );
  
  const targetRect = targetRef.current?.getBoundingClientRect();
  const position = targetRect ? {
    top: targetRect.bottom + window.scrollY + 5,
    left: targetRect.left + window.scrollX
  } : { top: 0, left: 0 };
  
  return createPortal(
    <div 
      ref={tooltipRef}
      className="tooltip"
      style={{
        position: 'absolute',
        top: `${position.top}px`,
        left: `${position.left}px`,
        zIndex: 1000
      }}
      onMouseEnter={showTooltip}
      onMouseLeave={hideTooltip}
    >
      {content}
    </div>,
    document.body
  );
}
 
// Использование
function App() {
  return (
    <div>
      <p>Наведите на <Tooltip content="Это тултип!">это слово</Tooltip> чтобы увидеть тултип.</p>
    </div>
  );
}

Когда использовать порталы

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

// ✅ Идеальное использование порталов
function Modal({ isOpen, children }) {
  if (!isOpen) return null;
  
  return createPortal(
    <div className="modal-backdrop">
      <div className="modal-dialog">
        {children}
      </div>
    </div>,
    document.body
  );
}

2. Выпадающие меню

// ✅ Выпадающие меню вне контейнера с overflow: hidden
function Dropdown({ options, isOpen }) {
  if (!isOpen) return null;
  
  return createPortal(
    <div className="dropdown-menu">
      {options.map(option => (
        <div key={option.value} className="dropdown-item">
          {option.label}
        </div>
      ))}
    </div>,
    document.body
  );
}

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

// ✅ Уведомления, которые должны быть поверх всего
function Notification({ message, isVisible }) {
  if (!isVisible) return null;
  
  return createPortal(
    <div className="notification toast">
      {message}
    </div>,
    document.getElementById('notification-root') || document.body
  );
}

Распространенные ошибки

1. Неправильная очистка

// ❌ Проблема с утечками памяти
function BadModal({ isOpen }) {
  if (!isOpen) return null;
  
  // Создаем новый элемент при каждом рендере!
  const container = document.createElement('div');
  document.body.appendChild(container);
  
  return createPortal(<div>Модальное окно</div>, container);
}
 
// ✅ Правильная очистка
function GoodModal({ isOpen }) {
  const [container, setContainer] = useState(null);
  
  useEffect(() => {
    const div = document.createElement('div');
    document.body.appendChild(div);
    setContainer(div);
    
    return () => {
      // Очистка при размонтировании
      document.body.removeChild(div);
    };
  }, []);
  
  if (!isOpen || !container) return null;
  
  return createPortal(<div>Модальное окно</div>, container);
}

2. Игнорирование доступности

// ❌ Недоступное модальное окно
function BadModal({ isOpen }) {
  if (!isOpen) return null;
  
  return createPortal(
    <div>
      <h2>Заголовок</h2>
      <p>Содержимое</p>
    </div>,
    document.body
  );
}
 
// ✅ Доступное модальное окно
function GoodModal({ isOpen, onClose }) {
  const modalRef = useRef(null);
  
  useEffect(() => {
    if (isOpen && modalRef.current) {
      // Фокус на модальное окно
      modalRef.current.focus();
      
      // Обработка Escape
      const handleEscape = (e) => {
        if (e.key === 'Escape') onClose();
      };
      
      document.addEventListener('keydown', handleEscape);
      return () => document.removeEventListener('keydown', handleEscape);
    }
  }, [isOpen, onClose]);
  
  if (!isOpen) return null;
  
  return createPortal(
    <div 
      role="dialog" 
      aria-modal="true"
      ref={modalRef}
      tabIndex="-1"
    >
      <h2>Заголовок</h2>
      <p>Содержимое</p>
      <button onClick={onClose}>Закрыть</button>
    </div>,
    document.body
  );
}

Лучшие практики

1. Переиспользуемый хук для порталов

import { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
 
function usePortal(containerId) {
  const [container, setContainer] = useState(null);
  
  useEffect(() => {
    // Пытаемся найти существующий контейнер
    let element = document.getElementById(containerId);
    
    // Если нет - создаем новый
    if (!element) {
      element = document.createElement('div');
      element.id = containerId;
      document.body.appendChild(element);
    }
    
    setContainer(element);
    
    return () => {
      // Удаляем только если создали сами
      if (!document.getElementById(containerId)) {
        document.body.removeChild(element);
      }
    };
  }, [containerId]);
  
  return container;
}
 
// Использование хука
function Modal({ isOpen, children, containerId = 'modal-root' }) {
  const container = usePortal(containerId);
  
  if (!isOpen || !container) return null;
  
  return createPortal(children, container);
}

2. Управление z-index

// ✅ Система z-index для порталов
const Z_INDEX = {
  tooltip: 1000,
  dropdown: 1100,
  modal: 1200,
  notification: 1300
};
 
function Modal({ children }) {
  return createPortal(
    <div 
      className="modal-overlay"
      style={{ zIndex: Z_INDEX.modal }}
    >
      <div className="modal-content">
        {children}
      </div>
    </div>,
    document.body
  );
}

Резюме

Порталы — это как волшебная дверь, которая позволяет элементам появляться в другом месте DOM! 🚪✨

  • Порталы рендерят элементы вне иерархии родительского компонента
  • События всплывают через React-дерево, а не DOM
  • Контекст сохраняется при использовании порталов

Когда использовать:

  • Модальные окна ✅
  • Тултипы и выпадающие меню ✅
  • Уведомления ✅

Когда не использовать:

  • Для обычных компонентов ❌
  • Когда не нужен выход из контекста родителя ❌

Практические советы:

  1. Всегда очищайте порталы
  2. Не забывайте про доступность
  3. Используйте систему z-index
  4. Создавайте переиспользуемые хуки

Порталы — мощный инструмент для решения специфических задач позиционирования в React! 💪


Хотите больше полезных статей о React? Подписывайтесь на EasyAdvice, добавляйте сайт в закладки и прокачивайтесь каждый день! 🚀