Skip to content

Ref 与 DOM 边界 ​

问题:状态更新后,DOM 什么时候存在 ​

点击“编辑”时调用 setOpen(true),这一刻输入框还没有提交到 DOM,事件处理函数不能可靠地立刻聚焦它。Ref 提供一个在提交后指向节点的可变容器;Effect 再把“弹层已打开”同步成浏览器焦点。

修改 ref.current 不触发渲染。需要显示在界面上的值属于 state;计时器 ID、上一次焦点节点、第三方实例等不参与渲染的信息才适合 Ref。

完整实验:进入并归还焦点 ​

jsx
import { useEffect, useRef, useState } from 'react'

export default function App() {
  const [open, setOpen] = useState(false)
  const [name, setName] = useState('Ada')
  const triggerRef = useRef(null)
  const inputRef = useRef(null)

  useEffect(() => {
    if (!open) return
    inputRef.current?.focus()
    return () => {
      triggerRef.current?.focus()
    }
  }, [open])

  return (
    <main>
      <h1>个人资料</h1>
      <p>当前姓名:{name}</p>
      <button ref={triggerRef} onClick={() => setOpen(true)}>编辑姓名</button>
      {open && (
        <section role="dialog" aria-modal="true" aria-labelledby="dialog-title">
          <h2 id="dialog-title">编辑姓名</h2>
          <label>
            姓名
            <input
              ref={inputRef}
              value={name}
              onChange={(event) => setName(event.target.value)}
            />
          </label>
          <button onClick={() => setOpen(false)}>完成</button>
        </section>
      )}
    </main>
  )
}

过程是:事件提出 open = true;React 提交输入框;将 inputRef.current 指向节点;Effect 才调用 focus()。关闭时旧 Effect 的 cleanup 归还焦点。渲染期间用 ref.current 决定 JSX 通常错误:Ref 变化不触发渲染,首次渲染时节点仍是 null。

React 与命令式 DOM 的所有权 ​

聚焦、选择文本、滚动、读取尺寸和调用媒体方法通常不会改变 React 负责的结构。不要对 React 管理的节点调用 remove()、插入兄弟节点或改写 innerHTML;React 的树仍以为旧结构存在。若外部库必须接管一块 DOM,给它一个 React 不渲染子节点的空容器,并在 cleanup 销毁实例。

动态列表:先建立节点索引,再处理焦点 ​

一个 ref 只能记录一个当前值。输入框由数组动态生成时,不应在 map 循环里调用 useRef;Hook 的调用数量和顺序必须稳定。可以只调用一次 useRef,把 Map 放进去,以业务 id 查 DOM 节点。React 提交节点时调用 ref 回调,移除节点或替换回调时执行它返回的清理函数。

下面是独立的 src/App.jsx,先练习“按 id 聚焦”再做循环聚焦题:

jsx
import { useRef, useState } from 'react'

const ids = ['a', 'b', 'c']

export default function App() {
  const nodes = useRef(new Map())
  const [showB, setShowB] = useState(true)
  const [message, setMessage] = useState('尚未定位')
  function focus(id) {
    const node = nodes.current.get(id)
    if (node) node.focus()
    setMessage(node ? `已聚焦 ${id}` : `${id} 不在列表中`)
  }
  return <main>
    <button onClick={() => setShowB(value => !value)}>切换 b</button>
    <button onClick={() => focus('b')}>定位 b</button>
    {ids.filter(id => showB || id !== 'b').map(id => <label key={id}>
      输入 {id}
      <input ref={node => {
        nodes.current.set(id, node)
        return () => { nodes.current.delete(id) }
      }} />
    </label>)}
    <p>{message}</p>
  </main>
}

先定位 b,应聚焦它;隐藏 b 后再定位,必须报告“不在列表中”;显示后应能再次定位新节点。Map 的修改没有触发界面更新,message 才是显示结果的 state。不能在渲染中用 Map.size 当成实时显示的列表长度,界面数量应从 ids 和 showB 推导。

本例使用 React 19 的 ref 清理返回值。写成 ref={node => nodes.current.set(id, node)} 会意外返回 Map,不是合法的清理函数;应保留花括号并显式返回清理。旧代码常用回调收到 null 时删除节点,仍可遇到,但不要与本例的返回清理模式混写。

内联回调每次渲染身份不同,React 可能先清理旧回调再设置新回调,因此清理不能被理解成“一定是节点从页面删除”。Strict Mode 也会额外进行设置与清理检查。需要减少外部昂贵登记时,可提取单项组件,再用 useCallback 为它稳定回调;不能以跳过清理换取调用次数减少。

边界 ​

示例演示焦点进入和归还,却不是完整无障碍 Dialog。产品还需焦点圈定、Escape 关闭、背景不可交互等;优先使用原生 <dialog> 或成熟方案。本章不引入第三方库,因此不把小实验冒充基础设施。

练习:契约任务 ​

实现三项列表和“聚焦下一项”按钮:

  1. 输入由 id a、b、c 标识,Ref 回调维护 Map,不得在循环内调用 useRef。
  2. 按 a → b → c → a 循环聚焦;界面用 state 显示“当前:x”。
  3. 隐藏 b 后循环跳过它;ref 清理回调从 Map 删除节点。
  4. 不查询或修改 document,不手工移动 React 管理的节点。

完成后对照答案 04-03。

下一篇把焦点访问扩展为绘制前测量与窄命令接口,再讨论 DOM 节点放到另一容器时 React 关系是否改变。

用心记录每一步成长