Redux状态管理实战:从原理到项目优化技巧
又踩坑了,Redux状态更新后组件不重新渲染
昨天改一个老项目,加了个筛选功能,结果死活不生效。点完筛选按钮,Redux里的状态明明变了,但页面上就是不动。我盯着 DevTools 里 Redux 的 action 和 state 变化看了十分钟,确认数据确实更新了,但 React 组件就是不 re-render。这下可好,典型的“数据对了,界面不对”问题。
一开始我以为是 connect 没写对,或者 useSelector 没监听到变化。于是先去翻了下组件代码:
const filters = useSelector(state => state.filters);
看起来没问题啊。然后我试了下在组件里加个 console.log(filters),发现每次点击筛选,log 确实会打印,但内容和上次一样——也就是说,虽然 Redux state 变了,但 selector 返回的引用没变。这下有点懵了。
折腾了半天,原来是对象引用没变
我开始怀疑是不是 reducer 写错了。打开一看,我的 reducer 是这么写的:
case 'SET_FILTER':
state.filters[type] = value;
return state;
……完了,直接 mutate 了原 state。Redux 要求 state 必须是 immutable 的,React-Redux 通过 shallow equality 判断是否需要更新组件。如果返回的是同一个对象引用,即使内部值变了,useSelector 也会认为“没变”,于是不触发 re-render。
这里我踩了个大坑:以为只要 state 里字段变了就行,忽略了引用一致性的问题。其实这问题我之前也遇到过,但每次都会忘,真是记吃不记打。
赶紧改成 immutable 写法:
case 'SET_FILTER':
return {
...state,
filters: {
...state.filters,
[type]: value
}
};
改完一试,好了!组件正常更新了。但事情没完——我突然想到,这个 filters 对象可能嵌套更深,比如:
filters: {
category: { id: 1, name: 'tech' },
dateRange: { start: '2023-01-01', end: '2023-12-31' }
}
如果我只是浅拷贝一层,那 category 这个对象还是原来的引用。万一后续有组件用 useSelector(state => state.filters.category),还是会因为引用没变而不更新。
要不要用 immer?算了,先搞定眼前问题
其实用 immer 能彻底解决这个问题,写起来也舒服:
import produce from 'immer';
const reducer = produce((draft, action) => {
switch (action.type) {
case 'SET_FILTER':
draft.filters[action.type] = action.value;
break;
}
}, initialState);
但这个老项目没装 immer,而且团队对引入新依赖比较谨慎。我不想为了一个小功能提个 MR 去加依赖,干脆手动 deep clone 一下?
等等,deep clone 性能太差,尤其是 filters 数据大的时候。而且没必要,因为我的筛选结构其实就两层,手动展开就行。
最后我决定:只对可能被单独 selector 监听的字段做深拷贝。比如如果有人监听 filters.category,那我就得确保 category 也是新对象。
所以最终的 reducer 长这样:
case 'SET_FILTER':
const { filterType, value } = action.payload;
if (filterType === 'category') {
return {
...state,
filters: {
...state.filters,
category: { ...value } // 确保 category 是新对象
}
};
}
if (filterType === 'dateRange') {
return {
...state,
filters: {
...state.filters,
dateRange: { ...value }
}
};
}
// 其他简单字段
return {
...state,
filters: {
...state.filters,
[filterType]: value
}
};
虽然啰嗦了点,但清晰、可控,而且不用额外依赖。亲测有效。
顺便优化了 selector,避免重复计算
解决了 re-render 问题后,我发现另一个小问题:有些组件用了复杂的 selector,比如把 filters 转成 URL 参数。每次 state 变化都会重新计算,哪怕 filters 没变。
这时候就得用 reselect 了。虽然项目里没装,但我觉得值得加,因为性能影响明显。
装完之后,我重构了 selector:
import { createSelector } from 'reselect';
const selectFilters = state => state.filters;
const selectFilterParams = createSelector(
[selectFilters],
(filters) => {
console.log('recalculating filter params'); // 只在 filters 引用变化时才打印
return {
category: filters.category?.id || '',
start: filters.dateRange?.start || '',
end: filters.dateRange?.end || ''
};
}
);
然后在组件里用:
const filterParams = useSelector(selectFilterParams);
现在只有当 filters 真正变化时,才会重新计算参数。控制台里那句 log 也只在我点筛选时才出现,而不是每次父组件 re-render 都打一遍。
还剩个小问题:初始化时的默认值
改完后测试,发现页面首次加载时,如果 URL 里带了筛选参数,会触发一次 action 设置 filters,但某些子组件没响应。查了下,是因为这些组件在 filters 初始化完成前就 mount 了,而它们的 selector 返回的是 undefined 或 null,后续虽然 state 更新了,但由于 selector 的输入 selector(比如 state.filters.category)从 null 变成 object,引用变了,所以应该会更新……
但实际没更新。我又懵了。
后来发现,是某个子组件用了错误的解构:
const { category } = useSelector(state => state.filters);
// 如果 filters 初始是 {},category 是 undefined
// 后来变成 { id: 1 },但这里解构出来的 category 是值,不是引用
// 所以每次都是新值,应该会更新啊?
其实会更新,只是我测试时加了个 React.memo 包裹子组件,而 memo 的默认比较是 shallow equal,对 primitive 类型(比如字符串、数字)是按值比较的,所以其实没问题。是我自己搞混了。
不过为了保险,我还是把子组件的 props 明确声明了一下,避免 future 的坑:
const FilterBadge = React.memo(({ categoryId }) => {
// 只接收具体值,不传整个对象
});
这样更清晰,也避免了不必要的 re-render。
总结一下我踩的几个坑
- 直接 mutate state:Redux 要求 immutable,否则 React-Redux 无法检测变化
- 浅拷贝不够深:如果子对象会被单独 selector 监听,必须确保子对象也是新引用
- 复杂 selector 重复计算:用 reselect 缓存结果,提升性能
- 初始化时机问题:注意组件 mount 时 state 可能还没准备好,但通常不是 Redux 问题,而是数据流设计问题
其实最核心的就一点:Redux 的 state 更新必须返回新引用,且所有层级都要保证这一点。只要你记住这个,80% 的“状态变了但界面不动”的问题都能解决。
以上是我踩坑后的总结,如果你有更好的方案欢迎评论区交流。比如有没有更优雅的方式处理多层嵌套 state 的 immutable 更新?或者不用 reselect 也能缓存 selector 的方法?我挺好奇的。

暂无评论