前言
在现代 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>
);
}