Перейти к содержимому

30. Порталы

Компонент <Portal> позволяет рендерить JSX за пределами текущего дерева компонентов — например, прямо в document.body. Это критически важно для модальных окон, тостов, тултипов и выпадающих меню, которые должны визуально “вырываться” из родительского контейнера.

Без порталов компонент рендерится внутри своего родителя. Если родитель имеет overflow: hidden или z-index-контекст, модальное окно или тултип будет обрезан:

// ❌ Проблема: модалка рендерится внутри .card
function 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>
);
}
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>
);
}

По умолчанию Portal рендерит содержимое в document.body, но можно указать другой элемент:

// Рендерим в конкретный элемент
<Portal mount={document.getElementById('toast-container')!}>
<Toast message="Успешно сохранено!" />
</Portal>
// Или в кастомный ref
function App() {
let toastRoot!: HTMLDivElement;
return (
<>
<div ref={toastRoot} id="toast-root" />
<Portal mount={toastRoot}>
<Toast />
</Portal>
</>
);
}

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>
);
}
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>
);
}
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>
</>
);
}

При 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>;
}

Полная система: модальное окно + тосты + тултипы через порталы: