@[toc]
为什么一个小交互,能拖垮整个列表
在 RN 项目里,有一种性能问题特别"冤":
- 点的是一个 item 的 ❤️
- 卡的是整个列表
- Debug 半天发现 FlatList 配置都没问题
最后大家会陷入一种无力感:
"就点个赞,怎么会影响这么大?"
这篇文章,我们就只盯这一件事 :
一次点赞操作,在 RN 里到底会触发什么?
一、先说结论:点赞不是问题,扩散才是问题
点赞这个动作本身非常轻:
text
点击 → setLiked(true)
但在 RN 项目中,它经常会被无意中放大成:
text
点击
→ state 更新
→ 父组件 render
→ FlatList render
→ N 个 Item render
→ JS 线程拥堵
→ 滑动掉帧
问题不在点赞,
而在你让这次更新"扩散到了不该扩散的地方"。
二、我们先构建一个"非常真实"的业务场景
这是我在多个项目里反复见到的结构。
典型列表结构
text
ListScreen
├─ Header
├─ FlatList
│ └─ Item (点赞)
数据结构
ts
{
id: number
title: string
liked: boolean
}
三、错误示例:点赞状态放在列表父组件
我们直接看代码,这种写法你一定很熟。
ListScreen
tsx
function ListScreen() {
const [list, setList] = useState(mockList)
const onLike = (id: number) => {
setList(prev =>
prev.map(item =>
item.id === id
? { ...item, liked: !item.liked }
: item
)
)
}
return (
<FlatList
data={list}
renderItem={({ item }) => (
<Item item={item} onLike={onLike} />
)}
/>
)
}
Item
tsx
function Item({ item, onLike }) {
return (
<TouchableOpacity onPress={() => onLike(item.id)}>
<Text>{item.title}</Text>
<Text>{item.liked ? '❤️' : '🤍'}</Text>
</TouchableOpacity>
)
}
四、从"点击"开始,渲染链路是如何扩散的?
我们一层一层拆。
第一步:点赞触发 state 更新
ts
setList(prev => ...)
这是一个全量 list 更新,即使你只改了一个 item。
第二步:ListScreen render
state 在父组件:
- 父组件必定重新 render
- FlatList 作为子组件,也会重新 render
第三步:renderItem 函数重新创建
tsx
renderItem={({ item }) => (
<Item item={item} onLike={onLike} />
)}
注意两个点:
- renderItem 是一个新函数
- onLike 引用是新的
即使 item 没变,props 也不稳定。
第四步:所有可见 Item 重新 render
即便你加了:
ts
React.memo(Item)
也很容易失效,因为:
- item 是新对象
- onLike 是新引用
第五步:JS 线程被"无意义 render"占满
这一步非常关键。
点赞发生时:
- JS 线程在算 diff
- 同时用户可能在滑动
- 滚动需要 JS 配合
掉帧就出现了。
五、为什么 Web 项目里,这种写法问题不大?
同样的结构,在 Web 中:
- 滚动是浏览器原生完成的
- React render 的压力没那么直观
- 用户不会频繁"快速点赞 + 滑动"
所以问题被"掩盖"了。
RN 没有。
六、真正的问题:你把"局部状态"设计成了"全局更新"
点赞的本质是什么?
一个 Item 的局部 UI 状态变化
但你给它的代价是:
整个列表的数据更新
这就是扩散的根源。
七、正确做法一:把点赞状态下沉到 Item
改造思路
- 列表数据保持"稳定结构"
- 点赞只影响当前 Item
改造后的 Item
tsx
function Item({ item }) {
const [liked, setLiked] = useState(item.liked)
return (
<TouchableOpacity onPress={() => setLiked(v => !v)}>
<Text>{item.title}</Text>
<Text>{liked ? '❤️' : '🤍'}</Text>
</TouchableOpacity>
)
}
效果变化
- 父组件不 render
- FlatList 不 render
- 只有当前 Item render
那数据同步怎么办?
这是很多人会立刻问的问题。
答案是:
UI 状态 ≠ 业务最终状态
你可以在合适的时机(如失焦、批量提交)再同步。
八、正确做法二:用"局部 store",而不是全局 store
如果你确实需要跨组件共享点赞状态。
错误思路
"那我放 Redux / Zustand 吧。"
结果是:
- 一个点赞
- 全列表订阅者都被通知
更合理的方式:item 级别 store
ts
const useLikeStore = create(() => ({
likedMap: new Map<number, boolean>()
}))
在 Item 内部订阅:
tsx
const liked = useLikeStore(s => s.likedMap.get(id))
只订阅你需要的那一块。
九、RN 项目里,"渲染扩散"是比"渲染次数"更重要的指标
不要只问:
"render 了几次?"
要问:
"这次 render,影响了谁?"
一次点赞:
- 只影响一个 Item → 正常
- 影响整个列表 → 危险
十、一个非常实用的自检问题
下次你写交互时,问自己一句话:
如果这个 state 变了,最坏情况下会导致多少组件重新 render?
如果答案是:
- "我也不太清楚"
- "可能是整个页面"
那这个 state 的位置,大概率放错了。
十一、工程实践总结
从一次点赞,我们能总结出 RN 列表的三条铁律:
- 能不让父组件更新,就别让它更新
- 列表 item 的 UI 状态,尽量局部化
- 避免"数据正确,但渲染路径错误"
十二、最后一句总结
RN 列表性能问题,
往往不是"算不动",
而是"扩散得太远"。
当你学会控制渲染扩散路径,你会发现:
- 列表顺了
- 滑动稳了
- 很多"玄学卡顿"自己消失了