【TS】虚拟列表无渲染逻辑内核

VirtualCore

虚拟列表无渲染逻辑内核,专注于位置计算与状态管理,不绑定任何 UI 框架。

特性

  • 框架无关:纯 TypeScript 实现,可配合 Vue、React、原生 JS 等任意框架使用
  • 动态高度:支持每行不同高度,实测后自动更新位置信息
  • 滚动锚定:高度变化时自动计算滚动补偿,避免内容跳动
  • 精确跳转:迭代收敛式跳转算法,确保准确定位到目标行
  • 动态数据:支持运行时增减列表总数

快速开始

typescript 复制代码
import VirtualCore from './VirtualCore'

// 1. 创建实例
const core = new VirtualCore({
  total: 1000,           // 列表总数
  defaultHeight: 50,     // 预估行高
  buffer: 5,             // 上下缓冲行数(可选,默认 5)
  onTotalHeightChange: (height) => {
    // 更新容器总高度
    container.style.height = `${height}px`
  }
})

// 2. 滚动时获取渲染范围
container.addEventListener('scroll', () => {
  const { startIndex, endIndex, offset } = core.getRenderRange(
    container.scrollTop,
    container.clientHeight
  )
  
  // 渲染 startIndex ~ endIndex 范围的数据
  // offset 是列表容器的 translateY 偏移量
})

// 3. 元素渲染后更新真实高度
const updates = renderedItems.map(item => ({
  index: item.index,
  height: item.element.offsetHeight
}))

const { scrollCorrection } = core.updateHeights(updates, container.scrollTop)

// 应用滚动补偿,防止内容跳动
if (scrollCorrection !== 0) {
  container.scrollTop += scrollCorrection
}

API

构造函数

typescript 复制代码
new VirtualCore(config: VirtualCoreConfig)
参数 类型 必填 默认值 说明
total number - 列表总行数
defaultHeight number - 预估默认行高
buffer number 5 可视区域外的缓冲行数
onTotalHeightChange (height: number) => void - 总高度变化回调

核心方法

getRenderRange(scrollTop, viewHeight): RenderRange

根据滚动位置计算需要渲染的行范围。

typescript 复制代码
const range = core.getRenderRange(scrollTop, viewHeight)

// 返回值
{
  startIndex: number   // 渲染起始索引
  endIndex: number     // 渲染结束索引
  offset: number       // 列表容器的 Y 轴偏移量
  anchorIndex: number  // 锚点索引(可视区域第一行)
}
updateHeights(updates, currentScrollTop): UpdateCorrection

批量更新行高,返回滚动补偿值。

typescript 复制代码
const { scrollCorrection } = core.updateHeights([
  { index: 0, height: 80 },
  { index: 1, height: 60 }
], container.scrollTop)

// 应用补偿
container.scrollTop += scrollCorrection
scrollToIndex(index, callbacks, options?)

迭代收敛式精确跳转到指定行。

typescript 复制代码
core.scrollToIndex(100, {
  onScroll: (targetTop) => {
    // 执行滚动,可返回 Promise
    container.scrollTo({ top: targetTop, behavior: 'smooth' })
    return new Promise(resolve => {
      setTimeout(resolve, 300) // 等待动画完成
    })
  },
  onComplete: (finalTop, iterations) => {
    console.log(`跳转完成,经过 ${iterations} 次迭代`)
  },
  onAbort: (reason) => {
    console.log(`跳转中断: ${reason}`)
  }
}, {
  threshold: 10,      // 收敛阈值(可选)
  maxIterations: 10   // 最大迭代次数(可选)
})

辅助方法

方法 说明
getTotalHeight() 获取列表总高度
getTopByIndex(index) 获取指定行的 top 值
getItemPosition(index) 获取指定行的完整位置信息
setTotal(newTotal) 动态设置列表总数
getTotal() 获取当前列表总数
reset(newTotal?) 重置所有位置信息
abortScrollTo() 手动中断跳转
isScrolling() 检查是否正在跳转中

使用示例

Vue 3 组合式 API

vue 复制代码
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import VirtualCore from './VirtualCore'

const props = defineProps<{ items: any[] }>()

const containerRef = ref<HTMLElement>()
const scrollTop = ref(0)
const totalHeight = ref(0)

const core = new VirtualCore({
  total: props.items.length,
  defaultHeight: 50,
  onTotalHeightChange: (h) => { totalHeight.value = h }
})

const renderRange = computed(() => 
  core.getRenderRange(scrollTop.value, containerRef.value?.clientHeight || 0)
)

const visibleItems = computed(() => {
  const { startIndex, endIndex } = renderRange.value
  return props.items.slice(startIndex, endIndex + 1).map((item, i) => ({
    ...item,
    _index: startIndex + i
  }))
})

function onScroll(e: Event) {
  scrollTop.value = (e.target as HTMLElement).scrollTop
}

// 渲染后更新高度
function updateItemHeights(elements: HTMLElement[]) {
  const updates = elements.map((el, i) => ({
    index: renderRange.value.startIndex + i,
    height: el.offsetHeight
  }))
  
  const { scrollCorrection } = core.updateHeights(updates, scrollTop.value)
  if (scrollCorrection && containerRef.value) {
    containerRef.value.scrollTop += scrollCorrection
  }
}
</script>

<template>
  <div ref="containerRef" class="virtual-container" @scroll="onScroll">
    <div :style="{ height: totalHeight + 'px' }">
      <div :style="{ transform: `translateY(${renderRange.offset}px)` }">
        <div v-for="item in visibleItems" :key="item._index">
          <!-- 渲染内容 -->
        </div>
      </div>
    </div>
  </div>
</template>

跳转到指定行

typescript 复制代码
function scrollToRow(index: number) {
  core.scrollToIndex(index, {
    onScroll: (top) => {
      return new Promise(resolve => {
        containerRef.value?.scrollTo({ top, behavior: 'smooth' })
        // 等待滚动动画
        setTimeout(resolve, 300)
      })
    },
    onComplete: () => {
      console.log('跳转完成')
    }
  })
}

注意事项

性能相关

  • 超大列表:超过 10 万行时,频繁的高度更新可能有性能影响
  • 批量更新 :尽量合并多次 updateHeights 调用为一次批量更新
  • 预估高度defaultHeight 越接近真实高度,初次渲染的跳动越小

回调限制

  • onTotalHeightChange 回调中不要调用 VirtualCore 的任何修改方法,避免重入问题

跳转行为

  • 跳转采用迭代收敛算法,目标行之前的元素高度确定后才能精确定位
  • 如果目标行很远且中间元素高度差异大,可能需要多次迭代
  • 可通过 maxIterations 限制最大迭代次数,避免极端情况

滚动锚定

  • 只有完全在视口上方的元素高度变化才会触发滚动补偿
  • 跨越视口边界或在视口内的元素高度变化不补偿,避免干扰用户操作

类型定义

typescript 复制代码
interface ItemPosition {
  index: number
  height: number
  top: number
  bottom: number
}

interface HeightUpdate {
  index: number
  height: number
}

interface RenderRange {
  startIndex: number
  endIndex: number
  offset: number
  anchorIndex: number
}

interface UpdateCorrection {
  scrollCorrection: number
}

interface ScrollToCallbacks {
  onScroll: (targetTop: number) => void | Promise<void>
  onComplete?: (finalTop: number, iterations: number) => void
  onAbort?: (reason: string) => void
}

interface ScrollToOptions {
  threshold?: number
  maxIterations?: number
}

源码

ts 复制代码
/**
 * 每一行位置信息的接口定义
 */
interface ItemPosition {
  index: number
  height: number
  top: number
  bottom: number
}

/**
 * 更新行高请求的接口定义
 */
interface HeightUpdate {
  index: number
  height: number
}

/**
 * 渲染范围的接口定义
 */
interface RenderRange {
  startIndex: number
  endIndex: number
  offset: number
  anchorIndex: number
}

/**
 * 高度更新返回的修正值接口
 */
interface UpdateCorrection {
  scrollCorrection: number
}

/**
 * 初始化配置接口
 */
interface VirtualCoreConfig {
  total: number
  defaultHeight: number
  buffer?: number
  onTotalHeightChange?: (totalHeight: number) => void
}

/**
 * 跳转回调接口
 */
interface ScrollToCallbacks {
  /** 需要滚动到新位置时调用,返回 Promise 表示滚动动画完成 */
  onScroll: (targetTop: number) => void | Promise<void>
  /** 跳转完成时调用 */
  onComplete?: (finalTop: number, iterations: number) => void
  /** 跳转被中断或失败时调用 */
  onAbort?: (reason: string) => void
}

/**
 * 跳转配置
 */
interface ScrollToOptions {
  /** 收敛阈值,位置差小于此值认为完成(默认为 defaultHeight) */
  threshold?: number
  /** 最大迭代次数(默认 10) */
  maxIterations?: number
}

/**
 * 跳转状态
 * - idle: 空闲
 * - scrolling: 正在执行滚动动画
 * - waiting: 滚动完成,等待高度更新
 */
type ScrollToStatus = 'idle' | 'scrolling' | 'waiting'

/**
 * 跳转上下文(内部使用)
 */
interface ScrollToContext {
  targetIndex: number
  /** 上一次迭代时目标索引的 top 值 */
  lastTop: number
  /** 当前迭代次数 */
  iterations: number
  maxIterations: number
  threshold: number
  callbacks: ScrollToCallbacks
  status: ScrollToStatus
  /** 在 scrolling 状态期间是否收到了高度更新 */
  pendingHeightUpdate: boolean
}

/**
 * VirtualCore: 虚拟列表无渲染逻辑内核 (TypeScript 版)
 *
 * 功能:
 * 1. 维护每一行的位置信息 (top, bottom, height)
 * 2. 根据滚动位置计算需要渲染的行范围
 * 3. 支持动态高度更新,并提供滚动锚定
 * 4. 支持动态调整列表总数
 * 5. 迭代收敛式精确跳转
 *
 * 注意事项:
 * - onTotalHeightChange 回调中不应该调用 VirtualCore 的任何修改方法,避免重入问题
 * - 对于超大列表(> 10万行),频繁的高度更新可能会有性能影响
 */
class VirtualCore {
  private total: number
  private defaultHeight: number
  private buffer: number
  private onTotalHeightChange?: (totalHeight: number) => void

  private positions: ItemPosition[] = []

  /** 跳转上下文 */
  private scrollToCtx: ScrollToContext | null = null

  constructor(config: VirtualCoreConfig) {
    this.total = config.total
    this.defaultHeight = config.defaultHeight
    this.buffer = config.buffer ?? 5
    this.onTotalHeightChange = config.onTotalHeightChange

    this._initPositions()
  }

  // ========================
  // 基础方法
  // ========================

  /**
   * 初始化位置表,预估初始高度
   */
  private _initPositions(): void {
    this.positions = []
    this.positions.length = this.total

    for (let i = 0; i < this.total; i++) {
      this.positions[i] = {
        index: i,
        height: this.defaultHeight,
        top: i * this.defaultHeight,
        bottom: (i + 1) * this.defaultHeight
      }
    }
    this._notifyHeightChange()
  }

  /**
   * 获取当前总高度
   */
  public getTotalHeight(): number {
    if (this.total === 0 || this.positions.length === 0) {
      return 0
    }
    const lastIndex = this.total - 1
    if (lastIndex >= this.positions.length) {
      return 0
    }
    return this.positions[lastIndex].bottom
  }

  /**
   * 获取渲染区间及偏移量 (核心用于 UI 渲染)
   */
  public getRenderRange(scrollTop: number, viewHeight: number): RenderRange {
    if (this.total === 0) {
      return {
        startIndex: 0,
        endIndex: -1,
        offset: 0,
        anchorIndex: 0
      }
    }

    const anchorIndex = Math.min(this._findStartIndex(scrollTop), this.total - 1)
    const startIndex = Math.max(0, anchorIndex - this.buffer)

    const endAnchor = this._findStartIndex(scrollTop + viewHeight)
    const endIndex = Math.min(this.total - 1, endAnchor + this.buffer)

    const offset = this.positions[startIndex].top

    return {
      startIndex,
      endIndex,
      offset,
      anchorIndex
    }
  }

  /**
   * 获取指定索引的 top 值(仅查询,不触发跳转)
   */
  public getTopByIndex(index: number): number {
    if (index < 0) return 0
    if (index >= this.total) return this.getTotalHeight()
    return this.positions[index].top
  }

  // ========================
  // 高度更新
  // ========================

  /**
   * 更新行高并返回修正值
   *
   * @param updates 实测到的真实高度数据集合
   * @param currentScrollTop 容器当前的滚动位置
   */
  public updateHeights(updates: HeightUpdate[], currentScrollTop: number): UpdateCorrection {
    if (updates.length === 0) {
      return { scrollCorrection: 0 }
    }

    const validUpdates = updates.filter(({ index }) => index >= 0 && index < this.total)

    if (validUpdates.length === 0) {
      return { scrollCorrection: 0 }
    }

    validUpdates.sort((a, b) => a.index - b.index)

    let scrollCorrection = 0
    let hasHeightChanged = false
    let firstChangedIndex = this.total

    validUpdates.forEach(({ index, height }) => {
      const item = this.positions[index]
      const oldHeight = item.height
      const diff = height - oldHeight

      if (diff !== 0) {
        hasHeightChanged = true

        if (index < firstChangedIndex) {
          firstChangedIndex = index
        }

        // 改进的滚动锚定逻辑
        // 只有当元素完全在视口上方时才进行补偿
        const itemTop = item.top
        if (itemTop + oldHeight <= currentScrollTop) {
          // 元素完全在视口上方
          scrollCorrection += diff
        }
        // 元素跨越视口边界或在视口内/下方,不补偿

        item.height = height
      }
    })

    if (hasHeightChanged) {
      this._recalculatePositions(firstChangedIndex)
      this._notifyHeightChange()

      // 标记有高度更新待处理
      if (this.scrollToCtx) {
        if (this.scrollToCtx.status === 'scrolling') {
          // 滚动进行中收到高度更新,标记待处理
          this.scrollToCtx.pendingHeightUpdate = true
        } else if (this.scrollToCtx.status === 'waiting') {
          // 已经在等待状态,直接检查收敛
          this._checkScrollToConvergence()
        }
      }
    }

    return { scrollCorrection }
  }

  // ========================
  // 迭代收敛式跳转
  // ========================

  /**
   * 开始跳转到指定索引(迭代收敛式)
   *
   * @param index 目标行索引
   * @param callbacks 回调函数
   * @param options 配置选项
   */
  public scrollToIndex(index: number, callbacks: ScrollToCallbacks, options?: ScrollToOptions): void {
    // 边界处理
    if (index < 0) index = 0
    if (index >= this.total) index = Math.max(0, this.total - 1)

    // 如果列表为空,直接完成
    if (this.total === 0) {
      callbacks.onComplete?.(0, 0)
      return
    }

    // 如果已有跳转进行中,先中断
    if (this.scrollToCtx) {
      this._abortScrollTo('new scroll requested')
    }

    const threshold = options?.threshold ?? this.defaultHeight
    const maxIterations = options?.maxIterations ?? 10
    const targetTop = this.positions[index].top

    // 初始化跳转上下文
    this.scrollToCtx = {
      targetIndex: index,
      lastTop: targetTop, // 记录初始目标位置
      iterations: 0,
      maxIterations,
      threshold,
      callbacks,
      status: 'idle',
      pendingHeightUpdate: false
    }

    // 触发第一次滚动
    this._doScroll(targetTop)
  }

  /**
   * 手动中断当前跳转
   */
  public abortScrollTo(): void {
    this._abortScrollTo('manually aborted')
  }

  /**
   * 检查是否正在跳转中
   */
  public isScrolling(): boolean {
    return this.scrollToCtx !== null
  }

  /**
   * 通知滚动动画完成(外部调用)
   *
   * 当外部滚动动画完成后,应调用此方法通知 VirtualCore
   * 这样可以在下一次 updateHeights 时检查收敛
   */
  public notifyScrollComplete(): void {
    if (!this.scrollToCtx || this.scrollToCtx.status !== 'scrolling') {
      return
    }

    // 处理竞态:检查是否有待处理的高度更新
    this.scrollToCtx.pendingHeightUpdate = false
    this.scrollToCtx.status = 'waiting'

    // [修复1] 每次滚动完成后都检查收敛
    this._checkScrollToConvergence()
  }

  /**
   * 执行滚动
   */
  private _doScroll(targetTop: number): void {
    if (!this.scrollToCtx) return

    this.scrollToCtx.status = 'scrolling'
    this.scrollToCtx.iterations++
    this.scrollToCtx.pendingHeightUpdate = false // 重置待处理标记

    // 保存当前上下文引用,用于在回调中判断上下文是否已变化
    const currentCtx = this.scrollToCtx

    const result = currentCtx.callbacks.onScroll(targetTop)

    // 统一处理同步和异步情况
    if (result instanceof Promise) {
      // 异步情况:等待 Promise 完成后通知
      result
        .then(() => {
          // 只有上下文未变化时才处理,防止误操作新的跳转
          if (this.scrollToCtx === currentCtx) {
            this.notifyScrollComplete()
          }
        })
        .catch(() => {
          // 同样检查上下文
          if (this.scrollToCtx === currentCtx) {
            this._abortScrollTo('scroll failed')
          }
        })
    } else {
      // 同步情况:使用 microtask 延迟通知
      queueMicrotask(() => {
        // 检查上下文是否仍然是同一个,且状态仍为 scrolling
        if (this.scrollToCtx === currentCtx && currentCtx.status === 'scrolling') {
          this.notifyScrollComplete()
        }
      })
    }
  }

  /**
   * 检查跳转是否收敛
   */
  private _checkScrollToConvergence(): void {
    const ctx = this.scrollToCtx
    if (!ctx || ctx.status !== 'waiting') return

    const currentTop = this.positions[ctx.targetIndex].top
    const diff = Math.abs(currentTop - ctx.lastTop)

    // 检查是否收敛:位置变化小于阈值
    if (diff <= ctx.threshold) {
      // 收敛完成
      const finalTop = currentTop
      const iterations = ctx.iterations
      const callbacks = ctx.callbacks

      this.scrollToCtx = null
      callbacks.onComplete?.(finalTop, iterations)
      return
    }

    // 检查是否超过最大迭代次数
    if (ctx.iterations >= ctx.maxIterations) {
      this._abortScrollTo(`max iterations (${ctx.maxIterations}) reached`)
      return
    }

    // 继续迭代:更新 lastTop 并再次滚动
    ctx.lastTop = currentTop
    this._doScroll(currentTop)
  }

  /**
   * 中断跳转
   */
  private _abortScrollTo(reason: string): void {
    if (!this.scrollToCtx) return

    const callbacks = this.scrollToCtx.callbacks
    this.scrollToCtx = null
    callbacks.onAbort?.(reason)
  }

  // ========================
  // 列表管理
  // ========================

  /**
   * 动态设置列表总数
   */
  public setTotal(newTotal: number): void {
    if (newTotal < 0) {
      newTotal = 0
    }

    if (newTotal === this.total) return

    // 如果正在跳转,检查目标是否还有效
    if (this.scrollToCtx && this.scrollToCtx.targetIndex >= newTotal) {
      this._abortScrollTo('target index out of range after setTotal')
    }

    if (newTotal > this.total) {
      const oldTotal = this.total
      const lastBottom = oldTotal > 0 && this.positions.length > 0 ? this.positions[oldTotal - 1].bottom : 0

      // 预先扩展数组长度
      this.positions.length = newTotal

      for (let i = oldTotal; i < newTotal; i++) {
        this.positions[i] = {
          index: i,
          height: this.defaultHeight,
          top: lastBottom + (i - oldTotal) * this.defaultHeight,
          bottom: lastBottom + (i - oldTotal + 1) * this.defaultHeight
        }
      }
    } else {
      this.positions.length = newTotal
    }

    this.total = newTotal
    this._notifyHeightChange()
  }

  /**
   * 获取当前列表总数
   */
  public getTotal(): number {
    return this.total
  }

  /**
   * 获取指定索引的位置信息
   * 返回深拷贝,防止外部修改内部状态
   */
  public getItemPosition(index: number): ItemPosition | null {
    if (index < 0 || index >= this.total) return null
    return { ...this.positions[index] }
  }

  /**
   * 重置所有位置信息
   */
  public reset(newTotal?: number): void {
    // 中断进行中的跳转
    if (this.scrollToCtx) {
      this._abortScrollTo('reset called')
    }

    if (newTotal !== undefined) {
      this.total = newTotal < 0 ? 0 : newTotal
    }
    this._initPositions()
  }

  /**
   * 获取当前默认行高
   */
  public getDefaultHeight(): number {
    return this.defaultHeight
  }

  // ========================
  // 私有工具方法
  // ========================

  /**
   * 从指定索引开始,重新计算所有后续行的 top 和 bottom
   */
  private _recalculatePositions(fromIndex: number): void {
    for (let i = fromIndex; i < this.total; i++) {
      if (i === 0) {
        this.positions[i].top = 0
      } else {
        this.positions[i].top = this.positions[i - 1].bottom
      }
      this.positions[i].bottom = this.positions[i].top + this.positions[i].height
    }
  }

  /**
   * 二分查找:找到第一个 bottom > scrollTop 的索引
   * 当 scrollTop 超出范围时返回 this.total
   */
  private _findStartIndex(scrollTop: number): number {
    if (this.positions.length === 0) return 0

    const lastIndex = this.positions.length - 1

    if (scrollTop <= 0) return 0

    if (scrollTop >= this.positions[lastIndex].bottom) {
      return this.total
    }

    let low = 0
    let high = lastIndex

    while (low <= high) {
      const mid = Math.floor((low + high) / 2)
      const midBottom = this.positions[mid].bottom

      if (midBottom > scrollTop) {
        if (mid === 0 || this.positions[mid - 1].bottom <= scrollTop) {
          return mid
        }
        high = mid - 1
      } else {
        low = mid + 1
      }
    }

    return 0
  }

  /**
   * 通知外部总高度发生变化
   */
  private _notifyHeightChange(): void {
    if (this.onTotalHeightChange) {
      this.onTotalHeightChange(this.getTotalHeight())
    }
  }
}

export default VirtualCore
export type { ItemPosition, HeightUpdate, RenderRange, UpdateCorrection, VirtualCoreConfig, ScrollToCallbacks, ScrollToOptions }
相关推荐
Lupino24 分钟前
被 React “玩弄”的 24 小时:为了修一个不存在的 Bug,我给大模型送了顿火锅钱
前端·react.js
米丘31 分钟前
了解 Javascript 模块化,更好地掌握 Vite 、Webpack、Rollup 等打包工具
前端
Heo32 分钟前
深入 React19 Diff 算法
前端·javascript·面试
滕青山34 分钟前
个人所得税计算器 在线工具核心JS实现
前端·javascript·vue.js
小怪点点34 分钟前
手写promise
前端·promise
国思RDIF框架43 分钟前
RDIFramework.NET Web 敏捷开发框架 V6.3 发布 (.NET8+、Framework 双引擎)
前端
Mintopia44 分钟前
如何在有限的时间里,活出几倍的人生
前端
炫饭第一名1 小时前
速通Canvas指北🦮——变形、渐变与阴影篇
前端·javascript·程序员
Neptune11 小时前
让我带你迅速吃透React组件通信:从入门到精通(上篇)
前端·javascript
阿懂在掘金1 小时前
Vue 表单避坑(一):为什么 v-model 绑定对象属性会偷偷修改父组件数据?
前端·vue.js