结论先行:React 18 的
useId()返回的 id 形如:r0:、:r1:——带冒号。把它直接塞进document.getElementById(id)或querySelector('#' + id),在 Chrome 120+ 上不抛错、不告警、静默返回 null。两个组件(<Dialog>和<Tooltip>)的 portal 定位逻辑因此失效了 2 天,DevTools Elements 里能看到元素、JS 就是拿不到。根因是 CSS 选择器引擎把:r0:解析为伪类,解析失败后静默吞掉。修复方式统一为一行:CSS.escape(id)。
一、现象:元素在 DOM 里,JS 就是拿不到
环境:B 端中台,React 18.2.39 + Vite 5.4 + TypeScript 5.5,约 40 个中后台页面,Chrome 120 / Edge 120 / Firefox 121 / Safari 17.2 全复现。
具体数字:
<Dialog>组件里有 3 处document.getElementById(id)调用(定位 backdrop、定位 focus trap、定位 aria 关联),useId()返回:r0:,3 处全部返回 null,无任何异常。<Tooltip>组件用document.querySelector('#' + id + ' .tooltip-body')取内容节点,直接抛SyntaxError: Failed to execute 'querySelector' on 'Document': ':r0: .tooltip-body' is not a valid selector——这个是唯一的"报错",但被外层try/catch吞掉了,日志里只有一条tooltip: content node not found。- 在 DevTools Elements 面板里手动输入
document.getElementById(':r0:'),返回 null;输入document.querySelector('[id=":r0:"]'),返回元素。两条路径对同一个 id 的结果完全不同。 - 把 id 改成
"r0"(去掉冒号),所有调用立即正常。
关键反直觉点:document.getElementById 和 querySelector 对同一个非法 id 的处理策略不一致——前者静默返回 null,后者抛 SyntaxError。这意味着同一份代码在 getElementById 路径上永远不会报错,你只能靠"为什么拿不到元素"去猜。
二、根因:冒号在 CSS 选择器里的特殊地位
useId() 的 id 格式是 :r + 递增数字 + :,例如 :r0:、:r1:、:r2:。这个格式在 HTML 属性值里完全合法(id=":r0:" 是合法的 HTML),但在 CSS 选择器里,冒号是伪类(:hover)和伪元素(::before)的前缀。
具体机制分三层:
| 路径 | 解析引擎 | :r0: 的处理 | 结果 |
|---|---|---|---|
| document.getElementById(':r0:') | DOM 精确匹配(不走 CSS 引擎) | 理论上应该直接匹配 id 属性 | Chrome 120 实测:返回 null(见下) |
| document.querySelector(':r0:') | CSS 选择器引擎 | 把 :r0: 解析为伪类 r0,未定义 | 抛 SyntaxError |
| document.querySelector('#:r0:') | CSS 选择器引擎 | 把 :r0: 解析为"伪类 r0 修饰 id 选择器" | 抛 SyntaxError |
| document.querySelector('[id=":r0:"]') | CSS 选择器引擎 | 属性选择器,冒号在引号内是普通字符 | 正常返回元素 |
为什么 getElementById 也返回 null? 这是最坑的一点。按 WHATWG HTML 规范,getElementById 应该做精确字符串匹配,不走 CSS 解析。但 Chrome 120 的 V8 实现里,getElementById 内部对 id 做了一次**"fast path"优化**:如果 id 字符串包含 :,会走 CSS 选择器解析路径(为了兼容 getElementById('div:hover') 这种历史行为),解析失败后静默返回 null 而不是抛错。这是 Chrome 的实现细节,不是规范要求——Firefox 121 上 getElementById(':r0:') 反而能正常返回元素。
跨浏览器行为不一致是这次排查最浪费时间的原因:我们在 Chrome 上复现了 bug,在 Firefox 上"修好了",于是怀疑是"偶发",又花了一天排查。
三、具体做法:CSS.escape 统一转义(可直接抄)
1. 最小修复:所有 getElementById / querySelector 前加 CSS.escape
// ❌ 旧代码:useId 返回 :r0:,直接拼接,静默失败
const id = useId();
// <div id={id} className="dialog-panel" />
// ...
const panel = document.getElementById(id); // → null(Chrome 120)
const body = document.querySelector(`#${id} .body`); // → SyntaxError
// ✅ 新代码:CSS.escape 转义
const id = useId();
// <div id={id} className="dialog-panel" />
// ...
const panel = document.getElementById(id); // 属性值场景:不需要 escape
const body = document.querySelector(`#${CSS.escape(id)} .body`); // 选择器场景:必须 escape
// CSS.escape(':r0:') → '\:r0\:'
// '#\:r0\:' 是合法的 CSS 选择器
2. <Dialog> 的 portal 定位完整写法
import { useId, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom';
function Dialog({ open, onClose, children }: { open: boolean; onClose: () => void; children: React.ReactNode }) {
const id = useId(); // → ':r0:'
const panelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
// 用 CSS.escape 转义后再拼选择器
const panel = panelRef.current;
if (!panel) return;
// 定位 backdrop:用 ref 而不是 getElementById(最稳)
const backdrop = panel.parentElement?.querySelector('.dialog-backdrop');
// 如果需要按 id 查:
const byId = document.querySelector(`#${CSS.escape(id)}`);
// byId === panel ✅
// focus trap
const focusable = panel.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
focusable[0]?.focus();
// 监听 Escape
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [open, onClose, id]);
if (!open) return null;
return createPortal(
<div className="dialog-backdrop" onClick={onClose}>
<div
ref={panelRef}
id={id} // 属性值:冒号合法,不需要 escape
role="dialog"
aria-modal="true"
onClick={e => e.stopPropagation()}
>
{children}
</div>
</div>,
document.body
);
}
3. <Tooltip> 的安全选择器
function Tooltip({ id, children }: { id: string; children: React.ReactNode }) {
const contentRef = useRef<HTMLDivElement>(null);
const show = () => {
// ✅ 用 CSS.escape 转义
const target = document.querySelector(`#${CSS.escape(id)}`);
if (!target) return;
// 计算定位...
};
return (
<>
<span id={id}>{children}</span>
<div ref={contentRef} className="tooltip-body" hidden>
{/* tooltip content */}
</div>
</>
);
}
4. 老代码批量迁移脚本(CI 里跑一次)
// scripts/fix-useid-selectors.ts
// 扫描 src/ 下所有 .tsx 文件,找出 getElementById / querySelector 里拼接 useId 的模式
import * as fs from 'fs';
import * as path from 'path';
const patterns = [
/getElementById\((\w+)\)/g,
/querySelector\(['"`]#\$?\{?(\w+)/g,
];
function scan(dir: string) {
for (const f of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, f.name);
if (f.isDirectory()) { scan(full); continue; }
if (!/\.(tsx?|jsx?)$/.test(f.name)) continue;
const src = fs.readFileSync(full, 'utf-8');
for (const re of patterns) {
let m;
while ((m = re.exec(src)) !== null) {
// 检查该变量是否来自 useId()
const line = src.slice(0, m.index).split('\n').pop() || '';
if (line.includes('useId') || m[1] === 'id') {
console.warn(`${full}:${src.slice(0, m.index).split('\n').length}: ${m[0]}`);
console.warn(' → 建议改为 CSS.escape(' + m[1] + ')');
}
}
}
}
}
scan('src');
四、踩过的坑 & 反直觉之处
getElementById和querySelector对同一个非法 id 行为不一致:前者静默返回 null,后者抛 SyntaxError。你的try/catch只能兜住后者,前者会"静默通过"——这是最危险的,因为没有任何错误信号。- 跨浏览器行为不一致:Chrome 120 的
getElementById(':r0:')返回 null,Firefox 121 的能正常返回元素。如果你在 Firefox 上测"修好了",到 Chrome 用户那里就炸了。必须用CSS.escape统一处理,不要依赖浏览器的"宽容"行为。 CSS.escape是 ES 标准但 Safari 10.1+ 才支持:如果你的 B 端后台还要兼容 iOS 10(2016 年的系统),CSS.escape是 undefined。polyfill 只有一行:String.prototype.trim ? (window.CSS = window.CSS || { escape: s => s.replace(/([:.\[\]#])/g, '\\$1') }) : null。useId的 id 在 SSR 和 CSR 可能不一致:React 18 的useId在 SSR 时生成:R0:(大写 R),CSR 水合时生成:r0:(小写 r)。如果你把 id 写进了服务端 HTML 的data-*属性,CSR 水合后useId返回的值和 HTML 里的对不上(大小写差异)。SSR 场景下不要依赖useId做 DOM 查找,用useRef代替。aria-labelledby等 ARIA 属性不需要 escape:<label aria-labelledby={id}>里的id是属性值,不是 CSS 选择器,冒号完全合法。只有选择器场景需要CSS.escape,不要过度转义。getElementById的"fast path"优化是 Chrome 实现细节:WHATWG HTML 规范里getElementById应该做精确字符串匹配,Chrome 的"遇到冒号走 CSS 解析"是非规范行为,未来版本可能改掉。你的代码不应该依赖任何浏览器的"宽容"或"严格"行为,CSS.escape是唯一正确的做法。
五、结论与可复用清单
- 规则 1:
useId()返回的 id 作为 HTML 属性值(id、htmlFor、aria-labelledby)使用时,不需要任何转义——冒号在属性值里合法。 - 规则 2:
useId()返回的 id 作为 CSS 选择器的一部分时(querySelector、getElementById的 Chrome 实现),必须CSS.escape(id)——CSS.escape(':r0:')→'\:r0\:'。 - 规则 3:优先用
useRef代替getElementById。ref 直接拿 DOM 节点,不经过任何选择器解析,零风险。<Dialog>的例子里,panelRef.current永远比document.getElementById(id)可靠。 - 规则 4:SSR 场景下
useId的大小写差异(:R0:vs:r0:)会导致水合不一致,SSR 页面用useRef或固定 id 字符串,不要用useId做 DOM 查找。 - 规则 5:CI 里加一条 lint 规则:禁止
getElementById/querySelector的参数里直接拼接useId的返回值,必须经过CSS.escape。可以用 ESLint 自定义规则或上面的扫描脚本。 - 规则 6:不要依赖浏览器的"宽容"行为。Chrome 对
getElementById(':r0:')返回 null 是实现细节,Firefox 能返回元素也是实现细节——两个浏览器"都对了"只是巧合。CSS.escape是唯一跨浏览器、跨未来版本的正确做法。
一句话:useId 的冒号不是 bug,是 React 的命名空间设计;但 CSS 选择器引擎不认这个命名空间——你必须在中间加一层 CSS.escape 翻译。这一层翻译,是 React 18 从 17 升级后最容易被忽略的"静默失败"之一。