likes
comments
collection
share

手把手实现支持百万级数据量的Tree组件

作者站长头像
站长
· 阅读数 25

笔者前段时间接到一个需求,需要支持百万数据量在Tree组件中能自由操作。笔者同研发等同事做了简短的沟通,综合判断在当前需求的特定场景下,将百万数据量放在前端处理有其合理性。本文将重点介绍Tree组件的实现,及支持大数据量需要注意的一些点。

交互效果:

手把手实现支持百万级数据量的Tree组件 需求描述与定义:

  1. 数据以树形结构展示,图中仅展示了四层数据结构,实际不限
  2. 数据可以逐级展开与收起
  3. 每项数据可以进行勾选,父级Checkbox存在 未选、全选、部分选中 三种状态形式;勾选父级时,子孙节点自动全部选中;子孙节点只要有一个未选中,自动取消全选。
  4. 需要支持百万级别的数据量

属性定义

Tree 组件可以定义的属性很多,本示例仅基于需求定义以下 5 个属性值,其中 visibleHeight 和 itemHeight 两个属性是实现虚拟滚动所需的。

  • data:用于渲染的数据,树形结构的对象数组
  • expandedIds:展开的节点,由 key(string) 组成的数组
  • checkedIds:被选中的节点,由 key(string) 组成的数组
  • visibleHeight:整棵树可视区域的高度
  • itemHeight:树形结构中单个节点的高度

注:本示例仅支持单项高度一致且固定的场景节点的展开与选中,我采用了独立两个组件Props值的方式,而非做为字段放在树形结构数据 data 项里。笔者认为,将经常变动的值和几乎不变的值分开是更有利于数据组织的;另外对简单数据结构的数据做变化,要比对复杂数据结构的数据做变化来得简单得多。这个其实很容易理解,设想一下在展开或收起节点的时候,对由字符串项组成 Array 或 Set 类型的 expandedIds 插入或移除一个 key 要远比对一个树形结构的数据进行遍历修改简单得多了。

写代码之前,我们先把测试数据构建起来,方便基于数据的渲染逻辑的实现。

// 构建 dataSource 所需的数据
function createTreeData (path = '0', level = 3, count = 10) {
  const list = [];
  for (let i = 0; i < count; i += 1) {
    const key = `${path}-${i}`;
    const treeNode = {
      title: key,
      label: key,
      key,
    };

    if (level === 1 && key === '0-0-0-0') {
      // 构建100万个子节点
      treeNode.children = treeData(key, level - 1 , 1000000);
    } else if (level > 0) {
      treeNode.children = treeData(key, level - 1);
    }

    list.push(treeNode);
  }
  return list;
};

createTreeData函数在不传入参数的情况下,可以构建出具备以下特点的数据:

  1. 四层结构的树形数据,第四层为叶子节点
  2. key 为 ‘0-0-0-0’ 节点下有100万个子节点,如此设计是为了测试极端情况的性能
  3. 其它非叶子节点下都有十个子节点

定义 HTML 结构

对于 Tree 组件常见的 HTML 结构有两种:

  1. 列表形式,形如最初的动图中所示的;如图
<ul>
  <li></li>
  <li></li>
  <li></li>
  ... ...
</ul>
  1. 跟数据结构保持一致的嵌套形式;如图
<ul>
  <li>
    <ul>
      <li>... ...</li>
      ... ...
    </ul>
    ... ...
  </li>
  ... ...
</ul>

如果没有虚拟滚动,笔者认为这两种结构都挺好的;在有虚拟滚动的场景下,笔者更推荐使用列表形式。在列表结构中,无论截取哪一段作为可视区域(含缓冲区)作为渲染,其结构都是一样的。列表形式的问题是无法体现缩进关系,这个可以通过样式来解决。笔者采用在 <li>标签上加 style 属性的形式实现,形如:

<ul>
  <li style={{ paddingLeft: '24px' }}></li>
  <li style={{ paddingLeft: '48px' }}></li>
  <li style={{ paddingLeft: '72px' }}></li>
  ... ...
</ul>

构建基本Tree

然后使用 React 写一个最基本的 Tree 最基本的组件,没有展开、收起逻辑,也没有选择框的逻辑,更没有虚拟滚动的逻辑,只要把数据按照树形结构展示出来就好。直接上代码:

import React, { PureComponent } from 'react';
import TreeNodeItem from './TreeNodeItem.jsx';

import './index.less';

class Tree extends PureComponent {
  static defaultProps = {
    /**
     * keysMap 对数组对象中的每个 Key 做一个映射关系,当服务端返回的数据key不一样时,可以直接使用映射即可
     * text: 必选,显示的文字
     * children: 必选,子节点
     * id: 可选,节点ID(此参数可以没有),如需指定节点展开,必须指定此字段
     */
    keysMap: { text: 'title', children: 'children', id: 'key' },
  };

  constructor(props) {
    super(props);
    const { data } = props;
    this.state = {
      ...getNewState(props),
      // 用于在 getDerivedStateFromProps 中进行比对
      props: { data },
    };
  }

  static getDerivedStateFromProps(props, state) {
    const { data } = props;
    const { props: sProps } = state;
    if (data !== sProps.data) {
      return {
        ...getNewState(props),
        props: { data },
      };
    }
    return null;
  }

  virtualRender = () => {
    const { keysMap } = this.props;
    const { id: kId } = keysMap;
    const { list = [] } = this.state;

    return (
      <div className="vui-virtual-container">
        <ul
          className="vui-tree-group vui-tree-virtual"
        >
          {list.map((item) => {
            return <TreeNodeItem key={item[kId]} data={item} keysMap={keysMap} />;
          })}
        </ul>
      </div>
    );
  };

  render() {
    return (
      <div className="vui-tree vui-tree-small">
        <div className="vui-group-container">
          {this.virtualRender()}
        </div>
      </div>
    );
  }
}

function getNewState(props) {
  const { data = [], keysMap = {} } = props;
  // obj 的数据结构为 { [key: string]: treeItem } 的格式;并补充 parentIds, paretNames, $pos 等辅助数据,后面可能需要使用到
  const { obj, list } = resolveTreeDataToList(data, keysMap);

  return { obj, list };
}


function resolveTreeDataToList(treeData, keysMap) {
  const { id: kId, children, text } = keysMap;
  // 将 Tree 结构的数据打平成一个 list 存在其中,用于渲染
  const list = [];
  // 将 Tree 中的每一项使用 id(key) 作为键存到 obj 中,方便后续通过 id(key) 取数
  const obj = {};

  // 使用递归遍历所有数据
  traverseData(treeData);

  return { obj, list };

  function traverseData(tree, pIds, pNames, pLevels) {
    const parentIds = pIds || [];
    const parentNames = pNames || [];
    const levels = pLevels || [];

    return tree.map((info, i) => {
      const levs = [].concat(levels);
      levs.push(i);

      // 记录位置信息,即 tree 中的 path,有'_'链接
      info.$pos = levs.join('_');
      if (!info[kId]) {
        info[kId] = info.$pos;
      }
      // 记录所有的祖先节点 Id
      info.parentIds = parentIds;
      info.parentNames = parentNames;

      list.push(info);
      obj[info[kId]] = info;

      if (info[children] && info[children].length) {
        const newParentIds = parentIds.slice();
        const newParentNames = parentNames.slice();
        const strId = String(info[kId]);

        newParentIds.push(strId);
        newParentNames.push(info[text]);

        info.childrenIds = traverseData(info[children], newParentIds, newParentNames, levs);
      }
      return String(info[kId]);
    });
  }
}

export default Tree;

React 组件继承 PureComponent,尽可能减少 render 方法的执行次数。

import React, { PureComponent } from 'react';

// 单位缩进宽度
const indentUnit = 24;

class TreeNodeItem extends PureComponent {
  render() {
    const { keysMap = {}, data = {} } = this.props;
    const { parentIds } = data;
    const pLen = parentIds.length;
    const { text: kText, id: kId } = keysMap;
    const strId = String(data[kId]);
    const tspnJsx = data[kText];

    return (
      <li
        className="vui-tree-item"
        // 定义当前项需要缩进的宽度
        style={{ paddingLeft: `${pLen * indentUnit}px` }}
        key={strId}
      >
        <span
          className={`vui-tree-item-title`} // level-${level}-title
        >
          <span className="text">{tspnJsx}</span>
        </span>
      </li>
    );
  }
}

export default TreeNodeItem;
@horizontalLineWidth: 14px;
@treeStyleTitlePadding: 6px;
@indentUnit: 24px;

.vui-tree {
  box-sizing: border-box;
  ul,
  li,
  p {
    list-style: none;
    margin: 0;
    padding: 0;
  }

  .vui-group-container {
    height: 100%;
    width: 100%;
    overflow-y: auto;
    overflow-anchor: none;

    > ul.vui-tree-group {
      overflow: hidden;
    }
  }

  .vui-tree-item {
    min-width: 100%;
    width: max-content;
  }

  .vui-tree-item-title {
    display: inline-block;
    height: 28px;
    line-height: 28px;
    font-size: 12px;
    -webkit-font-smoothing: auto;
    white-space: pre;
    font-size: 14px;
    min-width: 100%;
    width: max-content;
    cursor: pointer;

    &:hover {
      background-color: #e8f2ff;

      .next-icon,
      .next-checkbox-wrapper {
        margin-right: 4px;
      }
    }
  }
}

如上所示整个组件被分为三个文件,index.jsx、TreeNodeItem.jsx 以及 index.less,各个文件的作用通过其文件名或者文件内的内容很容易辨别,笔者不做过多解释。笔者仅对 resolveTreeDataToList 方法做一个简要说明。此方法主要是对传入的 tree 数据进行了再加工,加工内容为:

  1. 将树形结构的数据,转换成列表,存到 list 变量中,最后存入 state
  2. 将树形数据中的每一项用 id(key) 作为键,转换成 map,存到 obj 变量中,最后存入 state
  3. 收集整理出 $posparentIdsparentNames三个值存回到当前对象数据中
  4. 以上三步仅对当前对象数据的处理,不产生新的copy项;这么做可以节省内存消耗,且以上操作不对原数据产生破坏性影响。

注:以上代码还未实现虚拟滚动,如果使用有大量节点的Tree结构数据浏览器会被卡死

实现展开/收起

根据前面的需求,展开/收起需要实现的代码逻辑有:

  1. 在每个数据项之前增加展开或收起的 icon,且 icon 需要随着状态进行变化
  2. 展开时渲染其子节点,收起时不渲染其子节点
  3. 当某个节点被展开时,其祖先节点也要被展开;当某个节点被收起时,其子孙节点也都要被收起

前面1、2两天实现逻辑很简单,笔者不过多讲解,重点介绍一下第3点。第3点的逻辑会存在于 expandedIds属性变化时和点击展开或收起 icon 时。

expandedIds属性变化时,只需要将其祖先级节点都纳入到展开的列表中即可,实现逻辑如下:

function getExpandedArrIds(expandedIds, obj) {
  // 将数字转换成Set类型;原因是Set类型的性能会更好
  let expandedArrIds = new Set();
  if (expandedIds instanceof Array) {
    expandedArrIds = new Set(expandedIds);
  } else if (!(expandedIds instanceof Set)) {
    expandedArrIds = new Set();
    console.error('expandedIds 属性仅支持 Array 或 Set 类型');
  }

  // 检查指定展开节点的父级节点是否被展开,如果未被展开则设置为展开
  expandedArrIds.forEach((id) => {
    const { parentIds = [] } = obj[id] || {};
    parentIds.forEach((pId) => {
      if (!expandedArrIds.has(pId)) {
        expandedArrIds.add(pId);
      }
    });
  });
  return expandedArrIds;
}

当点击收起 icon 时将其子孙节点都从展开列表中移除,实现逻辑如下:

// 递归移除当前节点及其子孙节点
function traverseRemove(id) {
  const sId = String(id);
  if (expandedArrIds.has(sId)) {
    const { childrenIds: cIds = [] } = obj[id];
    expandedArrIds.delete(sId, 1);

    if (cIds.length) {
      cIds.forEach((cId) => {
        traverseRemove(cId);
      });
    }
  }
}

更多的代码逻辑请看最后面的完整示例代码。

实现Checkbox

Checkbox 的实现逻辑跟展开/收起的逻辑很相似,当某个节点被选中时需要同时选中其子孙节点,当某个节点取消选中时需要将其所有祖先节点都从选中中移除。但 Checkbox 比展开/收起要复杂一些,具体体现在下面两点:

  1. 当节点被选中时,同时需要判断其父节点下面的子节点是否全部被选中;如果是,则要继续上卷进行判断;代码如下:
function getCheckedParents(id, ids, obj) {
  const { parentIds = [] } = obj[id] || {};
  for (let i = parentIds.length - 1; i >= 0; i--) {
    const pId = parentIds[i];
    const { childrenIds } = obj[pId];
    let checkedAll = true;
    // 判断是否是所有子节点都被选中
    for (let j = 0, l = childrenIds.length; j < l; j++) {
      const cId = childrenIds[j];
      if (!ids.has(cId)) {
        checkedAll = false;
        break;
      }
    }
    if (checkedAll) {
      // 如果所有的子元素被选中,则注入checkedArrIds
      ids.add(pId);
    } else {
      // 否则退出循环
      break;
    }
  }
}
  1. 判断当前状态是全选、部分选中还是未选;全选是会在 state.checkedArrIds 状态值中出现的,而未选和部分选择都不会此状态值中出现;代码如下:
// 递归探查子孙节点被选中的状态,全选、部分选中、未选
function traverseChecked(cIds) {
  let checkedAll = true;
  let checkedCount = 0;
  let cStatus = false;
  cIds.forEach((cId) => {
    if (!checkedArrIds.has(cId)) {
      checkedAll = false;

      const { childrenIds: subCIds } = obj[cId] || {};
      if (subCIds && subCIds.length) {
        cStatus = cStatus || traverseChecked(subCIds);
      }
    } else {
      checkedCount += 1;
    }
  });

  // true:全选,false: 未选,'some':部分选中
  return checkedAll || (checkedCount || cStatus ? 'some' : false);
}

更多的代码逻辑请看最后面的完整示例代码。

实现虚拟滚动

虚拟滚动可以有效解决因需要渲染的节点数过多而引起的页面卡顿问题,它的做法就是仅渲染可视区域内的元素(如图)。因没有真实的渲染所有内容,所以大部分情况下出现的滚动条是虚拟出来的,故而得名为虚拟滚动。

手把手实现支持百万级数据量的Tree组件 因此,实现虚拟滚动的步骤就变成了:

  1. 计算渲染全部所需的总高度
  2. 找出所有需要渲染在可视区域的数据(含缓冲区)
  3. 渲染可视区域的数据,并将其移动到可视区域
  4. 实时监听滚动事件,重复前面三步的计算

本示例中,笔者用 getVisibleRange 函数实现 1、2、3步,代码如下:

/**
 * 入参说明:
 * treeData: 当前用于渲染树的数据
 * scrollTop: 滚动条滚动的Top值
 * visibleHeight: 可视区域高度
 * itemHeight: 渲染树的单项高度
 * expandedArrIds: 被收起的节点Key
 * keysMap: 用于隐射字段的键值对
 */

// 缓冲区的高度
const OFFSET_VERTICAL = 200;

function getVisibleRange({ treeData = [], scrollTop, visibleHeight, itemHeight, expandedArrIds, keysMap }) {
  // idKey: id对应的键名;childrenKey: 子节点对应的键名
  const { id: idKey, children: childrenKey } = keysMap;

  let totalHeight = 0; // 树形结构内容的总高度;
  // 0: 顶部被隐藏阶段;1: 可视区域阶段;2: 可视区域以下阶段;
  // 注:此处的可视区域包含上下缓冲区
  let currentStep = 0;
  let translateY = 0; // 纵向需要被移动的值
  const items = [];

  // 递归解析树形结构的数据,计算整体高度并找出需要在可视区域内展示的内容
  loopData(treeData);

  function loopData(list) {
    list.forEach((item) => {
      const key = item[idKey];
      const children = item[childrenKey];
      totalHeight += itemHeight;

      if (currentStep === 0) {
        if (totalHeight >= scrollTop - OFFSET_VERTICAL) {
          currentStep += 1;
          // 开始收集需要渲染的项
          items.push(item);
        } else {
          translateY += itemHeight;
        }
      } else if (currentStep === 1) {
        items.push(item);
        if (totalHeight > scrollTop + visibleHeight + OFFSET_VERTICAL) {
          // 结束收集可渲染项
          currentStep += 1;
        }
      }

      if (children && children.length && expandedArrIds.has(key)) {
        loopData(children);
      }
    });
  }

  return {
    items,
    translateY,
    height: totalHeight,
  };
}

第4步事件监听逻辑比较简单不做详细描述,请查阅完整示例代码。

完整示例

Tree 组件的主体文件:

import React, { PureComponent } from 'react';
import TreeNodeItem from './TreeNodeItem.jsx';

import './index.less';

const OFFSET_VERTICAL = 200

class Tree extends PureComponent {
  static defaultProps = {
    /**
     * keys 用于指定数据字段映射
     * text: 必选,显示的文字
     * children: 必选,子节点
     * id: 可选,节点ID(此参数可以没有),如需指定节点展开,必须指定此字段
     * _target: 可选,链接打开方式,同href合用;可选值:_blank, _self等
     */
    keysMap: { text: 'title', children: 'children', id: 'key' },
    // 可视区域的高度
    visibleHeight: 500,
    // 单行高度
    itemHeight: 28,
  };

  constructor(props) {
    super(props);
    const { data, expandedIds, checkedIds } = props;

    this.state = {
      ...getNewState(props),
      // 记录原props传入的值,用于后面的数据对比
      props: {
        data,
      },
    };
  }

  static getDerivedStateFromProps(props, state) {
    const { data, expandedIds, checkedIds } = props;
    const { props: sProps, obj } = state;

    // 树形结构数据有变化时
    if (data !== sProps.data) {
      return {
        ...getNewState(props),
        props: {
          data,
        },
      };
    }

    const stateProps = {};
    const newState = {};
    // 展开的keys有变化时
    if (expandedIds !== state.expandedArrIds) {
      const newExpanedIds = getExpandedArrIds(expandedIds, obj);
      newState.expandedArrIds = newExpanedIds;
      stateProps.expandedIds = expandedIds;
    }

    // 复选框有变化时
    if (checkedIds !== state.checkedArrIds) {
      newState.checkedArrIds = getCheckedArrIds(checkedIds, obj);
      stateProps.checkedIds = checkedIds;
    }

    newState.props = {
      ...state.props,
      ...stateProps,
    };

    return newState;
  }

  // 监听纵向滚动事件的处理
  handleTreeScroll = (e) => {
    const { scrollTop = 0 } = e.target;

    this.setState({
      scrollTop,
    });
  };

  getScrollRef = (dom) => {
    const { virtualConfig = {} } = this.props;
    const { scrollEl } = virtualConfig;
    this.scrollEl = scrollEl || dom;
  };

  // 手动点击展开/收起 icon 的处理
  handleItemExpanded = (record = {}, isExpanded, e) => {
    const { childrenIds, isLeaf } = record;
    const { expandedArrIds = [], obj } = this.state;
    const { keysMap = {}, hasLeaf } = this.props;
    const { id: kId } = keysMap;
    const strId = String(record[kId]);
    const has = expandedArrIds.has(strId);
    if (childrenIds && childrenIds.length) {
      if (isExpanded && !has) {
        // 手动操作时不存在某个节点展开了,但其祖先节点没有展开的情况,所以不予处理
        expandedArrIds.add(strId);
      } else if (!isExpanded) {
        // 当收起时需要移除所有子孙节点,即收起所有子孙节点
        traverseRemove(strId);
      }

      this.setOnExpanded(record, isExpanded, expandedArrIds, e);

      this.setState({
        expandedArrIds: expandedArrIds,
      });
    }

    // 递归移除当前节点及其子孙节点
    function traverseRemove(id) {
      const sId = String(id);
      if (expandedArrIds.has(sId)) {
        const { childrenIds: cIds = [] } = obj[id];
        expandedArrIds.delete(sId, 1);

        if (cIds.length) {
          cIds.forEach((cId) => {
            traverseRemove(cId);
          });
        }
      }
      }
      };

        setOnExpanded = (...args) => {
        const { onExpanded } = this.props;
        if (typeof onExpanded === 'function') {
        onExpanded(...args);
      }
      };

        // 手动改变Checkbox状态的事件处理
        handleItemChecked = (record, isChecked, e) => {
        const { checkedArrIds, obj } = this.state;
        const { keysMap, onChecked } = this.props;
        const { id: kId, children: childrenKey } = keysMap;
        const { parentIds } = record;
        const strId = String(record[kId]);

        if (isChecked && !checkedArrIds.has(strId)) {
        checkedArrIds.add(strId);
        // 判断其父级&祖先节点是否需要被选中
        getCheckedParents(strId, checkedArrIds, obj);
        // 选中其所有其子孙节点
        getCheckedChildren(strId, checkedArrIds, obj);
      } else if (!isChecked) {
        checkedArrIds.delete(strId);
        // 移除祖先节点的选中状态
        parentIds.forEach((pId) => {
        if (checkedArrIds.has(pId)) {
        checkedArrIds.delete(pId);
      }
      });

        traverseRemove(record);
      }
        if (typeof onChecked === 'function') {
        onChecked(record, isChecked, checkedArrIds, e);
      }

        this.setState({
        checkedArrIds: checkedArrIds,
      });

        // 递归移除当前节点及其子孙节点
        function traverseRemove(item) {
        const children = item[childrenKey];
        if (children && children.length) {
        children.forEach((pre) => {
        // const i = checkedIdsMap[pre[kId]];
        if (checkedArrIds.has(pre[kId])) {
        checkedArrIds.delete(pre[kId])
      }
        traverseRemove(pre);
      });
      }
      }
      };

        getCheckedStatus = (record) => {
        const { checkedArrIds, obj } = this.state;
        const { keysMap = {} } = this.props;
        const { id: kId } = keysMap;
        const strId = String(record[kId]);
        // 查看自身是否被选中
        if (checkedArrIds.has(strId)) {
        return true;
      } else {
        const { childrenIds } = record;
        let checked = false;

        // 查看子孙元素被选中情况
        if (!checked && childrenIds && childrenIds.length) {
        checked = traverseChecked(childrenIds);
      }

        return checked;
      }

        // 递归探查子孙节点被选中的状态,全选、部分选中、未选
        function traverseChecked(cIds) {
        let checkedAll = true;
        let checkedCount = 0;
        let cStatus = false;
        cIds.forEach((cId) => {
        if (!checkedArrIds.has(cId)) {
        checkedAll = false;

        const { childrenIds: subCIds } = obj[cId] || {};
        if (subCIds && subCIds.length) {
        cStatus = cStatus || traverseChecked(subCIds);
      }
      } else {
        checkedCount += 1;
      }
      });

        // true:全选,false: 未选,'some':部分选中
        return checkedAll || (checkedCount || cStatus ? 'some' : false);
      }
      };

        virtualRender = () => {
        const { keysMap, visibleHeight, itemHeight, data } = this.props;
        const { expandedArrIds } = this.state;
        const scrollTop = this.scrollEl?.scrollTop || 0;
        const { id: kId } = keysMap;

        // 获取虚拟滚动可视区域&缓存区域的渲染项,纵向移动值和整体高度
        const { items, translateY, height } = getVisibleRange({
        treeData: data,
        scrollTop,
        visibleHeight,
        itemHeight,
        expandedArrIds,
        keysMap,
      });

        const style = { height: `${String(height)}px` };

        return (
        <div className="vui-virtual-container" style={style}>
        <ul
        className="vui-tree-group vui-tree-virtual"
        style={{ transform: `translate(0, ${translateY}px)` }}
        >
        {items.map((item) => {
        const strId = String(item[kId]);
        const isExpanded = expandedArrIds.has(strId);
        return <TreeNodeItem
        key={item[kId]}
        data={item}
        keysMap={keysMap}
        isExpanded={isExpanded}
        isChecked={this.getCheckedStatus(item)}
        onChecked={this.handleItemChecked}
        onExpanded={this.handleItemExpanded}
        />;
      })}
        </ul>
        </div>
        );
      };

        render() {
        return (
        <div className="vui-tree vui-tree-small">
        <div className="vui-group-container" ref={this.getScrollRef} onScroll={this.handleTreeScroll}>
        {this.virtualRender()}
        </div>
        </div>
        );
      }
      }

        function getNewState(props) {
        const { data = [], keysMap = {}, expandedIds = [], checkedIds = [] } = props;
        // obj 的数据结构为 { [key: string]: treeItem } 的格式;并补充 parentIds, paretNames, $pos 等辅助数据,后面可能需要使用到
        const { obj, list } = resolveTreeDataToList(data, keysMap);
        const expandedArrIds = getExpandedArrIds(expandedIds, obj);
        const checkedArrIds = getCheckedArrIds(checkedIds, obj);

        return { obj, list, expandedArrIds, checkedArrIds };
      }

        function resolveTreeDataToList(treeData, keysMap) {
        const { id: kId, children, text } = keysMap;
        const list = [];
        const obj = {};

        traverseData(treeData);

        return { obj, list };

        function traverseData(tree, pIds, pNames, pLevels) {
        const parentIds = pIds || [];
        const parentNames = pNames || [];
        const levels = pLevels || [];

        return tree.map((info, i) => {
        const levs = [].concat(levels);
        levs.push(i);

        // 记录位置信息,即 tree 中的 path,有'_'链接
        info.$pos = levs.join('_');
        if (!info[kId]) {
        info[kId] = info.$pos;
      }
        // 记录所有的祖先节点 Id
        info.parentIds = parentIds;
        info.parentNames = parentNames;

        list.push(info);
        obj[info[kId]] = info;

        if (info[children] && info[children].length) {
        const newParentIds = parentIds.slice();
        const newParentNames = parentNames.slice();
        const strId = String(info[kId]);

        newParentIds.push(strId);
        newParentNames.push(info[text]);

        info.childrenIds = traverseData(info[children], newParentIds, newParentNames, levs);
      }
        return String(info[kId]);
      });
      }
      }

        function getExpandedArrIds(expandedIds, obj) {
        // 展开节点逻辑
        let expandedArrIds = new Set();
        if (expandedIds instanceof Array) {
        expandedArrIds = new Set(expandedIds);
      } else if (!(expandedIds instanceof Set)) {
        expandedArrIds = new Set();
        console.error('expandedIds 属性仅支持 Array 或 Set 类型');
      }
        // 检查指定展开节点的父级节点是否被展开,如果未被展开则设置为展开
        expandedArrIds.forEach((id) => {
        const { parentIds = [] } = obj[id] || {};
        parentIds.forEach((pId) => {
        if (!expandedArrIds.has(pId)) {
        expandedArrIds.add(pId);
      }
      });
      });

        return expandedArrIds;
      }

        function getCheckedArrIds(checkedIds, obj) {
        let checkedArrIds = checkedIds;
        // 选中节点逻辑
        if (checkedIds instanceof Array) {
        checkedArrIds = new Set(checkedIds);
      } else if (!(checkedIds instanceof Set)) {
        checkedArrIds = new Set();
        console.error('checkedIds 属性仅支持 Array 或 Set 类型');
      }

        for (let i = 0, l = checkedArrIds.length; i < l; i++) {
        const id = checkedArrIds[i];
        getCheckedParents(id, checkedArrIds, obj);
        getCheckedChildren(id, checkedArrIds, obj);
      }

        return checkedArrIds;
      }

        // 收集被选中节点下的所有子孙节点的ID
        function getCheckedChildren(id, ids, obj) {
        const { childrenIds } = obj[id] || {};
        if (childrenIds && childrenIds.length) {
        childrenIds.forEach((cId) => {
        if (!ids.has(cId)) {
        ids.add(cId);
      }
        getCheckedChildren(cId, ids, obj);
      });
      }
      }

        // 收集所有子节点被选中的父节点
        function getCheckedParents(id, ids, obj) {
        const { parentIds = [] } = obj[id] || {};
        for (let i = parentIds.length - 1; i >= 0; i--) {
        const pId = parentIds[i];
        const { childrenIds } = obj[pId];
        let checkedAll = true;
        for (let j = 0, l = childrenIds.length; j < l; j++) {
        const cId = childrenIds[j];
        if (!ids.has(cId)) {
        checkedAll = false;
        break;
      }
      }
        if (checkedAll) {
        // 如果所有的子元素被选中,则注入checkedArrIds
        ids.add(pId);
        // checkedIdsMap[pId] = ids.length - 1;
      } else {
        // 否则退出循环
        break;
      }
      }
      }

        /**
        * 入参说明:
        * treeData: 当前用于渲染树的数据
        * scrollTop: 滚动条滚动的Top值
        * visibleHeight: 可视区域高度
        * itemHeight: 渲染树的单项高度
        * expandedArrIds: 被收起的节点Key
        * keysMap: 用于隐射字段的键值对
        */

        function getVisibleRange({ treeData = [], scrollTop, visibleHeight, itemHeight, expandedArrIds, keysMap }) {
        // idKey: id对应的键名;childrenKey: 子节点对应的键名
        const { id: idKey, children: childrenKey } = keysMap;

        let totalHeight = 0; // 树形结构内容的总高度;
        // 0: 顶部被隐藏阶段;1: 可视区域阶段;2: 可视区域以下阶段;
        // 注:此处的可视区域包含上下缓冲区
        let currentStep = 0;
        let translateY = 0; // 纵向需要被移动的值
        const items = [];

        // 递归解析树形结构的数据,计算整体高度并找出需要在可视区域内展示的内容
        loopData(treeData);

        function loopData(list) {
        list.forEach((item) => {
        const key = item[idKey];
        const children = item[childrenKey];
        totalHeight += itemHeight;

        if (currentStep === 0) {
        if (totalHeight >= scrollTop - OFFSET_VERTICAL) {
        currentStep += 1;
        // 开始收集需要渲染的项
        items.push(item);
      } else {
        translateY += itemHeight;
      }
      } else if (currentStep === 1) {
        items.push(item);
        if (totalHeight > scrollTop + visibleHeight + OFFSET_VERTICAL) {
        // 结束收集可渲染项
        currentStep += 1;
      }
      }

        if (children && children.length && expandedArrIds.has(key)) {
        loopData(children);
      }
      });
      }

        return {
        items,
        translateY,
        height: totalHeight,
      };
      }

        export default Tree;

Tree 组件中单项内容的渲染子组件:

import React, { PureComponent } from 'react';
import { Checkbox } from '@alifd/next';

// 引入本组件使用到的第三方样式,Checkbox 和 展开/收起 icon 样式
import '@alifd/next/es/icon/index.css';
import '@alifd/next/es/checkbox/index.css';

const indentUnit = 24;

class TreeNodeItem extends PureComponent {
  handleExpandIconClick = (e) => {
    e.stopPropagation();

    const { data, onExpanded, isExpanded } = this.props;
    const result = !isExpanded;
    if (typeof onExpanded === 'function') {
      onExpanded(data, result, e);
    }
  };

  handleCheckChange = (checked, e) => {
    const { data, onChecked } = this.props;
    if (typeof onChecked === 'function') {
      onChecked(data, checked, e);
    }
  };

  // 展开/收起 icon 属性定义
  getIconProps = () => {
    const {
      isExpanded,
      keysMap = {},
      data = {},
    } = this.props;
    const { children } = keysMap;
    const childrenData = data[children];
    const iconProps = {
      className: isExpanded ? 'next-icon next-icon-arrow-down' : 'next-icon next-icon-arrow-right',
    };

    if (!(childrenData && childrenData.length)) {
      iconProps.className = 'icon-empty';
    }
    iconProps.onClick = this.handleExpandIconClick;

    return iconProps;
  };

  checkboxRender = () => {
    const { isChecked } = this.props;
    const cProps = {
      checked: isChecked,
      indeterminate: false,
    };
    if (isChecked === 'some') {
      cProps.checked = false;
      cProps.indeterminate = true;
    }
    return (
      <Checkbox
        {...cProps}
        onClick={(e) => {
          e.stopPropagation();
        }}
        onChange={this.handleCheckChange}
        />
    );
  };

  render() {
    const { keysMap = {}, data = {} } = this.props;
    const { parentIds } = data;
    const pLen = parentIds.length;
    const { text: kText, id: kId } = keysMap;
    const strId = String(data[kId]);
    const tspnJsx = data[kText];

    return (
      <li
        className="vui-tree-item"
        // 定义当前项需要缩进的宽度
        style={{ paddingLeft: `${pLen * indentUnit}px` }}
        key={strId}
        >
        <span
          className="vui-tree-item-title"
          >
          <i {...this.getIconProps()} />
          {this.checkboxRender()}
          <span className="text">{tspnJsx}</span>
        </span>
      </li>
    );
  }
}

export default TreeNodeItem;

CSS 实现:

@horizontalLineWidth: 14px;
@treeStyleTitlePadding: 6px;
@indentUnit: 24px;

.tree-container {
  width: 500px;
  height: 532px;
  margin: 16px;
  padding: 16px;
  border: 1px solid #ddd;
  overflow: hidden;
}

.tree-container .vui-tree .vui-group-container {
  height: 500px;
}

.vui-tree {
  box-sizing: border-box;
  ul,
  li,
  p {
    list-style: none;
    margin: 0;
    padding: 0;
  }

  .vui-group-container {
    height: 100%;
    width: 100%;
    overflow-y: auto;
    overflow-anchor: none;

    > ul.vui-tree-group {
      overflow: hidden;
    }
  }

  .vui-tree-item {
    min-width: 100%;
    width: max-content;
  }

  .vui-tree-item-title {
    display: inline-block;
    height: 28px;
    line-height: 28px;
    font-size: 12px;
    -webkit-font-smoothing: auto;
    white-space: pre;
    font-size: 14px;
    min-width: 100%;
    width: max-content;
    cursor: pointer;

    .next-icon,
    .next-checkbox-wrapper {
      margin-right: 4px;
      font-size: 12px;
    }

    &:hover {
      background-color: #e8f2ff;
    }
  }
}

以上是 Tree 组件的所有内容,下面是调用示例:

import { render } from 'react-dom';
import React, { useEffect, useCallback, useState } from 'react';
import Tree from './Tree2';
import { treeData } from './utils';

import './tree.css';

// 生成4层(从0开始)结构,每层10个节点;除 '0-0-0-0' 节点外
function treeData (path = '0', level = 3, count = 10) {
  const list = [];
  for (let i = 0; i < count; i += 1) {
    const key = `${path}-${i}`;
    const treeNode = {
      title: key,
      label: key,
      key,
    };

    if (level === 1 && key === '0-0-0-0') {
      // 100万个子节点
      treeNode.children = treeData(key, level - 1 , 1000000);
    } else if (level > 0) {
      treeNode.children = treeData(key, level - 1);
    }

    list.push(treeNode);
  }
  return list;
};

const dataSource = treeData();
const keysMap = { text: 'title', children: 'children', id: 'key' };
const virtualConfig = { visibleHeight: 500, itemHeight: 28 };

function App() {
  const [checkedKeys, setCheckedKeys] = useState(new Set());
  const [expandedKeys, setExpandedKeys] = useState(new Set());

  const handleCheck = useCallback((record, isChecked, checkedKeys) => {
    setCheckedKeys(checkedKeys);
  }, []);
  const handleExpand = useCallback((record, isExpanded, expandedKeys) => {
    setExpandedKeys(expandedKeys);
  }, []);

  return <div className="tree-container">
    <Tree
      data={dataSource}
      visibleHeight={500}
      itemHeight={28}
      expandedIds={expandedKeys}
      checkedIds={checkedKeys}
      onChecked={handleCheck}
      onExpanded={handleExpand}
      />
  </div>
}

render(<App />, document.getElementById('root'));

总结

本文主要还是按照如何实现的逻辑在讲解Tree组件,没有特别强调大数据量这点,以下几个是对大数据量提升性能的关键点:

  1. 启用虚拟滚动,大大减少了dom渲染数量
  2. 将耗时长的 Array 方法改成 Set 数据类型
  3. 启用 PureComponent 尽可能减少 render 方法的执行;因开启虚拟滚动后,渲染的节点数比较少,故而影响不那么大了

笔者截取了比较耗时的“勾选 0-0-0-0 这个节点(含有 100 万个子节点)”操作所消耗的时常大概在 500ms 以下,如图:

手把手实现支持百万级数据量的Tree组件 更多测试有兴趣的读者可以用上面完整的示例代码进行,此组件在100W的数据量下,不会出现明显的卡顿。

本示例中的组件只是实现了基本方法,需要更多能力可以基于此进行进一步扩展。