Redux store更新触发全组件额外重渲染的解决方法
你现在遇到的全列表重渲染,核心是Redux更新+React默认渲染逻辑共同导致的:
- 每次计时器加1时,reducer里的
map会返回一个全新的tasks数组引用,哪怕只有一个任务的timer值变了,其他任务对象原封不动,整个数组的引用也变了 - 顶层App/TaskPage组件订阅了整个
state.tasks,数组引用一变就会触发重渲染,子组件如果没做重渲染拦截,就会跟着父组件全部重跑render
按优先级从高到低选择即可,组合使用效果最好。
1. 拆分Redux订阅粒度,不要在顶层拿全量tasks
最彻底的方案:不要让App、TaskPage这类父组件连接store拿整个tasks数组,改成让每个单独的任务项组件自己连接store,只取自己id对应的任务数据。
这样只有timer在跑的那个任务,因为自己订阅的store数据变了才会重渲染,其他任务订阅的数据引用完全没变化,根本不会触发更新。
类组件connect写法示例:
// 单个任务项组件 const mapStateToProps = (state, ownProps) => ({ task: state.tasks.find(t => t.id === ownProps.taskId) }) export default connect(mapStateToProps)(TaskItem);
如果用函数组件+Hooks,用useSelector更方便,它默认会做严格相等判断,只有取到的任务对象引用变了才会重渲染:
// TaskItem内部 const task = useSelector(state => state.tasks.find(t => t.id === taskId))
父组件TaskPage只需要拿所有任务id的列表,循环渲染TaskItem、传taskId就行,不用传整个task对象。
2. 给任务项组件加重渲染拦截
如果暂时不想改订阅逻辑,直接给单个TaskItem组件加浅比较判断,跳过无意义的重渲染就行——你现在reducer更新时,没在计时的任务都是直接返回原对象,引用完全没变,浅比较就能识别出不需要更新:
- 类组件直接继承
PureComponent,它内置了props/state的浅比较逻辑:class TaskItem extends React.PureComponent { render() { // 任务项渲染逻辑 } } - 函数组件用
React.memo包裹,效果和PureComponent一致:const TaskItem = React.memo(({ task }) => { // 任务项渲染逻辑 })
注意:不要给TaskItem传内联函数、每次渲染都会新建的对象/数组这类临时引用值,否则浅比较会失效,该拦截的更新拦不住。
3. (可选)优化reducer数据结构,降低更新开销
如果任务量比较大,可以把tasks从数组改成扁平化的字典结构(Redux官方推荐的normalized范式),更新单个任务的时候不用遍历整个数组,性能更好,也方便单个任务订阅数据:
// 初始state const initialState = { tasks: {}, // 以taskId为key,对应值为任务对象 taskIds: [], // 存所有任务id,用来渲染列表顺序 isLoading: false, error: "", }; // 计时器更新的reducer逻辑 case "TIMER_INCREAMENT": const { taskId } = action.payLoad; return { ...state, tasks: { ...state.tasks, [taskId]: { ...state.tasks[taskId], timer: state.tasks[taskId].timer + 1 } } }
这种结构下,更新单个任务完全不会影响其他任务的对象引用,配合前面两种方案,非激活任务的重渲染会被完全拦截。
你现在的saga里没有处理TIMER_STOPPED的中断逻辑,计时器启动后会一直无限循环派发加1动作,哪怕触发停止动作也不会停,记得补上中断:在handleProgressTimer收到TIMER_STOPPED动作时直接return,配合你写的takeLatesById按id隔离channel的逻辑,takeLatest会自动取消对应id正在跑的计时循环。
内容的提问来源于stack exchange,提问作者milad shirian

