前端高性能渲染大型树形结构组件(附全部代码React、Vue)

目录

抛出问题

解决思路

实际效果

完整代码

优化:封装成组件VirtualTree

其他地方使用 

附:源数据


抛出问题

使用一般的tree组件渲染大量数据(如几千个树节点)的时候会非常卡顿,主要原因是页面中绘制的大量的Dom,滚动或展开、收起不断造成页面重绘、回流,使得性能不佳。


解决思路

Step1:将树形数据拍平成一般的List

Step2:采用padding缩进的方式营造树形结构

Step3:在结合虚拟列表高效渲染长列表

 虚拟列表大致原理:当列表data中有n个item项,我们只渲染可视区域(比如10条)的item,页面滚动时获取到scrollTop,scrollTop / itemHeight = startIndex(当前滚动了多少条的索引),可视区域的数据 = data.slice(startIndex, startIndex + 10)),将可视区域数据渲染到页面即可。

数据说明:

列表项高度固定:itemHeight

列表数据:data,源数据

当前滚动位置:scrollTop

可视区域的数据:visibleData ,就是你要真实渲染的数据

列表真实长度:itemHeight * data.length,制造滚动条

接着监听的scroll事件,获取滚动位置scrollTop

计算当前可视区域起始数据索引(startIndex = Math.floor(scrollTop / itemHeight) )

计算当前可视区域结束数据索引(endIndex = startIndex + visibleCount)

计算当前可视区域的数据 (visibleData = data.slice(startIndex,endIndex))

计算startIndex对应的数据在整个列表中的偏移量offset并设置到列表上


实际效果


 

完整代码

import React, { useCallback, useEffect, useRef, useState } from 'react';

import './index.css';

import { originData } from './mockData';

// 配置项
const options = {
  defaultExpand: 1,
  itemHeight: 30,
  visibleCount: 15,
};

// 将树形数据转成普通列表数据(我用的是中国省-市-区的树形数据)
function flattenData() {
  function flatten(tree, childKey = 'children', level, parent = null) {
    let res = [];
    tree.forEach((item) => {
      item.level = level;
      item.expand = level === 1;
      item.parent = parent;

      if (item.visible === undefined) {
        item.visible = true;
      }
      if (!parent.visible || !parent.expand) {
        item.visible = false;
      }
      res.push(item);

      if (item[childKey] && item[childKey].length) {
        res.push(...flatten(item[childKey], childKey, level + 1, item));
      }
    });
    return res;
  }
  return flatten(originData, 'children', 1, {
    level: 0,
    visible: true,
    expand: true,
    value: '中国',
    children: originData,
  });
}

// 定义组件
function VirtualTree() {
  // 如果是vue把data、visibleData...等定义在data() {}里,把setXxx定义在methods里即可
  const treeRef = useRef();
  const [data, setData] = useState([]);
  const [visibleData, setVisibleData] = useState([]);
  const [contentHeight, setContentHeight] = useState(10000);
  const [offset, setOffset] = useState(0);

  // 模拟获取接口数据
  const getData = useCallback(() => {
    return new Promise((resolve) => {
      setTimeout(() => {
        resolve(flattenData());
      }, 500);
    });
  }, []);

  // 挂载时获取原始数据,相当于vue的 mounted()
  useEffect(() => {
    getData().then((res) => {
      setData(res);
    });
  }, []);

  // data变化更新可视数据,相当于vue的watch data
  useEffect(() => {
    if (data.length) {
      updateVisibleData();
    }
  }, [data]);

  // 可视数据变化更新容器高度, 相当于vue的watch visibleData
  useEffect(() => {
    setContentHeight(visibleData.length * options.itemHeight);
  }, [visibleData]);

  // 获取所有可视数据
  function getAllVisibleData() {
    return data.filter((item) => item.visible);
  }

  // 滚动页面时更新 visibleData、offset
  function updateVisibleData(scrollTop = 0) {
    const start = Math.floor(scrollTop / options.itemHeight);
    const end = start + options.visibleCount * 2;
    const allVisibleData = getAllVisibleData();
    const _visibleData = allVisibleData.slice(start, end);

    setVisibleData(_visibleData);
    setOffset(scrollTop);
  }

  function handleScroll() {
    const { scrollTop } = treeRef.current;
    updateVisibleData(scrollTop);
  }

  function recursionVisible(children = [], status) {
    children.forEach((node) => {
      // 如果是折叠-->折叠所有子项; 如果是展开-->显示下一级
      node.visible = status;
      if (!status) {
        node.expand = false;
      }
      if (node.children && node.children.length && !status) {
        recursionVisible(node.children, status);
      }
    });
  }

  // 折叠与展开
  function toggleExpand(item) {
    const isExpand = !item.expand;
    item.expand = isExpand;
    recursionVisible(item.children, isExpand);

    // 更新视图
    handleScroll();
  }

  return (
    <div ref={treeRef} className="tree" onScroll={handleScroll}>
      {/* tree-phantom是用于制造滚动条 ,= 所有可视item的高度之和 */}
      <div className="tree-phantom" style={{ height: contentHeight }}></div>
      <div
        className="tree-content"
        style={{ transform: `translateY(${offset}px)` }}
      >
        {visibleData.map((item, index) => {
          return (
            <div
              key={item.value + item.parent.value}
              className="tree-list"
              style={{
                paddingLeft:
                  15 * (item.level - 1) + (item.children ? 0 : 15) + 'px',
                height: options.itemHeight + 'px',
              }}
            >
              {item.children && item.children.length && (
                <span
                  onClick={(e) => {
                    e.stopPropagation();
                    toggleExpand(item);
                  }}
                >
                  <i className={item.expand ? 'tree-expand' : 'tree-close'} />
                </span>
              )}
              <span>{item.label}</span>
            </div>
          );
        })}
      </div>
    </div>
  );
}

export default VirtualTree;

优化:封装成组件VirtualTree

就是把列表数据data、和配置项options传入。考虑到每个item项你可能还需要自定义其他内容(比如说 收藏、添加、删除节点等)通过render.props把你要展示的每个item的content传进来(你可以自定义一些事件回调,我这里没写,只做了展示)

import React, { useEffect, useRef, useState } from 'react';

import './index.css';

// 定义组件
function VirtualTree(props) {
  const { data, options } = props;
  // 如果是vue把data、visibleData...等定义在data() {}里,把setXxx定义在methods里即可
  const treeRef = useRef();
  const [visibleData, setVisibleData] = useState([]);
  const [contentHeight, setContentHeight] = useState(10000);
  const [offset, setOffset] = useState(0);

  // data变化更新可视数据,相当于vue的watch data
  useEffect(() => {
    if (data.length) {
      updateVisibleData();
    }
  }, [data]);

  // 可视数据变化更新容器高度, 相当于vue的watch visibleData
  useEffect(() => {
    setContentHeight(visibleData.length * options.itemHeight);
  }, [visibleData]);

  // 获取所有可视数据
  function getAllVisibleData() {
    return data.filter((item) => item.visible);
  }

  // 滚动页面时更新 visibleData、offset
  function updateVisibleData(scrollTop = 0) {
    const start = Math.floor(scrollTop / options.itemHeight);
    const end = start + options.visibleCount * 2;
    const allVisibleData = getAllVisibleData();
    const _visibleData = allVisibleData.slice(start, end);

    setVisibleData(_visibleData);
    setOffset(scrollTop);
  }

  function handleScroll() {
    const { scrollTop } = treeRef.current;
    updateVisibleData(scrollTop);
  }

  function recursionVisible(children = [], status) {
    children.forEach((node) => {
      // 如果是折叠-->折叠所有子项; 如果是展开-->显示下一级
      node.visible = status;
      if (!status) {
        node.expand = false;
      }
      if (node.children && node.children.length && !status) {
        recursionVisible(node.children, status);
      }
    });
  }

  // 折叠与展开
  function toggleExpand(item) {
    const isExpand = !item.expand;
    item.expand = isExpand;
    recursionVisible(item.children, isExpand);

    // 更新视图
    handleScroll();
  }

  return (
    <div ref={treeRef} className="tree" onScroll={handleScroll}>
      {/* tree-phantom是用于制造滚动条 ,= 所有可视item的高度之和 */}
      <div className="tree-phantom" style={{ height: contentHeight }}></div>
      <div
        className="tree-content"
        style={{ transform: `translateY(${offset}px)` }}
      >
        {visibleData.map((item, index) => {
          return (
            <div
              key={item.value + item.parent.value}
              className="tree-list"
              style={{
                paddingLeft:
                  15 * (item.level - 1) + (item.children ? 0 : 15) + 'px',
                height: options.itemHeight + 'px',
              }}
            >
              {item.children && item.children.length && (
                <span
                  onClick={(e) => {
                    e.stopPropagation();
                    toggleExpand(item);
                  }}
                >
                  <i className={item.expand ? 'tree-expand' : 'tree-close'} />
                </span>
              )}
              {props.render(item)}
            </div>
          );
        })}
      </div>
    </div>
  );
}

export default VirtualTree;

其他地方使用 

import React, { useCallback, useEffect, useState } from 'react';

import { originData } from './mockData';
import VirtualTree from '../components/VirtualTree';

// 配置项
const options = {
  defaultExpand: 1,
  itemHeight: 30,
  visibleCount: 15,
};

// 将树形数据转成普通列表数据(我用的是中国省-市-区的树形数据)
function flattenData() {
  function flatten(tree, childKey = 'children', level, parent = null) {
    let res = [];
    tree.forEach((item) => {
      item.level = level;
      item.expand = level === 1;
      item.parent = parent;

      if (item.visible === undefined) {
        item.visible = true;
      }
      if (!parent.visible || !parent.expand) {
        item.visible = false;
      }
      res.push(item);

      if (item[childKey] && item[childKey].length) {
        res.push(...flatten(item[childKey], childKey, level + 1, item));
      }
    });
    return res;
  }
  return flatten(originData, 'children', 1, {
    level: 0,
    visible: true,
    expand: true,
    value: '中国',
    children: originData,
  });
}

function TreeItem({ item }) {
  return <span>{item.label}</span>;
}

function Tree() {
  const [data, setData] = useState([]);

  // 模拟获取接口数据
  const getData = useCallback(() => {
    return new Promise((resolve) => {
      setTimeout(() => {
        resolve(flattenData());
      }, 500);
    });
  }, []);

  // 挂载时获取原始数据,相当于vue的 mounted()
  useEffect(() => {
    getData().then((res) => {
      setData(res);
    });
  }, []);

  return (
    <VirtualTree
      data={data}
      options={options}
      render={(item) => <TreeItem item={item} />}
    />
  );
}

export default Tree;

附:源数据 

【因为太多了只截取了北京市数据】

export const originData = [
  {
    children: [
      {
        value: '东城区',
        label: '东城区',
      },
      {
        value: '西城区',
        label: '西城区',
      },
      {
        value: '朝阳区',
        label: '朝阳区',
      },
      {
        value: '丰台区',
        label: '丰台区',
      },
      {
        value: '石景山区',
        label: '石景山区',
      },
      {
        value: '海淀区',
        label: '海淀区',
      },
      {
        value: '门头沟区',
        label: '门头沟区',
      },
      {
        value: '房山区',
        label: '房山区',
      },
      {
        value: '通州区',
        label: '通州区',
      },
      {
        value: '顺义区',
        label: '顺义区',
      },
      {
        value: '昌平区',
        label: '昌平区',
      },
      {
        value: '大兴区',
        label: '大兴区',
      },
      {
        value: '怀柔区',
        label: '怀柔区',
      },
      {
        value: '平谷区',
        label: '平谷区',
      },
      {
        value: '密云区',
        label: '密云区',
      },
      {
        value: '延庆区',
        label: '延庆区',
      },
    ],
    value: '北京市',
    label: '北京市',
  }
]

  • 7
    点赞
  • 32
    收藏
    觉得还不错? 一键收藏
  • 3
    评论
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值