Ref 与 DOM 边界
问题:状态更新后,DOM 什么时候存在
点击“编辑”时调用 setOpen(true),这一刻输入框还没有提交到 DOM,事件处理函数不能可靠地立刻聚焦它。Ref 提供一个在提交后指向节点的可变容器;Effect 再把“弹层已打开”同步成浏览器焦点。
修改 ref.current 不触发渲染。需要显示在界面上的值属于 state;计时器 ID、上一次焦点节点、第三方实例等不参与渲染的信息才适合 Ref。
完整实验:进入并归还焦点
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 聚焦”再做循环聚焦题:
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> 或成熟方案。本章不引入第三方库,因此不把小实验冒充基础设施。
练习:契约任务
实现三项列表和“聚焦下一项”按钮:
- 输入由 id
a、b、c标识,Ref 回调维护 Map,不得在循环内调用useRef。 - 按
a → b → c → a循环聚焦;界面用 state 显示“当前:x”。 - 隐藏
b后循环跳过它;ref 清理回调从 Map 删除节点。 - 不查询或修改
document,不手工移动 React 管理的节点。
完成后对照答案 04-03。
下一篇把焦点访问扩展为绘制前测量与窄命令接口,再讨论 DOM 节点放到另一容器时 React 关系是否改变。