阿里云主机折上折
  • 微信号
您当前的位置:网站首页 > 虚拟滚动技术实现

虚拟滚动技术实现

作者:陈川 阅读数:13865人阅读 分类: 性能优化

虚拟滚动技术实现

虚拟滚动是一种优化长列表渲染性能的技术,通过仅渲染可视区域内的元素来减少DOM节点数量。传统滚动会渲染所有列表项,当数据量达到数千甚至上万时,会导致严重的性能问题。虚拟滚动计算可见范围,动态渲染和回收DOM节点,保持页面流畅。

核心原理

虚拟滚动实现依赖三个关键参数:容器高度(clientHeight)、滚动位置(scrollTop)和列表项高度(itemHeight)。通过数学计算确定可视区域的起始索引和结束索引:

const startIndex = Math.floor(scrollTop / itemHeight)
const endIndex = Math.min(
  startIndex + Math.ceil(clientHeight / itemHeight),
  itemCount - 1
)

实际实现需要考虑滚动缓冲(renderBuffer),通常会在可视区域上下额外渲染若干项,避免快速滚动时出现空白。典型缓冲方案:

const bufferSize = 5
const startIndexWithBuffer = Math.max(0, startIndex - bufferSize)
const endIndexWithBuffer = Math.min(
  endIndex + bufferSize,
  itemCount - 1
)

具体实现方案

固定高度实现

当列表项高度固定时实现最简单。假设每个列表项高度为50px,容器高度500px:

class VirtualScroll {
  constructor(container, items, itemHeight) {
    this.container = container
    this.items = items
    this.itemHeight = itemHeight
    this.visibleItems = []
    
    container.style.overflow = 'auto'
    container.style.height = '500px'
    container.addEventListener('scroll', this.handleScroll.bind(this))
    
    this.render()
  }

  handleScroll() {
    this.render()
  }

  render() {
    const scrollTop = this.container.scrollTop
    const clientHeight = this.container.clientHeight
    const startIndex = Math.floor(scrollTop / this.itemHeight)
    const endIndex = startIndex + Math.ceil(clientHeight / this.itemHeight)
    
    // 更新可见项
    this.visibleItems = this.items.slice(startIndex, endIndex + 1)
    
    // 设置容器padding实现滚动条正确比例
    const totalHeight = this.items.length * this.itemHeight
    this.container.style.paddingTop = `${startIndex * this.itemHeight}px`
    this.container.style.paddingBottom = `${totalHeight - (endIndex + 1) * this.itemHeight}px`
    
    // 渲染DOM(实际项目应使用文档片段优化)
    this.container.innerHTML = ''
    this.visibleItems.forEach(item => {
      const element = document.createElement('div')
      element.style.height = `${this.itemHeight}px`
      element.textContent = item
      this.container.appendChild(element)
    })
  }
}

动态高度实现

当列表项高度不固定时,需要更复杂的方案。常见做法是:

  1. 首次渲染时测量并缓存每个项的高度
  2. 使用二分查找快速定位滚动位置对应的项索引
class DynamicVirtualScroll {
  constructor(container, items) {
    this.container = container
    this.items = items
    this.itemHeights = [] // 缓存各项目高度
    this.totalHeight = 0
    
    container.style.overflow = 'auto'
    container.style.height = '500px'
    container.addEventListener('scroll', this.handleScroll.bind(this))
    
    this.measureItems()
    this.render()
  }

  measureItems() {
    // 创建测量容器
    const measureContainer = document.createElement('div')
    measureContainer.style.position = 'absolute'
    measureContainer.style.visibility = 'hidden'
    document.body.appendChild(measureContainer)
    
    // 测量每个项目高度
    this.items.forEach((item, index) => {
      const element = this.createItemElement(item)
      measureContainer.appendChild(element)
      const height = element.getBoundingClientRect().height
      this.itemHeights[index] = height
      this.totalHeight += height
    })
    
    document.body.removeChild(measureContainer)
  }

  findNearestItem(scrollTop) {
    let accumulatedHeight = 0
    for (let i = 0; i < this.itemHeights.length; i++) {
      accumulatedHeight += this.itemHeights[i]
      if (accumulatedHeight >= scrollTop) {
        return i
      }
    }
    return this.items.length - 1
  }

  render() {
    const scrollTop = this.container.scrollTop
    const clientHeight = this.container.clientHeight
    
    const startIndex = this.findNearestItem(scrollTop)
    let endIndex = startIndex
    let renderHeight = 0
    
    // 计算结束索引
    while (endIndex < this.items.length && renderHeight < clientHeight * 2) {
      renderHeight += this.itemHeights[endIndex]
      endIndex++
    }
    
    // 渲染可见项
    this.container.innerHTML = ''
    let offset = 0
    for (let i = 0; i < startIndex; i++) {
      offset += this.itemHeights[i]
    }
    
    this.container.style.paddingTop = `${offset}px`
    
    const fragment = document.createDocumentFragment()
    for (let i = startIndex; i <= endIndex; i++) {
      const element = this.createItemElement(this.items[i])
      fragment.appendChild(element)
    }
    this.container.appendChild(fragment)
    
    // 计算底部padding
    let bottomOffset = 0
    for (let i = endIndex + 1; i < this.items.length; i++) {
      bottomOffset += this.itemHeights[i]
    }
    this.container.style.paddingBottom = `${bottomOffset}px`
  }
  
  createItemElement(item) {
    const element = document.createElement('div')
    element.textContent = item
    return element
  }
}

性能优化技巧

  1. 使用文档片段:避免频繁重排

    const fragment = document.createDocumentFragment()
    items.forEach(item => {
      fragment.appendChild(createItem(item))
    })
    container.appendChild(fragment)
    
  2. 滚动节流:避免滚动事件过频触发

    function throttle(fn, delay) {
      let lastCall = 0
      return function(...args) {
        const now = Date.now()
        if (now - lastCall >= delay) {
          fn.apply(this, args)
          lastCall = now
        }
      }
    }
    
    container.addEventListener('scroll', throttle(handleScroll, 16))
    
  3. 回收DOM节点:使用对象池技术复用DOM元素

    class DOMPool {
      constructor(createElement) {
        this.pool = []
        this.createElement = createElement
      }
      
      get() {
        return this.pool.pop() || this.createElement()
      }
      
      release(element) {
        this.pool.push(element)
      }
    }
    
  4. Intersection Observer API:现代浏览器更高效的可见性检测

    const observer = new IntersectionObserver(entries => {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          // 处理可见项
        }
      })
    }, { threshold: 0.1 })
    
    listItems.forEach(item => observer.observe(item))
    

框架集成方案

React实现示例

import { useState, useRef, useEffect } from 'react'

function VirtualList({ items, itemHeight, bufferSize = 5 }) {
  const [startIndex, setStartIndex] = useState(0)
  const containerRef = useRef(null)
  
  useEffect(() => {
    const container = containerRef.current
    if (!container) return
    
    const handleScroll = () => {
      const scrollTop = container.scrollTop
      const newStartIndex = Math.floor(scrollTop / itemHeight) - bufferSize
      setStartIndex(Math.max(0, newStartIndex))
    }
    
    container.addEventListener('scroll', handleScroll)
    return () => container.removeEventListener('scroll', handleScroll)
  }, [itemHeight, bufferSize])
  
  const clientHeight = containerRef.current?.clientHeight || 0
  const visibleItemCount = Math.ceil(clientHeight / itemHeight) + 2 * bufferSize
  const endIndex = Math.min(startIndex + visibleItemCount, items.length - 1)
  
  const paddingTop = startIndex * itemHeight
  const paddingBottom = (items.length - endIndex - 1) * itemHeight
  
  return (
    <div 
      ref={containerRef}
      style={{ 
        height: '100vh', 
        overflow: 'auto',
        position: 'relative'
      }}
    >
      <div style={{ paddingTop, paddingBottom }}>
        {items.slice(startIndex, endIndex + 1).map((item, index) => (
          <div 
            key={startIndex + index}
            style={{ height: itemHeight }}
          >
            {item}
          </div>
        ))}
      </div>
    </div>
  )
}

Vue实现示例

<template>
  <div 
    ref="container"
    class="virtual-container"
    @scroll="handleScroll"
  >
    <div 
      class="virtual-content"
      :style="{
        paddingTop: paddingTop + 'px',
        paddingBottom: paddingBottom + 'px'
      }"
    >
      <div 
        v-for="item in visibleItems"
        :key="item.id"
        class="virtual-item"
        :style="{ height: itemHeight + 'px' }"
      >
        {{ item.content }}
      </div>
    </div>
  </div>
</template>

<script>
export default {
  props: {
    items: Array,
    itemHeight: Number,
    bufferSize: {
      type: Number,
      default: 5
    }
  },
  data() {
    return {
      startIndex: 0
    }
  },
  computed: {
    visibleItemCount() {
      const clientHeight = this.$refs.container?.clientHeight || 0
      return Math.ceil(clientHeight / this.itemHeight) + 2 * this.bufferSize
    },
    endIndex() {
      return Math.min(this.startIndex + this.visibleItemCount, this.items.length - 1)
    },
    visibleItems() {
      return this.items.slice(this.startIndex, this.endIndex + 1)
    },
    paddingTop() {
      return this.startIndex * this.itemHeight
    },
    paddingBottom() {
      return (this.items.length - this.endIndex - 1) * this.itemHeight
    }
  },
  methods: {
    handleScroll() {
      const scrollTop = this.$refs.container.scrollTop
      this.startIndex = Math.max(
        0, 
        Math.floor(scrollTop / this.itemHeight) - this.bufferSize
      )
    }
  }
}
</script>

<style>
.virtual-container {
  height: 100vh;
  overflow: auto;
  position: relative;
}
.virtual-content {
  position: relative;
}
.virtual-item {
  border-bottom: 1px solid #eee;
}
</style>

实际应用中的挑战

  1. 动态内容加载:当结合无限滚动时需要特殊处理

    async function loadMoreItems() {
      if (isLoading) return
      
      isLoading = true
      const newItems = await fetchItems()
      items = [...items, ...newItems]
      isLoading = false
      
      // 需要重新计算滚动位置和可见区域
      recalculateLayout()
    }
    
  2. 浏览器兼容性:旧版浏览器可能需要polyfill

    // 兼容性处理scroll事件
    const supportsPassive = (() => {
      let supported = false
      try {
        const opts = Object.defineProperty({}, 'passive', {
          get() { supported = true }
        })
        window.addEventListener('test', null, opts)
      } catch (e) {}
      return supported
    })()
    
    container.addEventListener('scroll', handleScroll, 
      supportsPassive ? { passive: true } : false
    )
    
  3. 移动端优化:处理触摸事件和惯性滚动

    let lastTouchY = 0
    container.addEventListener('touchstart', e => {
      lastTouchY = e.touches[0].clientY
    }, { passive: true })
    
    container.addEventListener('touchmove', e => {
      const deltaY = e.touches[0].clientY - lastTouchY
      lastTouchY = e.touches[0].clientY
      
      // 自定义滚动处理
      container.scrollTop -= deltaY
      e.preventDefault()
    }, { passive: false })
    
  4. 辅助功能:确保屏幕阅读器可访问

    <div role="list" aria-label="虚拟滚动列表">
      <div role="listitem" v-for="item in visibleItems">
        {{ item }}
      </div>
    </div>
    

高级应用场景

表格虚拟滚动

实现原理类似,但需要同时处理横向和纵向滚动:

class VirtualTable {
  constructor(table, rows, cols) {
    this.table = table
    this.rows = rows
    this.cols = cols
    
    // 初始化表头和左侧固定列
    this.initFixedElements()
    
    // 设置可滚动区域
    this.scrollBody = table.querySelector('.scroll-body')
    this.scrollBody.addEventListener('scroll', this.handleScroll.bind(this))
    
    this.render()
  }

  handleScroll() {
    const { scrollTop, scrollLeft } = this.scrollBody
    this.renderVisibleCells(scrollTop, scrollLeft)
    
    // 同步固定列的垂直滚动
    this.fixedColsContainer.scrollTop = scrollTop
    
    // 同步表头的水平滚动
    this.headerContainer.scrollLeft = scrollLeft
  }

  renderVisibleCells(scrollTop, scrollLeft) {
    // 计算可见行列范围
    const startRow = Math.floor(scrollTop / ROW_HEIGHT)
    const endRow = startRow + Math.ceil(this.scrollBody.clientHeight / ROW_HEIGHT)
    
    const startCol = Math.floor(scrollLeft / COL_WIDTH)
    const endCol = startCol + Math.ceil(this.scrollBody.clientWidth / COL_WIDTH)
    
    // 渲染可见单元格
    // ...
  }
}

树形结构虚拟滚动

需要处理展开/折叠状态下的高度计算:

class VirtualTree {
  constructor(container, treeData) {
    this.container = container
    this.treeData = treeData
    this.flatNodes = this.flattenTree(treeData)
    
    // 初始化展开状态
    this.expandedState = new Map()
    this.calculateNodeHeights()
    
    container.addEventListener('scroll', this.handleScroll.bind(this))
    this.render()
  }

  flattenTree(nodes, result = [], depth = 0) {
    nodes.forEach(node => {
      result.push({ node, depth })
      if (node.children && this.expandedState.get(node.id)) {
        this.flattenTree(node.children, result, depth + 1)
      }
    })
    return result
  }

  calculateNodeHeights() {
    this.nodeHeights = this.flatNodes.map(node => {
      return node.node.children ? 40 : 30 // 不同层级不同高度
    })
    
    this.totalHeight = this.nodeHeights.reduce((sum, h) => sum + h, 0)
  }

  toggleExpand(nodeId) {
    const isExpanded = this.expandedState.get(nodeId)
    this.expandedState.set(nodeId, !isExpanded)
    
    // 重新扁平化树并计算高度
    this.flatNodes = this.flattenTree(this.treeData)
    this.calculateNodeHeights()
    
    this.render()
  }
}

性能监控与调试

  1. Chrome DevTools 性能分析

    • 使用Performance面板记录滚动过程
    • 检查Layout、Paint和Composite事件
  2. 关键指标测量

    // 测量FPS
    let lastTime = performance.now()
    let frameCount = 0
    
    function checkFPS() {
      const now = performance.now()
      frameCount++
      
      if (now > lastTime + 1000) {
        const fps = Math.round((frameCount * 1000) / (now - lastTime))
        console.log(`FPS: ${fps}`)
        
        frameCount = 0
        lastTime = now
      }
      
      requestAnimationFrame(checkFPS)
    }
    
    checkFPS()
    
  3. 内存使用监控

    // 检查DOM节点数量
    setInterval(() => {
      console.log('DOM nodes:', document.getElementsByTagName('*').length)
    }, 1000)
    
  4. 滚动卡顿分析工具

    let lastScrollTime = 0
    container.addEventListener('scroll', () => {
      const now = performance.now()
      const delta = now - lastScrollTime
      if (delta > 50) {  // 超过50ms视为卡顿
        console.warn(`Scroll jank detected: ${delta}ms`)
      }
      lastScrollTime = now
    })
    

现代浏览器优化特性

  1. CSS Containment:减少浏览器重排重绘范围

    .virtual-item {
      contain: strict;
      content-visibility: auto;
    }
    
  2. Will-Change:提前告知浏览器可能的变化

    .virtual-container {
      will-change: transform;
    }
    
  3. OffscreenCanvas:Worker线程渲染

    const offscreen = canvas.transferControlToOffscreen()
    worker.postMessage({ canvas: offscreen }, [offscreen])
    
  4. Web Workers:将计算密集型任务移出主线程

    // 主线程
    const worker = new Worker('virtual-scroll-worker.js')
    worker.onmessage = (e) => {
      updateVisibleItems(e.data.visibleItems)
    }
    
    container.addEventListener('scroll', () => {
      worker.postMessage({
        scrollTop: container.scrollTop,
        clientHeight: container.clientHeight
      })
    })
    
    // Worker线程
    self.onmessage = (e) => {
      const { scrollTop, clientHeight } = e.data
      // 计算可见项...
      self.postMessage({ visibleItems })
    }
    

本站部分内容来自互联网,一切版权均归源网站或源作者所有。

如果侵犯了你的权益请来信告知我们删除。邮箱:cc@cccx.cn

前端川

前端川,陈川的代码茶馆🍵,专治各种不服的Bug退散符💻,日常贩卖秃头警告级的开发心得🛠️,附赠一行代码笑十年的摸鱼宝典🐟,偶尔掉落咖啡杯里泡开的像素级浪漫☕。‌