一、虚拟滚动原理
虚拟滚动(Virtual Scrolling)是一种用于优化长列表性能的技术,其核心思想是:只渲染可视区域内的列表项,当用户滚动时动态更新列表项的内容。这种技术可以显著减少 DOM 节点数量,提高页面性能。
1. 基本概念
- 可视区域:用户当前能看到的列表区域
- 缓冲区:可视区域外预渲染的额外项
- 虚拟列表:实际渲染在 DOM 中的列表项
- 偏移量:用于模拟真实滚动位置的空白区域
二、基础实现
1. 简单虚拟滚动
| 12
 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. 动态高度支持
| 12
 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. 防抖与节流
| 12
 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 复用
| 12
 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. 无限滚动
| 12
 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. 分组支持
| 12
 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 节点复用
- 优化重排重绘
参考文献