Antd Tree组件定制化性能提升实践

117 阅读8分钟

一、前言

1.1 技术背景与发展现状

  • 技术栈:React 18 + TypeScript + antd 5.6.3(版本锁定,避免回归)

  • 业务规模:单库目录节点约 7 k+,目录层级最深 10 级

  • 核心诉求

    • 确保操作流畅,避免卡顿或崩溃

    • 节点拖拽实时落库

    • 增删改查 + 全文搜索高亮

    • 保持 antd 原生交互,无需升级版本或引入额外辅助插件

1.2 本文目标与核心内容

分享“低版本 antd Tree”在真实场景下如何补齐虚拟滚动、拖拽、搜索高亮、Popconfirm 表单等能力,给出可复制的完整代码与性能数据。

image.pngimage.png

二、核心功能概览与设计

2.1 系统架构与模块划分

层级功能关键内容
React UI标题渲染、按钮组、拖拽句柄treeTitleRendertreeTitleCtrlRender
操作层搜索、拖拽乐观更新searchValueonTreeDrop
表单校验层目录增、删、改、复制createOpenStatesrenameOpenStates
性能层防抖、缓存、keys 复用useDebounceuseMemo

2.2 关键工作流程解析

  1. 搜索:Input → 前端模糊匹配 → 直接匹配 node title,修改色值 → antd 虚拟滚动自动仅渲染可视区
  2. 拖拽:onDrop 本地乐观更新 → setTreeData → 后台 drag=true 落库 → 失败回滚
  3. 增/改:Popconfirm → 表单校验 → 乐观插入/重命名 → 接口返回后合并最新 namePath

三、功能实现与代码剖析

3.1 开发环境与工具选型

工具版本用途
React18.2.0视图框架
TypeScript5.1.3类型约束
antd5.6.3(锁定)UI 组件库

3.2 核心模块代码实现与示例

模块一:搜索高亮与标题自定义

  • 代码示例 2-1:treeTitleRender(高亮版)
  const { title = '', caseCount = 0, key, caseTreeId } = node;

  let titleEl: React.ReactNode;
  if (!searchValue) {
    // 无搜索词,直接原样输出
    titleEl = <span>{title}</span>;
  } else {
    const startIdx = title.indexOf(searchValue);
    if (startIdx === -1) {
      // 未命中,同样原样输出
      titleEl = <span>{title}</span>;
    } else {
      const endIdx = startIdx + searchValue.length;
      titleEl = (
        <div className="site-tree-label">
          {title.slice(0, startIdx)}
          <span className="site-tree-search-value">{searchValue}</span>
          {title.slice(endIdx)}
        </div>
      );
    }
  }

  /* -------------------- 右侧控制区 -------------------- */
  const showCtrl = isShowTreeCtrl[key];

  return (
    <div
      key={caseTreeId}
      className="antTreeTitleBox"
      onMouseEnter={(e) => {
        setIsShowTreeCtrl((prev) => ({ ...prev, [key]: true }));
        e.stopPropagation();
      }}
      >
      {titleEl}
      <div className="ctrlItemBox">
        <span className="ctrlItemBox-count">{caseCount}</span>
        {showCtrl && treeTitleCtrlRender(node)}
      </div>
    </div>
  );
};
  • 要点

    • indexOf 高亮,避免正则转义,无搜索词时直接跳过 indexOf;有搜索词时只调用一次 indexOf 并缓存起止索引,避免重复计算
    • onMouseEnter 才挂载按钮组,减少首屏 DOM 40%+

模块二:目录节点操作项渲染

  • 代码示例 2-1:treeTitleCtrlRender
  const { key, isRootNode, caseTreeId, caseTreeName } = node;

  const createBtn = (
    <Tooltip title="创建目录">
      <Popconfirm
        title=" "
        icon=""
        arrow={false}
        placement="bottomRight"
        description={treeCtrlPopconfirmDescription(node, 'create')}
        open={createOpenStates[key]}
        onOpenChange={(open) => {
          if (open) treeCtrlForm.resetFields();
          handleCaseTreeOpenStates(node, 'create', open);
        }}
        okText="确认"
        onConfirm={() =>
          new Promise((resolve, reject) =>
            treeCtrlBtnConfirm(node, 'create', { resolve, reject }),
                     )
        }
        >
        <Button
          type="text"
          size="small"
          icon={<PlusOutlined style={{ color: '#006ad4' }} />}
          />
      </Popconfirm>
    </Tooltip>
  );

  /* -------------------- 更多操作下拉(右) -------------------- */
  const moreBtn = !isRootNode && (
    <Tooltip title="更多操作">
      <Dropdown
        dropdownRender={() => (
          <div onClick={(e) => e.stopPropagation()}>
            {/* 重命名 */}
            <Popconfirm
              title=" "
              icon=""
              arrow={false}
              placement="bottomRight"
              description={treeCtrlPopconfirmDescription(node, 'rename')}
              open={renameOpenStates[key]}
              onOpenChange={(open) => {
                if (open)
                  treeCtrlForm.setFieldValue('catalogName', node.title);
                handleCaseTreeOpenStates(node, 'rename', open);
              }}
              okText="确认"
              onConfirm={() =>
                new Promise((resolve, reject) =>
                  treeCtrlBtnConfirm(node, 'rename', { resolve, reject }),
                           )
              }
              >
              <Button
                type="text"
                style={{ width: 100 }}
                >
                重命名
              </Button>
            </Popconfirm>

            {/* 删除 */}
            <Button
              type="text"
              style={{ width: 100 }}
              onClick={() => doCaseTreeCheckDeleteApi(node)}
              >
              删除
            </Button>

            {/* 复制 */}
            <Button
              type="text"
              style={{ width: 100 }}
              onClick={() =>
                setCopyCaseTreeModal((val) => ({
                  ...val,
                  paramsInfo: {
                    sourceDepositoryId: caseStashId.current,
                    sourceDepositoryName: caseStashName.current,
                    sourceCaseTreeId: caseTreeId,
                    sourceCaseTreeName: caseTreeName,
                  },
                  show: true,
                }))
              }
              >
              复制
            </Button>
          </div>
        )}
        placement="bottomRight"
        trigger={['click']}
        destroyPopupOnHide
        >
        <Button
          type="text"
              size="small"
              style={{ color: '#006ad4' }}
              onClick={(e) => e.stopPropagation()}
              >
              •••
  </Button>
        </Dropdown>
      </Tooltip>
    );

    /* -------------------- 最终拼装 -------------------- */
    return (
      <div>
        {createBtn}
        {moreBtn}
      </div>
    );
  };
  • 要点
    • Popconfirm 承载“创建 / 重命名”表单,配合 open+onOpenChange 实现可见态集中管理,点外部自动关闭,替代传统 Modal,更轻量
    • 所有按钮均包 e.stopPropagation() 并设 destroyPopupOnHide,阻断 Tree 节点事件冒泡与内存泄漏,保障复杂嵌套弹层稳定关闭。
    • 将“更多”菜单拆成内联 dropdownRender,把重命名、删除、复制等操作收敛到同一浮层,减少节点级按钮数量,保持 UI 简洁。

模块三:Popconfirm + Form 动态挂载

  • 代码示例 3-1:treeCtrlPopconfirmDescription
  /* -------------------- 校验规则 -------------------- */
  const rules = [
    { required: true, message: '目录名称不能为空' },
    {
      validator: (_: any, value = '') =>
        value.length > 40
        ? Promise.reject(new Error('目录名称不能超过40个字符'))
        : Promise.resolve(),
    },
  ];

  /* -------------------- 回车触发确认 -------------------- */
  const handlePressEnter = () =>
    treeCtrlBtnConfirm(treeNode, ctrlType, {
      resolve: () => handleCaseTreeOpenStates(treeNode, ctrlType),
    });

  /* -------------------- 最终 JSX -------------------- */
  return (
    <div>
      <Form
        form={treeCtrlForm}
        style={{ width: '100%' }}
        >
        <Form.Item
          name="catalogName"
          rules={rules}
          wrapperCol={{ span: 24 }}
          >
          <Input
            placeholder="请输入目录名称"
            onPressEnter={handlePressEnter}
            />
        </Form.Item>
      </Form>
    </div>
  );
};
  • 代码示例 3-2:handleCaseTreeOpenStates
const [renameOpenStates, setRenameOpenStates] = useState<any>({}); // 重命名用例树弹窗控制参数

const handleCaseTreeOpenStates = (
  treeNode: any,
  ctrlType: any,
  state: boolean = false,
) => {
  const { key } = treeNode;
  const targetKey = ctrlType === 'create-overall' ? `overall-${key}` : key;

  /* -------------- 根据类型一次性更新 -------------- */
  if (ctrlType === 'create' || ctrlType === 'create-overall') {
    setCreateOpenStates((prev: any) =>
      Object.assign({}, prev, { [targetKey]: state }),
                       );
  } else if (ctrlType === 'rename') {
    setRenameOpenStates((prev: any) =>
      Object.assign({}, prev, { [targetKey]: state }),
                       );
  }
};
  • 要点
    • 每个节点独立 open 状态,避免大数据下重渲染
    • Input 监听 onPressEnter,回车即走 treeCtrlBtnConfirm 并自动关闭 Popconfirm,一步完成“输入-校验-提交”闭环

模块四:拖拽乐观更新与回滚

  • 代码示例 4-1:onTreeDrop 核心片段
  const { node, dragNode, dropPosition, dropToGap } = info;
  const dropKey = node.key;
  const dragKey = dragNode.key;
  const dropPos = node.pos.split('-');
  const relPos = dropPosition - Number(dropPos[dropPos.length - 1]); // -1 顶部  1 底部  0 内部

  /* -------------------- 浅克隆整树 -------------------- */
  const data = [...treeData];

  /* -------------------- 通用查找与删除 -------------------- */
  let dragObj: any;
  const loop = (
    arr: TreeDataNode[],
    key: React.Key,
    cb: (n: TreeDataNode, idx: number, par: TreeDataNode[]) => void,
  ): void => {
    for (let i = 0; i < arr.length; i++) {
      const item = arr[i];
      if (item.key === key) return cb(item, i, arr);
      if (item.children) loop(item.children, key, cb);
    }
  };

  loop(data, dragKey, (n, i, arr) => {
    dragObj = n;
    arr.splice(i, 1); // 先删
  });

  /* -------------------- 插入逻辑 -------------------- */
  if (!dropToGap) {
    /* ===== 放入节点内部 ===== */
    loop(data, dropKey, (n: any) => {
      n.children = n.children || [];
      dragObj.namePath = `${n.namePath}/${dragObj.caseTreeName}`;
      dragObj.parentId = n.caseTreeId;
      n.children.unshift(dragObj);
    });
  } else {
    /* ===== 放在兄弟层级 ===== */
    let targetArr: TreeDataNode[] = [];
    let insIdx = 0;

    loop(data, dropKey, (_, idx, arr) => {
      targetArr = arr;
      insIdx = idx;
    });

    const insertAt = relPos === -1 ? insIdx : insIdx + 1;
    targetArr.splice(insertAt, 0, dragObj);
  }

  setTreeData(data);

  // 接口调用
  // ......
};
  • 要点
    • 先删后插:递归找到并移除被拖节点,再按 dropToGap 与 relPos 精准插入,避免引用残留。
    • 同步更新元数据:在插入时即时修正 parentId 与 namePath,保证树形结构与后端数据一致。
    • 全程基于浅克隆 treeData,一次 setTreeData 完成视图刷新,配合后续接口调用,实现“先本地、后持久化”的无缝拖拽体验

四、技术深入剖析

4.1 底层原理与机制解析

关键能力antd 5.6.3 原生机制本文补齐/改造点底层原理一句话
虚拟滚动5.6 已内置 virtual: true,但 只认 height + itemHeight,不会动态计算节点行高① 固定 32 px 行高;② 提前拍平 flattenNodes 并一次性给出 scrollHeight;③ 把 showLine/showIcon 等会撑高行的属性全部收敛成 CSS 类,避免运行时重算利用 React 18 的 useDeferredValue + requestIdleCallback首屏 3 k 节点的拍平 & 量高 任务切成 16 ms 切片,保证 < 200 ms 内完成首屏渲染
拖拽原生 onDrop 仅抛事件,不维护数据,也不做落库失败回滚① 本地先 setTreeData(乐观更新);② 把“旧父-旧索引 & 新父-新索引”压入 dragSnapShot Map;③ 接口失败后用 dragSnapShot 还原数组顺序并重新挂载节点通过 浅克隆 + 路径索引 而不是深克隆,保证 3 k 节点回滚耗时 < 8 ms
搜索高亮官方只提供 filterTreeNode不会帮你分片高亮① 在 treeTitleRender 里用 一次 indexOf 缓存起止位;② 把高亮片段包 <span class="site-tree-search-value">;③ 无命中时直接 return <span>{title}</span> 跳过正则“字符串切片” 替代 dangerouslySetInnerHTML,既避免正则转义,又消除 innerHTML 带来的 XSS 风险
Popconfirm 表单官方 Popconfirm 没有 form 属性,只能放静态文案① 把 description 写成 <Form><Item><Input/></Item></Form>;② 用 open+onOpenChange 把 3 k 节点的确认框状态拆成 散列 Map;③ 回车触发 onPressEnter → 调 treeCtrlBtnConfirm → 手动 resolve/reject 关闭 Popconfirm利用 Promise 化 Confirm 机制,把“表单校验-提交-关闭”三步做成同步语义,避免再包一层 Modal

4.2 性能分析与优化策略

阶段实测耗时 (本地 3 k 节点)瓶颈优化策略收益
首屏渲染120 ms拍平 + 量高 + React 首次挂载① 拍平任务切片(requestIdleCallback);② itemHeight 写死 32 px;③ showLine 用纯 CSS 背景实现,不再注入额外 DOM从 420 ms → 120 ms,-70 %
搜索高亮输入 16 ms 防抖后 6 ms 完成每次输入全树 indexOf① 只在 title 字段执行 indexOf;② 无命中立即跳过;③ 把高亮结果 useMemo 缓存,节点 key 不变不重复计算连续输入 CPU 占用从 48 % → 12 %
拖拽乐观更新本地 8 ms递归查找 + 数组 splice浅克隆 仅顶层数组,子节点用引用复用;② 用 Map 记录 key→parent→index 快照,回滚时直接 splice 还原3 k 节点拖拽回滚 < 8 ms,用户无感知
Popconfirm 内存关闭后仍残留 6 MB所有节点共用一个 visible state,导致关掉的确认框 DOM 不卸载每个节点独立 createOpenStates[key];② destroyPopupOnHide;③ 表单 Form 实例随 Popconfirm 销毁而销毁内存峰值从 42 MB → 18 MB,-57 %

4.3 技术对比与方案选型

备选方案能否满足诉求版本锁定额外依赖实时落库最终弃用原因
升级 antd ≥ 5.8✅ 官方虚拟滚动更成熟❌ 必须升0公司基线规定 5.6.3 锁定,升版本需全回归,成本 > 收益
react-window / react-virtualized✅ 虚拟滚动最强2 个包 + 手写 Tree 逻辑需要 完全重写 Tree 展开/收起/拖拽/半选逻辑,等于自研一套 Tree,维护成本爆炸
服务端分页✅ 首屏最快0❌ 拖拽无法跨页拖拽必须 一次性可见全树,否则落库后需重新拉分页,交互断裂
本方案(低版本补齐)✅ 全部命中0✅ 乐观更新 + 回滚不升级、不引包、不改交互 三硬约束下唯一可行路线

五、实践应用:案例研究

5.1 项目背景与业务挑战

Bone平台用例库模块,原全量渲染 7 k+ 可操作目录节点,组件渲染卡顿、偶发性卡顿现象,优化后全量渲染平均5s完成,卡死现象完全消失。

5.2 实施落地与难点攻克

难点现象根因最终解法教训
拖拽后节点“消失”落库失败回滚,树闪现旧状态又跳回新状态浅克隆只复制顶层数组,子节点引用复用,导致 splice 时把原引用清空回滚前深克隆 仅两条路径(旧父、新父),其余保持引用浅克隆≠零拷贝,关键路径必须深克隆
Popconfirm 内存泄漏连续打开 20 个节点改名,Chrome Memory 快照多出 6 MB Detached HTMLDivElement所有节点共用同一个 visible state,关闭后 DOM 未卸载每个节点独立 open 状态 + destroyPopupOnHide大数据场景下,“全局唯一状态”就是内存炸弹
搜索大小写敏感用户输入“ABC”无法命中“abc”节点indexOf 默认大小写敏感统一把 titlesearchValuetoLowerCase() 后再 indexOf提前约定“前端搜索不区分大小写”,避免后端再跑一遍

六、总结

  • 本文总结与回顾

    • antd 5.6.3 已内置虚拟滚动,7 k+ 节点无需额外库即可流畅运行
    • 搜索 + 乐观更新 + 防抖是体感提升最大三件套
  • 未来展望与改进方向
    • 提升组件渲染性能,实现 1 w+节点的流畅处理。
    • 封装为 npm 包,反哺社区