前端大量数据层级展示及搜索定位预览

875 阅读4分钟

前段时间还写了关于大量节点dom渲染卡顿的排查分析。展示大量数据节点(tree),引发的一次性能排查

最近两个月做了一个需求, 大致功能如下

  • 大量数据节点(每个用户有不同的数据量),并且需要展示清晰的层级结构信息。(涉及到大量dom渲染,页面卡顿)
  • 每个数据节点中挂载多种数据资源(列表数据,截图,镜像,当前节点相关资源等等),需要进行tab切换展示。(涉及到数据状态保存)
  • 需要进行搜索定位。(就是搜索列表数据,并且定位到对应的节点,进行数据回溯)(涉及到数据节点和数据表格联动)
  • 支持数据导出,链表导出等等。(这个主要是后端功能)

需求功能预览

下面来看看需求的展示情况

  • 数据分层预览

整体预览.gif

  • 数据合并预览

合并数据预览.gif

  • 数据搜索定位

搜索定位.gif

  • 修改数据节点折叠逻辑后预览

修改树节点折叠逻辑.gif

节点树形层级展示

当时这个功能查找了很多组件去实现,因为我们当前项目使用的是element-plus,于是就去查看tree相关组件,发现对于正常的tree组件他是可以进行懒加载请求数据的(展开节点时请求数据)。对于节点加载过多后,导致页面渲染很卡。对于tree-v2它支持虚拟列表展示,但是不支持节点懒加载。对于大量数据来说显然是不合适的。

然后又查找了网上相关tree组件,发现都和element-plus这两种相同。基本都是只支持虚拟列表展示。但是发现antd却可以既支持懒加载又支持虚拟列表。就想到了使用antdv了。所以下面总结下使用时遇到的问题和实现思路。

方便交互

为了让用户更容易操作,我们应该是点击节点就可以展开节点,而不是点击展开箭头再进行展开。所以我们需要使用DirectoryTree组件。

<DirectoryTree
  ref="treeRef"
  :height="menuWrapperHeight"
  :load-data="loadMoreMenu"
  :tree-data="manualTaskList" // 进入页面时主动请求的数据赋值
  :fieldNames="fieldNames"
  :checkable="store.previewData.allManualTaskCount !== 1" // 是否展示复选框(可根据css样式选择性展示)
  v-model:checkedKeys="checkedKeys"
  v-model:selectedKeys="currentNodeKey"
  :expanded-keys="saveExpandedKeys"
  :loaded-keys="loadedKeys"
  blockNode
  checkStrictly
  autoExpandParent
  @check="selectManualTaskNode"
  @select="handleNodeClick"
  @load="handleLoaded"
>
  <template #title="{ name, nid }">
    <span :class="`node-item-${nid}`" :title="name">{{ name }}</span>
  </template>
  <template #switcherIcon="data">
    <component :is="data.defaultIcon" @click.stop="handleIcon(data)" />
  </template>
</DirectoryTree>

如果想要使用虚拟列表,我们需要制定展示区域的高度,这里我是动态计算的高度。

// 设置菜单虚拟高度
const setMenuHeight = () => {
  menuWrapper = document.querySelector(".tree-menu-wrapper");
  menuWrapperHeight.value = menuWrapper?.clientHeight || 600;
};

加载数据

这里主要是将请求的数据赋值给对应节点响应式对象node.dataRef!.children

// 加载数据
const loadMoreMenu: TreeProps["loadData"] = async (node) => {
  return new Promise<void>(async (resolve) => {
    // 请求数据
    let data = await requestManualNodeData(node.nid, node.dataRef);
    if (!node.dataRef?.children?.length) {
      node.dataRef!.children = data as any;
    }
    resolve();
  });
};

选中节点

因为这里我们有两个树节点需要同步节点选中状态,所以我们需要将选中状态同步到全局store中。

// 选中节点
const selectManualTaskNode: TreeProps["onCheck"] = async (
  checkedKeys: any,
  { node }
) => {
  // 保存选中的节点
  const currentSelectTasks = store.previewData.currentSelectTasks;
  const currentSelectTaskNIds = currentSelectTasks.map((item) => item.nid);
  if (node.node_type === "manualTask") {
    if (!currentSelectTaskNIds.includes(node.nid)) {
      await store.previewData.SET_CURRENT_SELECT_MANUAL_TASK([
        ...currentSelectTasks,
        {
          nid: node.nid,
          name: node.name,
          id: node.id,
        },
      ]);
    } else {
      const currentTaskIndex = currentSelectTasks.findIndex(
        (item) => item.nid === node.nid
      );
      currentSelectTasks.splice(currentTaskIndex, 1);
      await store.previewData.SET_CURRENT_SELECT_MANUAL_TASK(
        currentSelectTasks
      );
    }
  }
};

// 联动分组和全部数据选中状态(切换tab同步)
watch(
  () => props.isAllDataPreview,
  (flag) => {
    const currentSelectTasks = store.previewData.currentSelectTasks;
    // 赋值选中状态
    !flag && (checkedKeys.value = currentSelectTasks.map((item) => item.nid));
  },
  {
    immediate: true,
  }
);

点击节点

这里需要注意,我们点击节点会触发节点请求和点击回调。所以说如果想要和父组件通信传递数据,我们需要在请求的函数中也进行emit。在点击回调函数中判断node.loaded当前是否加载过来阻止emit无效处理一次事件。这两个地方处理的事件逻辑都是一样的。主要是在点击回调函数中是用于后续非第一次点击时进行emit的。

// 点击节点(以后点击)
const handleNodeClick: TreeProps["onSelect"] = async (
  selectedKeys: any,
  { node }
) => {
  const key = node.key as number;
  // TODO: 这里是为了处理当前节点收起逻辑的
      // expanded属性表示的是点击之前的状态
      const _keys = getExpandedKeys(node);
      _keys && (saveExpandedKeys.value = _keys);
      saveExpandedStatus(key);
      // 重置点击图表的状态
      isClickIconExpand.value = false;
  
  node.loaded && emit() // 做一些事情
};

节点加载

这个回调主要是用于后续数据定位使用,来让每次节点加载都进行下一次节点滚动到对应的位置进行后续节点加载。因为这个是虚拟列表。

// 数据加载完毕
const handleLoaded: TreeProps["onLoad"] = (keys, { node }) => {
  loadedKeys.value = keys as any;
  
  // 数据定位
  if (isSearchPosition.value) {
    // 在定位节点的树节点id集合中查找当前加载节点的索引
    const findIndex = saveExpandedKeys.value.findIndex(
      (item) => item === node.nid
    );
    const scrollNId = saveExpandedKeys.value[findIndex + 1];
    // 拿到索引下一个节点的唯一标识
    scrollNId !== undefined && gotoLevelData(scrollNId);
  }
};

// 定位数据行
const gotoLevelData = (nid: number) => {
  nextTick(async () => {
    treeRef.value?.scrollTo({
      key: nid,
    });
  });
};

节点展示收起优化

  • 手风琴模式
 // 获取当前操作节点链路id
const getExpandedKeys = (node: any): number[] => {
  let resultKeys: number[] = [];
  let currentNode = node; // 不加入当前节点
  // 遍历当前节点知道节点parent为null,获取节点标识
  while ((currentNode = currentNode.parent)) {
    resultKeys.unshift(currentNode.key as number);
  }
  return resultKeys;
};

// 节点展示或者收起
const handleNodeExpandOrCollapse: TreeProps["onExpand"] = (
  expandedKeys: any,
  { expanded, node }
) => {
  const _keys = getExpandedKeys(node);
  if (expanded) {
    // 展开需要加上当前节点
    _keys.push(+node.key);
  }
  saveExpandedKeys.value = _keys;
  // 保存节点展开状态,刷新时自动展开上次点击的位置
  store.previewData.SAVE_EXPANDED_KEYS(saveExpandedKeys.value);
  store.previewData.SET_CURRENT_NODE_BY_LEVEL(+node.key);
};
  • 展示节点任何位置,收起点击图标 由于我们需要点击不同节点查看对应的节点中挂载的内容,所以当节点展开时不能进行数据点击后合并,不然对于我们产品来说体验不好。

但是对于antv来说,他的理念是让我们可以很容易通过逻辑去处理我们想要的功能,就比较容易去实现。

  • 首先需要定义图标插槽,因为组件内部没有提供事件供我们使用。
<template #switcherIcon="data"> 
    <component :is="data.defaultIcon" @click.stop="handleIcon(data)" /> 
</template>
  • 由于我们将图标绑定了我们自己的事件,所以我们需要来处理节点数据请求的回调,来保存我们已经加载的节点。
// 数据加载完毕
const handleLoaded: TreeProps["onLoad"] = (keys, { node }) => {
  loadedKeys.value = keys as any;
};
  • 定义图标点击事件
    • 获取图标节点,主动加载数据,保存当前节点到加载数组和展开数组中。
    • 展开时点击我们将当前节点标识在展开数组中删除,收起时点击我们直接获取节点元素进行点击即可。
// 点击图标
const handleIcon = async (data: any) => {
  // 获取点击的元素触发点击即可。
  const currentTreeItem = document.querySelector<HTMLElement>(
    `span.node-item-${data.nid}`
  );
  if (!loadedKeys.value.includes(data.nid)) {
    // currentTreeItem?.click();
    await loadMoreMenu(data);
    // 加载并展开
    loadedKeys.value = [...loadedKeys.value, data.nid];
    saveExpandedKeys.value = [...saveExpandedKeys.value, data.nid];
  }
  if (data.expanded) {// 点击前为展开状态
    const findIndex = saveExpandedKeys.value.findIndex(
      (item) => item === data.nid
    );
    findIndex !== -1 &&
      (saveExpandedKeys.value = saveExpandedKeys.value.slice(0, findIndex));
  } else {
    isClickIconExpand.value = true;
    currentTreeItem?.click();
  }
};
  • 我们不能在监听expand事件,我们需要在点击节点的回调中进行节点展开的收集。
// 获取当前操作节点链路id
const getExpandedKeys = (node: any): number[] | undefined => {
  // 当点击的不是展开图标 isClickIconExpand我们在每次点击图标时赋值为true,在点击节点时重置为false
  if (saveExpandedKeys.value.includes(node.key) && !isClickIconExpand.value) {
    return;
  }
  // 实现手风琴,收集当前点击节点的链路标识
  const resultKeys = [node.key];
  let currentNode = node;
  while ((currentNode = currentNode.parent)) {
    resultKeys.push(currentNode.key);
  }
  return resultKeys.sort((a, b) => a - b);
};

// 点击节点(以后点击)
const handleNodeClick: TreeProps["onSelect"] = async (
  selectedKeys: any,
  { node }
) => {
  const key = node.key as number;
  // expanded属性表示的是点击之前的状态
  const _keys = getExpandedKeys(node);
  _keys && (saveExpandedKeys.value = _keys);
  // 重置点击图标的状态
  isClickIconExpand.value = false;
};

今天太晚了(2024-8-18 1:03),后续在总结ag-grid表格组件的使用注意点。

往期年度总结

往期文章

专栏文章

🔥如果此文对你有帮助的话,欢迎💗关注、👍点赞、⭐收藏✍️评论,    支持一下博主~

公众号:全栈追逐者,不定期的更新内容,关注不错过哦!