一、虚拟滚动原理
虚拟滚动(Virtual Scrolling)是一种用于优化长列表性能的技术,其核心思想是:只渲染可视区域内的列表项,当用户滚动时动态更新列表项的内容。这种技术可以显著减少 DOM 节点数量,提高页面性能。
1. 基本概念
- 可视区域:用户当前能看到的列表区域
- 缓冲区:可视区域外预渲染的额外项
- 虚拟列表:实际渲染在 DOM 中的列表项
- 偏移量:用于模拟真实滚动位置的空白区域
二、基础实现
1. 简单虚拟滚动
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71
| class VirtualScroll { constructor(options) { this.containerHeight = options.height; this.itemHeight = options.itemHeight; this.totalCount = options.totalCount; this.bufferSize = options.bufferSize || 5; this.visibleCount = Math.ceil(this.containerHeight / this.itemHeight); this.startIndex = 0; this.endIndex = this.startIndex + this.visibleCount + this.bufferSize; this.init(); } init() { this.container = document.createElement('div'); this.container.style.height = `${this.containerHeight}px`; this.container.style.overflow = 'auto'; this.content = document.createElement('div'); this.content.style.position = 'relative'; this.content.style.height = `${this.totalCount * this.itemHeight}px`; this.container.appendChild(this.content); this.bindEvents(); } bindEvents() { this.container.addEventListener('scroll', this.onScroll.bind(this)); } onScroll(e) { const scrollTop = e.target.scrollTop; this.updateVisibleRange(scrollTop); } updateVisibleRange(scrollTop) { this.startIndex = Math.floor(scrollTop / this.itemHeight); this.endIndex = this.startIndex + this.visibleCount + this.bufferSize; this.render(); } render() { this.content.innerHTML = ''; for (let i = this.startIndex; i < this.endIndex; i++) { if (i >= this.totalCount) break; const item = document.createElement('div'); item.style.position = 'absolute'; item.style.top = `${i * this.itemHeight}px`; item.style.height = `${this.itemHeight}px`; item.textContent = `Item ${i}`; this.content.appendChild(item); } } }
const virtualScroll = new VirtualScroll({ height: 400, itemHeight: 50, totalCount: 10000, bufferSize: 5 });
document.body.appendChild(virtualScroll.container);
|
2. 动态高度支持
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118
| class DynamicVirtualScroll { constructor(options) { this.containerHeight = options.height; this.estimatedItemHeight = options.estimatedItemHeight; this.totalCount = options.totalCount; this.positions = []; this.cachedHeights = new Map(); this.init(); } init() { this.initPositions(); this.container = document.createElement('div'); this.container.style.height = `${this.containerHeight}px`; this.container.style.overflow = 'auto'; this.content = document.createElement('div'); this.content.style.position = 'relative'; this.container.appendChild(this.content); this.bindEvents(); } initPositions() { this.positions = new Array(this.totalCount); let accumHeight = 0; for (let i = 0; i < this.totalCount; i++) { this.positions[i] = { index: i, top: accumHeight, bottom: accumHeight + this.estimatedItemHeight, height: this.estimatedItemHeight }; accumHeight += this.estimatedItemHeight; } this.content.style.height = `${accumHeight}px`; } updateItemHeight(index, height) { const oldHeight = this.positions[index].height; const dHeight = height - oldHeight; this.positions[index].bottom = this.positions[index].top + height; this.positions[index].height = height; this.cachedHeights.set(index, height); for (let i = index + 1; i < this.totalCount; i++) { this.positions[i].top += dHeight; this.positions[i].bottom += dHeight; } this.content.style.height = `${this.positions[this.totalCount - 1].bottom}px`; } findStartIndex(scrollTop) { return this.binarySearch(scrollTop); } binarySearch(scrollTop) { let start = 0; let end = this.totalCount - 1; while (start <= end) { const mid = Math.floor((start + end) / 2); const midVal = this.positions[mid]; if (midVal.bottom >= scrollTop && midVal.top <= scrollTop) { return mid; } else if (midVal.bottom < scrollTop) { start = mid + 1; } else { end = mid - 1; } } return 0; } render(scrollTop) { const startIndex = this.findStartIndex(scrollTop); const endIndex = this.findEndIndex(startIndex, scrollTop); this.content.innerHTML = ''; for (let i = startIndex; i <= endIndex; i++) { const item = this.createItem(i); this.content.appendChild(item); } } createItem(index) { const item = document.createElement('div'); item.style.position = 'absolute'; item.style.top = `${this.positions[index].top}px`; item.style.width = '100%'; const observer = new ResizeObserver(entries => { const height = entries[0].contentRect.height; if (height !== this.positions[index].height) { this.updateItemHeight(index, height); } }); observer.observe(item); return item; } }
|
三、性能优化
1. 防抖与节流
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| class OptimizedVirtualScroll extends VirtualScroll { constructor(options) { super(options); this.scrollTimer = null; } onScroll(e) { if (this.scrollTimer) return; this.scrollTimer = setTimeout(() => { const scrollTop = e.target.scrollTop; this.updateVisibleRange(scrollTop); this.scrollTimer = null; }, 16); } render() { requestAnimationFrame(() => { super.render(); }); } }
|
2. DOM 复用
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49
| class RecycleVirtualScroll extends VirtualScroll { constructor(options) { super(options); this.itemPool = []; } getItem() { if (this.itemPool.length > 0) { return this.itemPool.pop(); } const item = document.createElement('div'); item.className = 'virtual-item'; return item; } recycleItem(item) { this.itemPool.push(item); } render() { const oldItems = Array.from(this.content.children); const newItems = []; for (let i = this.startIndex; i < this.endIndex; i++) { if (i >= this.totalCount) break; const item = this.getItem(); item.style.transform = `translateY(${i * this.itemHeight}px)`; item.textContent = `Item ${i}`; newItems.push(item); } oldItems.forEach(item => { this.recycleItem(item); }); this.content.innerHTML = ''; newItems.forEach(item => { this.content.appendChild(item); }); } }
|
四、进阶特性
1. 无限滚动
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| class InfiniteVirtualScroll extends VirtualScroll { constructor(options) { super(options); this.loading = false; this.hasMore = true; } async onScroll(e) { super.onScroll(e); const scrollTop = e.target.scrollTop; const scrollHeight = e.target.scrollHeight; const clientHeight = e.target.clientHeight; if (scrollHeight - scrollTop - clientHeight < 100) { await this.loadMore(); } } async loadMore() { if (this.loading || !this.hasMore) return; this.loading = true; try { const newItems = await this.fetchMoreData(); this.totalCount += newItems.length; this.updateContent(); } finally { this.loading = false; } } }
|
2. 分组支持
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
| class GroupVirtualScroll { constructor(options) { this.groups = options.groups; this.groupHeaderHeight = options.groupHeaderHeight; this.itemHeight = options.itemHeight; this.initGroupPositions(); } initGroupPositions() { let accumHeight = 0; this.groupPositions = this.groups.map(group => { const groupHeight = this.groupHeaderHeight + group.items.length * this.itemHeight; const position = { top: accumHeight, height: groupHeight, bottom: accumHeight + groupHeight }; accumHeight += groupHeight; return position; }); } findVisibleGroups(scrollTop, viewportHeight) { const visibleGroups = []; const viewportBottom = scrollTop + viewportHeight; for (let i = 0; i < this.groupPositions.length; i++) { const group = this.groupPositions[i]; if (group.bottom > scrollTop && group.top < viewportBottom) { visibleGroups.push(i); } } return visibleGroups; } }
|
五、最佳实践
- 合理设置缓冲区大小
- 优化滚动事件处理
- 使用 transform 代替 top
- 实现 DOM 节点复用
- 优化重排重绘
参考文献