Говоря о принципе реализации виртуального списка

внешний интерфейс контейнер Chrome React.js

существуетОптимизация дисплея данных спискаВ этой статье упоминается рендеринг отображения данных в виде списка по требованию. Этот метод относится к отображению определенной части данных длинного списка в соответствии с высотой элемента контейнера и высотой элемента элемента списка, а не полного отображения длинного списка, чтобы улучшить производительность бесконечной прокрутки. Реализация схемы отображения по запросу — виртуальный список, упомянутый в заголовке этой статьи.

Существуют различные схемы реализации виртуальных списков, в данной статье используютсяreact-virtual-listкомпонентный анализ

Что такое виртуальный список?

Перед основным текстом дадим простое определение виртуального списка.

Согласно вышеизложенному, виртуальный список является реализацией идеи отображения по требованию, т. е.Виртуальный список — это метод рендеринга части длинного списка данных на основе видимой области прокручиваемого элемента контейнера.

Короче говоря, виртуальный список относится к списку «рендеринга видимой области». Есть три понятия, которые нужно понять:

  • элемент контейнера прокрутки: Как правило, элементы контейнера с прокруткойwindowобъект. Однако мы можем произвольно указать один или несколько элементов контейнера прокрутки на странице с помощью макета. Пока элемент может прокручиваться по горизонтали или вертикали внутри, этот элемент является элементом контейнера с прокруткой, учитывая, что каждый элемент списка просто отображает некоторый обычный текст. В этой статье обсуждается только вертикальная прокрутка элементов.
  • прокручиваемая область: прокручивает внутреннюю область содержимого элемента-контейнера. Предположим, что имеется 100 фрагментов данных, а высота каждого элемента списка равна 50, тогда высота прокручиваемой области составляет 100*50. Текущее конкретное значение высоты прокручиваемой области обычно может быть передано через элемент (контейнер прокрутки).scrollHeightприобретение имущества. Пользователь может прокручивать, чтобы изменить отображаемую часть списка в видимой области.
  • Видимая область: визуально видимая область элемента контейнера прокрутки. если элемент контейнераwindowобъект, видимая область - это размер области просмотра браузера (т.е.визуальное окно просмотра); если элемент-контейнер являетсяdivЭлемент, его высота 300, справа есть вертикальная полоса прокрутки для прокрутки, тогда визуально видимая область - это видимая область.

Реализация виртуального списка заключается в изменении отображаемой части списка в видимой области при прокрутке пользователем. Конкретные шаги заключаются в следующем:

  • Вычислить startIndex начальных данных текущей видимой области
  • Вычислить endIndex конечных данных текущей видимой области
  • Рассчитать данные текущей видимой области и отобразить их на странице
  • Вычислить позицию смещения startOffset данных, соответствующих startIndex во всем списке, и установить его в список
  • Вычислите позицию смещения endOffset данных, соответствующих endIndex, относительно нижней части прокручиваемой области и установите ее в список

Рекомендации относятся к следующей таблице, чтобы понять, какие шаги описаны выше:

步骤图

элемент L относится к последнему элементу в текущем списке

Как видно из приведенного выше рисунка,startOffsetа такжеendOffsetРастянет высоту содержимого элемента-контейнера, позволяя ему непрерывно прокручиваться; кроме того, он будет удерживать полосу прокрутки в правильном положении.

Зачем нужен фиктивный список?

Виртуальные списки — это оптимизация длинных списков. В разработке интерфейса вы столкнетесь с некоторыми бизнес-формами, которые не могут использовать разбиение на страницы для загрузки данных списка.Мы называем этот список длинным списком. Например, в некоторых системах торговли иностранной валютой внешний интерфейс будет отображать позицию пользователя (прибыль, убыток, размер лота и т. д.) в квазиреальном времени.В настоящее время список позиций пользователя, как правило, не может быть разбит на страницы.

В этой статье мы определяем длинный список как список, длина данных которого превышает 999 и не может отображаться в разбиении на страницы.

Если длинные списки не оптимизированы, сколько времени потребуется, чтобы полностью отобразить длинный список? Далее я напишу простую демонстрацию, чтобы проверить следующее.

Тестовая среда этой демонстрации: Macbook Pro (Core i7 2.2G, 16G), Chrome 69, React 16.4.1.

В демоверсии давайте сначала измерим, сколько времени требуется браузеру для отображения 10 000 простых узлов:

import React from 'react'

const count = 10000

function createMarkup (doms) {
  return doms.length ? { __html: doms.join(' ') } : { __html: '' }
}

export default class DOM extends React.Component {
  constructor (props) {
    super(props)
    this.state = {
      simpleDOMs: []
    }

    this.onCreateSimpleDOMs = this.onCreateSimpleDOMs.bind(this)
  }

  onCreateSimpleDOMs () {
    const array = []

    for (var i = 0; i < count; i++) {
      array.push('<div>' + i + '</div>')
    }

    this.setState({
      simpleDOMs: array
    })
  }

  render () {
    return (
      <div style={{ marginLeft: '10px' }}>
        <h3>Creat large of DOMs:</h3>
        <button onClick={this.onCreateSimpleDOMs}>Create Simple DOMs</button>
        <div dangerouslySetInnerHTML={createMarkup(this.state.simpleDOMs)} />
      </div>
    )
  }
}

Когда кнопка нажата, она будет вызванаonCreateSimpleDOMsСоздайте 10000 простых узлов. Данные, видимые на вкладке «Производительность» Chrome, выглядят следующим образом:

simple doms

Как видно из рисунка выше, всего от Event Click до Paint прошло около 693 мс, основные затраты времени при рендеринге следующие:

  • Стиль пересчета: 40,80 мс
  • Макет: 518,55 мс
  • Обновление дерева слоев: 11,84 мс

На этапах пересчета стиля и макета ReactDOM вызываетsetInnerHTMLметод, который внутренне в основном проходитinnerHTMLметод добавления созданного html-фрагмента в соответствующий узел

Затем мы создаем 10 000 более сложных узлов. Измените компоненты следующим образом:

import React from 'react'

function createMarkup (doms) {
  return doms.length ? { __html: doms.join(' ') } : { __html: '' }
}

export default class DOM extends React.Component {
  constructor (props) {
    super(props)
    this.state = {
      complexDOMs: []
    }

    this.onCreateComplexDOMs = this.onCreateComplexDOMs.bind(this)
  }

  onCreateComplexDOMs () {
    const array = []
    for (var i = 0; i < 5000; i++) {
      array.push(`
        <div class='list-item'>
          <p>#${i} eligendi voluptatem quisquam</p>
          <p>Modi autem fugiat maiores. Doloremque est sed quis qui nobis. Accusamus dolorem aspernatur sed rem.</p>
        </div>
      `)
    }

    this.setState({
      complexDOMs: array
    })
  }

  render () {
    return (
      <div style={{ marginLeft: '10px' }}>
        <h3>Creat large of DOMs:</h3>
        <button onClick={this.onCreateComplexDOMs}>Create Complex DOMs</button>
        <div dangerouslySetInnerHTML={createMarkup(this.state.complexDOMs)} />
      </div>
    )
  }
}

Когда кнопка нажата, она будет вызванаonCreateComplexDOMs. Данные, видимые на вкладке «Производительность» Chrome, выглядят следующим образом:

complex doms

Как видно из рисунка выше, всего от Event Click до Paint прошло около 964,2 мс, основные затраты времени при рендеринге следующие:

  • Стиль пересчета: 117,07 мс
  • Макет: 538,00 мс
  • Обновление дерева слоев: 31,15 мс

Для каждого из вышеперечисленных тестов 5 раз, а затем взять среднее значение каждого показателя, статистические результаты следующие:

- Recalculate Style Layout Update Layer Tree Total
Отрисовка простых узлов 199.66ms 523.72ms 12.572ms 735.952ms
Рендер сложные узлы 114.684ms 806.05ms 31.328ms 952.512ms
  1. Total = Recalculate Style + Layout + Update Layer Tree
  2. Тестовый код демо:test code

Как видно из приведенных выше результатов теста, для рендеринга 10 000 узлов требуется 700 мс+.В реальном бизнес-списке на каждый узел нужно около 20 узлов.Макет будет намного сложнее, и потребуется больше времени в Recalculate Style и Recalculate Style. Этапы верстки.долгое время. Затем 700 мс могут отображать только от 300 до 500 элементов списка, поэтому для полного отображения длинного списка в принципе сложно удовлетворить бизнес-требованиям. Вместо полного рендеринга длинного списка обычно есть два способа: рендеринг по запросу и отложенный рендеринг (т.е. ленивый рендеринг). Обычная бесконечная прокрутка — это реализация отложенного рендеринга, а виртуальный список — это реализация рендеринга по запросу.

Отложенный рендеринг выходит за рамки этой статьи. Далее в этой статье будет кратко представлена ​​реализация виртуального списка.

выполнить

В этой главе будет созданVirtualizedListКомпоненты и в сочетании с кодом медленно разбираются в реализации виртуального списка.

Для простоты положимwindowЧтобы прокрутить элемент контейнера, дайтеhtmlа такжеbodyэлементы добавляются с правилами стиляheight: 100%, задайте для области просмотра размер окна браузера.VirtualizedListбудет ссылаться на макет элементов DOMТвиттер для мобильных устройств:

class VirtualizedList extends Component {
  constructor (props) {
    super(props)
    
    this.state = {
      startOffset: 0,
      endOffset: 0,
      visibleData: []
    }
    
    this.data = new Array(1000).fill(true)
    this.startIndex = 0
    this.endIndex = 0
    this.scrollTop = 0
  }
  
  render () {
    const {startOffset, endOffset} = this.state
    
    return (
      <div className='wrapper'>
        <div style={{ paddingTop: `${startOffset}px`, paddingBottom: `${endOffset}px` }}>
          {
            // render list
          }
        </div>
      </div>
    )
  }
}

В реализации виртуального списка также есть два случая: элемент списка имеет фиксированную высоту и элемент списка имеет динамическую высоту.

Элементы списка имеют фиксированную высоту

Поскольку элементы списка имеют фиксированную высоту, согласовано, что высота каждого элемента списка равна 60, а длина данных списка — 1000.

Во-первых, мы оцениваем количество элементов, которые может отображать видимая область, исходя из высоты видимой области:

const height = 60
const bufferSize = 5
// ...

this.visibleCount = Math.ceil(window.clientHeight / height)

Затем рассчитайтеstartIndexа такжеendIndexИ инициализировать исходные данные для отображения:

// ...

updateVisibleData (scrollTop) {
  const visibleData = this.data.slice(this.startIndex, this.endIndex)
  const endOffset = (this.data.length - this.endIndex) * height
    
  this.setState({
    startOffset: 0,
    endOffset,
    visibleData
  })
}

componentDidMount () {
  // 计算可渲染的元素个数
  this.visibleCount = Math.ceil(window.innerHeight / height) + bufferSize
  this.endIndex = this.startIndex + this.visibleCount
  this.updateVisibleData()
}

Как указано выше,endOffsetэто рассчитатьendIndexПоложение смещения соответствующих данных относительно нижней части прокручиваемой области. В этой демонстрации высота прокручиваемой области составляет 1000 * 60, поэтомуendIndexСмещение соответствующих данных снизу равно (1000 - endIndex) * 60.

Поскольку это инициализация данных, которые должны быть отображены в первый раз, поэтомуstartOffsetНачальное значение равно 0.

В соответствии с вышеописанным кодом может быть известно, что для вычисления данных необходимо визуализировать видимую область, пока вычисляетсяstartIndexпросто отлично, потому чтоvisibleCountявляется фиксированным значением,bufferSizeЭто значение буфера, используемое для увеличения определенной области буфера, чтобы оно не было таким резким при скольжении с нормальной скоростью. а такжеendIndexзначение равноstartIndexплюсvisibleCount; В то же время, когда пользователь прокручивает, чтобы изменить данные в видимой области, ему также необходимо вычислитьstartOffsetзначение, чтобы новые данные отображались в окне просмотра браузера пользователя:

startOffset

ИсключаяstartOffsetзначение, элементы, которые должны отображаться в видимой области, будут отображаться за пределами видимой области. Как видно из приведенного выше рисунка,startOffsetЗначение является верхней границей элемента 8.(верхний элемент в видимой области)Смещение до верхней границы элемента 1. Элемент 8 называетсяЯкорный элемент, первый элемент в окне просмотра.Поэтому нам нужно определить переменную для кэширования некоторой информации о позиции элемента привязки, а также кэшировать информацию о позиции отображаемого элемента:

// ...
// 缓存已渲染元素的位置信息
this.cache = []
// 缓存锚点元素的位置信息
this.anchorItem = {
  index: 0, // 锚点元素的索引值
  top: 0, // 锚点元素的顶部距离第一个元素的顶部的偏移量(即 startOffset)
  bottom: 0 // 锚点元素的底部距离第一个元素的顶部的偏移量
}
// ...

cachePosition (node, index) {
  const rect = node.getBoundingClientRect()
  const top = rect.top + window.pageYOffset
  
  this.cache.push({
    index,
    top,
    bottom: top + height
  })
}

// ...

методcachePositionбудет выполняться после рендеринга каждого компонента элемента списка (componentDidMount) звонить,node- соответствующий элемент узла элемента списка,indexявляется значением индекса узла:

// Item.jsx

// ...
componentDidMount () {
  this.props.cachePosition(this.node, this.props.index)
}

render () {
  /* eslint-disable-next-line */
  const {index} = this.props

  return (
    <div className='list-item' ref={node => { this.node = node }}>
      <p>#${index} eligendi voluptatem quisquam</p>
      <p>Modi autem fugiat maiores. Doloremque est sed quis qui nobis. Accusamus dolorem aspernatur sed rem.</p>
    </div>
  )
}
// ...

После кэширования позиции элемента привязки и отображаемого элемента пришло время обработать поведение пользователя при прокрутке. когда пользователь прокручивает вниз (scrollTopнаправление увеличения значения) в качестве примера:

// ...
// 计算 startIndex 和 endIndex
updateBoundaryIndex (scrollTop) {
  scrollTop = scrollTop || 0
  //用户正常滚动下,根据 scrollTop 找到新的锚点元素位置
  const anchorItem = this.cache.find(item => item.bottom >= scrollTop)

  this.anchorItem = {
    ...anchorItem
  }

  this.startIndex = this.anchorItem.index
  this.endIndex = this.startIndex + this.visibleCount
}

// 滚动事件处理函数
handleScroll (e) {
  if (!this.doc) {
    // 兼容 iOS Safari/Webview
    this.doc = window.document.body.scrollTop ? window.document.body : window.document.documentElement
  }

  const scrollTop = this.doc.scrollTop
  if (scrollTop > this.scrollTop) {
    if (scrollTop > this.anchorItem.bottom) {
      this.updateBoundaryIndex(scrollTop)
      this.updateVisibleData()
    }
  } else if (scrollTop < this.scrollTop) {
    // 向上滚动(`scrollTop` 值减小的方向)
  }

  this.scrollTop = scrollTop
}
// ...

В обработчике событий прокрутки он обновитсяstartIndex,endIndexи информацию о положении нового элемента привязки (т.е. обновлениеstartOffset), а затем вы можете динамически обновлять данные рендеринга видимой области:

demo.gif

Полный код можно напечатать по адресу:Реализация виртуального списка фиксированной высоты

Элементы списка имеют динамическую высоту

В этом случае идея реализации аналогична элементам списка. Небольшая разница заключается в том, что когда информация о местоположении элемента списка кэшируется, как получить точную высоту элемента списка? первый, кто изменилcachePositionчасть логики:

// ...
cachePosition (node, index) {
  const rect = node.getBoundingClientRect()
  const top = rect.top + window.pageYOffset

  this.cache.push({
    index,
    top,
    bottom: top + rect.height // 将 height 更为 rect.height
  })
}
// ...

Поскольку высота элементов списка не фиксирована, как рассчитатьvisibleCountШерстяная ткань? мы первыеУчтите, что каждый элемент списка просто отображает некоторый обычный текст. В реальном проекте некоторые элементы списка могут иметь только одну строку текста, а некоторые элементы списка могут иметь несколько строк текста.расчетная высота:estimatedItemHeight.

Например, если имеется длинный список для рендеринга аннотации статьи пользователя, и оговорено, что аннотация не должна превышать трех строк, то в качестве предполагаемой высоты мы берем среднюю высоту первых 10 элементов списка в списке. Конечно, для более точной оценки высоты мы можем расширить выборку.

Теперь, когда у вас есть расчетная высота, заменитеheightзаменитьestimatedItemHeight, можно вычислитьvisibleCountсейчас:

// ...
const estimatedItemHeight = 80

// ...

// 计算可渲染的元素个数
this.visibleCount = Math.ceil(window.innerHeight / estimatedItemHeight) + bufferSize

// ...

мы проходимfaker.jsчтобы создать некоторые случайные данные и назначить ихdata:

// ...
function fakerData () {
  const a = []
  for (let i = 0; i < 1000; i++) {
    a.push({
      id: i,
      words: faker.lorem.words(),
      paragraphs: faker.lorem.sentences()
    })
  }

  return a
}
// ...

this.data = fakerData()

// ...

Изменить элемент спискаrenderЛогика, в остальном без изменений:

// Item.jsx

// ...

render () {
  /* eslint-disable-next-line */
  const {index, item} = this.props

  return (
    <div className='list-item' style={{ height: 'auto' }} ref={node => { this.node = node }}>
      <p>#${index} {item.words}</p>
      <p>{item.paragraphs}</p>
    </div>
  )
}
// ...

На данный момент высота элемента списка уже является динамической.В соответствии с реальной ситуацией рендеринга предполагаемая высота, которую мы даем, равна 80:

demo2.gif

Полный код можно напечатать по адресу:Реализация виртуального списка с динамической высотой

Что делать, если элемент списка не отображается как обычный текст? Например, если отрисовка графика, то в компоненте ItemcomponentDidMountзвонитьcachePositionметод, могу ли я получить правильную высоту соответствующего узла? В случае рендеринга графики, поскольку изображение инициирует сетевой запрос, не гарантируется, что компонент элемента списка будет смонтирован (выполнен).componentDidMount) при рендеринге изображения высота соответствующего узла неточна, поэтому при прокрутке пользователем для изменения отображаемых данных в видимой области элементы могут перекрывать друг друга:

error

В этом случае, если мы сможем прослушать изменение размера узла компонента Item, мы сможем получить его правильную высоту. ResizeObserver может удовлетворить наши потребности, он предоставляет возможность отслеживать размер элементов DOM, но на момент написания его поддерживает только Chrome 67 и выше, а также другие основные браузеры. Вот некоторая информация, которую я собрал для справки (принесите свою лестницу):

Суммировать

В этой статье сначала определяется виртуальный список, а затем анализируется причина необходимости виртуального списка с точки зрения длинного списка. Наконец, виртуальный список подробно описывается с помощью простой демонстрации в двух сценариях: элементы верхнего и нижнего списка.Перечислите идеи реализации.

В сцене, где элемент списка имеет динамическую высоту, анализируется сцена рендеринга простого текста и смешанной графики и текста. Первый дает конкретную демонстрацию, а второй предоставляет справочное решение ResizeObserver для отслеживания изменений размера элемента. На основе решения ResizeObserver я также реализовал компонент виртуального списка, который поддерживает рендеринг смешивания изображения и текста (конечно, он также поддерживает обычный текст).react-virtual-list,довожу до вашего сведения.

Конечно, это не единственный способ реализации виртуальных списков. в компонентеreact-virtual-listВ процессе реализации я также читал исходный код различных компонентов виртуального списка, таких как: react-tiny-virtual-list, react-window, react-virtualized и т. д. Я буду анализировать их один за другим с точки зрения исходного кода. код в последующих сериях статей.

Ссылаться на