CategoryTree 性能优化完整演进史

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 渲染模型理解

希望这条演进路径,能对你正在维护的树组件有所启发。

相关推荐
超哥--2 小时前
B站视频内容智能分析系统(九):React 前端与管理面板
前端·react.js·前端框架
devilnumber3 小时前
Java 递归算法 详解 + 核心要点 + 实战运用 + 避坑指南
java·开发语言·算法
asdfg12589635 小时前
JavaBean是什么?怎么理解?有什么用途?
java·开发语言
Cutecat_5 小时前
视频字幕处理工具横向:提取模式 vs 编辑模式,该如何选择
android·前端·ios·语音识别
dsyyyyy11015 小时前
JavaScript变量
开发语言·javascript·ecmascript
qq_422152575 小时前
PDF 加水印工具怎么选?2026 年文档版权保护方案对比
前端·pdf·github
kyriewen6 小时前
手写 Promise.all、race、any:不到 30 行代码,解决并发异步的所有姿势
前端·javascript·面试
z落落6 小时前
C#WinForm 窗体切换与窗体传值(登录跳转案例)+WinForm 窗体传值(从上往下传、从下往上传)
开发语言·windows·c#
allway26 小时前
How to Echo Multiline to a File in Bash [3 Methods]
开发语言·chrome·bash
weixin_462446236 小时前
手把手教你用 Bash 脚本自动更新 /etc/hosts —— 自动绑定网卡 IP 与节点名
开发语言·tcp/ip·bash