阿里云主机折上折
  • 微信号
Current Site:Index > Implementation of virtual scrolling technology

Implementation of virtual scrolling technology

Author:Chuan Chen 阅读数:13866人阅读 分类: 性能优化

Implementation of Virtual Scrolling Technology

Virtual scrolling is a technique to optimize the rendering performance of long lists by only rendering elements within the visible area, thereby reducing the number of DOM nodes. Traditional scrolling renders all list items, which can cause severe performance issues when the data volume reaches thousands or even tens of thousands. Virtual scrolling calculates the visible range and dynamically renders and recycles DOM nodes to maintain smooth page performance.

Core Principles

The implementation of virtual scrolling relies on three key parameters: container height (clientHeight), scroll position (scrollTop), and item height (itemHeight). The start and end indices of the visible area are determined through mathematical calculations:

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

In practice, a scroll buffer (renderBuffer) must be considered. Typically, additional items are rendered above and below the visible area to avoid blank spaces during rapid scrolling. A common buffer solution:

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

Implementation Approaches

Fixed-Height Implementation

The simplest implementation is when list items have a fixed height. Assuming each item is 50px tall and the container is 500px high:

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)
    
    // Update visible items
    this.visibleItems = this.items.slice(startIndex, endIndex + 1)
    
    // Set container padding for correct scrollbar proportions
    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`
    
    // Render DOM (use document fragments for optimization in real projects)
    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)
    })
  }
}

Dynamic-Height Implementation

When list items have variable heights, a more complex approach is required. Common practices include:

  1. Measuring and caching each item's height during initial render
  2. Using binary search to quickly locate the item index corresponding to the scroll position
class DynamicVirtualScroll {
  constructor(container, items) {
    this.container = container
    this.items = items
    this.itemHeights = [] // Cache item heights
    this.totalHeight = 0
    
    container.style.overflow = 'auto'
    container.style.height = '500px'
    container.addEventListener('scroll', this.handleScroll.bind(this))
    
    this.measureItems()
    this.render()
  }

  measureItems() {
    // Create a measurement container
    const measureContainer = document.createElement('div')
    measureContainer.style.position = 'absolute'
    measureContainer.style.visibility = 'hidden'
    document.body.appendChild(measureContainer)
    
    // Measure each item's height
    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
    
    // Calculate end index
    while (endIndex < this.items.length && renderHeight < clientHeight * 2) {
      renderHeight += this.itemHeights[endIndex]
      endIndex++
    }
    
    // Render visible items
    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)
    
    // Calculate bottom 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
  }
}

Performance Optimization Techniques

  1. Use Document Fragments: Avoid frequent reflows

    const fragment = document.createDocumentFragment()
    items.forEach(item => {
      fragment.appendChild(createItem(item))
    })
    container.appendChild(fragment)
    
  2. Throttle Scrolling: Prevent excessive scroll event triggers

    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. Recycle DOM Nodes: Reuse DOM elements with object pooling

    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: Modern browser API for efficient visibility detection

    const observer = new IntersectionObserver(entries => {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          // Handle visible items
        }
      })
    }, { threshold: 0.1 })
    
    listItems.forEach(item => observer.observe(item))
    

Framework Integration Examples

React Implementation Example

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 Implementation Example

<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>

Challenges in Practical Applications

  1. Dynamic Content Loading: Special handling required when combined with infinite scrolling

    async function loadMoreItems() {
      if (isLoading) return
      
      isLoading = true
      const newItems = await fetchItems()
      items = [...items, ...newItems]
      isLoading = false
      
      // Recalculate scroll position and visible area
      recalculateLayout()
    }
    
  2. Browser Compatibility: Older browsers may require polyfills

    // Compatibility handling for scroll events
    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. Mobile Optimization: Handling touch events and inertial scrolling

    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
      
      // Custom scroll handling
      container.scrollTop -= deltaY
      e.preventDefault()
    }, { passive: false })
    
  4. Accessibility: Ensure screen reader compatibility

    <div role="list" aria-label="Virtual scrolling list">
      <div role="listitem" v-for="item in visibleItems">
        {{ item }}
      </div>
    </div>
    

Advanced Application Scenarios

Table Virtual Scrolling

Similar principles apply, but both horizontal and vertical scrolling must be handled:

class VirtualTable {
  constructor(table, rows, cols) {
    this.table = table
    this.rows = rows
    this.cols = cols
    
    // Initialize fixed headers and left columns
    this.initFixedElements()
    
    // Set up scrollable area
    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)
    
    // Synchronize vertical scrolling for fixed columns
    this.fixedColsContainer.scrollTop = scrollTop
    
    // Synchronize horizontal scrolling for headers
    this.headerContainer.scrollLeft = scrollLeft
  }

  renderVisibleCells(scrollTop, scrollLeft) {
    // Calculate visible row and column ranges
    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)
    
    // Render visible cells
    // ...
  }
}

Tree Structure Virtual Scrolling

Requires handling height calculations for expanded/collapsed states:

class VirtualTree {
  constructor(container, treeData) {
    this.container = container
    this.treeData = treeData
    this.flatNodes = this.flattenTree(treeData)
    
    // Initialize expanded state
    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 // Different heights for different levels
    })
    
    this.totalHeight = this.nodeHeights.reduce((sum, h) => sum + h, 0)
  }

  toggleExpand(nodeId) {
    const isExpanded = this.expandedState.get(nodeId)
    this.expandedState.set(nodeId, !isExpanded)
    
    // Re-flatten tree and recalculate heights
    this.flatNodes = this.flattenTree(this.treeData)
    this.calculateNodeHeights()
    
    this.render()
  }
}

Performance Monitoring and Debugging

  1. Chrome DevTools Performance Analysis:

    • Use the Performance panel to record scrolling processes
    • Check Layout, Paint, and Composite events
  2. Key Metrics Measurement:

    // Measure 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. Memory Usage Monitoring:

    // Check DOM node count
    setInterval(() => {
      console.log('DOM nodes:', document.getElementsByTagName('*').length)
    }, 1000)
    
  4. Scroll Jank Analysis Tool:

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

Modern Browser Optimization Features

  1. CSS Containment: Reduce browser reflow and repaint scope

    .virtual-item {
      contain: strict;
      content-visibility: auto;
    }
    
  2. Will-Change: Inform the browser of potential changes in advance

    .virtual-container {
      will-change: transform;
    }
    
  3. OffscreenCanvas: Rendering in Worker threads

    const offscreen = canvas.transferControlToOffscreen()
    worker.postMessage({ canvas: offscreen }, [offscreen])
    
  4. Web Workers: Move compute-intensive tasks off the main thread

    // Main thread
    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 thread
    self.onmessage = (e) => {
      const { scrollTop, clientHeight } = e.data
      // Calculate visible items...
      self.postMessage({ visibleItems })
    }
    

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

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

Front End Chuan

Front End Chuan, Chen Chuan's Code Teahouse 🍵, specializing in exorcising all kinds of stubborn bugs 💻. Daily serving baldness-warning-level development insights 🛠️, with a bonus of one-liners that'll make you laugh for ten years 🐟. Occasionally drops pixel-perfect romance brewed in a coffee cup ☕.