实现高性能虚拟列表的实践与核心原理解析
我的写法,亲测靠谱
先说说我常用的虚拟列表实现方式吧。经过几个项目的实践,我发现基于Intersection Observer的方案最稳定,代码结构也清晰。
class VirtualList {
constructor(options) {
this.container = options.container
this.itemHeight = options.itemHeight
this.items = options.items
this.renderCount = Math.ceil(window.innerHeight / this.itemHeight) + 2
this.startIndex = 0
this.endIndex = this.renderCount
this.init()
}
init() {
this.container.style.position = 'relative'
this.container.style.height = ${this.items.length * this.itemHeight}px
this.updateVisibleItems()
const observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
if (entry.isIntersecting) {
this.updateVisibleItems()
}
})
}, { threshold: 0.5 })
observer.observe(this.container.lastElementChild)
}
updateVisibleItems() {
const scrollTop = this.container.scrollTop
const newStartIndex = Math.floor(scrollTop / this.itemHeight)
const newEndIndex = newStartIndex + this.renderCount
if (newStartIndex !== this.startIndex || newEndIndex !== this.endIndex) {
this.startIndex = newStartIndex
this.endIndex = newEndIndex
this.render()
}
}
render() {
this.container.innerHTML = ''
for (let i = this.startIndex; i < this.endIndex; i++) {
const item = document.createElement('div')
item.style.position = 'absolute'
item.style.top = ${i * this.itemHeight}px
item.style.height = ${this.itemHeight}px
item.textContent = this.items[i]
this.container.appendChild(item)
}
}
}
这种写法有几个好处:首先是性能稳定,Intersection Observer对滚动事件的处理很友好;其次是代码逻辑清晰,init、update、render三个核心方法分工明确,维护起来特别方便。
这几种错误写法,别再踩坑了
说说那些年我踩过的坑,有些真是折腾到怀疑人生。
最常见的错误就是直接监听scroll事件:
// 错误示范:直接监听scroll
container.addEventListener('scroll', () => {
// 处理逻辑
})
这种方法问题很大。首先scroll事件触发频率极高,容易造成页面卡顿;其次在快速滚动时经常出现漏算的情况。我之前在一个电商项目里就这么写的,结果用户疯狂吐槽列表闪动。
还有个坑是用setTimeout节流:
// 错误示范:用setTimeout节流
let timer
container.addEventListener('scroll', () => {
if (!timer) {
timer = setTimeout(() => {
// 处理逻辑
timer = null
}, 16)
}
})
这种方式看似解决了性能问题,但实际体验很差。因为setTimeout的时间间隔很难把握,设短了还是卡,设长了又会有明显延迟。我在一个聊天应用里这么干过,结果用户反馈说消息列表总是慢半拍。
最后一个常见错误是计算visible items的时候没考虑容器padding:
// 错误示范:没考虑padding
const visibleCount = Math.ceil(container.clientHeight / itemHeight)
这个问题害我debug了整整一天。当时在一个管理后台项目里,列表容器有padding值,结果导致计算出来的可见项数总是偏少,底部总是空出一块。
实际项目中的坑
说几个真实项目里的注意事项。首当其冲的就是动态高度的问题。虽然固定高度实现简单,但实际项目中item高度往往是不固定的。我一般会用ResizeObserver来解决:
// 动态高度处理
const ro = new ResizeObserver(entries => {
for (let entry of entries) {
const index = Array.from(container.children).indexOf(entry.target)
heights[index] = entry.contentRect.height
updatePositions()
}
})
另一个大坑是图片加载导致的高度变化。我现在的做法是在图片onload之后重新计算:
// 图片加载处理
images.forEach(img => {
img.onload = () => updatePositions()
})
还有就是滚动位置恢复的问题。特别是在SPA应用里,用户从详情页返回列表页时,需要记住之前的滚动位置。我一般会在路由切换时保存scrollTop:
// 滚动位置恢复
let savedScrollTop = 0
window.onpopstate = () => {
container.scrollTop = savedScrollTop
}
最后提醒一下,在移动端使用时要特别注意touch事件的处理。建议统一使用passive event listener:
// passive event listener
container.addEventListener('touchmove', handler, { passive: true })
一些实用的小技巧
分享几个实战中总结的小技巧。首先是预渲染的优化,可以在初始化时多渲染几屏数据:
// 预渲染优化
this.renderCount = Math.ceil(window.innerHeight / this.itemHeight) * 3
这样能减少初次渲染时的白屏时间。不过要注意,预渲染太多反而会影响性能,我一般是乘以2或3比较合适。
其次是回收机制。对于已经滚出可视区的元素,可以缓存起来而不是直接remove:
// 元素回收池
const pool = []
function recycle(element) {
pool.push(element)
}
function getFromPool() {
return pool.pop() || document.createElement('div')
}
这个技巧在处理复杂DOM结构时特别有用,能显著减少DOM操作的开销。
以上是我总结的最佳实践
总的来说,虚拟列表虽然原理不复杂,但在实际项目中要考虑的细节还真不少。我也是踩了不少坑才总结出这套相对稳定的方案。
当然,每个项目的需求都不一样,这套方案肯定也有局限性。如果你有更好的实现方式,或者遇到什么特殊的场景,欢迎在评论区交流。毕竟前端这条路,谁不是一边踩坑一边成长呢。

暂无评论