props 变了,state 为什么没变?——一次线上分类过滤 BUG 的完整解剖

这个 BUG 就发生在这个博客上,而且是刚刚。
晚上收到反馈:博客首页点分类,没反应。我自己打开线上试了一遍——在列表页点侧边栏的「网络基础」,URL 正常变成了 /blog?category=...,页面顶上的筛选提示也出来了,唯独文章列表纹丝不动,还是全部 6 篇。该出现的 3 篇没出现,不该在的 3 篇也赖着不走。
我的第一反应是后端查询写错了,毕竟「筛选不生效」听起来就是查询的事。打开 API 一测:
GET /api/blog/posts?category=wang-luo-ji-chu
→ total: 2,返回的文章也是对的
后端没问题。那 SSR 呢?把带 query 的完整 URL 贴进地址栏回车——居然也是对的,列表干干净净只有该分类的文章。
同一个页面,两种打开方式,一个对一个错。到这一步,基本可以和后端说再见了:问题一定藏在客户端的某次「状态切换」里。
1)一个很好用的二分:直连,还是点击
这俩为什么不一样?在 Next.js(以及所有走客户端路由的 SPA)里:
| 打开方式 | 实际发生什么 | 组件状态 |
|---|---|---|
| 地址栏直连 / 刷新 | 浏览器完整加载,服务端重新渲染 | 全新挂载,一切从头来 |
| 页内点链接 | 客户端导航,只换数据不换页面 | 组件不卸载,拿新 props 重渲染 |
所以「直连对、点击错」翻译过来就是:这个组件全新挂载时是对的,被复用时是错的。病不在数据,在组件肚子里那份没跟着换的本地状态。
这个二分我后来用顺手了,不止 Next:任何「刷新就好、切页面就坏」的问题,都值得先问这一句。
2)根因:useState 的初始值,只有第一次算数
顺着线索翻代码,很快锁定了列表组件。当时它长这样(简化后):
function LoadMoreButton({ initialPosts, totalPosts, categorySlug }) {
const [posts, setPosts] = useState<BlogPost[]>(initialPosts)
const [page, setPage] = useState(1)
// ...
}
父组件(页面)在服务端按筛选条件查好数据,通过 initialPosts 传进来;子组件把它存进本地 state,负责「加载更多」时往里追加。首屏直连时,这条路完全正常。
问题出在 useState(initialPosts) 这行的语义上。React 文档写得明白,这里意译一下:
初始值只在首次渲染时被采用。组件后续被重新渲染时,React 会保留第一次的状态,忽略你再次传进来的初始值。
把点击分类那一刻的数据流画出来就全明白了:
数据其实到了。initialPosts 作为普通参数每次渲染都会求值、也确实换成了过滤后的 3 篇——是 useState 自己说了句「不用了,我有」。
顺便说这个 BUG 为什么一直没被发现:本地开发时我们都习惯改完代码刷新页面,而刷新恰好就是全新挂载——恰好是对的。只有「先逛进列表页、再点筛选」这条路径才触发,测试脚本和肉眼刷新全都绕开了它。
3)换成 Vue3 写一遍:为什么没事
写 Vue 的朋友看到这里可能想说:这病跟我们没关系。还真别说,最自然的写法确实没事。
同样的表格筛选,Vue3 的常规姿势:
<script setup lang="ts">
import { ref } from 'vue'
const tableData = ref<Row[]>([])
async function onFilterChange(key: string) {
tableData.value = await fetchRows(key) // ← 直接赋值
}
</script>
<template>
<el-table :data="tableData" />
</template>
这种写法天然免疫,原因朴素:tableData 这个 ref 本身就是唯一数据源,模板的渲染副作用订阅的就是它。tableData.value = 新数组 这一下,等于直接往活容器里换水,表格立刻跟着更新——全程压根没有「把数据抄一份」这个动作。
React 那个 BUG 的本质从来不是「数据变了界面没变」,而是把 props 抄进了一份本地快照,然后数据源继续往前走,快照留在了原地。上面这段 Vue 代码里没有抄,所以没有病灶。
4)但 Vue 先别走:ref(props.row) 是同款病
不过 Vue 有一处一模一样的镜像陷阱,而且高发得多——弹窗表单:
<script setup lang="ts">
const props = defineProps<{ row: Row }>()
// setup 只执行一次:这是一次性快照
const form = ref({ ...props.row })
</script>
场景你一定眼熟:表格点「编辑」打开弹窗,表单用 ref({ ...props.row }) 初始化。编辑 A 行,没问题;关掉弹窗再点 B 行——props.row 变了(props 本身是响应式的),但 form 是第一次那份拷贝,弹窗里躺着的还是 A 行的数据。
和分类过滤那个 BUG 是同一个病:一次性快照 + 数据源后续变化。连药方都一样——弹窗组件加 :key="row.id" 强制重建,或者 watch(() => props.row, ...) 手动同步。
所以这事真不怪框架。病灶永远长在同一个位置:你动手「抄」的那一刻。
5)那 ref 和 useState 到底差在哪
既然两边都会踩,就值得把这俩放到一起看清楚了:
ref(Vue3) | useState(React) | |
|---|---|---|
| 本质 | 活容器:盒子本身是响应式的,赋 .value 就通知所有读它的人 | 快照 + setter:每轮渲染拿到当时的值,想换只能调 setter |
| 组件执行 | setup() 只跑一次,之后靠依赖追踪局部更新 | 状态一变,整个组件函数重新执行一遍 |
| 更新方式 | x.value = 新值,连续赋值也行 | setX(新值),本轮拿到的还是旧值,下一轮才是新的 |
| 追踪思路 | 「谁读过我」→ 数据变了通知谁 | 「状态变了」→ 整个 f(state) 重放 |
| props 转本地 | ref(props.x) 一次性快照,会踩 | useState(props.x) 只认第一次,会踩 |
一句话版:Vue 把响应式做在数据上,React 把响应式做在执行上。 ref 是活的盒子,你换水它就通知人;useState 是每轮发一张当时的照片,照片本身不会动,想让下一张变只能自己喊。
有意思的是两者的「陷阱口感」不一样。React 这边,useState(initialPosts) 姿势太顺了,看起来就像官方支持的动作——语法合法、文档确实这么写、第一次还偏偏是对的。Vue 那边,ref(props.row) 反而因为「props 是单向数据流」被文档念叨得多,踩之前多少有点心理预警。同一个坑,一个铺了草垫,一个撒了落叶。
6)三种修法,各有各的场合
定位到「快照过期」,修法其实就三条路:
| 修法 | React 写法 | Vue 写法 | 适合什么时候 |
|---|---|---|---|
| key 重挂载 | <List key={filter} ...> | <Dialog :key="row.id" ...> | 「身份」变了,本地状态本就该全部清零 |
| 显式同步 | useEffect(() => setX(p), [p]) | watch(() => props.row, v => form.value = v) | 本地有自己的编辑态,只想跟着外部某几个变化走 |
| 干脆不抄 | render 里直接用 props(或 useMemo) | computed(() => ...) | 数据能从现有值算出来,就别存第二份 |
这次的分类 BUG 我选了 key:筛选条件一变,列表的 posts、page 全都该归零重来,没有任何需要保留的本地状态——key 一个顶所有。修复全部代码三行(含注释):
{/* 筛选条件变化时强制重挂载,内部 state 自然重置 */}
<LoadMoreButton
key={`${tagSlug ?? "all"}-${categorySlug ?? "all"}`}
...
/>
修完线上复测:点「网络基础」,列表从 6 篇正确变成该分类的文章;再点掉筛选,又正确回到全量。两个方向都验了一遍,收工。
7)花两分钟自查一下
最后留个可以立刻上手的小动作,在自己项目里搜这两句:
# React 项目
grep -rn "useState(props\|useState(initial\|useState(row" src/
# Vue 项目
grep -rn "ref(props\.\|ref({...props" src/
搜出来的每一处,问自己一句:这个 prop 以后会变吗?变了之后,我要不要跟?
要跟,就补同步(watch / useEffect);不该跟、它变了我就该重置,上 key;根本不用跟的,删掉这份拷贝,改成派生。三种答案,总有一款。
这个 BUG 从发现到修复一共不到一小时,代码改动三行。但顺着它把 React 的快照语义、Vue 的活容器、两边各自的踩坑姿势串了一遍——这种「小 BUG 牵出大模型」的感觉,还挺上瘾的。
派生数据别抄第二份。抄了,就记得告诉框架:什么时候该换新的。
评论
登录后即可发表评论