基于 Body 滚动的虚拟滚动组件技术实现

前言

在现代 Web 应用中,树形结构是一种常见的数据展示方式,广泛应用于文件管理、组织架构、菜单导航等场景。然而,当树节点数量达到成千上万时,传统的全量渲染方式会导致严重的性能问题。本文将分享一个基于 React 实现的高性能虚拟滚动树组件,特别是其使用 Body 滚动条控制虚拟滚动的创新实现方案。

功能亮点

1. 🚀 高性能虚拟滚动

  • 按需渲染:只渲染视口内可见的节点,大幅减少 DOM 数量
  • 动态计算:实时计算可见范围,支持数万节点流畅滚动
  • 智能预加载 :通过 overscan 参数预渲染视口外的节点,避免滚动时的白屏

2. 📏 不定高节点支持

  • 自适应高度:每个节点可以有不同的高度
  • ResizeObserver 监听:自动检测节点高度变化并更新缓存
  • 精确定位:基于高度缓存精确计算每个节点的位置

3. 🎯 Body 滚动条控制

这是本组件的核心创新点

  • 全局滚动体验:使用页面的原生滚动条,而非组件内部滚动
  • 无缝集成:树组件可以与页面其他内容(如表单、卡片)自然融合
  • 单一滚动条:整个页面只有一个滚动条,符合用户习惯

4. 🎨 拖拽排序

  • 直观交互:支持节点拖拽重新排序
  • 三种放置模式:before(前面)、after(后面)、inside(内部)
  • 视觉反馈:拖拽过程中提供清晰的视觉指示

5. 🌲 完整的树操作

  • 展开/收起:支持单个节点或全部节点的展开收起
  • 节点点击:自定义节点点击事件处理
  • 图标定制:支持自定义节点图标

技术实现原理

核心架构

scss 复制代码
┌─────────────────────────────────────┐
│         Window (Body Scroll)        │
│  ┌───────────────────────────────┐  │
│  │      Form Area (Fixed)        │  │
│  └───────────────────────────────┘  │
│  ┌───────────────────────────────┐  │
│  │   Virtual Tree Container      │  │
│  │  ┌─────────────────────────┐  │  │
│  │  │  Visible Node 1         │  │  │ ← 视口内
│  │  │  Visible Node 2         │  │  │
│  │  │  Visible Node 3         │  │  │
│  │  ├─────────────────────────┤  │  │
│  │  │  (Hidden Nodes)         │  │  │ ← 虚拟占位
│  │  │  Total Height: 10000px  │  │  │
│  │  └─────────────────────────┘  │  │
│  └───────────────────────────────┘  │
└─────────────────────────────────────┘

1. Body 滚动监听机制

这是本组件最具特色的技术实现:

javascript 复制代码
useEffect(() => {
  const handleScroll = () => {
    if (!containerRef.current) return;
    
    const rect = containerRef.current.getBoundingClientRect();
    const containerTop = rect.top;
    
    // 计算容器相对于视口的滚动位置
    // 如果容器顶部在视口上方,scrollTop为正值
    const newScrollTop = Math.max(0, -containerTop);
    setScrollTop(newScrollTop);
  };

  // 初始化滚动位置
  handleScroll();
  
  // 监听window滚动事件
  window.addEventListener('scroll', handleScroll, { passive: true });
  
  return () => {
    window.removeEventListener('scroll', handleScroll);
  };
}, []);

关键点解析

  • 监听 window.scroll 事件而非容器的 scroll 事件
  • 通过 getBoundingClientRect() 获取容器相对于视口的位置
  • 当容器顶部滚出视口时(rect.top < 0),计算出虚拟的 scrollTop
  • 使用 passive: true 优化滚动性能

2. 可见范围计算

基于 Body 滚动位置计算哪些节点应该被渲染:

javascript 复制代码
const visibleRange = useMemo(() => {
  if (positions.length === 0) {
    return { start: 0, end: 0 };
  }

  const rect = containerRef.current.getBoundingClientRect();
  const viewportHeight = window.innerHeight;
  
  // 计算视口内可见的范围
  const viewportTop = Math.max(0, -rect.top);
  const viewportBottom = viewportTop + viewportHeight;
  
  // 找到第一个可见节点
  let start = 0;
  for (let i = 0; i < positions.length; i++) {
    if (positions[i].top + positions[i].height >= viewportTop) {
      start = Math.max(0, i - overscan);
      break;
    }
  }
  
  // 找到最后一个可见节点
  let end = positions.length - 1;
  for (let i = start; i < positions.length; i++) {
    if (positions[i].top > viewportBottom) {
      end = Math.min(positions.length - 1, i + overscan);
      break;
    }
  }
  
  return { start, end };
}, [positions, scrollTop, overscan]);

算法优势

  • 基于视口高度和容器位置动态计算
  • 支持 overscan 预渲染,提升滚动流畅度
  • 使用二分查找可进一步优化(当前为线性查找)

3. 不定高节点处理

每个节点的高度可能不同,需要精确测量和缓存:

javascript 复制代码
// 使用 ResizeObserver 监听高度变化
useEffect(() => {
  if (!nodeRef.current) return;

  const resizeObserver = new ResizeObserver((entries) => {
    for (const entry of entries) {
      const height = entry.contentRect.height;
      onUpdateHeight(node.key, height);
    }
  });

  resizeObserver.observe(nodeRef.current);

  return () => {
    resizeObserver.disconnect();
  };
}, [node.key, onUpdateHeight]);

高度缓存策略

javascript 复制代码
const { positions, totalHeight } = useMemo(() => {
  const positions = [];
  let currentTop = 0;
  
  flattenedData.forEach((node) => {
    const height = nodeHeights[node.key] || itemMinHeight;
    positions.push({
      key: node.key,
      top: currentTop,
      height
    });
    currentTop += height;
  });
  
  return {
    positions,
    totalHeight: currentTop
  };
}, [flattenedData, nodeHeights, itemMinHeight]);

4. 树数据扁平化

将树形结构转换为一维数组,便于虚拟滚动处理:

javascript 复制代码
const flattenTree = useCallback((nodes, level = 0, parentKey = null) => {
  const result = [];
  
  nodes.forEach((node, index) => {
    const key = node.key || `${parentKey}-${index}`;
    const item = {
      ...node,
      key,
      level,
      parentKey,
      hasChildren: node.children && node.children.length > 0,
      isExpanded: expandedKeys.has(key)
    };
    
    result.push(item);
    
    // 只有展开的节点才递归处理子节点
    if (item.hasChildren && item.isExpanded) {
      result.push(...flattenTree(node.children, level + 1, key));
    }
  });
  
  return result;
}, [expandedKeys]);

扁平化优势

  • 将树形结构转换为线性数组,便于索引访问
  • 只包含可见的节点(未展开的子节点不在数组中)
  • 记录每个节点的层级信息,用于缩进显示

5. 拖拽实现

支持节点拖拽重新排序:

javascript 复制代码
const handleDrop = ({ dragNode, dropNode, position }) => {
  // 1. 深拷贝树数据
  const newTreeData = JSON.parse(JSON.stringify(treeData));
  
  // 2. 从原位置删除节点
  const removedNode = removeNode(newTreeData, dragNode.key);
  
  // 3. 插入到新位置
  const inserted = insertNode(newTreeData, dropNode.key, removedNode, position);
  
  // 4. 更新树数据
  setTreeData(newTreeData);
};

拖拽位置判断

javascript 复制代码
const handleDragOver = (e, node, position) => {
  const rect = nodeRef.current.getBoundingClientRect();
  const offsetY = e.clientY - rect.top;
  const height = rect.height;
  
  let position;
  if (offsetY < height * 0.25) {
    position = 'before';  // 上方 25%
  } else if (offsetY > height * 0.75) {
    position = 'after';   // 下方 25%
  } else {
    position = 'inside';  // 中间 50%
  }
};

性能优化策略

1. 渲染优化

  • useMemo 缓存计算结果:避免重复计算可见范围和节点位置
  • useCallback 缓存函数:防止子组件不必要的重新渲染
  • React.memo:对 TreeNode 组件进行记忆化

2. 滚动优化

  • passive 事件监听:提升滚动性能
  • requestAnimationFrame:可选的滚动节流(当前未使用)
  • overscan 预渲染:减少滚动时的白屏

3. 内存优化

  • 按需渲染:只渲染可见节点,大幅减少 DOM 数量
  • 高度缓存:避免重复测量节点高度
  • 及时清理:组件卸载时清理事件监听和 Observer

使用示例

jsx 复制代码
import VirtualTree from './components/VirtualTree';

function App() {
  const treeRef = useRef(null);
  const [treeData, setTreeData] = useState([...]);

  const handleNodeClick = (node) => {
    console.log('点击节点:', node);
  };

  const handleDrop = ({ dragNode, dropNode, position }) => {
    // 处理拖拽逻辑
  };

  return (
    <div>
      {/* 页面其他内容 */}
      <Form>...</Form>
      
      {/* 树组件 - 使用 body 滚动条 */}
      <VirtualTree
        ref={treeRef}
        data={treeData}
        itemMinHeight={32}
        overscan={5}
        draggable={true}
        onNodeClick={handleNodeClick}
        onDrop={handleDrop}
      />
    </div>
  );
}
相关推荐
b***74882 小时前
前端正在进入“超级融合时代”:从单一技术栈到体验、架构与智能的全维度进化
前端·架构
白杨SEO营销2 小时前
白杨SEO:看“20步:从0-1做项目的笨办法”来学习如何选一个项目做及经验分享
前端·学习
AY呀2 小时前
# 🌟 JavaScript原型与原型链终极指南:从Function到Object的完整闭环解析 ,深入理解JavaScript原型系统核心
前端·javascript·面试
用户434662153132 小时前
无废话之 useState、useRef、useReducer 的使用场景与选择指南
前端
GinoWi2 小时前
HTML标签 - 表格标签
前端
码是生活2 小时前
老板:能不能别手动复制路由了?我:写个脚本自动扫描
前端·node.js
小皮虾2 小时前
护航隐私!小程序纯前端“证件加水印”:OffscreenCanvas 全屏平铺实战
前端·javascript·微信小程序
chushiyunen2 小时前
未设置X-XSS-Protection响应头安全漏洞
前端·xss
yingjuxia菜鸟com2 小时前
制作一个简单的HTML个人网页
前端