Portals传送门 #

一、Portals 概述 #

1.1 什么是 Portals #

Portals 提供了一种将子组件渲染到父组件 DOM 层级之外的 DOM 节点的方式。

text
组件树                    DOM 树
┌─────────┐              ┌─────────┐
│   App   │              │   App   │
└────┬────┘              └────┬────┘
     │                        │
┌────┴────┐              ┌────┴────┐
│  Modal  │ ──────────►  │  Modal  │ (渲染到 body)
└─────────┘              └─────────┘

1.2 使用场景 #

场景 说明
模态框 避免父元素 overflow 遮挡
悬浮提示 脱离父组件层级
通知消息 全局显示
下拉菜单 避免裁剪问题

1.3 Preact 中的 Portals #

Preact 使用 createPortal 函数实现 Portals:

jsx
import { createPortal } from 'preact';

function Modal({ children }) {
  return createPortal(
    <div class="modal">{children}</div>,
    document.body
  );
}

二、基本用法 #

2.1 创建 Portal #

jsx
import { createPortal } from 'preact';

function Tooltip({ children, target }) {
  return createPortal(
    <div class="tooltip">
      {children}
    </div>,
    target
  );
}

// 使用
function App() {
  const containerRef = useRef(null);

  return (
    <div>
      <div ref={containerRef} class="tooltip-container" />
      <Tooltip target={containerRef.current || document.body}>
        Tooltip content
      </Tooltip>
    </div>
  );
}

2.2 模态框示例 #

jsx
import { createPortal } from 'preact';
import { useEffect, useRef } from 'preact/hooks';

function Modal({ isOpen, onClose, children }) {
  const modalRef = useRef(null);

  useEffect(() => {
    const handleEscape = (e) => {
      if (e.key === 'Escape') {
        onClose();
      }
    };

    if (isOpen) {
      document.addEventListener('keydown', handleEscape);
      document.body.style.overflow = 'hidden';
    }

    return () => {
      document.removeEventListener('keydown', handleEscape);
      document.body.style.overflow = '';
    };
  }, [isOpen, onClose]);

  if (!isOpen) return null;

  return createPortal(
    <div class="modal-overlay" onClick={onClose}>
      <div 
        class="modal-content" 
        ref={modalRef}
        onClick={(e) => e.stopPropagation()}
      >
        <button class="modal-close" onClick={onClose}>×</button>
        {children}
      </div>
    </div>,
    document.body
  );
}

// 使用
function App() {
  const [isModalOpen, setIsModalOpen] = useState(false);

  return (
    <div>
      <button onClick={() => setIsModalOpen(true)}>
        Open Modal
      </button>
      
      <Modal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)}>
        <h2>Modal Title</h2>
        <p>Modal content goes here.</p>
      </Modal>
    </div>
  );
}

2.3 通知系统 #

jsx
import { createPortal } from 'preact';
import { useState, useEffect } from 'preact/hooks';

const notifications = [];
let setNotifications = null;

function notify(message, type = 'info', duration = 3000) {
  const id = Date.now();
  const notification = { id, message, type };
  
  if (setNotifications) {
    setNotifications(prev => [...prev, notification]);
    
    setTimeout(() => {
      setNotifications(prev => prev.filter(n => n.id !== id));
    }, duration);
  }
}

function NotificationContainer() {
  const [items, setItems] = useState([]);
  setNotifications = setItems;

  return createPortal(
    <div class="notification-container">
      {items.map(item => (
        <div key={item.id} class={`notification notification-${item.type}`}>
          {item.message}
        </div>
      ))}
    </div>,
    document.body
  );
}

// 使用
function App() {
  return (
    <div>
      <NotificationContainer />
      
      <button onClick={() => notify('Success!', 'success')}>
        Show Success
      </button>
      <button onClick={() => notify('Error!', 'error')}>
        Show Error
      </button>
      <button onClick={() => notify('Warning!', 'warning')}>
        Show Warning
      </button>
    </div>
  );
}

三、高级用法 #

3.1 动态容器 #

jsx
function Portal({ container, children }) {
  const [target, setTarget] = useState(null);

  useEffect(() => {
    if (typeof container === 'string') {
      setTarget(document.querySelector(container));
    } else if (container instanceof Element) {
      setTarget(container);
    } else {
      setTarget(document.body);
    }
  }, [container]);

  if (!target) return null;

  return createPortal(children, target);
}

// 使用
function App() {
  return (
    <div>
      <Portal container="#sidebar">
        <div>Sidebar content</div>
      </Portal>
      
      <Portal container={customElement}>
        <div>Custom container content</div>
      </Portal>
    </div>
  );
}

3.2 嵌套 Portal #

jsx
function NestedPortal() {
  return createPortal(
    <div>
      <h2>Outer Portal</h2>
      {createPortal(
        <div>
          <h3>Inner Portal</h3>
        </div>,
        document.getElementById('inner-portal')
      )}
    </div>,
    document.getElementById('outer-portal')
  );
}

3.3 下拉菜单 #

jsx
function Dropdown({ trigger, children }) {
  const [isOpen, setIsOpen] = useState(false);
  const [position, setPosition] = useState({ top: 0, left: 0 });
  const triggerRef = useRef(null);

  const updatePosition = () => {
    if (triggerRef.current) {
      const rect = triggerRef.current.getBoundingClientRect();
      setPosition({
        top: rect.bottom + window.scrollY,
        left: rect.left + window.scrollX
      });
    }
  };

  useEffect(() => {
    if (isOpen) {
      updatePosition();
      window.addEventListener('scroll', updatePosition, true);
      window.addEventListener('resize', updatePosition);
    }

    return () => {
      window.removeEventListener('scroll', updatePosition, true);
      window.removeEventListener('resize', updatePosition);
    };
  }, [isOpen]);

  return (
    <>
      <div 
        ref={triggerRef}
        onClick={() => setIsOpen(!isOpen)}
      >
        {trigger}
      </div>
      
      {isOpen && createPortal(
        <div 
          class="dropdown-menu"
          style={{
            position: 'absolute',
            top: position.top,
            left: position.left
          }}
        >
          {children}
        </div>,
        document.body
      )}
    </>
  );
}

// 使用
function App() {
  return (
    <div style={{ overflow: 'hidden', height: '100px' }}>
      <Dropdown trigger={<button>Open Menu</button>}>
        <ul>
          <li>Option 1</li>
          <li>Option 2</li>
          <li>Option 3</li>
        </ul>
      </Dropdown>
    </div>
  );
}

四、事件冒泡 #

4.1 事件传播 #

Portal 内的事件会冒泡到父组件:

jsx
function App() {
  const handleClick = () => {
    console.log('Clicked!');
  };

  return (
    <div onClick={handleClick}>
      <p>Click inside or outside portal</p>
      {createPortal(
        <button>Portal Button</button>,
        document.body
      )}
    </div>
  );
}
// 点击 Portal Button 也会触发 handleClick

4.2 阻止冒泡 #

jsx
function Modal({ onClose, children }) {
  return createPortal(
    <div class="modal-overlay" onClick={onClose}>
      <div 
        class="modal-content"
        onClick={(e) => e.stopPropagation()}
      >
        {children}
      </div>
    </div>,
    document.body
  );
}

五、样式处理 #

5.1 CSS 样式 #

css
/* 模态框样式 */
.modal-overlay {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background-color: rgba(0, 0, 0, 0.5);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 1000;
}

.modal-content {
  background: white;
  padding: 20px;
  border-radius: 8px;
  max-width: 500px;
  width: 90%;
  max-height: 90vh;
  overflow: auto;
}

.modal-close {
  position: absolute;
  top: 10px;
  right: 10px;
  background: none;
  border: none;
  font-size: 24px;
  cursor: pointer;
}

/* 通知样式 */
.notification-container {
  position: fixed;
  top: 20px;
  right: 20px;
  z-index: 1001;
}

.notification {
  padding: 12px 20px;
  margin-bottom: 10px;
  border-radius: 4px;
  color: white;
  animation: slideIn 0.3s ease;
}

.notification-success {
  background-color: #28a745;
}

.notification-error {
  background-color: #dc3545;
}

.notification-warning {
  background-color: #ffc107;
  color: #333;
}

@keyframes slideIn {
  from {
    transform: translateX(100%);
    opacity: 0;
  }
  to {
    transform: translateX(0);
    opacity: 1;
  }
}

5.2 CSS-in-JS #

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

  return createPortal(
    <div
      style={{
        position: 'fixed',
        top: 0,
        left: 0,
        right: 0,
        bottom: 0,
        backgroundColor: 'rgba(0, 0, 0, 0.5)',
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'center',
        zIndex: 1000
      }}
      onClick={onClose}
    >
      <div
        style={{
          background: 'white',
          padding: '20px',
          borderRadius: '8px',
          maxWidth: '500px',
          width: '90%'
        }}
        onClick={(e) => e.stopPropagation()}
      >
        {children}
      </div>
    </div>,
    document.body
  );
}

六、最佳实践 #

6.1 确保 DOM 节点存在 #

jsx
function Portal({ children, selector }) {
  const [container, setContainer] = useState(null);

  useEffect(() => {
    const element = document.querySelector(selector);
    setContainer(element);
  }, [selector]);

  if (!container) return null;

  return createPortal(children, container);
}

6.2 清理副作用 #

jsx
function Modal({ isOpen, onClose, children }) {
  useEffect(() => {
    if (isOpen) {
      document.body.style.overflow = 'hidden';
    }

    return () => {
      document.body.style.overflow = '';
    };
  }, [isOpen]);

  if (!isOpen) return null;

  return createPortal(
    <div class="modal-overlay" onClick={onClose}>
      <div class="modal-content" onClick={(e) => e.stopPropagation()}>
        {children}
      </div>
    </div>,
    document.body
  );
}

6.3 无障碍访问 #

jsx
function Modal({ isOpen, onClose, children, title }) {
  const modalRef = useRef(null);

  useEffect(() => {
    if (isOpen && modalRef.current) {
      // 焦点陷阱
      modalRef.current.focus();
    }
  }, [isOpen]);

  useEffect(() => {
    const handleTab = (e) => {
      if (e.key !== 'Tab' || !modalRef.current) return;

      const focusableElements = modalRef.current.querySelectorAll(
        'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
      );

      const firstElement = focusableElements[0];
      const lastElement = focusableElements[focusableElements.length - 1];

      if (e.shiftKey && document.activeElement === firstElement) {
        e.preventDefault();
        lastElement.focus();
      } else if (!e.shiftKey && document.activeElement === lastElement) {
        e.preventDefault();
        firstElement.focus();
      }
    };

    if (isOpen) {
      document.addEventListener('keydown', handleTab);
    }

    return () => {
      document.removeEventListener('keydown', handleTab);
    };
  }, [isOpen]);

  if (!isOpen) return null;

  return createPortal(
    <div
      class="modal-overlay"
      onClick={onClose}
      role="dialog"
      aria-modal="true"
      aria-labelledby="modal-title"
    >
      <div
        ref={modalRef}
        class="modal-content"
        onClick={(e) => e.stopPropagation()}
        tabIndex={-1}
      >
        <h2 id="modal-title">{title}</h2>
        {children}
      </div>
    </div>,
    document.body
  );
}

七、总结 #

要点 说明
createPortal 创建 Portal
容器 指定渲染目标
事件 事件正常冒泡
样式 需要处理 z-index

核心原则:

  • 确保 DOM 容器存在
  • 处理事件冒泡
  • 清理副作用
  • 注意无障碍访问
最后更新:2026-03-28