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

zijieLeo
约 24 分钟
3 次浏览
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 牵出大模型」的感觉,还挺上瘾的。

派生数据别抄第二份。抄了,就记得告诉框架:什么时候该换新的。

评论

登录后即可发表评论

暂无评论,快来发表第一条评论吧!