复杂表格 React.memo 仍卡:把状态订阅缩到单元格

复杂表格 React.memo 仍卡:把状态订阅缩到单元格

复杂表格 React.memo 仍卡:把状态订阅缩到单元格

复杂表格卡顿时,React.memo不是万能贴纸。用 Profiler 确认更新从哪一层扩散,再把订阅缩到rowId + colKey,同时核对 props 引用是否稳定。

为什么 React.memo 救不了复杂表格

很多 React 开发者有一个误区:只要给子组件套一层React.memo,就能阻止无意义的重新渲染。

复杂表格中,React.memo很容易因不稳定的对象和回调失去效果,但并非一定无效。

看一个最常见的错误写法:

// ❌ 错误示范:内联回调与对象直接击碎 React.memo 的浅比较 <TableBody> {rows.map((row) => ( <TableRow key={row.id} data={row} onChange={(val) => handleCellChange(row.id, val)} /> ))} </TableBody>

在这段代码里,即使TableRowReact.memo包裹了,但因为onChange传递的是一个内联箭头函数,每次父组件渲染时,该函数的引用都是全新的。浅比较(Object.is)立刻判定 Props 已改变,React.memo形同虚设。

更糟糕的是,如果把表格状态全量存在顶层 React 组件的useState中,修改某一个单元格的值,就会生成一个新的表格对象。

即使使用useCallback,如果row引用变化,子组件仍可能重新渲染。单次渲染成本取决于单元格内容、浏览器和设备,应以实际 profile 为准。

外部 store 的细粒度订阅是一种选择;拆分组件、虚拟化、延迟非关键更新也可能更合适。

基于 useSyncExternalStore 的单元格级原子状态仓

React 18 引入的useSyncExternalStore是处理这种高频局部更新的杀手锏。

一种做法是把二维表格放入按rowIdcolKey订阅的数据仓。更新单元格后,用 Profiler 确认渲染范围是否收缩;不要预先声称其余单元格一定完全不参与比较。

下面是经过重构的表格核心实现示例。

import { useSyncExternalStore, useCallback, useRef } from 'react'; export type CellValue = string | number | boolean; export type TableDataMap = Map<string, Map<string, CellValue>>; type Listener = () => void; /** * 外部表格数据存储仓 */ export class FastTableStore { private data: TableDataMap = new Map(); private cellListeners: Map<string, Set<Listener>> = new Map(); private getCellKey(rowId: string, colKey: string): string { return `${rowId}:${colKey}`; } /** * 初始化/全量更新表格数据 */ public initData(rawData: Record<string, Record<string, CellValue>>) { this.data.clear(); Object.entries(rawData).forEach(([rowId, cols]) => { const rowMap = new Map<string, CellValue>(); Object.entries(cols).forEach(([colKey, val]) => { rowMap.set(colKey, val); }); this.data.set(rowId, rowMap); }); } /** * 获取单个单元格快照 */ public getCellValue = (rowId: string, colKey: string): CellValue => { return this.data.get(rowId)?.get(colKey) ?? ''; }; /** * 更新单个单元格,并仅通知该单元格的订阅者 */ public setCellValue(rowId: string, colKey: string, value: CellValue) { let rowMap = this.data.get(rowId); if (!rowMap) { rowMap = new Map(); this.data.set(rowId, rowMap); } const oldValue = rowMap.get(colKey); if (oldValue === value) return; // 值未变动,直接跳过 rowMap.set(colKey, value); // 精准通知订阅了该单元格的组件 const cellKey = this.getCellKey(rowId, colKey); const listeners = this.cellListeners.get(cellKey); if (listeners) { listeners.forEach((fn) => fn()); } } /** * 单元格级别精确订阅 */ public subscribeCell = (rowId: string, colKey: string, listener: Listener) => { const cellKey = this.getCellKey(rowId, colKey); let listeners = this.cellListeners.get(cellKey); if (!listeners) { listeners = new Set(); this.cellListeners.set(cellKey, listeners); } listeners.add(listener); return () => { listeners?.delete(listener); if (listeners?.size === 0) { this.cellListeners.delete(cellKey); } }; }; } /** * 单元格专属订阅 Hook */ export function useTableCell(store: FastTableStore, rowId: string, colKey: string) { const subscribe = useCallback( (listener: Listener) => store.subscribeCell(rowId, colKey, listener), [store, rowId, colKey] ); const getSnapshot = useCallback( () => store.getCellValue(rowId, colKey), [store, rowId, colKey] ); const value = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); const setValue = useCallback( (val: CellValue) => { store.setCellValue(rowId, colKey, val); }, [store, rowId, colKey] ); return [value, setValue] as const; }

配合该状态仓,单元格组件可以直接写成极简的“原子组件”:

import React, { memo } from 'react'; import { FastTableStore, useTableCell } from './FastTableStore'; interface FastCellProps { store: FastTableStore; rowId: string; colKey: string; } // 结合外部 store,此组件只有在所属 cellValue 改变时才会执行 re-render export const FastTableCell = memo(({ store, rowId, colKey }: FastCellProps) => { const [value, setValue] = useTableCell(store, rowId, colKey); return ( <div className="table-cell"> <input type="text" value={String(value)} onChange={(e) => setValue(e.target.value)} style={{ width: '100%', border: '1px solid #ccc', padding: '4px' }} /> </div> ); }); FastTableCell.displayName = 'FastTableCell';

Profiler 排查基准

比较方案时,使用同一份表格数据、输入序列、浏览器版本和设备,记录 React Profiler 的 commit 时长、渲染组件范围,以及 Performance 面板中的脚本、布局和长任务。若接入外部 store,也要验证批量初始化、撤销重做和服务端渲染的快照一致性。

排查 React 卡顿总结三条打硬仗的经验:

第一,Profiler 是重要证据之一;还应结合浏览器 Performance 面板判断 React 之外的工作。

第二,高频数据不宜放进会让大量消费者更新的粗粒度 Context。

第三,学会使用 React 以外的状态管理器。React 的 UI 层负责渲染,数据层的变动频率如果远高于 UI 更新要求,就把数据从 React 组件树里拿出来,用外部订阅机制反向驱动局部更新。