CategoryTree 性能优化完整演进史
从「暴力刷新」到「精准更新」的一次真实工程优化之路
在复杂后台系统中,树形结构(CategoryTree / OrgTree / MenuTree) 是性能问题的高发区。
本文记录了一次真实的 CategoryTree 性能优化过程:从最初的"任何操作都刷新整棵树",逐步演进到 O(1) 定位 + O(depth) 更新 + 精准重渲染 的完整方案。
这不是一蹴而就的"最佳实践",而是一条问题驱动、逐步演进的工程优化路径。
一、第一阶段:最初版本 ------ 暴力刷新整棵树
实现方式
ts
const handleSave = async () => {
await orgApi.createOrg(params);
onRefreshData(); // 刷新整个树
};
const onRefreshData = () => {
fetchCategoryData(currentDataSpace);
};
存在的问题
- ❌ 新增 1 个节点 → 刷新整棵树(可能 100+ 节点)
- ❌ 重命名 / 删除 → 同样刷新整棵树
- ❌ UI 明显闪烁(临时节点消失 → 列表重载 → 新节点出现)
- ❌ 展开/折叠状态可能丢失
- ❌ 网络请求冗余、性能差
典型特征:
功能正确,但性能和体验都不可接受。
二、第二阶段:Props 优化 ------ 降低重渲染成本
问题发现
CategoryItem 组件一度接收 21 个独立 props,任何一个变化都会导致组件重渲染,维护成本和性能风险极高。
优化方案:Props 分组
tsx
<CategoryItem
category={category}
level={level}
state={categoryTreeState} // 状态组
config={categoryTreeConfig} // 配置组
callbacks={categoryTreeCallbacks} // 回调组
hoverHandlers={hoverMenuHandlers} // 悬停处理组
/>
收益
- ✅ 代码结构更清晰
- ✅ 便于
useMemo缓存 - ✅ 降低 props drilling 复杂度
三、第三阶段:乐观 UI ------ 新增节点不再闪烁
问题
新增节点时的典型体验:
临时节点出现
→ API 请求中
→ 临时节点消失
→ 列表刷新
→ 新节点出现(闪)
优化方案:引入 isSaving 状态
ts
export interface CreatingOrgState {
parentId?: string;
tempId: string;
defaultName: string;
isSaving?: boolean;
}
ts
setCreatingOrg(prev => ({ ...prev, isSaving: true }));
tsx
{isSaving && <span className={styles.savingIndicator}>保存中...</span>}
效果
- ✅ 临时节点保持显示
- ✅ 明确的"保存中"反馈
- ❌ 成功后仍需刷新整棵树
四、第四阶段:重命名优化 ------ 本地更新替代刷新
原始实现(反模式)
ts
await orgApi.renameOrg(...);
onRefreshData(); // 只改名字却刷新整棵树
本地更新(递归查找)
ts
const updateCategoryName = (id, newName) => {
const update = nodes =>
nodes.map(node => {
if (node.id === id) return { ...node, name: newName };
if (node.children) return { ...node, children: update(node.children) };
return node;
});
setCategoryData(prev => ({
...prev,
[currentDataSpace]: update(prev[currentDataSpace]),
}));
};
问题
- ❌ 时间复杂度 O(n)
- ❌ 节点规模增大后仍有性能瓶颈
五、第五阶段:引入索引 ------ O(1) 精准定位节点
核心思想
用空间换时间:提前建立
id → path索引。
ts
nodePathMap = {
"10": [0],
"25": [0, 2],
"35": [0, 2, 1],
};
通过路径更新节点
ts
const updateNodeByPath = (nodes, path, newName) => {
const [idx, ...rest] = path;
return nodes.map((node, i) =>
i !== idx
? node
: rest.length === 0
? { ...node, name: newName }
: { ...node, children: updateNodeByPath(node.children, rest, newName) }
);
};
效果
- ✅ 查找从 O(n) → O(1)
- ✅ 更新从 O(n) → O(depth)
- ✅ 非路径节点保持引用不变(为 memo 奠定基础)
六、第六阶段:React.memo + Boolean Props ------ 精准重渲染
问题根源
ts
setExpandedCategories(prev => new Set(prev));
Set 每次都是新引用,导致所有节点 props 变化。
优化方案
- 父组件预计算 boolean
- 子组件只接收 primitive props
tsx
<CategoryItem
isSelected={isSelected}
isExpanded={isExpanded}
isEditing={isEditing}
isHovered={isHovered}
/>
渲染结果
- 展开一个节点 → 只重渲染该节点
- 其他节点保持稳定,不触发 render
七、第七阶段:控制索引重建时机
问题
任何 categoryData 变化都会触发索引重建,rename 也不例外。
解决方案:索引版本号
ts
const [indexVersion, setIndexVersion] = useState(0);
- 结构变化:
indexVersion++ - 字段更新:不变
效果
- ✅ rename 不再触发 O(n) 索引重建
- ✅ 索引只在真正必要时更新
八、第八阶段:新增成功后本地替换节点
优化目标
新增成功后:
- 不刷新列表
- 不重建索引
- 只更新 1 个节点
核心流程
- O(1) 更新索引 ID
- O(depth) 替换节点数据
ts
replaceNodeData(tempId, realData);
updateNodeIdInIndex(tempId, realId);
最终效果
- ✅ 新增只更新 1 个 DOM 节点
- ✅ 无网络重复请求
- ✅ 无整树重渲染
- ✅ 用户体验丝滑
总结
这次优化的核心原则始终一致:
- 字段变更 → 本地更新
- 结构变更 → 局部处理
- 能 O(1) 定位,绝不 O(n) 遍历
- 能只渲染 1 个节点,绝不重渲染整棵树
树组件的性能优化,本质是:
数据结构设计 + 状态粒度控制 + React 渲染模型理解。
希望这条演进路径,能对你正在维护的树组件有所启发。