ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

React Native鸿蒙适配:useEffect与ArkTS @Watch映射机制深度解析

2026/9/7 15:56:04 拓冰建站 浏览量
React Native鸿蒙适配:useEffect与ArkTS @Watch映射机制深度解析 做鸿蒙适配那阵子我们组里最热闹的讨论不是 ArkTS 语法难不难而是 useEffect。确切地说是 useEffect 的依赖数组[vitalSigns]在鸿蒙端应该怎么监听、怎么触发。一开始组里分两派一派说直接在 ArkTS 的Watch回调里执行业务逻辑简单粗暴另一派坚持要按 React 的调度来。争到第三个晚上我们把 RN 鸿蒙适配层的源码翻了一遍才得出结论适配层确实会把 useEffect 映射成 ArkTS 的Watch装饰器但真正决定副作用要不要跑、什么时候跑的仍然是 React 原生机制。这个结论听起来不复杂但落到代码里的细节非常多。这篇文章我会把整套机制掰开来讲从 React 依赖数组的比较逻辑到 ArkTSWatch的触发语义再到适配层的双向同步设计最后用一个vitalSigns传感器数据的例子完整串一遍。适合三类人看正在做 React Native 鸿蒙化改造的移动端开发、对 ArkTS 状态管理机制好奇的前端/客户端工程师以及需要自己设计跨运行时状态桥接层的框架开发者。1. 先说清楚RN 的 useEffect 在鸿蒙端到底卡在哪1.1 为什么能跑 JS不等于能跑 RN很多刚接触鸿蒙适配的同事第一反应是React Native 不是跨平台吗JS 引擎跑起来业务代码不就能跑了吗这句话只对了一半。React Native 的跨平台模型是双引擎的JS 侧的业务逻辑、组件树、Hooks 状态跑在 JS 引擎Hermes 或 JSC里而真正的界面渲染必须交给宿主的原生框架。在 Android 上是 Android View 体系在 iOS 上是 UIKit在鸿蒙上则是 ArkUI。JS 引擎只负责执行 JavaScript它根本不知道鸿蒙的页面怎么描述。组件树最终要映射成 ArkUI 的组件props 和 state 要映射成State/Prop包括每一个 Hooks 带来的副作用都要在 ArkTS 侧找到对应的通知机制。这就是适配层存在的意义。我习惯把这层适配形容成翻译官它不改变开发者使用 RN 的习惯只是在 JS 运行时代和 ArkUI 渲染引擎之间加了一层双向翻译。在这么多要翻译的机制里Hooks 的翻译是最微妙的其中 useEffect 又是最难搞明白的一块。1.2 useEffect 的三大特性在 ArkTS 里都找不到原生活React 的 useEffect 有三个核心设计任何一个都不能丢。第一个是延迟执行。React 明确保证effect 不会在组件 render 过程中同步执行而是等浏览器完成绘制之后才触发。这个设计是故意的副作用不应该阻塞 UI 更新也不应该在渲染途中被意外调用。但 ArkUI 的Watch装饰器完全不同它在属性被赋值的那一刻就会同步触发回调根本不关心渲染有没有完成。第二个是依赖控制。useEffect 不是每次 render 都重新执行React 会拿新的依赖数组和上一次记录做逐项比较只有发现某一项的值变了才去跑新的 effect。而Watch的语义是属性变化了我就通知你它不做值比较、不判断变化是否值得响应任何一次赋值都会触发回调。第三个是清理函数。useEffect 允许返回一个清理函数React 会在 effect 重新执行前或组件卸载时调用它用于取消订阅、释放资源。这个机制绑死了 React 的生命周期适配层无论如何都不能把 effect 的执行拆到 React 生命周期之外去。三个特性叠加在一起结论就出来了Watch可以作为变化感知器但它绝对替代不了 useEffect 的调度能力。这就是博文标题里触发逻辑为 React 原生机制这句话背后的含义。1.3 适配层的终极目标保留 React 调度翻译状态通知明确了 gap 之后适配层的设计目标就非常清晰了JS 侧继续用 React 原生的依赖比较和调度逻辑ArkTS 侧只用Watch负责感知属性变化然后把变化事件翻译成 JS 侧能识别的状态更新间接驱动 React 的 effect 机制。换句话说这个映射不是useEffect 在鸿蒙端变成了 Watch而是useEffect 的依赖项在鸿蒙端被 Watch 盯住了依赖一变Watch 负责传话最后决定权还是 React 的。把这层关系想明白后面所有的代码实现就都顺理成章了。2. 双机制拆解React 依赖数组与 ArkTS Watch2.1 React 依赖数组的严格比较流程先扣一下 React 依赖数组的原理因为这是整个适配逻辑的基准线。以最常见的写法为例useEffect(() { // 处理生命体征数据 handleVitalSigns(vitalSigns); }, [vitalSigns]);当组件首次 render 时React 会记录当前 effect 的依赖数组。之后每次 renderReact 都会执行下面的流程从 fiber 节点上取出上一次保存的依赖数组prevDeps把新的依赖数组nextDeps和prevDeps逐个元素做Object.is比较如果数组长度不同或者任意一个下标位置的元素Object.is结果为 false就认为这个 effect 需要更新React 进入更新流程先执行上上次 effect 返回的清理函数如果有再执行新的 effect 函数注意这里用到的不是而是Object.is两者在大多数场景下等价但在两个边界上有差异Object.is(NaN, NaN)为 true而NaN NaN为 falseObject.is(0, -0)为 false而0 -0为 true。React 统一用Object.is所以任何依赖比较的模拟实现最好也保持一致的语义不然就会出现React 认为没变但适配层认为变了或者反过来。另外很重要的一点React 的比较是针对依赖数组里的元素做的不是针对组件状态引用整体。依赖数组元素可以是基本类型、对象引用、函数引用React 只认引用不做深比较。如果依赖是一个对象而每次 render 都重新创建这个对象那么即便里面的字段一模一样Object.is也会判断为变化导致 effect 每次都执行。这就是很多人困惑的为什么 effect 莫名重复执行的根本原因。2.2 ArkTS Watch 的触发语义与两个容易踩的坑ArkTS 的Watch是装饰器体系中用于监听属性变化的工具常配合State、Prop、Link使用。基本写法如下State Watch(onVitalSignsChanged) vitalSigns: VitalSignsModel { hr: 72, spo2: 98 }; onVitalSignsChanged(propName: string) { // 属性变化后触发propName 是被变化属性的名字 console.info(vitalSigns changed: ${JSON.stringify(this.vitalSigns)}); }Watch的触发机制有一个非常关键的细节它被激活的前提是被装饰的属性发生了赋值动作。这里强调的是赋值动作而不是值的内容实际变化。哪怕你给属性赋的值和当前值指向同一个对象也只要发生了赋值Watch就会触发。在 ArkUI 的属性观察体系里State的 setter 一旦被调用依赖它的组件会进入脏检查队列Watch回调也会同步进入触发流程。这给适配层带来了两个很容易踩的坑。第一个坑Watch触发时机与渲染时机不同步。它可能在 ArkUI 完成界面刷新之前就回调了如果适配层在这个回调里试图访问原生视图信息拿到的可能是旧值。所以适配层不能在Watch回调里做任何依赖 UI 布局结果的操作。第二个坑Watch不做节流高频属性更新会导致回调被反复调用。传感器数据一秒钟可能更新几十次如果每次更新都走一次完整的桥接到 JS 侧性能会非常难看。后面我会专门讲怎么在适配层里做批次合并和节流。2.3 两类机制不是等价是分工很多人第一次听到useEffect 映射为 Watch的时候会理解成两者功能等价可以直接替换。这是不对的。我把两者的关系整理成一张对照表React useEffectArkTS Watch两者关系render 之后延迟执行属性赋值后同步触发时机不对齐不能直接替换依赖数组逐项 Object.is 比较不比较见变就报Watch 少了判断逻辑支持清理函数没有生命周期概念清理必须回到 React 调度由 React reconciler 统一管理由 ArkUI 属性系统管理两套调度体系所以正确的映射设计应该是分工Watch负责原生侧的状态感知和通知React 负责依赖判断和 effect 生命周期。适配层的价值就是把这两种机制拼接起来让使用者无感。3. 适配层方案设计一个三明治模型3.1 依赖项如何变成 Watch 可监听的属性适配层要做的第一件事是在 ArkTS 侧为 JS 的每个依赖项准备一个镜像属性。React 侧有一个useState变量vitalSignsArkTS 侧就有一个对应的State vitalSigns。JS 侧的状态变化要同步到这个镜像上镜像的变化也要能传播回 JS 侧。这就是典型的双写模型。不过不是所有依赖都需要这个镜像。只有满足下面任一条件的依赖项才需要做映射它来自原生侧的传感器或硬件数据它对应的原生 UI 组件属性需要在 ArkUI 侧感知它参与了 props 传递需要驱动原生组件刷新至于常量、纯 JS 内部变量映射过去只能是负优化白白增加跨运行时通知的次数。我在实际项目里的做法是适配层维护一张依赖映射表键是 JS 侧依赖的名称值是 ArkTS 侧镜像属性的地址。当组件注册 useEffect 时适配层扫描依赖数组逐个去映射表里查询命中才生成Watch监听没命中就跳过。这样既保证了必要状态的同步又避免了过度监听。3.2 在 Watch 回调里只做依赖预比较Watch回调不是用来执行业务逻辑的它是用来做预比较的。预比较的意思是适配层在 ArkTS 侧先把依赖的新旧值用Object.is对比一遍如果确定没变化就不往 JS 侧发通知让事件在源头被拦截如果确实变化了才触发跨运行时通知。为什么要多此一举做预比较因为Watch见变就报但 React 只关心依赖引用真正变化的情况。如果不预拦截ArkTS 侧一次无意义的赋值都会传个事件到 JS 侧JS 侧又要走一遍状态同步、导致 render最后 React 一比较发现依赖没变effect 不跑但整个 render 流程白走了一遍。高频状态下这种白跑会直接拉高 CPU 占用。ArkTS 侧的预比较逻辑可以这样描述onDependencyChanged(propName: string) { const currentValue this.getMirrorValue(propName); const previousValue this.lastDeps[propName]; // 使用 Object.is 语义做比较 if (Object.is(currentValue, previousValue)) { return; } this.lastDeps[propName] currentValue; this.notifyJSRuntime(propName, currentValue); }这段代码的核心是用上一次记录的快照和当前值做Object.is比较不同才继续。注意 ArkTS 本身也支持Object.is可以尽量保持和 React 一致的比较语义。3.3 桥接通知如何驱动 React 重新 render预比较通过之后适配层要把变化同步到 JS 侧。这一步通常通过 JSIJavaScript Interface或者自定义消息通道完成。JSI 是现在 RN 新架构里的标准桥接方式优点是同步、低延迟、能直接宿主机调用 JS 函数缺点是要求宿主端具备 C 能力。同步动作分成两步第一步更新 JS 侧的原始状态。比如调用 RN 内部的状态 setter把vitalSigns的最新值写入 JS 引擎内存。第二步触发一次组件更新。这里不直接调用 effect而是触发一个用于驱动 render 的setState或者等价的调度请求让 React reconciler 进入新一轮 render。组件重新执行函数体之后useEffect 的依赖数组会被重新求值React 自然会发现依赖变化并执行 effect。这里有个容易搞错的点为什么不能让Watch直接通知 React去执行某个 effect因为 React 的 effect 调度是建立在 render 基础上的。你必须让 React 先感知到某个状态变化并完成一次 renderReact 才能在提交阶段判断 effect 是否更新。跳过 render 直接调用 effect不仅违反 React 设计还让 effect 的清理函数、依赖比较全部失效。3.4 effect 最终由 React 原生调度适配层不越权整个设计的最后一步是把控制权交还给 React。适配层在 ArkTS 侧做的所有工作——镜像状态、Watch监听、预比较、跨运行时通知——都只是在喂数据。真正的 effect 业务逻辑包含订阅、请求、UI 操作依然由 React 在 commit 之后执行。适配层唯一可以做的是在 JS 侧用类似下面的逻辑把Watch的通知映射成一个状态刷新请求function useVitalSignsEffect(effect, deps) { // 注册依赖到适配层 const setSyncTick useSyncExternalStore( (callback) { return nativeBridge.subscribeToDependency(vitalSigns, callback); }, () nativeBridge.getLatestDependencySnapshot(vitalSigns) ); // 依赖的引用变化时useSyncExternalStore 会触发重新 render // 真正的 effect 仍然由 React useEffect 执行依赖比较也是 React 自己做的 useEffect(() { return effect(); }, deps); }这段代码展示了关键思想适配层只是把原生侧的状态变化灌进 React 状态流里至于 effect 在 render 之后怎么调度、依赖比较结果如何都是 React 原生的行为。哪怕适配层某些实现不够完美只要守住不越权这条线React 的语义就不会被破坏。4. 实操复盘以 [vitalSigns] 为例全程实现4.1 场景说明与数据结构为了让这套设计落地我们用一个真实场景来串一遍鸿蒙手表上的心率与血氧传感器每秒采集一次生命体征数据数据要传给 RN 侧的业务代码业务代码在useEffect里判断是否存在异常心跳并更新界面上的告警状态。RN 侧组件代码function VitalSignsMonitor() { const [vitalSigns, setVitalSigns] useState{ hr: number; spo2: number; }({ hr: 72, spo2: 98 }); const [warning, setWarning] useStatestring(); useEffect(() { if (vitalSigns.hr 120 || vitalSigns.spo2 90) { setWarning(检测到异常生命体征); } else { setWarning(); } // 这里只是日志方便排查 console.log(vitalSigns changed: hr${vitalSigns.hr}, spo2${vitalSigns.spo2}); }, [vitalSigns]); return WarningPanel message{warning} /; }这里vitalSigns是依赖数组里的唯一元素而且是对象类型。React 会比较它的引用是否变化而不是比较hr和spo2字段。所以适配层必须保证每次传感器数据更新时传入 JS 侧的是一个全新的对象引用否则 React 会认为依赖没变更新逻辑不会执行。4.2 ArkTS 侧镜像代码实现在鸿蒙端适配层负责接收原生传感器数据并维护 ArkTS 侧的镜像状态。// ArkTS 侧适配层组件 Component export struct VitalSignsHost { State Watch(onVitalSignsUpdated) vitalSigns: Recordstring, number { hr: 72, spo2: 98 }; private lastDeps: Recordstring, Object {}; onVitalSignsUpdated(propName: string) { if (this.isSame(this.lastDeps.vitalSigns, this.vitalSigns)) { return; } this.lastDeps.vitalSigns new Map(Object.entries(this.vitalSigns)); // 通过 JSI 或消息通道把变化同步给 JS 侧 this.notifyJsSide(vitalSigns, this.vitalSigns); } isSame(prev: Object, next: Object): boolean { if (prev next) return true; if (prev null || next null) return false; const prevMap prev as Mapstring, number; const nextMap new Map(Object.entries(next as Recordstring, number)); if (prevMap.size ! nextMap.size) return false; for (const key of prevMap.keys()) { if (!Object.is(prevMap.get(key), nextMap.get(key))) return false; } return true; } // 模拟来自传感器模块的新数据实际开发中由硬件回调调用 updateFromSensor(newData: Recordstring, number) { this.vitalSigns { ...newData }; } }这里有几个细节需要留意。第一isSame在做对象比较时我选择逐字段比较而不是直接比较引用这是因为 ArkTS 侧在赋值时已经生成了新对象直接比引用永远不相等。逐字段比较能在源头拦截没有实质变化的更新避免无意义的桥接开销。第二lastDeps存的是字段级的快照而不是对象引用这样每次更新都能保留上一轮的值用作对比。大家可以根据实际需要选择深拷贝或者结构化克隆但注意别把整个数组引用缓存下来否则比较时可能始终拿到同一份数据。4.3 JS 侧桥接代码从原生状态到 React 状态ArkTS 侧把vitalSigns的最新值传过来之后JS 侧需要一个桥接模块来接收并把数据喂给 React 状态。// JS 侧桥接模块简化示意 import { NativeModules, NativeEventEmitter } from react-native; const vitalSignsEmitter new NativeEventEmitter( NativeModules.VitalSignsModule ); let latestVitalSigns { hr: 72, spo2: 98 }; const listeners new Set() void(); vitalSignsEmitter.addListener(onVitalSignsData, (data: any) { // 注意这里必须赋一个新对象引用React 依赖比较才认为变化 latestVitalSigns { hr: data.hr, spo2: data.spo2 }; // 触发所有订阅的回调通知 React 重新 render listeners.forEach((listener) listener()); }); export function subscribeToVitalSigns(callback: () void) { listeners.add(callback); return () listeners.delete(callback); } export function getLatestVitalSigns() { return latestVitalSigns; }然后在自定义 Hook 里使用useSyncExternalStore来订阅数据源。useSyncExternalStore是 React 18 提供的专门用于订阅外部数据源的 Hook它会保证外部的数据变化能被 React 以一致的方式感知到可以在并发渲染下避免撕裂问题。import { useSyncExternalStore, useEffect } from react; import { subscribeToVitalSigns, getLatestVitalSigns, } from ../bridge/vitalSignsBridge; export function useVitalSignsEffect(effect: () void | (() void), deps: any[]) { // 这一步确保外部数据变化时组件重新 render useSyncExternalStore(subscribeToVitalSigns, getLatestVitalSigns); // 真正的 effect 交给 React useEffect(() { return effect(); }, deps); }这样在业务组件里我们的调用方式几乎和原来一样没有感知到鸿蒙适配的存在function VitalSignsMonitor() { const [warning, setWarning] useState(); useVitalSignsEffect(() { const vs getLatestVitalSigns(); if (vs.hr 120 || vs.spo2 90) { setWarning(检测到异常生命体征); } else { setWarning(); } }, [getLatestVitalSigns()]); // ... 省略 UI 渲染 }这里有个小技巧依赖数组里我直接用了getLatestVitalSigns()每次 render 都会返回最新状态的一个新引用React 用它做依赖比较自然能感知到变化。如果你觉得这个写法太绕也可以继续用原来的vitalSigns状态效果一样核心是变化能进入 React 状态流。4.4 完整时序演练与日志验证整套流程跑起来的时序如下鸿蒙原生传感器回调数据ArkTS 侧调用updateFromSensor更新State vitalSignsWatch(onVitalSignsUpdated)触发适配层做字段级预比较确认hr或spo2有变化适配层通过 JSI/事件通道把最新数据发送给 JS 侧桥接模块JS 桥接模块更新latestVitalSigns并触发useSyncExternalStore的订阅回调React 调度新一轮 render组件函数重新执行useEffect依赖数组重新求值React 用Object.is比较新旧依赖发现引用变化执行依赖更新React 先执行业务代码原有的清理函数如果存在然后执行新的useEffect回调effect 里判断心率或血氧是否异常更新 warning 状态为了验证链路是否正常我在三个位置打了日志ArkTSWatch回调里打印onWatch: vitalSigns updatedJS 桥接模块里打印bridge: vitalSigns synceduseEffect 回调里打印effect: vitals changed如果日志顺序是onWatch - bridge - effect说明链路通顺如果onWatch - effect而缺少 bridge说明适配层在绕过 React 调度需要立刻检查是否直接去调了 effect。5. 避坑记录与性能调优5.1 常见问题速查表实际开发里下面这些坑我们基本都踩过一轮整理成速查表方便对照现象根因解决方案effect 不执行但 ArkTS 日志显示 Watch 已触发Watch回调没有触发 JS 侧状态更新或更新没进 React 状态流检查桥接事件是否注册成功useSyncExternalStore是否被正确调用effect 重复执行依赖项的引用没变ArkTS 每次赋值都触发Watch预比较没拦截住在Watch回调里做字段级比较或者用节流合并高频更新首次进入页面白屏useEffect 首次执行时机被提前到原生视图创建前业务代码访问了未创建的 UI 组件确认首次 effect 是在 React commit 之后触发Watch 回调里不要做 UI 操作清理函数不执行或乱序适配层直接调 effect 业务逻辑绕过 React 生命周期把 effect 执行完全交还给 React适配层只做状态同步依赖是对象每次 render 都触发 effectReact 对对象做引用比较新引用必然不等在业务侧使用 useMemo 或拆分为原子状态减少对象引用变化5.2 性能开销链路与调优方向Watch本身的触发开销在 ArkUI 里很小真正的开销在于属性变化 - Watch 回调 - 跨运行时通知 - JS setState - React render这条链路的累积。针对高频生命体征数据我做了三个方向的调优。第一个方向是降低通知频次。传感器原始数据通常在 Hz 级别甚至更高。适配层在 ArkTS 侧可以做节流每 100ms 或 200ms 合并一次数据再同步到 JS 侧。这样既保证了业务感知的实时性又避免了 JS 侧被频繁打断。第二个方向是缩小依赖粒度。刚才那个例子里依赖是整个vitalSigns对象但它包含心率和血氧两个字段。如果业务代码只关心心率那么血氧变化也会触发整个 effect。更合理的做法是拆成hr和spo2两个独立状态分别映射Watch让每个字段变化只影响关注它的 effect。第三个方向是批量同步。ArkTS 侧的多个依赖同时变化时不要逐个发通知而是把它们打包成一次跨运行时事件JS 侧收到后一次性更新状态。这样能有效减少 JSI 调用的次数在真实设备上收益明显。5.3 如何判断你的适配实现是否越权判断适配层设计是否正确的标准很简单你看代码里有没有绕过 React 直接执行 effect 逻辑的地方。如果在Watch回调里看到了类似this.executeEffect()的调用或者看到了从 ArkTS 侧直接调用某个 JS 业务副作用函数而不经过状态更新的代码那基本可以断定越权了。正确的做法永远是Watch只做两件事——预比较和发通知JS 侧只更新状态和触发 rendereffect 交给 React 在 commit 后执行。守住这条线useEffect在鸿蒙端的行为就和在 Web 端、Android 端完全一致业务代码不需要感知平台差异。6. 一些经验之谈如果让我用一句话总结这次的适配经验那就是跨框架适配时最容易出问题的不是语法差异而是调度模型的冲突。React 有自己的一套 render、commit、effect 调度体系ArkUI 也有自己的状态观察、脏检查、布局刷新体系。硬要把两者掰成一种模型通常会两头不讨好。最好的方式是像这次这样让每套机制管自己最擅长的事中间用状态同步来衔接。最后再分享一个小技巧。调试这类适配问题时别急着看业务代码先在Watch回调、桥接模块、React effect 三个位置埋好带时间戳的日志然后跑一个最简单的依赖更新用例观察调用顺序和时延。只要位置顺序对、时延在合理范围适配层的核心逻辑基本不会有问题。顺序乱了查桥接层时延大了查节流和批次合并。这套排查方法在我们后面适配其他 HooksuseMemo、useLayoutEffect时依然非常管用。