上个月我把一个用 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 条的 FlatList,renderItem 里写onPress={() => handlePress(item.id)},父组件每次 setState,500 个 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 出来的;
- 事件函数是不是真的传了同一个引用,比如是否误在外面又包了一层箭头函数;
- 子组件是否消费了 context,context 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 里动态拼接 key | key 用数据自带的稳定 id |
| FlatList 滚动仍卡 | renderItem 每次新建,列表项未 memo | renderItem 用 useCallback,子组件 memo |
| 过度优化后代码难维护 | 到处 useCallback + memo | 只对重组件和列表优化,用渲染次数 hook 验证 |
我在鸿蒙项目里把这套流程跑通之后,最大的感受是:性能优化不是玄学,它是一套可以量化的排查流程。先用小 hook 确认重渲染发生在哪,再对症下药。事件处理函数该 useCallback 就 useCallback,交互组件该拆纯组件就拆,但别为了优化而优化,每一步都拿数据说话,就不会把代码改得面目全非还自我感动。
最后再分享一个小技巧:我在列表组件的 props 里加了版本号字段,上线后如果发现某些机型仍然卡,可以用 Ab 实验动态关掉列表项的 memo,快速定位是不是重渲染优化在特殊机型上失效。这个兜底方案帮我处理过几次线上反馈,比对着代码猜高效得多。