1. 为什么在 RN + OpenHarmony 里 useId 成了一道必答题
先说我遇到的实际场景。手头有个已经成熟的 React Native 业务包,要迁移到 OpenHarmony 设备上跑。最初大家觉得这是一次“换壳”工作:JS 逻辑不用动,原生适配层负责渲染、事件和桥接,再补几个平台差异文件就完事。可真到联调阶段,第一个让我失眠的问题,竟然是useId()这种不起眼的 Hook。
做过 Web 的同学都知道,React 18 之后useId是给无障碍属性生成唯一标识用的。一个输入框配一个 label,两边用同一个 ID 关联起来,读屏软件才能正常播报。到了 React Native 里,没有 DOM,没有htmlFor,没有 CSS 选择器,很多人根本用不到useId。但如果你要做跨端自动化测试、做无障碍焦点管理、或者要处理动态列表里多个控件之间的关联关系,useId就会突然变得非常重要。
在普通 RN 开发里,我们习惯给组件挂testID或者nativeID。前者给测试框架用,后者给原生侧用。可真到了 OpenHarmony 适配场景,问题就变成:RN 这层生成的 ID,到底有没有可靠地传到 ArkUI 那层?如果传了,两边的 ID 是同一个东西吗?如果不传,自动化测试脚本靠什么定位组件?
这恰恰是整个“React Native + OpenHarmony” 组合里最容易被低估的坑。很多接入适配层的团队最开始只关心布局对不对、事件灵不灵,很少有人会专门去查 ID 传递链路。但它会以极其隐蔽的方式反咬一口:焦点乱跳、点击错位、测试脚本时灵时不灵,甚至渲染后组件状态被莫名其妙重置。排查到最后,罪魁祸首往往就是useId生成的那串字符串没有被正确同步到原生节点。
所以这篇文章,我想把整个问题拆开讲:useId在 React 内部是怎么工作的,它到了 React Native 环境有什么差异,再到 OpenHarmony 的适配层为什么会出问题,以及最后我用什么方案把它跑稳。如果你也在做类似的双端适配,或者正被自动化测试节点的稳定性折磨,这篇文章应该能给你省下不少排查时间。
2. 先吃透 useId 的实现原理:从 Fiber 树到字符串 ID
2.1 它不是计数器,不是随机数,而是树坐标
很多人以为useId就是包了一层“全局计数器”,调用一次加一。如果真是这样,React 完全可以用一个 module 级别的变量搞定,没必要专门为它做一套内部机制。
实际实现里,React 是把 ID 和组件在 Fiber 树里的位置绑定在一起的。每当 React 在渲染过程中进入一个 Fiber 节点时,内部会维护一个类似“路径坐标”的上下文。这个上下文决定了当前节点在整棵组件树中的相对位置。useId在组件渲染时读取这个上下文,再把路径编码成一串可读字符串。
用生活类比来解释:你可以把 Fiber 树想象成一座办公楼的房间分布。useId不是按“第几个进入大楼的人”发工牌,而是告诉你“你在 3 层 2 号区 5 号房间”。只要房间布局没变,你任何时候来都是同一个编号。这就是树坐标设计思想。
因此useId的第一个特性是:同一个组件,在同一个树位置反复渲染,拿到的 ID 永远一样。它不依赖调用次数,不依赖时间,不依赖随机种子。
2.2 唯一性来自树路径的不可重复
为什么不同组件的useId不会撞车?因为 React 给每个节点分配的“路径编码”天然不同。只要两个组件处在树上不同的分支或不同序号位置,它们的路径编码就不可能完全一致。这比全局计数器更健壮。全局计数器的问题是:如果服务器端和客户端各维护一套计数,两端值很容易不一致;一旦组件并发渲染,计数顺序被打乱,ID 就会错位。
而树坐标机制天然是幂等的:React 在每次渲染时都会从根节点重新遍历 Fiber 树,沿途记录路径信息。即使 React 因为并发渲染暂停又恢复,只要最终提交的树结构一致,坐标就会一致,useId的结果也就一致。
你可能会问:那 React 最终返回的字符串是长什么样?通常它包含一个前缀(默认是点号),后面跟着一串编码字符,看起来像是.1.2.3或者.b7qz9这种感觉。它的具体格式并不是我们要背的重点,重点在于它是“位置定制”的、稳定的、并且在一个 React 根实例内部不会重复。
2.3 React Native 环境下的行为差异
到了 React Native 环境,Fiber 树照常构建,Hook 逻辑也完全复用,所以useId在 JS 层的行为和 Web 端没有本质区别。它照样返回一组稳定字符串。
但区别出现在“宿主环境”。Web 端有全局document,你可以拿着 ID 去查 DOM 节点、设置aria-labelledby。React Native 没有 DOM,也没有全局节点查询能力。RN 组件渲染到原生层后,原生视图的标识体系和 JS 层完全是两套。
在 iOS/Android 上,RN 有nativeID和testID这类官方属性,至少有一条明确的通道告诉原生层“这个组件叫什么”。React Native 在底层会把nativeID映射到原生视图的nativeID字段,自动化框架可以据此定位。
但 OpenHarmony 适配层不一定实现了这套映射。实际项目中,适配层需要自行决定如何把 RN 的nativeID/testID/useId语义翻译成 ArkUI 组件可识别的标识。如果翻译层没做,useId在 JS 侧生成得再稳定,原生侧也拿不到任何信息。
这就是我们要面对的核心矛盾:useId解决的是 JS 层一致性,适配层解决的是跨层传递,两者缺一不可。
3. 适配层 ID 失联现场:现象、排查链路与根因
3.1 问题现象:显存突然错乱,点击回调串线
我把问题贴上项目群的时候,第一反应是 “ArkUI 布局渲染 bug”。现象非常具体:
首页是一个消息列表,每条消息下面有两个按钮“删除”和“恢复”。用自动化脚本点击“删除”,预期是弹删除确认框。但真机上点“删除”按钮,偶尔会触发旁边消息项的“恢复”回调。如果快速滚动列表再回来,按钮的焦点高亮会显示在错误的项上。
另一个现象发生在读屏模式。开启无障碍后,每一条消息都由一个标签文本和一个“更多”按钮组成。正常情况下焦点应该按顺序走:标签一、按钮一、标签二、按钮二。实测结果是:焦点会从标签一直接跳到按钮二,中间的对应关系全乱了。
这类问题非常误导人,因为它看起来像事件机制或者无障碍适配的问题,没人会想到去查 ID。
3.2 排查链路:对比两层 ID
我按照“事件是怎么注册的”去查,先看桥接层事件绑定。点击事件确实绑在了正确组件上,逻辑没问题。然后查列表复用,也没发现组件实例错乱。
最后没办法,把 JS 侧的信息和原生侧信息同时打日志。我拿到两条线索:
- JS 侧组件打印出它调用
useId()得到的值,假设是.r123; - ArkUI 原生节点打印出的节点 ID,假设是
10023。
两边没有任何关系。测试框架如果用原生节点 ID 去定位“删除”按钮,找到的可能是列表里另一个节点。而读屏服务拿到的是 ArkUI 原生节点,它根本不知道 JS 层.r123是谁。于是焦点关系自然无法正确关联。
3.3 根因:两层 ID 体系各自为政
继续往深处查,发现适配层在创建 ArkUI 组件时,几乎没有把 RN 的语义化 ID 同步过去。适配层自己给每个原生节点分配了一个自增序列号,用作 ArkUI 的id或者节点句柄。这个序列号只在原生层有意义,JS 侧完全不可见。
更隐蔽的是,适配层在新建原生节点时,如果检测到 RN 组件传了nativeID或testID,会把它作为“字符串类型属性”保存到属性表,但并没有设置到 ArkUI 组件的相关节点字段上。等于这个 ID 到了原生层就变成了一个无用的字符串,既不能参与焦点管理,也不能被测试框架识别。
这种情况下,即使 JS 层useId生成逻辑完全正确,页面该乱还是乱,因为关键信息在传输中被丢弃了。打个比方:你寄快递时填对了收件人姓名和电话,但驿站从来没把这两个字段录入系统,那快递自然没法按姓名电话派送。
3.4 为什么测试没早发现
还有一个值得反思的点:为什么单测和常规功能测试都没能第一时间暴露这个问题?
因为单测往往跑在 Jest 环境里,只验证 JS 逻辑。Renderer 被 mock 掉了,useId返回什么字符串都能通过测试。常规功能测试默认点击后看页面变化,有时候误触附近按钮也能通过,因为回调逻辑本身没错。只有当你做焦点级、节点级、读屏级验证时,ID 失联的漏洞才会彻底现形。
这次排查给我的教训是:跨端适配项目里,接口字段“传没传”和“传对了有没有被消费”是两码事。常规测试很难覆盖后者,必须在设计阶段就明确 ID 的传递链路。
4. 一套可落地的跨端 ID 映射方案:注册表加语义化前缀
4.1 设计目标
修这个问题之前,我先给适配层定了四个目标。有了目标,方案才不会做成临时补丁:
- JS 侧的
useId/nativeID/testID必须稳定传送到原生侧,并且能被 ArkUI 节点消费。 - 原生节点在生命周期结束后,对应的注册信息必须回收,不能无限增长。
- 对已有业务代码侵入性尽量小,最好封装成一个 Hook,业务侧改一行就能用。
- 性能不能有明显回退,不能让每次渲染都产生昂贵的同步调用。
4.2 为什么最终选“注册表 + 前缀”而不是只换自增 ID
一开始我考虑过最暴力的方案:不用useId,直接让原生侧分配一个自增 ID,回传给 JS。这样 JS 拿到的 ID 和原生节点天然一致。
这个方案在小型 Demo 里可以跑,但一推到真实项目就发现问题。第一,业务代码里已经有大量useId()调用,全部替换成本不低。第二,React 18 的并发渲染模型下,自增 ID 无法保证同一树位置的组件在重新渲染后拿到同一个 ID。组件如果卸载再重建,自增拿到的就是新 ID,导致焦点和动画状态都丢失。第三,原生回传是异步的,某些渲染路径下 JS 同步需要 ID,根本等不起。
所以我的选择是:JS 层仍然使用 React 自己的useId,保证稳定性和幂等性;原生层通过注册表建立“JS ID 到原生节点句柄”的映射,保证链路的完整性。
4.3 关键实现:一个封装好的 Hook
业务侧最终只暴露一个 Hook,名字就叫useNativeUniqueId。它内部调用useId拿到 React 分配的稳定 ID,再通过桥接模块把它注册到原生侧。
伪代码大致长这样:
// RN 侧 import { useId, useEffect } from 'react'; import { NativeModules } from 'react-native'; const RNOIdRegistry = NativeModules.RNOIdRegistry; export function useNativeUniqueId(): string { const id = useId(); useEffect(() => { // 组件挂载时注册,告诉原生侧:这个 JS ID 对应了一个新原生节点 RNOIdRegistry.register(id); return () => { // 组件卸载时注销,避免 ID 泄漏 RNOIdRegistry.unregister(id); }; }, [id]); return id; }这个 Hook 返回的id可以直接挂到组件的nativeID属性上:
import { View } from 'react-native'; import { useNativeUniqueId } from '../hooks/useNativeUniqueId'; export function MessageItem() { const uniqueId = useNativeUniqueId(); return ( <View nativeID={uniqueId} testID={`message-item-${uniqueId}`}> {/* 业务内容 */} </View> ); }useId保证同一树位置的组件不会因重新渲染改变 ID,原生注册表则保证 JS ID 和原生节点句柄一一对应。两者各司其职。
4.4 原生侧如何消费这个 ID
原生侧的核心工作是:当 ArkUI 节点创建或更新时,读取 RN 传来的nativeID字符串,把它写入 ArkUI 组件的 ID 字段,同时建一条索引,方便后续按 ID 查询原生节点。
如果使用 ArkUI 的能力做,实现上类似给组件补一个 ID 属性:
@Component export struct RnNodeAdapter { @Prop nativeId: string = ''; @Prop childContent: string = ''; build() { Column() { Text(this.childContent) } .id(this.nativeId) } }这段代码只是示意,真实适配层远比这个复杂,因为要处理testID、nativeID、无障碍标签、点击事件等多套属性。但核心思路是一致的:原生组件在生成时必须把 JS 传入的 ID 放到 ArkUI 组件真正会消费的字段上,而不是存到一个无处使用的属性表里。
4.5 为什么还要加语义化前缀
项目里还有一块业务是动态表单。表单的 Label 和 Input 需要靠 ID 关联。为了避免useId生成的 ID 和业务自定义 ID 撞车,我统一给所有跨层 ID 加了前缀:
const nativeId = `rnoh_${useId()}`;这样做有几个实际好处。第一,日志里一眼能看出这个节点是 RN 层创建的,排查问题时不用猜来源。第二,多套 React 根实例共存时,不同根实例之间可能产生相同的路径编码,加上业务前缀可以降低碰撞概率。第三,自动化测试框架可以通过前缀过滤掉非 RN 原生节点,提高选择效率。
4.6 为什么不建议自己写 ID 生成算法
顺带回答一个很多同学会问的问题:既然都接注册表了,为什么不在 JS 层自己维护一个全局 Map 生成稳定 ID,非要绕一圈用useId?
答案很简单:自己写全局 Map 很容易,但你会发现它其实就是重新发明一个不完整的useId。你要处理组件卸载时的回收,要处理跨根实例的唯一性,要处理并发渲染时“同一个逻辑组件出现两次”的情况。React 已经把这些问题在 Fiber 体系里解决好了,我们没有理由在业务代码里再维护一套重复机制。useId的稳定性和唯一性是框架级保证,我们自己写很难达到同等可靠性。
5. 真机验证:怎么确认 ID 真的稳定
5.1 单测覆盖“幂等性”
JS 侧我先写了针对性的单元测试,核心验证目标只有一个:同一棵树位置,两次渲染返回的 ID 必须一致。
在 React 测试工具下,思路是:渲染一个固定组件结构,拿到useId()返回值;然后触发一次空状态更新,强制组件重渲染,再次拿到返回值。两次值应该完全相等。
这个测试能挡住大部分“把useId换成随机数或计数器”的修改。它同时验证了自定义 Hook 里的桥接注册逻辑没有因为重渲染而重复执行register调用(useEffect的依赖数组能确保这一点)。
5.2 桥接层验证“不重不漏不泄漏”
原生侧注册表我做了三类验证:
- 幂等注册:同一个 JS ID 重复注册,第二次应该被忽略或者返回已有句柄,而不是覆盖掉旧节点。
- 卸载回收:组件卸载时,原生侧注册表对应条目必须移除。如果移除失败,连续创建和销毁 500 个列表项后,注册表大小应该保持在 100 以内的小数值,而不是线性增长到 500。
- 跨进程查询:测试框架按 ID 查找原生节点,必须找到且只找到一个节点。如果某个 ID 对应多个节点,直接视为失败。
这三类验证在自动化脚本里都可以做,关键在于把“注册表大小”作为一个可观测指标暴露到调试页面。我在 Debug 模式下的设置面板里加了一行“注册表当前条目数”,方便同学在看板首页直接观察内存和节点数量是否正常。
5.3 真机上的读屏和焦点检查
功能层验证靠自动化脚本完成,但读屏行为必须真机验证。我的检查清单包括:
- 开启系统读屏后,焦点按预期顺序遍历消息列表和操作按钮;
- 点击“删除”按钮后,焦点后续落在确认弹窗上,而不是继续在按钮间乱跑;
- 快速滚动列表后,读屏播报的内容和视觉高亮位置一致;
- 自动化脚本能通过
nativeID稳定定位到每一个消息项,连续执行 50 次不出错。
真机验证阶段最容易暴露的坑是:ArkUI 节点上的 ID 虽然在调试日志里存在,但读屏服务有时不会刷新。如果遇到这种情况,多半是因为 ID 是在节点创建后异步设置上去的,而读屏服务已经缓存了旧的辅助节点快照。解决方法是把 ID 尽量放在节点初始创建参数里,随节点一同生成,而不是生成后单独 set。
5.4 性能观察
加了一层注册表,最担心的就是性能退化。我在低端参考设备上做了 200 个消息项的列表压测。
结果比我预想的好。useEffect注册本身是异步且轻量的,Map 查找在几百到几千条目范围内都是亚毫秒级。真正需要注意的反而是nativeID字符串本身通过桥接传递的开销:如果每次渲染都把整个 ID 字符串通过 bridge 同步到原生侧,会在频繁更新场景下产生额外开销。
我的优化办法是:ID 注册只在 mount 时执行一次,卸载时执行一次。更新阶段(比如文本内容变化)不重复传递 ID。useEffect的空依赖正好保证了这一点。如果适配层不规范,每条更新都带一遍 ID 字符串,那 200 个列表项的连续刷新就会产生明显的卡顿。
6. 后续维护中最容易被忽略的三个细节
6.1 不要把 useId 当 key 用
这是 React 社区反复强调过的点,但到了 RN + OpenHarmony 场景,踩坑概率更高。列表项重排序时,组件在树中的位置会变化,useId的返回值也会随之变化。如果你把useId直接当作列表组件的key,排序后 React 会认为所有项都换了个新身份证号,导致对应原生节点重建,滚动位置、输入状态全丢。
正确做法是:列表数据的业务 ID 当key(比如消息 ID、用户 ID),useId只用来做辅助功能关联和原生注册表映射。关于这点,实测教训是——拿useId当key的列表在 Web 端可能还能忍,因为 DOM 重建代价小;但在 OpenHarmony 的 ArkUI 节点上,每次都是原生组件销毁重建,体感就是明显白屏加闪动。
6.2 自动化测试别把 useId 当 selectors
很多自动化测试同学习惯把页面元素的id直接当成定位符。在 Web 端没问题,但在 RN + OpenHarmony 的跨层环境里,useId并不是一个稳定的对外定位标识。
原因是useId的稳定性建立在组件树结构不变的前提下。页面上如果加了权限判断、条件渲染、动态插入模块,树的整体结构会变,前面组件的useId路径编码也就变了。自动化测试如果依赖useId精确定位,下次发版就崩。
对外部测试框架,稳定的选择是testID,它代表业务语义,不随树结构变化。我个人的习惯是双管齐下:组件内部关联用useId,对外测试暴露用testID。两者各自发挥擅长的地方,不容易互相拖累。
6.3 注册表的回收要写专门的测试
前面说过注册表必须回收。这个逻辑在业务代码里很容易写,但很难测,因为组件卸载的路径千奇百怪——用户退出页面、列表项滑出屏幕、动态路由切换、错误边界兜底。每一条路径都可能导致useEffect的 cleanup 没有正常执行。
我给适配层代码里专门加了一个“注册表自检”函数,Debug 模式下每 30 秒检查一次:是否有 JS ID 仍然存在但是对应的原生节点已经不在 ArkUI 组件树中。如果有,自动清理并输出一条警告日志。线上模式不打日志,只做静默清理,但保留计数器指标供监控。
这个自检函数上线后,确实抓到过几次异常:比如某个页面在路由切换时没有正常卸载,导致注册表积累了一直不用的 ID。虽然最终影响不大,但能及时发现问题,总比等用户读屏乱套了再排查要省事。
React Native 和 OpenHarmony 的适配组合,很多时候不是难在“能不能跑通”,而是难在跑通之后怎么保证每个细小的标识都正确传达到位。useId只是其中一个缩影。如果你在适配过程中也遇到焦点乱跳、测试脚本定位失败、组件状态重置这些问题,先不要急着怀疑渲染引擎,去查一下 ID 从 JS 到 ArkUI 的整条传输链路,往往能一击命中。