ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

React Native鸿蒙列表卡顿优化:useCallback与纯组件压减重渲染

React Native鸿蒙列表卡顿优化:useCallback与纯组件压减重渲染 上个月我把一个用 React Native 做的跨平台项目往鸿蒙适配第一轮跑起来后同事反馈最快的问题是“列表有点卡滚动时一卡一卡”。我打开性能面板看了一眼发现一个非常典型的坑列表项的事件处理函数全都内联写的父组件一 setState几千个列表项跟着一起重渲染。后来我把事件处理函数用 useCallback 稳定下来再把带交互的列表项封装成纯展示型纯组件滚动立刻顺了。今天这篇文章就是围绕这个场景的完整记录关于 React Native 鸿蒙跨平台开发里怎样用 useCallback 和纯组件把不必要的重渲染压下去。1. 为什么鸿蒙场景必须较真重渲染1.1 先看 React Native 的渲染流程问题出在哪一环React Native 虽然跑在原生端但逻辑层仍然是 React 那一套JS 线程里维护虚拟 DOM状态变化后触发 reconciler 做 diff然后把差异以指令的形式同步给原生 UI 层。这里有一个容易被忽略的点函数组件每次执行都是一次“从零开始”的组件函数调用。父组件只要 setState无论子组件的数据有没有变只要子组件没做任何保护函数体就会重新执行diff 就重新做。这个机制在设计上没问题因为 React 靠 diff 保证最终视图正确。问题是当子组件数量大、单个子组件渲染成本高或者事件处理函数在每次渲染里都生成新的引用时“必要的 diff”就被放大成了“大量子组件白白重算”。举一个我在鸿蒙项目里遇到的真实场景一个消息列表每条消息有标题、摘要、时间还有一个“展开/收起”按钮。页面顶部有个搜索框用户每输入一个字搜索列表的 state 就变一次结果整个列表几百条 item 全部重新渲染。每个 item 里又嵌套子组件事件函数全部是 JSX 里写的内联箭头函数。一次输入触发几百次组件函数执行UI 线程被无谓的 diff 和原生指令同步拖住滚动当然卡。这件事在普通 Android/iOS 上也会发生但在鸿蒙适配场景下被放大了原因见下一节。1.2 RNOH 给性能优化加了哪些“新账”鸿蒙端的 React Native 目前主要走社区适配框架业内一般叫 RNOH也就是 React Native on HarmonyOS。它要解决的不仅是 JS 和原生组件之间的映射还要把 React 的虚拟组件树映射到 ArkUI 的组件体系上。这意味着一次渲染指令往往比传统双端多经过一层组件树转换。在这个架构下JS 端一次大范围的 reconciliation最终会变成更多 native 层更新指令。如果组件树本身庞大、事件函数引用每次都在变原本只是 JS 线程多算几下现在会放大成整条渲染链路的额外开销在低端鸿蒙真机上尤其明显。还有一个很常见的表象是“启动白屏”和“进入页面卡死感”。很多时候不是首帧代码跑得慢而是页面首屏一次性渲染了大量列表项每个列表项又带着复杂的内联事件闭包导致一次 setState 要推动整棵子树反复计算。把重渲染压下来之后这类问题往往会跟着缓解不少。1.3 useCallback 与纯组件两条腿走路针对“不必要的重渲染”业界最常见的组合拳就是标题里那两条路一是用 useCallback 让事件处理函数的引用保持稳定二是把列表项这类交互组件做成真正的纯组件配合 React.memo 或 PureComponent 做浅比较跳过渲染。这两件事不是二选一而是上下游配合。你可以这样理解useCallback 负责让“传给子组件的函数”不变纯组件负责在“所有 props 都不变”时直接跳过子组件渲染。如果只做前者不包 memo父组件一渲染子组件照样跟着重新执行如果只包 memo 不处理事件函数每次父组件渲染都会生成新函数浅比较到的 props 永远在变memo 形同虚设。所以正确姿势是父组件里把事件处理函数用 useCallback 稳定化子组件用 React.memo 包裹双管齐下。后面几节我会把每一步的细节和坑都说清楚。2. useCallback把事件处理函数的引用“焊死”2.1 匿名函数是重渲染的隐形推手很多人写事件处理函数时图省事直接在 JSX 里写箭头函数Item onPress{() alert(hi)} /这行代码在每次父组件渲染时都会创建一个全新的函数实例。对 React 来说函数也是 props 的一部分。哪怕 item 数据本身没变因为 onPress 的引用变了子组件浅比较时就会认为“props 变化了”于是重新渲染。这个模式在单个组件上完全没感觉在列表场景里就是雪崩。比如一个 500 条的 FlatListrenderItem 里写onPress{() handlePress(item.id)}父组件每次 setState500 个 item 组件全部认为自己的 onPress 变了全部重跑渲染函数。实际业务里 item 往往还有图片、文本、附属组件累计成本非常可观。正确做法是先定义一个稳定的回调再用它传参const handlePress useCallback((id) { // 处理点击 }, []); // 传给子组件 Item onPress{handlePress} /注意这里有一个关键点handlePress 本身接收 id而不是在 JSX 里包一层箭头函数去捕获 item。这样才能保证传给每个 Item 的 onPress 是同一个引用。2.2 依赖数组不是摆设空数组不等于万能useCallback 的依赖数组是很多人翻车的地方。它的规则和 useEffect 一样回调里引用了外部变量就必须写进依赖数组否则闭包里拿到的永远是旧值。最典型的问题是这个const [keyword, setKeyword] useState(); const handleSearch useCallback(() { // 这里的 keyword 永远是初始值 search(keyword); }, []);空依赖数组意味着 useCallback 只在首次渲染时创建一次函数之后一直复用那次渲染的闭包。如果回调里依赖了 keyword后续 keyword 变了函数里读取的 keyword 还是旧值。解决办法有三种思路把用到的变量写进依赖数组最简单但当依赖频繁变化时缓存失效优化效果打折如果只是 setState优先用函数式更新让状态更新逻辑不依赖外部读取const handleAdd useCallback(() { setCount(c c 1); }, []);如果业务复杂用 useReducer 把状态更新逻辑下沉到 reducer 里回调只 dispatch不读取状态。还有一种更通用的方案是 useRef 保存最新值。但说实话在事件处理函数场景里函数式更新和 useReducer 覆盖了大多数需求useRef 反而容易写出更绕的代码。2.3 一个可复制的列表改造示例我拿当时项目里的待办列表举例改造前和改造后的核心差异如下。先看改造后的结构function ListScreen() { const [items, setItems] useState(initialItems); // 函数式更新依赖可以放心写成空数组 const handleToggle useCallback((id) { setItems(prev prev.map(item item.id id ? { ...item, done: !item.done } : item ) ); }, []); const handleDelete useCallback((id) { setItems(prev prev.filter(item item.id ! id)); }, []); return ( FlatList data{items} keyExtractor{item item.id} renderItem{({ item }) ( TodoItem item{item} onToggle{handleToggle} onDelete{handleDelete} / )} / ); } const TodoItem React.memo(function TodoItem({ item, onToggle, onDelete }) { // 只依赖 item 和两个稳定回调props 不变时直接跳过渲染 return ( View style{styles.item} Text{item.content}/Text Pressable onPress{() onToggle(item.id)} Text{item.done ? 已完成 : 标为完成}/Text /Pressable /View ); });这里 handleToggle 和 handleDelete 因为用了函数式更新依赖数组可以保持空数组函数引用在整个生命周期内稳定不变。TodoItem 被 React.memo 包裹后只要 items 里对应项的数据没变onToggle、onDelete 引用没变它就不会重新渲染。关于 TodoItem 内部那行onPress{() onToggle(item.id)}注意这个箭头函数是在子组件内部创建的它不会反过来导致 TodoItem 重新渲染因为重新渲染的判定发生在父组件向子组件传 props 的环节子组件内部创建什么闭包都管不到自己。3. 纯组件让 React.memo 真正接住稳定引用3.1 纯组件的前提是先满足“同输入同输出”标题里那句“将交互组件制成纯组件”在 React 语境里其实有两层含义。第一层是理念上的一个组件给定相同的 props 和 state渲染结果始终一致没有外部副作用。第二层才是落地工具React.memo 和 PureComponent 通过浅比较帮你跳过渲染。但要泼一盆冷水React.memo 只做浅比较它不会魔法般地让组件变“纯”。如果组件内部读了 context、依赖了组件外部的可变对象、或者渲染结果受时间/随机数影响那么即便 props 相等渲染结果也可能不同。这种情况下强上 React.memo不只是优化无效还可能因为跳过了渲染而展示出过期内容。所以我更愿意理解为先把组件整理成“输入 props、输出 UI”的纯展示组件交互逻辑交给父级的稳定回调然后再包上 React.memo。这样 memo 的浅比较前提才是成立的。交互组件变成纯组件不是把逻辑删掉而是把“怎么处理事件”提升到父级子组件只负责“这个按钮按下去会触发 onToggle(id)”。3.2 浅比较挡不住内联对象常见失效现场盘点React.memo 默认的对比逻辑很简单逐个比较新旧 props 的引用。基本类型比值对象类型比引用地址。只要 props 每次都是新建对象memo 就等于没包。我见过至少四类现场全都让优化白费第一类内联样式。TodoItem style{{ padding: 8 }} /每次父组件渲染{ padding: 8 }都是新对象浅比较必然不相等。解决办法是提取成常量包在组件外面或者用 useMemo 缓存。第二类renderItem 里临时生成数据。const renderItem ({ item }) ( Item data{{ ...item, extra: getExtra(item) }} / );每次渲染都生成新对象。如果子组件本身不需要这个合并对象就改成传原始值如果一定要用就在父组件里用 useMemo 基于数据源生成稳定数组。第三类内联回调函数。虽然这是第 2 节的核心内容但值得反复强调onPress{() ...}每次都新。React.memo 遇到引用变化的函数直接判为“需要渲染”前面所有优化归零。第四类context 变化。如果组件消费了某个 context而 context value 在父级每次渲染时都是新对象memo 也无法阻止它渲染。这个常见于状态管理库里不恰当的 selector 用法或者有人直接把整个 store 对象丢进 value。排查时别忽略了这一层。3.3 展示型组件与交互型组件的拆分策略既然要做纯组件组件粒度至少要分成两层容器层负责状态和事件逻辑展示层负责渲染和触发回调。回到列表场景我的习惯是这样拆页面级组件 ListScreen只拿数据管理状态定义 useCallback 事件函数列表项组件 TodoItem接收 item 和回调纯展示React.memo 包裹更小的原子组件比如 IconButton只接收 onPress 和图标名不包 memo因为它的渲染成本极低。为什么不给 IconButton 也包上 memo因为 TodoItem 每次真的需要渲染时IconButton 本来就应该跟着渲染而 TodoItem 不需要渲染时IconButton 也不会被调用。在组件层级里多包一层 memo并不会多拦住一次渲染反而增加浅比较的开销和阅读复杂度。只有一种情况值得再往下包TodoItem 内部非常重比如包含长文本、富文本渲染或者它的局部 state 更新导致原本稳定的部分也被连坐。那时可以在 TodoItem 内部使用局部 useCallback 把事件参数固定再传给深层子组件。但请记住一个原则memo 不是装饰品每包一层都是在为“跳过渲染”付出额外的比较与维护成本只对真正昂贵的地方用。4. 鸿蒙端实战先量化再动手别用头发换性能4.1 如何确认优化点DevTools 与耗时统计任何性能优化我都不建议上来就改代码。先量化问题在哪改完再量化结果否则很容易被“感觉顺了”误导。在这个鸿蒙项目里我的做法分三步。第一步看帧率。开发模式下打开 React Native 的开发者菜单调出性能监控浮层同时观察 JS 帧率和 UI 帧率。也可以连上 DevTools 的 Profiler录制一段列表滚动。如果火焰图显示大量 TodoItem 同时重新渲染且耗时高那就是重渲染问题。第二步看渲染次数。如果 DevTools 链路不方便直接在组件里埋个小 hook 最直观function useRenderCount(name) { const countRef useRef(0); countRef.current 1; console.log(${name} 渲染次数: ${countRef.current}); return countRef.current; } function TodoItem(props) { useRenderCount(TodoItem); // ... }在鸿蒙真机上跑一遍操作把 console 输出拉出来数一遍就能分清是“每项都在重渲染”还是局部更新。第三步看 props 差异。写一个最朴素的对比函数专门打印本次渲染哪些 props 的引用变了function useWhyDidYouUpdate(name, props) { const prevProps useRef(props); const changed {}; Object.keys({ ...prevProps.current, ...props }).forEach(key { if (prevProps.current[key] ! props[key]) { changed[key] { from: prevProps.current[key], to: props[key] }; } }); if (Object.keys(changed).length 0) { console.log([${name}] props 变化:, changed); } prevProps.current props; }这个 hook 配合 React.memo 排查特别顺手它能明确告诉你 memo 失效是因为哪个 prop。4.2 优化前后的真实数据与结论我在一个真实业务模块里记录了改造前后的表现供参考。机器是某款鸿蒙真机开发模式列表固定 300 条消息操作是搜索框每输入一个字符列表根据关键词过滤并更新。场景优化前优化后单次输入触发的 ListItem 渲染数约 300 项约 1~5 项滚动时 JS 帧率48fps偶发掉到 35fps稳定 58~60fps点击“展开/收起”时页面卡顿感明显操作有延迟感几乎无感首屏列表加载耗时约 1.8s约 1.1s需要强调一下首屏耗时下降不只是重渲染优化直接带来的而是整个页面在渲染过程中不再被无谓的 reconcile 反复打断JS 线程的排队时间变短了。这个数据在模拟器上差异很小真机上才拉开差距所以鸿蒙端调试性能一定别只开模拟器。4.3 鸿蒙真机上的三个细节第一个细节真机与模拟器差异极大。我在模拟器里测优化前后几乎是同一水平一度怀疑优化没用。后来拿到真机差距立刻显现。鸿蒙设备的芯片型号、系统负载、ArkUI 渲染链路都会影响结果性能结论以真机为准。第二个细节FlatList 的 renderItem 最好也稳定。很多人只优化列表项组件但 renderItem 本身每次渲染都创建新函数会让 FlatList 内部难以判断是否需要更新。比较稳妥的做法是把 renderItem 抽出来或者用 useCallback 包一下const renderItem useCallback(({ item }) { return TodoItem item{item} onToggle{handleToggle} /; }, [handleToggle]);这样 FlatList 收到的 renderItem 引用稳定底层在做可视区计算时也能更准确地复用已渲染项。第三个细节不要在渲染路径里创建 id 或者拼接数据。之前见过有人为了给 keyExtractor 用在 render 里写id{String(item.id) - index}结果每个 item 的 id 每次都变key 不稳定导致整个列表被迫重建。key 和 props 都应该是数据驱动下的稳定值。5. 常见问题与排查技巧实录5.1 useCallback 闭包过期回调里拿不到最新 state这是 useCallback 翻车概率最高的问题。现象是回调在点击时读取某个 state结果读到的永远是初始值。根源就是上一节说的空依赖闭包。我的处理优先级是能用函数式更新的全部用setState(prev ...)写法依赖留空状态逻辑复杂把相关更新放进 useReducer事件函数里只 dispatch一定要在回调里读取最新值做判断的可以把最新值存进 ref每次渲染时同步const [count, setCount] useState(0); const countRef useRef(count); countRef.current count; const handleClick useCallback(() { if (countRef.current 0) { setCount(c c 1); } }, []);这里的 countRef 是可变对象引用永远稳定回调里读到的始终是最新值。但注意这段逻辑放在 useEffect 里可能违反直觉所以只在事件处理函数里这样用别在渲染副作用里依赖 ref。5.2 React.memo 不生效先查 props 里的“幽灵引用”如果你包了 React.memo也用了 useCallback列表还是全部重渲染不要急按顺序排查style 是不是内联对象传给子组件的 item 是不是每次从新的数组 map 出来的事件函数是不是真的传了同一个引用比如是否误在外面又包了一层箭头函数子组件是否消费了 contextcontext value 是否每次新建是否在子组件内部调用了父组件传入的“非 useCallback”函数然后把这个函数的计算结果当 prop 传给了更深的子组件。大多数时候问题就藏在这些“每次渲染顺手创建”的对象里。用上面那个 useWhyDidYouUpdate 打印一遍比眼睛看代码快得多。5.3 反向优化useCallback 和 React.memo 不是越多越好必须说句实话过度优化是真实存在的。每个函数都套 useCallback、每个组件都套 React.memo不仅代码可读性变差有时还会更慢。因为 useCallback 本身要做依赖数组的对比memo 要做浅比较这些都是成本。我的判断标准比较简单如果组件本身很轻只是渲染几行文字父组件重渲染带上它也就几微秒那包 memo 意义不大如果 props 经常变化memo 浅比较永远不相等那就完全没有跳过渲染反而多了比较成本。真正值得优化的场景有三个特征组件数量多列表、组件本身重大图、富文本、复杂布局、父组件更新频繁高频交互导致 setState。三者占两个才值得认真做这套优化。5.4 排查工具推荐why-did-you-render 接法如果你不想手写 hook可以接welldone-software/why-did-you-render。在入口文件顶部加这段只在开发模式生效import React from react; if (__DEV__) { const whyDidYouRender require(welldone-software/why-did-you-render); whyDidYouRender(React, { trackAllPureComponents: true, logOnDifferentSignatures: true, }); }然后在目标组件上标记TodoItem.whyDidYouRender true;它会在每次 render 时输出具体是哪个 prop 发生了变化。但提醒一句鸿蒙端开启 trackAllPureComponents 后如果项目大刷日志会非常猛烈我一般只对目标组件开启定位完立刻关掉。5.5 常见问题速查表问题可能原因推荐处理useCallback 回调拿到旧 state依赖数组漏了变量或空数组闭包函数式更新、useReducer 或 ref 同步React.memo 完全没生效内联对象/函数/样式导致 props 引用变化提取常量、useCallback、useMemo列表项 key 不稳定导致重建render 里动态拼接 keykey 用数据自带的稳定 idFlatList 滚动仍卡renderItem 每次新建列表项未 memorenderItem 用 useCallback子组件 memo过度优化后代码难维护到处 useCallback memo只对重组件和列表优化用渲染次数 hook 验证我在鸿蒙项目里把这套流程跑通之后最大的感受是性能优化不是玄学它是一套可以量化的排查流程。先用小 hook 确认重渲染发生在哪再对症下药。事件处理函数该 useCallback 就 useCallback交互组件该拆纯组件就拆但别为了优化而优化每一步都拿数据说话就不会把代码改得面目全非还自我感动。最后再分享一个小技巧我在列表组件的 props 里加了版本号字段上线后如果发现某些机型仍然卡可以用 Ab 实验动态关掉列表项的 memo快速定位是不是重渲染优化在特殊机型上失效。这个兜底方案帮我处理过几次线上反馈比对着代码猜高效得多。
返回列表