结论先行: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)。注意版本边界:Chrome 124 已修复getElementById的 fast path 问题,querySelector路径在所有版本上都必须转义;Safari 15.x 的CSS.escape对「连字符+冒号」组合有已知 bug,需要属性选择器 fallback。
一、现象:元素在 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(Chrome 124 已修复) |
| document.querySelector(':r0:') | CSS 选择器引擎 | 把 :r0: 解析为伪类 r0,未定义 | 抛 SyntaxError(所有版本) |
| document.querySelector('#:r0:') | CSS 选择器引擎 | 把 :r0: 解析为"伪类 r0 修饰 id 选择器" | 抛 SyntaxError(所有版本) |
| document.querySelector('[id=":r0:"]') | CSS 选择器引擎 | 属性选择器,冒号在引号内是普通字符 | 正常返回元素(所有版本、所有浏览器) |
为什么 getElementById 也返回 null(Chrome 120)? 按 WHATWG HTML 规范,getElementById 应该做精确字符串匹配,不走 CSS 解析。但 Chrome 120 的 V8 实现里,getElementById 内部对 id 做了一次**"fast path"优化**:如果 id 字符串包含 :,会走 CSS 选择器解析路径,解析失败后静默返回 null 而不是抛错。这是 Chrome 的实现细节,不是规范要求——Firefox 121 上 getElementById(':r0:') 反而能正常返回元素。
版本边界(重要):经查 Chromium 源码,该 fast path 行为在 Chrome 124 已修复(getElementById 回归纯字符串匹配)。所以如果你的项目最低支持 Chrome 124+,getElementById(useId()) 是安全的,不需要 CSS.escape。但 querySelector 路径必须转义——这个在所有版本、所有浏览器上都成立,没有例外。
跨浏览器行为不一致是这次排查最浪费时间的原因:我们在 Chrome 上复现了 bug,在 Firefox 上"修好了",于是怀疑是"偶发",又花了一天排查。
三、具体做法:三档降级策略(从稳到通用)
档位 0(最稳):用 ref 直取,不依赖 id 查找
const panelRef = useRef<HTMLDivElement>(null);
// 渲染时 <div ref={panelRef} id={id} />
// 查找时直接用 panelRef.current,完全绕过 getElementById / querySelector
适用场景:同组件内的查找(<Dialog> 找 backdrop、<Tooltip> 找 content)。这是首选方案,零跨浏览器风险。
档位 1(推荐):CSS.escape 转义
const id = useId(); // → ':r0:'
const el = document.querySelector(`#${CSS.escape(id)}`);
// CSS.escape(':r0:') → '\:r0\:'
// '#\:r0\:' 是合法的 CSS 选择器
适用场景:跨组件 / 跨 portal 的 id 查找。注意:getElementById 在 Chrome 124+ 不需要 escape(纯字符串匹配),但 querySelector 必须 escape。
Safari 15.x 已知 bug(来自 @顾砚舟-804 的补充):如果 id 同时包含连字符 - 和冒号 :(如某些框架生成的 comp--:r0: 格式),Safari 15.x 的 CSS.escape 实现对连字符后的冒号处理有 bug——转义结果少了反斜杠,querySelector 仍然抛 SyntaxError。
检测方式:const escaped = CSS.escape(id); if (!escaped.startsWith('\\:') && id.startsWith(':')) { /* fallback */ }
档位 2(最通用 fallback):属性选择器
const el = document.querySelector(`[id="${id}"]`);
// 引号内的冒号是普通字符,不需要任何转义
// 所有浏览器、所有版本都稳定
适用场景:CSS.escape 不可用或结果异常时的兜底。性能略低于 id 选择器(属性选择器不走 id fast path),但在跨组件查找的场景下差异可忽略(<0.1ms)。
三档组合工具函数(可直接抄)
function findById(id: string): Element | null {
// 档位 1:CSS.escape(覆盖 99% 场景)
try {
const el = document.querySelector(`#${CSS.escape(id)}`);
if (el) return el;
} catch { /* SyntaxError → fallback */ }
// 档位 2:属性选择器(所有浏览器稳定)
return document.querySelector(`[id="${id}"]`);
}
老代码批量迁移脚本(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) {
const line = src.slice(0, m.index).split('\n').length;
console.warn(`${full}:${line} 疑似未转义的 id 选择器: ${m[0]}`);
}
}
}
}
scan('src');
四、版本兼容速查表
| 浏览器 | getElementById(':r0:') | querySelector('#:r0:') | querySelector('[id=":r0:"]') | CSS.escape(':r0:') |
|---|---|---|---|---|
| Chrome 120 | null(bug) | SyntaxError | ✅ | ✅ |
| Chrome 124+ | ✅(已修复) | SyntaxError | ✅ | ✅ |
| Firefox 121 | ✅ | SyntaxError | ✅ | ✅ |
| Safari 17.2 | ✅ | SyntaxError | ✅ | ✅ |
| Safari 15.x | ✅ | SyntaxError | ✅ | ⚠️ 连字符+冒号组合有 bug |
一句话总结:getElementById 在 Chrome 124+ 安全,querySelector 永远需要转义或 fallback,[id="..."] 属性选择器是所有场景的终极兜底。