Skip to content

Portal 与可访问关联:位置和关系各自决定什么 ​

提示或浮层有时会被祖先容器的 overflow 裁掉。把它放到 document.body 可以改变 DOM 布局约束,但它仍应属于原组件:消费同一份 props,跟随父组件卸载,参与同一条 React 事件路径。Portal 就在分开这两种关系。

DOM 在外部,点击仍沿 React 树传播 ​

createPortal(children, container) 来自 react-dom,返回可放进 JSX 的 React 节点。container 必须已经存在;本例是纯客户端 Vite 页面,document.body 在入口执行前已经存在。服务端不能直接读取 document。

jsx
import { useRef, useState } from 'react'
import { createPortal } from 'react-dom'

export default function App() {
  const box = useRef(null)
  const [hits, setHits] = useState(0)
  const [inside, setInside] = useState(null)
  return <main>
    <p>React 父级收到点击:{hits}</p>
    <p>点击节点在父级 DOM 内:{String(inside)}</p>
    <section ref={box} onClick={event => {
      setHits(value => value + 1)
      setInside(event.currentTarget.contains(event.target))
    }} style={{ border: '1px solid', overflow: 'hidden', height: 70 }}>
      <p>React 父级容器</p>
      {createPortal(<button style={{ position: 'fixed', right: 20, bottom: 20 }}>
        Portal 按钮
      </button>, document.body)}
    </section>
  </main>
}

点击 Portal 按钮:父级计数增加,但 contains 得到 false。浏览器的 contains 检查物理 DOM 包含关系;React 的 onClick 沿 React 树向父组件传播。event.target 是实际点击节点,currentTarget 是当前执行监听器的 section,两者不必是 DOM 祖孙。

因此把弹层“移到 body”并不能阻止它触发 React 父级的点击逻辑。确实需要停止 React 冒泡时,在 Portal 内对应处理器调用 event.stopPropagation(),或调整 React 树上的边界。不能把它误解为任意原生监听器的传播规则也都被改写;若混合 document 原生监听器,需要另行检查两套路径。

Portal 也不创建第二个 React root,Context 仍来自原 React 父树,组件 state 仍按原身份保存。改变 container 会重建 Portal 内容;父组件卸载时 React 删除自己创建的 Portal 节点,通常不删除你传入的外部容器。

重复组件里的 id 必须分别关联 ​

两份表单都写 id="name" 会冲突,第二个 label 可能指到第一个输入。useId 在组件顶层取得用于 DOM 关联的标识,再派生输入和说明的后缀:

jsx
import { useId } from 'react'

function NameField({ label }) {
  const id = useId()
  return <section>
    <label htmlFor={`${id}-input`}>{label}</label>
    <input id={`${id}-input`} aria-describedby={`${id}-hint`} />
    <p id={`${id}-hint`}>请填写便于辨认的姓名。</p>
  </section>
}

export default function App() {
  return <main><NameField label="联系人" /><NameField label="收件人" /></main>
}

点击“收件人”标签,应该聚焦第二个输入;查看元素属性,每个 aria-describedby 指向自己旁边的说明。htmlFor 对应 HTML 的 for 属性,建立标签和控件关系;aria-describedby 额外关联说明,不能代替可见标签。

useId 解决的是组件实例中的关联,不是业务身份。不能用它替代列表 key、数据库主键或缓存键;列表 key 仍应来自数据。也不要推断 id 字符串的内部格式或使用随机数替代它。在服务端渲染与水合中,客户端和服务端仍需有相同的树结构;多个独立 root 还需协调 identifierPrefix,这属于后续服务端章节的运行边界。

Portal 改变节点位置,不妨碍同一文档内的 id 关联,但放到 body 本身不会完成无障碍弹窗。焦点进入、退出、Escape、背景交互约束必须单独设计。这里用普通按钮和输入检验结构关系,不赋予它未实现的模态语义。

练习 ​

将两份 NameField 放入 Portal,父级 section 保留点击计数。组件再接收 stopBubble:第一份为 false,第二份为 true,并在自身 section 的 onClick 中按该值停止传播。验证两份 label 仍聚焦对应输入;直接点击第一份输入让父级计数增加一次,直接点击第二份输入不增加。标签点击可能同时触发控件激活,计数实验因此只统计直接点击输入,不能把标签的原生行为当成 Portal 重复冒泡。

查看答案 · 下一章:并发更新

用心记录每一步成长