@[toc]
如果你做过一段时间 RN,一定遇到过这种场景:
- 点赞一个列表 item,整个列表都在重渲染
- 改了一个筛选条件,滑动开始卡
- 列表一多,哪怕只改一个布尔值,Profiler 里一片红
最后大家往往会得出一个结论:
FlatList 性能不行。
但只要你稍微深入看一眼,就会发现一个事实:
FlatList 只是"暴露问题"的地方,真正的问题是状态边界划错了。
这一篇,我们就只做一件事:
把 RN 列表里的"局部状态"和"全局状态"彻底掰开讲清楚。
不讲虚的,不上来就丢 Redux、Zustand、Recoil,
先回到最原始的问题:
👉 这个状态,到底属于谁?
一、一个最常见、也最容易踩坑的列表写法
我们先从一个"看起来完全没问题"的 Demo 开始。
场景
- 一个 Feed 列表
- 每个 item 可以点赞
- 点赞后数字 +1,红心高亮
代码(问题版)
tsx
function FeedPage() {
const [list, setList] = useState(
Array.from({ length: 100 }, (_, i) => ({
id: i,
liked: false,
count: 0,
}))
);
const onLike = (id: number) => {
setList(prev =>
prev.map(item =>
item.id === id
? { ...item, liked: !item.liked, count: item.count + 1 }
: item
)
);
};
return (
<FlatList
data={list}
keyExtractor={item => item.id.toString()}
renderItem={({ item }) => (
<FeedItem item={item} onLike={onLike} />
)}
/>
);
}
tsx
function FeedItem({ item, onLike }) {
console.log('render item', item.id);
return (
<TouchableOpacity onPress={() => onLike(item.id)}>
<Text>{item.liked ? '❤️' : '🤍'} {item.count}</Text>
</TouchableOpacity>
);
}
你会看到什么?
- 点赞一个 item
- 100 个 item 全部打印 render
- 滑动明显变卡
很多人到这一步就开始:
- memo
- useCallback
- extraData
- shouldComponentUpdate
但如果你停下来问一句:
"点赞"这个状态,本质上是谁的状态?
你就会意识到,这里一开始就把边界画错了。
二、RN 的核心问题:状态更新是"向下扩散"的
在 RN(准确说是 React)里,有一个非常重要但经常被忽略的事实:
状态一旦放在父组件,它的更新一定会导致子树重新计算。
哪怕你写了:
React.memouseCallbackPureComponent
它们只能"减轻后果",不能改变扩散方向。
在上面的代码里:
-
list在FeedPage -
每一次点赞:
setListFeedPage重新 render- FlatList 的
renderItem重新执行 - 所有
FeedItem都被重新计算
👉 这就是典型的"状态上提过度"。
三、为什么这个问题在 RN 列表里特别容易被放大?
这里要引入一个很关键的对比视角:页面模型差异。
Web(Vue / DOM)的直觉
在 Web 里,很多人会下意识觉得:
- 改一个 DOM
- 只影响那一块
因为:
- DOM 是节点级别更新
- 模板是"结构 + 指令"
- 状态和视图之间有"中间层"
RN(React)的真实模型
RN 的真实模型是:
- 状态 → 函数组件重新执行 → 生成新的 UI 描述
- 列表不是"节点集合"
- 而是"函数 render 的多次调用"
所以在 RN 里:
你把状态放在哪一层,本质上决定了重渲染的扩散半径。
四、重新定义什么是「局部状态」
我们先给一个非常实用的判断标准:
如果一个状态,只影响一个列表 item 的展示,它就不该出现在列表的父组件里。
在点赞这个例子里:
likedcount
本质上是 item 自己的状态,而不是列表的状态。
五、第一种拆法:把状态下沉到 Item 内部
我们直接改写刚才的 Demo。
改造后的代码
tsx
function FeedPage() {
const data = useMemo(
() => Array.from({ length: 100 }, (_, i) => ({ id: i })),
[]
);
return (
<FlatList
data={data}
keyExtractor={item => item.id.toString()}
renderItem={({ item }) => <FeedItem id={item.id} />}
/>
);
}
tsx
function FeedItem({ id }: { id: number }) {
const [liked, setLiked] = useState(false);
const [count, setCount] = useState(0);
console.log('render item', id);
const onLike = () => {
setLiked(v => !v);
setCount(c => c + 1);
};
return (
<TouchableOpacity onPress={onLike}>
<Text>{liked ? '❤️' : '🤍'} {count}</Text>
</TouchableOpacity>
);
}
发生了什么变化?
- 点赞一个 item
- 只有这个 item 会 render
- FlatList 完全不动
- 滑动流畅度立刻改善
这一步,本质上是:
缩小状态的影响范围。
六、但问题来了:那"全局状态"放哪?
你可能马上会问:
那如果点赞结果要同步给服务端?
或者要在详情页展示?
或者退出再进要恢复?
这就引出了第二个问题:
什么才算"全局状态"?
七、一个非常容易用错的判断标准
很多人会这样判断:
- 会请求接口的 → 全局状态
- 多个页面用到的 → 全局状态
这在 RN 里非常危险。
更合理的判断方式是:
这个状态的"读扩散范围"有多大?
举个对比例子
| 状态 | 是否全局 |
|---|---|
| 列表 item 是否展开 | 否 |
| 当前 Tab | 是 |
| 用户登录态 | 是 |
| 点赞是否成功 | 否(通常) |
| 点赞结果是否已同步 | 是 |
👉 行为是局部的,结果才可能是全局的。
八、正确的分层方式:行为局部,结果上浮
一个非常推荐的结构是:
FeedPage
├─ FlatList
│ ├─ FeedItem(局部 UI 状态)
│ ├─ FeedItem
│ └─ FeedItem
└─ likeStore / service(结果状态)
示例:局部状态 + 全局同步
tsx
function FeedItem({ id }) {
const [liked, setLiked] = useState(false);
const onLike = () => {
setLiked(true); // 立即反馈
likeService.sync(id); // 异步上报
};
return (
<TouchableOpacity onPress={onLike}>
<Text>{liked ? '❤️' : '🤍'}</Text>
</TouchableOpacity>
);
}
这里有一个非常重要的认知转变:
不是所有状态都需要"可追溯、可复原、可全局订阅"。
九、为什么 RN 项目更容易"状态污染"?
现在我们可以回到你前面写的那篇文章主题了:
为什么 RN 项目更容易暴露状态污染?
因为 RN 的状态:
- 天然是函数级的
- 更新会向下扩散
- 列表又是"高密度组件树"
一旦边界画错,问题会被无限放大。
十、给跨端开发者的一个统一心智模型
最后总结成一句话,非常适合放在文章结尾:
在 RN 里,列表性能问题本质上不是渲染问题,而是"状态所有权"问题。
一个可复用的判断口诀
- UI 行为 → 尽量局部
- 跨页面语义 → 再考虑全局
- 不要为了"好管理",提前牺牲渲染边界