头图

前言

就用户体验感来说说,以下控件来源and-design

  • 给定一个树形控件
  • 输入框控件,可模糊查询树形数据
  • 查询到数据后展开与之相关的节点

总体来说就是一个可查询的树组件,那么我们在中后台业务中,这应该是必不可缺的一个业务吧。

那么再来看看问题是什么呢

如果给定树形控件再给1w条数据会出现什么情况,3k条数据会有什么情况。

首先想到的就是数据量很大,那么肯定是会对渲染时间会造成影响

  1. 模糊查询所用时间
  2. 查询到目标节点后与之相关的节点(父节点与祖父节点)展开时所耗时间

那么大家对此有什么好的解决方案呢?可以一起分享下

下边呢,写下我的解决方法。因为呢本文在参加金石计划,如果感觉此文对您稍微有些帮助的话请点个赞支持一下

测试数据

先来测试一下一万多条数据通过模糊查询以及展开与之相关节点需要耗时多久

简单看看测试数据,对测试数据做个了解

1wtest.js

该文件里放了一万多条测试数据

2.png

下列是我将数据扁平化之后的条数,足有18402条测试数据

3.png

代码结构

看下整体代码结构

html结构

<template>
  <div>
    <Input
      v-model:value="searchValue"
      style="margin: 0 5px 8px; width: 250px"
      placeholder="Search"
    />
    <Button @click="search" type="primary">查询</Button>
    <Tree
      :expandedKeys="expandedKeys"
      :tree-data="treeData"
      :replace-fields="replaceFields"
      @expand="onExpand"
    >
      <template #title="{ label }">
        <span v-if="label.indexOf(searchValue) > -1">
          {{ label.substr(0, label.indexOf(searchValue)) }}
          <span style="color: #f50">{{ searchValue }}</span>
          {{ label.substr(label.indexOf(searchValue) + searchValue.length) }}
        </span>
        <span v-else>{{ label }}</span>
      </template>
    </Tree>
  </div>
</template>

javascript结构

<script lang="ts">
  import { Tree, Input, Button } from 'ant-design-vue';
  import { defineComponent, ref, nextTick } from 'vue';
  //   import { data as options } from '../common/3ktest.js';
  //   import { data as options } from '../common/5ktest.js';
  import { data as options } from '../common/1wtest.js';
​
  export default defineComponent({
    components: { Tree, Input, Button },
    setup() {
      const replaceFields = {
        title: 'label',
        key: 'id',
      };
      const expandedKeys = ref<string[]>([]);
      const searchValue = ref<string>('');
​
      const onExpand = (keys: string[]) => {
        expandedKeys.value = keys;
      };
      const treeData = ref(options);
      
      const search = () => {};
​
      return {
        expandedKeys,
        searchValue,
        treeData,
        replaceFields,
        onExpand,
        search,
      };
    },
  });
</script>

示例代码

and-design示例

先基于and-design来实现一下,直接拿了官网示例代码,数据给做了替换,换成上述一万多条测试数据

import { TreeDataItem } from 'ant-design-vue/es/tree/Tree';
​
const dataList: TreeDataItem[] = [];
const generateList = (data: TreeDataItem[]) => {
  for (let i = 0; i < data.length; i++) {
    const node = data[i];
    const id = node.id;
    dataList.push({ id, label: node.label });
    if (node.children) {
      generateList(node.children);
    }
  }
};
generateList(options);
​
const getParentKey = (id: string, tree: TreeDataItem[]): string | number | undefined => {
  let parentKey;
  for (let i = 0; i < tree.length; i++) {
    const node = tree[i];
    if (node.children) {
      if (node.children.some((item) => item.id === id)) {
        parentKey = node.id;
      } else if (getParentKey(id, node.children)) {
        parentKey = getParentKey(id, node.children);
      }
    }
  }
  return parentKey;
};
​
const search = () => {
  // 记录模糊查询以及dom展开时的耗时时间
  console.time();
  const value = searchValue.value;
  new Promise((resolve) => {
    const expanded = dataList
      .map((item: TreeDataItem) => {
        if ((item.label as string).indexOf(value) > -1) {
          return getParentKey(item.id as string, options);
        }
        return null;
      })
      .filter((item, i, self) => item && self.indexOf(item) === i);
    expandedKeys.value = expanded as string[];
    autoExpandParent.value = true;
    resolve();
  }).then(async () => {
    await nextTick();
    // 记录模糊查询以及dom展开时的耗时时间
    console.timeEnd();
  });
};

页面效果

<img src="https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/384ddc46b3804a7597534c0489c20a43~tplv-k3u1fbpfcp-watermark.image?" alt="4.png" width="70%" />

测试耗时结果

我们以模糊查询on为条件,先看下有多少条数据是符合on为条件的数据

5.png

看上述图中有7831条数据是符合模糊查询条件的,那么就是说这些数据以及它们的父节点都要与之相应的展开

那我们再来看下示例代码的耗时

<img src="https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/4e4df573bc8a48788db57009eb566595~tplv-k3u1fbpfcp-watermark.image?" alt="6.png" width="100%" />

21227.788818359375 ms这个数字看上去是非常恐怖的,试想哪个用户能在这等个20秒呢

解决方案

首先呢在文章首部也写了是哪些原因导致的耗时长,那就来根据这些原因说说解决方案

优化模糊查询所用时间

由于树形数据是一种嵌套数据结构,通常在查找目标节点时会进行深层次遍历,深层次遍历呢自然耗时会长一点,那我们知道,对于遍历来说肯定一维的数组还是非常快的,那就可以将树形数据先转化为扁平化数据然后再进行遍历查找目标节点

formatFlatTree方法呢就是接收树形结构数据并将其转化为扁平数据结构返回,那这个就简单了吧,下列代码不做过多描述了,自己看看

/**
 * @description 格式化树形数据结构为扁平数据结构
 * @param data 传入初始数据
 * @param treeData return 的返回结果
 * @param _params 替换初始数据中 key,title,children 字段为树组件中对应的字段
 * @param _level 层级级别
 * @param parentIds 父节点id集合用来设置pid
 * @param _params.other 自定义添加需要返回的字段
 */
export function formatFlatTree(
  data,
  _params: any = {},
  _level = 1,
  parentIds: string[] = [],
  treeData: TreeDataState[] = []
) {
  if (!data.length) {
    return treeData;
  }
  const list: TreeDataState[] = [];
  const param = {
    id: _params.id || 'key',
    label: _params.label || 'title',
    children: _params.children || 'children',
    other: _params.other || [],
  };
  const pIds: string[] = [];
  const obj = {};
  for (let i = 0; i < data.length; i++) {
    const node = data[i];
    const key = node[param.id];
    const child = node[param.children] || [];
    if (param.other.length) {
      param.other.forEach((element) => {
        obj[element] = node[element];
      });
    }
    treeData.push({
      id: key,
      label: node[param.label],
      pid: parentIds[i] || '0',
      level: _level,
      ...obj,
    });
    list.push(...child);
    pIds.push(...new Array(child.length).fill(key));
  }
  return formatFlatTree(list, param, _level + 1, pIds, treeData);
}

优化查询到目标节点后与之相关的节点展开时所耗时间

接着我们查询到了目标节点但是呢数据是扁平化的结构,但我们想要的是树形结构,所以呢我们还需要将扁平数据在合并为树形结构数据

并且我们需要做的是页面上只展示与目标节点相关的父节点以及祖父节点,无关的给过滤掉不给渲染,这样的话是不是可以节约渲染耗时

获取目标节点与之相关的节点并组成树

export function getFilterTree(source, list) {
  const initData = JSON.parse(JSON.stringify(list));
  const data = JSON.parse(JSON.stringify(source));
  const obj = {};
  data.forEach((item) => {
    obj[item.id] = item;
  });
  //合并完整树
  data.forEach((item) => {
    let pid = item.pid;
    while (pid) {
      const parent = obj[pid];
      if (!parent) {
        const organParent = initData.find((item) => item.id == pid);
        if (organParent) {
          obj[pid] = organParent;
          pid = organParent.pid;
          data.push(organParent);
        } else {
          pid = null;
        }
      } else {
        pid = null;
      }
    }
  });
  const trees = flatToTree(data, obj);
  return { data, trees };
}
​
export function flatToTree(data, obj) {
  const trees: TreeDataState[] = [];
  data.forEach((item) => {
    const parent = obj[item.pid];
    if (parent) {
      if (!item.children) {
        item.children = [];
      }
      (parent.children || (parent.children = [])).push(item);
    } else {
      trees.push(item);
    }
  });
  return trees;
}

来分析下上述代码,看是做了什么事情

  1. getFilterTree方法接收两个参数sourcelist

    • source:表示查询到的符合条件的目标节点数据集合
    • list:表示整个扁平化的数据集合
  2. 通过JSON.parse(JSON.stringify()解决深拷贝问题
  3. 定义一个obj,将目标数据id作为键,目标数据作为值
  4. 再继续通过forEachwhile对找出符合条件的数据,将与目标节点有关的父节点数据都push在data中,并将有关的数据以键值对形式都存放于obj
  5. flatToTree方法接收两个参数dataobj

    • data:目标节点与之相关联的所有数据
    • obj:以键值对形式存放目标节点与之相关联的所有数据
  6. 继续通过forEach组合完整树并返回trees
  7. data,与trees都返回

优化dom展开进行分批操作任务

这个时候拿到了查询后的树形数据trees,那还需要对dom展开做优化

根据getFilterTree方法可以拿到datadata呢就是需要展开的dom节点的数据

getTreeIds方法返回data中的id集合

export function getTreeIds(list) {
  const ids: TreeDataState[] = [];
  list.map((item) => {
    ids.push(item.id);
  });
  return ids;
}

下边示例代码是将整个集合都给赋值给展开指定的树节点属性,这种情况呢会导致一次性操作所有的dom节点展开,那么肯定是会造成页面卡顿的

expandedKeys.value = expanded

来看下我的解决方法

可以将展开dom节点这个任务给它进行拆分,就是说分批给它进行dom节点的展开,那这种方法的话效率是非常高的,因为我们每次只给它展开部分节点,不会操作一次性所有都需要展开的节点

function spread(num: number, index = 0, ids: string[]) {
  const keys: string[] = [];
  for (let i = 0; i < 50; i++) {
    if (num <= 0) break;
    num--;
    index++;
    keys.push(ids[index]);
  }
  if (num > 0) {
    timer = setTimeout(() => {
      return spread(num, index, ids);
    }, 600);
  }
  expandedKeys.value = [...expandedKeys.value, ...keys];
}
  1. spread方法就是用来做这个任务拆分操作的接收三个参数numindexids

    • num:表示ids的长度有多少个节点需要展开
    • index:表示将节点依次展开
    • ids:表示需要展开的节点集合
  2. for循环i < 50,表示每次展开50条,可依据个人需求作调整
  3. if(num > 0) 表示还有没展开的节点,需要继续展开
  4. setTimeout 中表示 600ms展开一次,可依据个人需求作调整

优化后代码

const flatTreeData = formatFlatTree(options, { id: 'id', label: 'label' });
// 扁平化数据
console.log(flatTreeData);
​
const search = () => {
  console.time();
  new Promise((resolve) => {
    const val = searchValue.value;
    
    // 获取目标节点数据
    const flatData = flatTreeData.filter((item) => item.label.includes(val));
    
    // 获取目标节点与之相关的数据,以及组合后新的树形结构数据
    const { data, trees } = getFilterTree(flatData, flatTreeData);
​
    const ids: string[] = getTreeIds(data);
    treeData.value = trees;
    
    // 展开任务分批操作
    spread(ids.length, 0, ids);
    resolve();
  }).then(async () => {
    await nextTick();
    console.timeEnd();
  });
};

优化后效果展现

那我们再来看看这个时候的耗时是多久

7.png

耗时677.60302734375 ms 与 示例代码耗时 做比较21227.788818359375 ms

看这个数据优化效果还是 非常可观的。

那这时页面上数据少了好多,那怎么恢复原始数据呢,最后清空搜索条件时,可以将原始树形数据再给渲染上去

以3000条数据为基准

不过也很少会用到这么大数据量,那来继续换个3000条测试数据来看下耗时对比

继续以查询on为条件,目标节点有1227条数据

示例代码

8.png

优化后代码

image-20230314160605581.png
再来比较一下2166.179931640625 ms205.125244140625 ms

那么可以看出结果基于(3000)个节点来说,比ant-design平均的渲染耗时减少(90%)

当然,大家也可以测试下基于不同个数的节点来说,耗时会减少多少

结语

扁平化与树形结构数据互相转换的方法可以看下边这篇文章

之前爆燃的高级前端开发不会树形转扁平化格式,你真的会手写了嘛


梁木由
27 声望0 粉丝