ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

OpenHarmony上React Native列表卡顿?useEffect依赖数组优化实战

OpenHarmony上React Native列表卡顿?useEffect依赖数组优化实战 最近在给一个跑在OpenHarmony上的React Native跨平台项目做性能优化最头疼的不是启动速度而是页面滚着滚着就卡住。长列表、媒体卡片、播放状态联动这些问题在普通RN环境里也就是掉几帧但一上OpenHarmony这套技术栈任何一次多余的渲染和副作用重放都会被放大成肉眼可见的卡顿。排查到最后锅几乎都指向同一个地方useEffect的依赖数组写得太随意了。所以这篇就专门聊聊在OpenHarmony上用React Native时怎么把useEffect依赖数组优化到既不丢更新、又不额外烧性能。文中不会有太多玄学全是能直接照做的写法、排查顺序和踩坑记录。1. 为什么在OpenHarmony上跑RN依赖数组的毛病会被放大1.1 从列表掉帧这个典型症状说起我手头这个模拟项目X页面结构是典型的FlatList加媒体卡片。每张卡片要拉取元数据、上报播放状态、还要响应父组件的选中态变化。一开始掉帧时我习惯性地以为是renderItem里的组件没加React.memo或者图片缓存策略不对。结果把这两样都优化了一遍滚动时依然一顿一顿的。后来我在每个卡片组件的useEffect开头加了一行日志滚动一屏发现日志刷了十几条。这说明滚动过程中大量组件在反复执行副作用而真正需要响应的业务状态根本没有变化。再往下看问题出在依赖数组里塞了整个props对象和函数引用。比如某个effect依赖的是[item, activeItem]而activeItem每次切换都会创建一个新对象引用所有卡片哪怕不相关也得跟着重新跑一遍effect。这个现象在普通RN环境里往往不致命因为副作用本身可能就是几次状态更新被React批量处理掉了。但在OpenHarmony的适配链路上JS线程的每一次副作用触发都可能引发跨语言层的调用最终落到系统原生组件上频率一高卡顿立刻显现。1.2 副作用重放的真实成本不只是JS那段代码很多同行对useEffect的理解还停留在“渲染之后执行一段代码”。这句话没错但在性能优化场景里我们要看的是完整链路组件render - React提交变更 - effect执行 - 如果effect里有setState再引发一轮render - 再提交 - 再执行其他effect。也就是说每多一次多余的effect重放不只是多执行几行JS而是多走了一整轮“提交-更新-再提交”的循环。如果在effect里还做了网络请求、读写AsyncStorage、调用原生模块那成本就更明显了。OpenHarmony环境下RN运行时和系统UI层之间往往不是同进程直接共享内存而是通过适配层做数据交换提交次数越多跨层调用就越频繁。我用一个不严谨但很直观的类比普通RN环境里一次多余的effect重放好比多按了一下电梯按钮最多等一趟在OpenHarmony上等于每次都要从一楼爬到顶楼再下来按十次就明显体感不对劲了。依赖数组优化的本质就是减少这种无意义的爬楼次数。1.3 同样一段代码标准RN环境和OpenHarmony环境的差异我在模拟项目X里专门做过一组对照把同一个存在依赖数组问题的组件分别跑在标准RN环境和OpenHarmony环境里结果差异非常典型。观察项标准RN环境OpenHarmony环境副作用调度时机提交后同步执行时机稳定经过适配层处理后可能存在一定延迟JS到原生的数据交换一次批量提交高频时多次单次调用开销更明显低端设备滚动表现偶发掉帧持续卡顿帧率明显下降同样的依赖数组失误可能感知不到日志计数翻倍性能开销成倍增加并不是说OpenHarmony本身性能差而是RN在这套系统上的运行路径比标准移动平台更长中间每一层的成本都更贵。依赖数组稍微写得宽一点浪费就会被指数级放大。所以在OpenHarmony上做RN开发useEffect依赖数组优化不是锦上添花而是直接影响页面能不能流畅跑起来的关键步骤。2. 依赖数组优化的切入顺序先认清误区再动手2.1 误区一依赖数组“多写总比少写安全”我在不少项目里见过这种写法一个useEffect里要用到七八个值于是把所有涉及的变量全都塞进依赖数组生怕漏掉哪个导致不更新。这个出发点可以理解但忽略了一个关键机制React比较依赖项时用的是Object.is也就是引用比较不是深比较。只要某个依赖项是对象、数组、函数并且每次渲染都会创建新引用那么即使业务数据完全没变React也会认为依赖变了effect照样重跑。典型的反例是这样// 反例user 是每次渲染都会新建的对象 const user getUser(id); useEffect(() { fetchProfile(user.id); }, [user]); // user 引用每次都变effect 每次都跑如果user.id是个字符串依赖数组写成[user.id]就足够了。对象本身没有变变的是外面的引用壳子。依赖数组要的是“业务上的变化信号”不是“引用的变化信号”。这个误区在OpenHarmony列表页里特别致命因为列表项动辄上百个每个组件都因为引用壳子变了而重跑effect叠加起来就是灾难。2.2 误区二见一个函数就包一个useCallback另一类极端是把所有函数都包上useCallback以为这样就能解决依赖数组不稳定问题。实际上过度包裹反而会引入新的问题useCallback本身有自己的依赖项如果写不准函数里读到的永远是旧值而且还会破坏子组件的memo化判断。我用一个简单的判断标准来区分该不该包useCallback这个函数要不要作为prop传给子组件或者要不要放进某个useEffect的依赖数组如果都不用那就没有必要包。函数创建本身成本很低真正贵的是函数引用不稳定之后引发的副作用重放和子树重渲。// 只有这个函数会作为依赖项时才需要稳定引用 const loadMeta useCallback(async (id) { const data await fetchMeta(id); setMeta(data); }, []);如果这个函数只在effect内部使用完全可以直接定义在effect里根本不用管引用稳定性。2.3 我常用的四步排查法遇到依赖数组导致的性能问题我不会上来就改代码而是按一套固定流程排查效率很高。第一步在useEffect开头加一行日志console.log(effect-run, dep1, dep2)。然后做一次固定的用户操作比如滚动20个列表项统计每个effect跑了多少次。这一步能把“哪些依赖导致了重跑”暴露出来。第二步删掉所有“能用基本类型字段替代”的复杂依赖。凡是在effect里只用到obj.id的地方就把obj整体替换成id本身。这个动作通常能砍掉80%的多余重放。第三步检查effect内部引用的外部函数。如果这个函数不是用useCallback包过的并且确实需要放进依赖数组那就补一个useCallback同时确保useCallback的内部依赖也尽量窄。第四步用性能工具看JS线程和UI线程的占用率对比。优化前跑一个高负载操作优化后再跑一次记录CPU占用和帧率变化。这一步是为了确认优化确实有效而不是靠感觉说“好像没那么卡了”。3. 手把手优化一个列表页从复现到验证3.1 原始代码一个“包含一切”的依赖数组我在模拟项目X里抽了一段很有代表性的代码结构大概是这样的function MediaCard({ item, list, onPlay, playState }) { const [meta, setMeta] useState({}); useEffect(() { loadMeta(list, item).then(setMeta); }, [item, list, onPlay, playState]); // 其他渲染逻辑 }这段代码的业务意图很简单当item变化时重新加载元数据。但依赖数组里塞了四个值其中list是数组onPlay是函数playState是对象。每次父组件因为滚动位置、播放状态变化而重渲时这四个里面至少有两三个引用会变于是每个卡片都在重跑loadMeta。我在真机上复现时滚动一屏大约有20个卡片可见每个卡片每秒可能重跑两三次effect瞬间就有五六十次网络请求或者本地缓存读取UI线程被拖住是完全正常的。3.2 改造动作解构字段、拆分副作用、稳定回调优化不是把依赖数组清空而是让它变得精确。我拆三步来做。第一步把复杂对象依赖换成基本类型字段。这个effect真正关心的是item.listId和list.pageLimit其他都不需要const { listId } item; const pageLimit list?.pageLimit ?? 20; useEffect(() { loadMeta(listId, pageLimit).then(setMeta); }, [listId, pageLimit]);第二步把没关系的副作用拆开。playState是控制播放高亮的和加载元数据完全是两件事。硬放在一个effect里会导致播放状态一变元数据也跟着重新加载。拆开后各自只在需要时触发useEffect(() { loadMeta(listId, pageLimit).then(setMeta); }, [listId, pageLimit]); useEffect(() { reportPlayState(item.id, playState); }, [item.id, playState.isActive]);第三步处理onPlay。这个函数在父组件里没有用useCallback包裹每次父组件重渲都生成新引用。如果它不出现在任何effect的依赖数组里那就好办但这里卡片组件需要点击时调用作为prop传进来为了保持引用稳定我在父组件里包了一层useCallbackconst handlePlay useCallback((mediaId) { startPlay(mediaId); }, []);这样MediaCard收到的onPlay引用是稳定的不会因为父组件重渲而改变哪怕偶尔出现在依赖数组里也不会引起多余触发。3.3 如何确认优化有效测量方法而不是靠感觉代码改完后我没有直接提交而是用之前提到的日志法重新测了一遍。优化前滚动20个卡片日志打印了大概120次优化后同一操作日志只打印了20次左右每个卡片只在真正需要响应时才跑一次effect。顺带记录了一下JS线程的CPU占用。同一个中低端测试机上优化前滚动时的JS线程占用一度到35%优化后稳定在8%上下帧率也从偶尔掉到30帧以下变得基本稳定在55帧以上。这个数据不算严谨但足够说明问题。还要提醒一句优化依赖数组后一定要重新验证业务逻辑完整性。有一次我把依赖项改成[item.id]结果某个场景下发live状态变化时需要重新拉元数据被我一刀切掉了导致页面信息不更新。所以每次改完依赖都要主动构造“依赖项其实没变但业务上需要重跑”的场景去测试而不仅仅是看日志数量变少了。4. 三个高频场景的依赖数组写法可直接抄4.1 事件监听注册与注销要当场实现RN在OpenHarmony上经常要监听系统级事件比如网络状态、App前后台切换、某个原生模块的数据推送。这类场景的正确写法是独立useEffect空依赖数组加清理函数useEffect(() { const subscription networkStatusModule.addListener((status) { setNetworkStatus(status); }); return () { subscription.remove(); }; }, []);监听回调里如果要用到最新的props或state不要把它们塞进依赖数组否则每次变化都会退订再重订。更好的做法是用ref保存最新值const statusRef useRef(currentStatus); statusRef.current currentStatus; useEffect(() { const subscription networkStatusModule.addListener((status) { // 这里读 statusRef.current 就能拿到最新值 handleStatusChange(status); }); return () subscription.remove(); }, []);这样既保持了监听只注册一次又在回调里能拿到最新数据。OpenHarmony上有些原生事件注册本身开销不小反复注册注销是很浪费的。4.2 远端数据加载避免依赖对象解构后又重新请求另一个高频场景是根据路由参数或上下文对象拉取远端数据。常见反例是把整个对象放进依赖数组// 反例route.params 引用变化就会重新请求 const { id, page } route.params; useEffect(() { fetchList({ id, page }); }, [route.params]);route.params只要被任何代码重新赋值引用就会变哪怕id和page完全没变也会触发重新请求。改成// 正例只依赖真正参与请求的基本类型 const { id, page } route.params; useEffect(() { fetchList({ id, page }); }, [id, page]);这里有个坑如果id和page本身是对象里的嵌套字段解构之后也可能得到新引用。稳妥的做法是先解构到基本类型再放到依赖数组里。比如const pageNum Number(page)确保依赖数组里存的是纯数值。4.3 滚动联动/多个状态互相触发用ref绕开活锁在OpenHarmony的RN页面里我遇到过滚动位置和激活项互相更新的情况。页面逻辑是滚动到某个位置时高亮对应卡片点击卡片时又要把该卡片滚到中间。这两个状态很容易写成互相触发的活锁。如果直接在useEffect里相互setState依赖数组里又各包含对方状态就会出现无限循环或者高频抖动。我的做法是把“跟随滚动”这个低优先级的状态从useEffect里拆出来只在滚动回调里更新ref让高亮组件在渲染时读取refconst activeIndexRef useRef(0); const onScroll (e) { const index computeActiveIndex(e.nativeEvent.contentOffset.y); activeIndexRef.current index; setNeedHighlight(true); }; useEffect(() { if (!needHighlight) return; setHighlightIndex(activeIndexRef.current); setNeedHighlight(false); }, [needHighlight]);这样useEffect只依赖一个布尔值needHighlight触发路径变得单一不会因为highlightIndex和scrollY互相依赖而形成循环。OpenHarmony上这类联动场景特别容易把调试器卡死我建议遇到任何“两个状态互相影响”的逻辑都优先考虑用ref承载其中一个方向的数据流。5. OpenHarmony环境特有的注意点与自检清单5.1 两个容易踩的坑任务时序差异与低端设备表现第一个坑是副作用执行时机不稳定。在标准RN环境里useEffect通常是在提交完成后同步执行的但OpenHarmony的适配层可能对某些任务做异步调度或合并同一个页面在不同时机进入时effect的执行顺序可能出现差异。我在某个页面里发现页面刚加载时一个依赖原生模块数据的effect会拿到undefined过几百毫秒再进入才正常。解决办法是在effect内部做空值保护同时不要把“页面是否就绪”这件事情交给effect执行顺序去猜。核心数据如果没有准备好宁可先渲染骨架屏也不要让effect带着undefined往下跑。第二个坑来自低端设备。不同OpenHarmony设备的性能差异很大有些低端机型CPU主频不高JS线程和UI线程资源争抢严重。一个看起来只要跑一次的effect乘以100个列表项之后就可能把整机拖垮。因此在优化依赖数组时不能只盯着“日志打印次数”还要多看“最小状态下能不能满足业务需求”。能空依赖数组运行的就别引入依赖能依赖基本类型的就别依赖对象这是我在低端设备上摸出来的硬道理。5.2 自检清单写完每个useEffect后过一遍为了不让后续开发再踩坑我把这套经验沉淀成了一份简短的自检清单每次写完一个useEffect就过一遍。依赖数组里的每一项真的是“值变了就必须重跑”的吗如果有一个值只在effect内部用到却不在依赖数组里那就说明effect可能读取了旧值如果依赖里放了多个引用类型说明很可能存在无意义重放。依赖项里有没有对象、数组、函数如果effect内部只使用它们的具体字段就先把字段解构到基本类型再放依赖。effect内部调用外部函数的情况下这个函数的引用是否稳定不稳定的话是包useCallback还是直接把函数定义挪进effect内部有没有出现“effect里setStatestate又出现在依赖数组”的情况如果有要么拆分effect要么用ref绕开。依赖数组为空或者依赖项很少时清理函数是否把应该解绑的资源都解绑了事件监听、定时器、订阅这些漏掉一个就是内存泄漏。在OpenHarmony真机上是不是能观察到符合预期的触发次数如果同一交互下日志数量翻倍通常说明依赖数组里的某个引用又不稳定了。我每次给团队评审代码时都会拿着这份清单过一遍哪怕逻辑完全正确的useEffect也能顺手找出几处可以收紧依赖的地方。最后再分享一个实际体会依赖数组优化这件事九成靠的不是技巧而是愿不愿意把一个useEffect的触发条件真正想清楚。尤其在OpenHarmony这条技术路线上多一次副作用重放代价都比想象中大。你如果现在也卡在类似的项目里别急着引入状态管理库也别全面重写渲染层先把每个useEffect的开头打一行日志跑一遍看看到底谁在被反复触发。把日志数量打下来页面一般就顺了。依赖数组写得越窄你脑子里的状态模型就越清楚这句话在哪个平台上都成立在OpenHarmony上尤其成立。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进