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

一、前言

1.1 技术背景与发展现状

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

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

  • 核心诉求

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

    • 节点拖拽实时落库

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

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

1.2 本文目标与核心内容

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

二、核心功能概览与设计

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 开发环境与工具选型

工具 版本 用途
React 18.2.0 视图框架
TypeScript 5.1.3 类型约束
antd 5.6.3(锁定) UI 组件库

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

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

  • 代码示例 2-1:treeTitleRender(高亮版)
const 复制代码
  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 复制代码
  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 复制代码
  /* -------------------- 校验规则 -------------------- */
  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 复制代码
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 复制代码
  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 包,反哺社区
相关推荐
Student_Zhang9 小时前
一个管理项目中所有弹窗的弹窗管理器(PopupManager)
前端·ios·github
网络风云9 小时前
HTML 模块化方案
前端·html
小满zs9 小时前
Next.js第十九章(服务器函数)
前端·next.js
仰望.9 小时前
vxe-table 如何实现分页勾选复选框功能,分页后还能支持多选的选中状态
前端·vue.js·vxe-table
铅笔侠_小龙虾9 小时前
html+css 实现键盘
前端·css·html
licongmingli9 小时前
vue2 基于虚拟dom的下拉选择框,保证大数据不卡顿,仿antd功能和样式
大数据·前端·javascript·vue.js·anti-design-vue
小笔学长10 小时前
Webpack 入门:打包工具的基本使用
前端·webpack·前端开发·入门教程·前端打包优化
黎明初时10 小时前
react基础框架搭建4-tailwindcss配置:react+router+redux+axios+Tailwind+webpack
前端·react.js·webpack·前端框架
小沐°10 小时前
vue3-父子组件通信
前端·javascript·vue.js
铅笔侠_小龙虾10 小时前
Ubuntu 搭建前端环境&Vue实战
linux·前端·ubuntu·vue