30. Порталы
Компонент <Portal> позволяет рендерить JSX за пределами текущего дерева компонентов — например, прямо в document.body. Это критически важно для модальных окон, тостов, тултипов и выпадающих меню, которые должны визуально “вырываться” из родительского контейнера.
Проблема без порталов
Заголовок раздела «Проблема без порталов»Без порталов компонент рендерится внутри своего родителя. Если родитель имеет overflow: hidden или z-index-контекст, модальное окно или тултип будет обрезан:
// ❌ Проблема: модалка рендерится внутри .cardfunction Card() { const [open, setOpen] = createSignal(false); return ( <div style="position: relative; overflow: hidden;"> <button onClick={() => setOpen(true)}>Открыть</button> {/* Модалка будет обрезана! */} <Show when={open()}> <div style="position: fixed; inset: 0;">...</div> </Show> </div> );}Синтаксис Portal в Solid.js
Заголовок раздела «Синтаксис Portal в Solid.js»import { Portal } from 'solid-js/web';
function Modal(props: { open: boolean; onClose: () => void }) { return ( <Show when={props.open}> {/* Всё внутри Portal рендерится в document.body */} <Portal> <div style="position: fixed; inset: 0; background: rgba(0,0,0,0.5);" onClick={props.onClose}> <div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);" onClick={e => e.stopPropagation()}> <slot /> </div> </div> </Portal> </Show> );}Кастомный mount point
Заголовок раздела «Кастомный mount point»По умолчанию Portal рендерит содержимое в document.body, но можно указать другой элемент:
// Рендерим в конкретный элемент<Portal mount={document.getElementById('toast-container')!}> <Toast message="Успешно сохранено!" /></Portal>
// Или в кастомный reffunction App() { let toastRoot!: HTMLDivElement; return ( <> <div ref={toastRoot} id="toast-root" /> <Portal mount={toastRoot}> <Toast /> </Portal> </> );}useShadow — изоляция стилей
Заголовок раздела «useShadow — изоляция стилей»Portal поддерживает Shadow DOM для полной изоляции стилей:
<Portal useShadow={true}> {/* Стили снаружи не проникнут сюда */} <div>Изолированный контент</div></Portal>Полный пример: модальное окно
Заголовок раздела «Полный пример: модальное окно»import { createSignal, Show } from 'solid-js';import { Portal } from 'solid-js/web';
interface ModalProps { open: boolean; onClose: () => void; title: string; children: JSXElement;}
function Modal(props: ModalProps) { // Блокируем прокрутку страницы пока модалка открыта createEffect(() => { if (props.open) { document.body.style.overflow = 'hidden'; onCleanup(() => { document.body.style.overflow = ''; }); } });
// Закрытие по Escape const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') props.onClose(); }; createEffect(() => { if (props.open) { document.addEventListener('keydown', handleKeyDown); onCleanup(() => document.removeEventListener('keydown', handleKeyDown)); } });
return ( <Show when={props.open}> <Portal> <div style={{ position: 'fixed', inset: 0, background: 'rgba(0, 0, 0, 0.6)', display: 'flex', alignItems: 'center', justifyContent: 'center', 'z-index': 1000, }} onClick={props.onClose} > <div role="dialog" aria-modal="true" aria-labelledby="modal-title" style={{ background: 'white', borderRadius: '12px', padding: '24px', minWidth: '320px' }} onClick={e => e.stopPropagation()} > <h2 id="modal-title">{props.title}</h2> {props.children} <button onClick={props.onClose}>Закрыть</button> </div> </div> </Portal> </Show> );}Toast-уведомления через Portal
Заголовок раздела «Toast-уведомления через Portal»interface Toast { id: number; message: string; type: 'success' | 'error' | 'info';}
function createToastStore() { const [toasts, setToasts] = createSignal<Toast[]>([]); let nextId = 0;
const add = (message: string, type: Toast['type'] = 'info') => { const id = nextId++; setToasts(t => [...t, { id, message, type }]); setTimeout(() => remove(id), 3000); };
const remove = (id: number) => { setToasts(t => t.filter(toast => toast.id !== id)); };
return { toasts, add, remove };}
const toastStore = createToastStore();
function ToastContainer() { return ( <Portal mount={document.body}> <div style={{ position: 'fixed', top: '16px', right: '16px', 'z-index': 9999 }}> <For each={toastStore.toasts()}> {toast => ( <div onClick={() => toastStore.remove(toast.id)}> {toast.message} </div> )} </For> </div> </Portal> );}Tooltip с позиционированием
Заголовок раздела «Tooltip с позиционированием»function Tooltip(props: { text: string; children: JSXElement }) { const [visible, setVisible] = createSignal(false); const [pos, setPos] = createSignal({ x: 0, y: 0 }); let triggerRef!: HTMLSpanElement;
const show = () => { const rect = triggerRef.getBoundingClientRect(); setPos({ x: rect.left + rect.width / 2, y: rect.top - 8 }); setVisible(true); };
return ( <> <span ref={triggerRef} onMouseEnter={show} onMouseLeave={() => setVisible(false)}> {props.children} </span> <Show when={visible()}> <Portal> <div style={{ position: 'fixed', left: `${pos().x}px`, top: `${pos().y}px`, transform: 'translate(-50%, -100%)', background: '#1e293b', color: 'white', padding: '4px 8px', borderRadius: '4px', fontSize: '12px', 'z-index': 9999, 'pointer-events': 'none', }}> {props.text} </div> </Portal> </Show> </> );}SSR-ограничения
Заголовок раздела «SSR-ограничения»При Server-Side Rendering порталы требуют особого внимания:
// ❌ document.body недоступен на сервере<Portal mount={document.body}>...</Portal>
// ✅ Используй isServer из 'solid-js/web'import { isServer } from 'solid-js/web';
function SafePortal(props: { children: JSXElement }) { if (isServer) return props.children; // на сервере — рендерим inline
return <Portal>{props.children}</Portal>;}Интерактивный пример
Заголовок раздела «Интерактивный пример»Полная система: модальное окно + тосты + тултипы через порталы: