性能优化
这一课你将能做到
- 正确区分 memo / useMemo / useCallback 的职责,消除不必要的组件重渲染
- 调优 FlatList 参数,知道什么场景该上 FlashList,让万级列表滚动不掉帧
- 优化图片加载和 App 启动速度,建立「先测量再优化」的科学方法论
为什么要学
功能做完只是及格线。用户用的不是你的 MacBook Pro,而是三年前充话费送的中端安卓机——列表一卡、图片一白,用户三秒后就划走了。性能优化是区分「能跑的代码」和「能上线的产品」的分水岭,也是技术面试里最高频的区域之一。
但优化最怕的是「盲目动手」。很多人把整棵组件树都包上 memo,结果比较 props 的开销反而超过了重渲染本身;或者还没定位瓶颈就把 FlatList 的参数全调一遍。性能优化的第一课是测量,第二课才是动手。
人话:性能优化的核心就一句话——少做事。能不渲染就不渲染(memo 跳过重渲染),能不重算就不重算(useMemo 缓存计算结果),能让原生做就让原生做(FlashList 回收复用、expo-image 的磁盘缓存、Hermes 预编译)。JS 线程是单线程,它一忙所有交互和动画都得排队。
先建立图像
一个重渲染失控的典型场景,这也是本课所有优化手段的出发点:
父组件 state 变了(比如搜索框打一个字)
→ 父组件重新渲染
→ 所有子组件跟着重新渲染(即使 props 没变)
→ 子组件的列表项全部重画
→ 100 行列表 → 掉帧卡顿
解决思路是在「父变不影响子」的地方加隔断:
父组件 state 变了
→ 父重新渲染
→ 子组件(包了一层 memo)发现 props 没变 → 跳过渲染
→ 只有真正相关的组件重画 → 流畅
另外一张图:列表滚动的内存视角。
FlatList 的「窗口化」策略(默认):
可视区(1 屏) ← 活跃渲染
上一屏 + 下一屏 ← 保留但不渲染(windowSize 控制)
更远的行 ← 组件实例销毁,下次滚回来重新创建
FlashList 的「回收复用」策略:
可视区 ← 活跃渲染
已滚出的行 ← 组件实例被复用,只换 props 和数据
不销毁、不重新创建 → 内存和帧率双优
跟着做
步骤 1:memo 阻止无效重渲染
import React, { useState } from 'react';
import { Text, TextInput, View } from 'react-native';
// 包一层 React.memo:props 不变就不重渲染
const ExpensiveRow = React.memo(({ title }) => {
console.log('Row 渲染了:', title);
return <Text style={{ padding: 16 }}>{title}</Text>;
});
export default function App() {
const [text, setText] = useState('');
return (
<View style={{ flex: 1, paddingTop: 60 }}>
<TextInput
value={text}
onChangeText={setText}
placeholder="打字试试"
style={{ borderWidth: 1, padding: 12, margin: 16 }}
/>
<ExpensiveRow title="我不受输入影响" />
</View>
);
}
不加 memo,每打一个字控制台都会输出一次「Row 渲染了」;加了 memo,Row 只在 title 变化时才渲染。打开控制台亲眼验证——这是理解 memo 最快的方式。
注意 React.memo 做的是浅比较:props 里的基本值(string/number/boolean)没问题,对象和数组只要引用没变就通过,引用一变就失效——所以内联对象会穿破 memo。
步骤 2:useCallback 固定函数引用
如果 memo 的子组件接收了一个回调函数,而父组件每次渲染都写一个内联函数,函数引用每轮都变,memo 白做:
// 错:每次渲染 handlePress 都是新函数
<ExpensiveRow onPress={() => handlePress(id)} />
// 对:用 useCallback 固定引用
const handlePress = useCallback((id) => {
navigation.navigate('Detail', { id });
}, [navigation]);
<ExpensiveRow onPress={handlePress} />
useCallback 的依赖数组 [navigation] 表示:navigation 不变,handlePress 就不变。空数组 [] 表示「初始化一次,永不再变」。
重要:别给所有函数都套 useCallback。函数创建成本极低,useCallback 本身也有依赖比较开销。只在两种场景用:①传给 memo 子组件的回调;②作为其他 useEffect / useMemo 的依赖项。
步骤 3:useMemo 缓存计算结果
const filteredList = useMemo(
() => list.filter(item => item.name.includes(keyword)),
[list, keyword]
);
假设 list 有 1 万条,每次父组件 state 变化(比如切换标签页)都会重新 filter——用 useMemo 包裹后,只要 list 和 keyword 没变,直接复用上一次的计算结果。
同样规则:只有计算开销大(过滤、排序、大对象映射)且依赖变化不频繁时,才值得上 useMemo。
步骤 4:FlatList 性能参数调优
FlatList 自带的几个参数,调好了性能提升显著:
<FlatList
data={list}
renderItem={renderItem}
keyExtractor={(item) => item.id} // key 必须稳,不要用 index
initialNumToRender={10} // 首屏渲染行数,控制启动时的渲染压力
maxToRenderPerBatch={10} // 每批增量渲染行数,滚动时逐批加载
windowSize={5} // 保留几屏内容(5 屏),默认 21(太大)
removeClippedSubviews // 卸载屏外视图,Android 收益大
getItemLayout={(_, i) => ({ length: 72, offset: 72 * i, index: i })} // 行高固定必给
/>
keyExtractor 不能用数组索引:删了中间一行之后索引变了,RN 会复用错误的行组件。
getItemLayout 是 FlatList 性能的灵魂参数。如果你知道每一行高度固定(比如 72dp),告诉 FlatList 它可以跳过 onLayout 测量,直接跳到对应位置——长列表快速滚到第 500 项时差异巨大。
windowSize 默认 21(21 屏),保持大量屏外组件会吃掉大量内存。调小到 3~5 够用。
removeClippedSubviews 卸载屏外组件释放内存,Android 效果显著;但遇到绝对定位或 zIndex 复杂的行,可能被误裁剪,出问题先排查这个。
步骤 5:FlashList——Shopify 的终极方案
当数据量过万、FlatList 调参也救不了时,换 FlashList。它的核心创新是「回收复用单元格」——滚出屏幕的行组件不销毁,只换数据再用,类似 iOS UITableView 的 dequeueReusableCell。
npx expo install @shopify/flash-list
import { FlashList } from '@shopify/flash-list';
<FlashList
data={list}
renderItem={renderItem}
estimatedItemSize={72} // 给一个估算行高就行,不像 getItemLayout 要求精确
/>
API 和 FlatList 几乎一样,但万级数据下滚动帧率稳定 60fps。迁移成本极低:改 import、把 windowSize/getItemLayout/removeClippedSubviews 去掉,加上 estimatedItemSize。
- 数据量小于 200 条:FlatList 足够
- 行内有复杂的绝对定位或 zIndex 重叠:FlashList 的回收复用可能破坏布局
步骤 6:图片与启动优化
图片是 RN 的性能黑洞——原图 5MB 不做处理直接丢进列表,低端机几秒后就 OOM 崩溃。
- 用 expo-image 替代自带 Image:自带磁盘缓存、WebP 支持、过渡动画,内存管理比原生 Image 好很多。
import { Image } from 'expo-image';
<Image
source={{ uri }}
style={{ width: 100, height: 100 }}
contentFit="cover"
transition={200} // 淡入过渡
cachePolicy="memory-disk" // 默认就是,显式声明更清晰
/>
- 列表里永远用缩略图:后端提供多尺寸图片,
?w=200 或 thumb_ 前缀。原图 4K 放列表里毫无意义。
- 图片懒加载:FlatList 配合 expo-image 默认只加载可视区的图片,但初始渲染时如果一次性挂载几百张,仍要控制 initialNumToRender。
- 延迟隐藏启动屏:在 App 加载关键数据期间保持启动屏显示,数据就绪再消失。
import * as SplashScreen from 'expo-splash-screen';
SplashScreen.preventAutoHideAsync(); // App 启动时先阻止自动隐藏
// 数据加载完后
SplashScreen.hideAsync();
- 首屏以外的库延迟加载:用动态 import 或把非首屏页面放到二级路由再 import,减小 JS bundle 体积。
const HeavyScreen = React.lazy(() => import('./HeavyScreen'));
- Hermes 引擎(Expo 默认已开):把 JS 编译成字节码而非源码运行,启动时间缩短 30%~50%。不需要你做任何配置,但要知道它在那里。
步骤 7:InteractionManager——把重活移出交互响应
用户点击按钮后,别立刻执行重计算。用 InteractionManager 把重活排到所有交互和动画结束后:
import { InteractionManager } from 'react-native';
const onPress = () => {
navigation.navigate('Result');
InteractionManager.runAfterInteractions(() => {
// 大 JSON 解析、复杂数据处理放在这里
const result = heavyComputation(data);
setResult(result);
});
};
这样跳页动画先流畅完成,数据计算在后台慢慢算,用户不会觉得卡。
讲清楚要点
- 优化三件套的分工:memo 管组件是否重渲染,useMemo 管计算值是否重算,useCallback 管函数引用是否稳定。三者都是「引用稳定」思维——只要依赖没变,就复用上一次的产物。
- 别到处乱包 memo:React.memo 本身有浅比较开销,给所有组件都包一层,比较成本可能超过重渲染。优先包「渲染成本高 + 父组件频繁更新 + 自身 props 很少变」的组件——先测量,再优化。
- FlatList 优化的第一原则:renderItem 轻量(别在 renderItem 里做复杂计算)、key 稳定(不能用 index)、行高固定给 getItemLayout。这三条做到,90% 的列表性能问题已经解决。
- 图片是性能重灾区:原图直接加载必卡。三招并用:后端出缩略图、expo-image 缓存、列表懒加载。把图片视为网络带宽 + CPU 解码 + 内存占用 + GPU 渲染的四重压力来源。
- JS 线程是单线程:大 JSON 解析、复杂数据处理、同步循环会卡住所有交互。要么分批处理(大数组 slice 后逐帧算),要么用 InteractionManager 推到交互之后。
- 先测量再优化:用 React DevTools Profiler 看组件渲染次数;Android 开启 GPU 渲染分析看每一帧的绘制时间;先找到真正的瓶颈再动手。不要「感觉卡就全包 memo」,那属于瞎优化。
你可能会懵的地方
-
用了 memo 为什么子组件还在重渲染?
用 React DevTools Profiler 查看,十有八九是因为 props 里有内联对象、数组或函数。例如父组件传 style={{ marginTop: 10 }} 或 onPress={() => ...},每次渲染引用都变,memo 比较失败。解决:不变的数据提到组件外、函数用 useCallback 固定、样式用 StyleSheet.create 预定义。
-
怎么判断哪里卡?
按优先级排查:① React DevTools Profiler 看哪个组件渲染最频繁;② Android 开启「开发者选项 → GPU 渲染分析 → 条形图」看每帧是否超过 16ms(绿线);③ 真机连 Flipper 或 Chrome DevTools 看 JS 执行耗时;④ 主观感受——白块、掉帧、点击无反应分别对应不同的根因。
-
FlashList 能完全替代 FlatList 吗?
99% 场景可以,API 几乎一致。但以下情况要注意:① 行内有复杂的绝对定位或 zIndex(回收复用可能破坏布局);② 依赖 FlatList 的 onViewableItemsChanged 做精确可见项逻辑(FlashList 的行为略有不同);③ 需要 ListHeaderComponent 撑满全宽时,FlashList 的回收逻辑可能导致 header 异常。遇到异常先切回 FlatList 排查。
-
removeClippedSubviews 要不要开?
Android 开,内存收益明显;iOS 默认行为已经不错,开不开都行。但行内有绝对定位元素时,这个属性可能把绝对定位内容误裁掉,出问题先关它。
-
useMemo 的依赖数组里对象引用一直变怎么办?
用稳定标识(如 id 字符串)替代整个对象作为依赖,或在 useMemo 内部通过 id 去查对象,不要直接把对象本身当依赖。
错法对照
| 错法 | 问题 | 正解 |
|---|
| memo 子组件接收内联函数 | 函数引用每轮都变,memo 失效 | useCallback 固定引用 |
| 无差别给所有组件包 memo | 比较成本反噬性能 | 只包「渲染昂贵 + 频繁被父更新」的组件 |
| 万级列表用 FlatList 裸奔 | 滚动掉帧、白块、内存上涨 | 调 FlatList 参数或换 FlashList |
| 列表里直接加载原图 | 内存爆炸 → OOM 崩溃 | 后端出缩略图 + expo-image 缓存 |
| 首屏 import 所有页面组件 | JS bundle 大 → 启动慢数秒 | 路由级懒加载,非首屏动态 import |
| FlatList keyExtractor 用 index | 删行后索引错位,复用错误行 | 用稳定唯一标识,如 item.id |
| 重计算直接阻塞交互响应 | 点击后卡死无反馈 | InteractionManager.runAfterInteractions |
| 盲目优化不测量 | 优化了不该优化的地方 | 先用 Profiler / GPU 分析找瓶颈 |
卡点急救
- 列表滚动白块:减小 windowSize(3~5)换内存;简化行布局(减少嵌套层级);换 FlashList;检查 removeClippedSubviews 是否误裁了绝对定位内容。
- Android 卡 iOS 流畅:Android 中端机 JS 线程性能弱是常态。检查:① 是否有同步重计算(大 JSON 解析、复杂循环);② Hermes 是否开启(Expo 默认开);③ 是否有大量 console.log 在生产环境(RN 的 console.log 是同步的,会拖慢 JS 线程)。
- 内存持续上涨:排查三个重灾区——① 图片缓存未清理(expo-image 默认有上限);② 定时器/事件监听器未在组件卸载时清理;③ 闭包持有大对象导致 GC 回收不了(用 WeakRef 或主动置 null)。
- 启动时间长达数秒:用 Flipper / Xcode Instruments 的 Time Profiler 看启动耗时大头;优先减少首屏 import 的模块数;延迟加载非必要依赖;确认 Hermes 已启用。
- FlatList 快速滚动时图片还没加载就划走了:这是正常现象,expo-image 的缓存会让下次滚回来时秒加载。如果视觉上不能忍,给图片占位色或骨架屏。
面试常问
-
memo / useMemo / useCallback 的区别与配合?
memo 是组件级优化:包裹组件后,props 浅比较不变则跳过渲染。useMemo 缓存计算值:依赖没变直接返回上次结果。useCallback 缓存函数引用:保证传给子组件的函数引用稳定,让子组件的 memo 生效。三者都是「引用稳定」思维,常配合使用——useMemo/useCallback 稳定传给 memo 子组件的 props。
-
FlatList 和 FlashList 的本质区别?
FlatList 用「窗口化」策略:只渲染可视区 + 少量屏外内容,但滚出屏幕的行组件被销毁,滚回来要重新创建。FlashList 用「回收复用」策略:滚出屏幕的行组件实例被保留、改数据再用(类似 iOS UITableView 的 dequeueReusableCell),极大减少组件创建开销,万级数据下帧率和内存都更优。API 几乎一致,迁移成本低。
-
RN 启动优化有哪些手段?
① Hermes 引擎(字节码预编译);② 减少首屏 JS bundle 体积(路由级懒加载、按需 require);③ SplashScreen.preventAutoHideAsync 延迟隐藏启动屏,掩盖初始化耗时;④ 减少启动时的同步存储读取和原生模块初始化;⑤ 图片、字体等资源预加载后按需释放。
自测三问
- 为什么 memo 的子组件收到内联函数会失效?正确的写法是什么?
- FlashList 比 FlatList 快的本质原因是什么?它有什么适用边界?
- 列表里图片应该怎么处理才能避免 OOM?
小结一句话
性能优化 = 引用稳定(memo / useMemo / useCallback) + 列表回收(FlashList) + 图片缓存 + 启动瘦身 + 重活异步化——先测量找瓶颈,再动手,别瞎优化。
延伸