Back:高性能渲染十万条数据

定高:item 高度固定且已知

代码

export interface VirtualListOpts<T> {
  dataSource: T[] // 数据源
  itemHeight: number // 固定 item 高度
  containerSelector: string | HTMLElement // 容器 DOM
  listSelector: string | HTMLElement // 列表 DOM
  getRenderDom: (item: T) => HTMLElement // 渲染列表项的 DOM
  loadMoreThreshold?: number // 加载更多的阈值, 单位:项,默认为 0, 即滚动到最后 1 项才会触发
  loadMore?: () => any // 加载更多
}
 
export default class VirtualList<T> {
  private _dataSource: VirtualListOpts<T>['dataSource'] // 数据源
  private _itemHeight: VirtualListOpts<T>['itemHeight'] // 固定 item 高度
  private _containerDOM: HTMLElement // 容器 DOM
  private _listDOM: HTMLElement // 列表 DOM
  private _getRenderDom: VirtualListOpts<T>['getRenderDom'] // 渲染列表项的 DOM
  private _loadMoreThreshold: number // 加载更多的阈值
  private _loadMore: VirtualListOpts<T>['loadMore'] // 加载更多
  private _containerHeight: number // 容器高度
  private _maxCount: number // 虚拟列表视图最大容纳量
 
  private _lastStartIdx = 0 // 上一次渲染时的起始索引
  private _startIdx = 0 // 当前视图列表在数据源中的起始索引
  private get _endIdx(): number {
    // 当前视图列表在数据源中的末尾索引
    return Math.min(this._startIdx + this._maxCount, this._dataSource.length)
  }
  private get _renderList(): T[] {
    // 渲染在视图上的列表项
    return this._dataSource.slice(this._startIdx, this._endIdx)
  }
  private get _listStyle(): Record<string, string> {
    // 列表需要动态计算的样式
    const { _dataSource, _startIdx, _endIdx, _itemHeight } = this
    // 注意这里 height 的计算,transform 的偏移量也会变相增加滚动的长度,
    // 因此在滚动时 height 需要减去偏移量,否则在滚动时滚动条会不断变小(相当于内容不断增多),
    // 而多余的内容都是偏移量造成的留白
    return {
      height: `${_dataSource.length * _itemHeight - _startIdx * _itemHeight}px`,
      transform: `translateY(${_startIdx * _itemHeight}px)`,
    }
  }
 
  constructor(opts: VirtualListOpts<T>) {
    const { dataSource, itemHeight, containerSelector, listSelector, getRenderDom, loadMoreThreshold, loadMore } = opts
    const containerDOM = typeof containerSelector === 'string' ? (document.querySelector(containerSelector) as HTMLElement) : containerSelector
    const listDOM = typeof listSelector === 'string' ? (document.querySelector(listSelector) as HTMLElement) : listSelector
    if (!containerDOM || !listDOM) throw new Error('containerDOM or listDOM is not found')
 
    // 初始化数据
    this._dataSource = dataSource
    this._itemHeight = itemHeight
    this._containerDOM = containerDOM
    this._listDOM = listDOM
    this._getRenderDom = getRenderDom
    this._loadMoreThreshold = loadMoreThreshold || 0
    this._loadMore = loadMore
    this._containerHeight = containerDOM.offsetHeight
    // +1 是为了无论滚动到哪都能填充满 container
    this._maxCount = Math.ceil(this._containerHeight / this._itemHeight) + 1
 
    // 初始化渲染
    this._containerDOM.addEventListener('scroll', this._onScroll)
    this._render()
  }
 
  destroy() {
    this._containerDOM.removeEventListener('scroll', this._onScroll)
    this._listDOM.innerHTML = ''
    this._containerDOM = null as any
    this._listDOM = null as any
  }
 
  /**
   * 虚拟列表视图最大容纳量
   */
  get maxCount(): number {
    return this._maxCount
  }
 
  private _onScroll = () => {
    const { scrollTop } = this._containerDOM
    this._startIdx = Math.floor(scrollTop / this._itemHeight)
    if (this._startIdx !== this._lastStartIdx) {
      // 性能优化:只在起始索引变化时重新渲染
      this._render()
      if (this._endIdx >= this._dataSource.length - this._loadMoreThreshold) {
        this._loadMore?.()
      }
    }
    this._lastStartIdx = this._startIdx
  }
 
  private _render() {
    const items = this._renderList.map(this._getRenderDom)
    const fragment = document.createDocumentFragment()
    for (const item of items) {
      fragment.appendChild(item)
    }
    this._listDOM.innerHTML = ''
    this._listDOM.appendChild(fragment)
    for (const key in this._listStyle) {
      if (Object.prototype.hasOwnProperty.call(this._listStyle, key)) {
        this._listDOM.style[key] = this._listStyle[key]
      }
    }
  }
}
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
    <style>
      .container {
        width: 600px;
        height: 600px;
        margin: 100px auto;
        border: 1px solid red;
      }
 
      .VirtualList-container {
        width: 100%;
        height: 100%; /* 需要设置具体高度 */
        overflow-y: auto; /* 需要保证垂直方向有滚动条 */
      }
 
      .VirtualList-list {
        width: 100%;
        /* height 和 transform 通过 JS 动态计算 */
      }
 
      .VirtualList-item {
        width: 100%;
        height: 100px;
        box-sizing: border-box;
        border: 1px solid #000;
        text-align: center;
        font-size: 20px;
        line-height: 100px;
      }
    </style>
  </head>
  <body>
    <div class="container">
      <div class="VirtualList-container">
        <div class="VirtualList-list">
          <!-- <div class="VirtualList-item"></div> -->
          <!-- VirtualList-item 通过 JS 动态插入 -->
        </div>
      </div>
    </div>
    <script type="module">
      import VirtualList from './VirtualList.js'
 
      const dataSource = Array.from({ length: 20 }, (_, i) => `item ${i + 1}`)
      new VirtualList({
        dataSource,
        itemHeight: 100,
        containerSelector: '.VirtualList-container',
        listSelector: '.VirtualList-list',
        getRenderDom: item => {
          const div = document.createElement('div')
          div.className = 'VirtualList-item'
          div.innerHTML = item
          return div
        },
        loadMoreThreshold: 3,
        loadMore: () => {
          for (let i = 0; i < 20; i++) {
            dataSource.push(`item ${dataSource.length + 1}`)
          }
        },
      })
    </script>
  </body>
</html>

优化

设立缓冲区来缓解白屏问题

参考

面试官:能否用原生JS手写一个虚拟列表…啊?你还真能写啊?