Метод реализации виртуальной прокрутки, понятный новичкам

JavaScript
Метод реализации виртуальной прокрутки, понятный новичкам

Эта статья посвящена принципу реализации виртуального скроллинга, понятного даже Xiaobai, пошаговому подробному сравнению и оптимизации схемы реализации, содержание понятное, но длина может быть длинной. Если вы просто хотите понять идеи реализации, вы можете сразу посмотреть на картинку или перейти к концу статьи.

Без дальнейших церемоний, давайте начнем.

Зачем нужна виртуальная прокрутка

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

截屏20200604 下午5.35.48.png

Время рендеринга составляет 4,5 секунды, а узлов DOM 550 000! ! ! Хотя производительность прокрутки в Chrome в порядке, ее следует оптимизировать. Но его нельзя открыть прямо в сафари.

Факты говорят нам, что насилие не работает, нам нужны другие способы, и некоторые люди говорят, что мы можем использовать разбиение на страницы. Но в некоторых сценариях (или под давлением некоторых менеджеров по продуктам), таких как списки контактов, списки чатов, нам по-прежнему необходимо использовать взаимодействия с прокруткой.

И даже для списков нормального размера (сотни или тысячи) использование виртуальной прокрутки заметно улучшает производительность страницы.

И в GoogleРекомендации по развитию маяканаписал:

  • Have more than 1,500 nodes total.
  • Have a depth greater than 32 nodes.
  • Have a parent node with more than 60 child nodes.

Это означает, что слишком много узлов DOM повлияет на производительность страницы, и указано рекомендуемое максимальное количество узлов.

Основная идея

Что ж, после определения того, что нам нужно оптимизировать производительность рендеринга длинных списков, следующим шагом будет, как это сделать?

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

Рендеринг по требованию

Наш объем данных огромен, но мы можем видеть только дюжину или две дюжины одновременно, так зачем рендерить их все сразу?Когда мы рендерим только те DOM-узлы, которые можем видеть одновременно, браузеру нужно рендерить очень-очень мало узлов, что значительно сокращает время рендеринга!

xxxr.jpg

Как показано выше, мы визуализируем только те элементы 3, 4, 5 и 6, которые видны в видимой области, а остальные не будут визуализироваться.

Имитация прокрутки

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

Когда промежуток времени операции достаточно мал, это выглядит как прокрутка.

mngd.jpg

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

Код

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

Затем реализуем первую идею: рендеринг по запросу. Мы визуализируем только элементы, видимые в окне просмотра, здесь есть несколько проблем:

  1. Сколько элементов списка может отображать окно просмотра?

Мы уже знаем высоту окна просмотра (высоту родительского элемента).Предполагая, что смещение равно 0, мы начинаем рендеринг с первого элемента, так сколько элементов списка он может вместить? Здесь нам нужно установить высоту для каждого элемента списка. Найдите первый элемент списка, высота которого превышает высоту области просмотра после добавления его высоты.未命名作品 3.jpgКак мы видим из приведенного выше рисунка, если каждый элемент имеет размер 30 пикселей, а высота области просмотра составляет 100 пикселей, то путем кумулятивного расчета мы можем узнать, что область просмотра может видеть не более четвертого элемента.

  1. Как узнать, какие элементы отображать?

Когда пользователь не прокручивает, смещение равно 0, и мы знаем, что рендеринг нужно начинать с первого элемента. Итак, если пользователь прокрутил в совокупности x пикселей, с какого элемента следует начинать рендеринг?未命名作品 2.jpgПервое, что нам нужно сделать, это записать общее расстояние прокрутки списка, которым манипулировал пользователь.virtualOffset, Тогда получаем старшую степень от первого элементаheightSum,когдаheightSumСравниватьvirtualOffsetКогда он большой, последний элемент, чья высота накапливается, является первым элементом, который необходимо отобразить в окне просмотра! На картинке мы видим, что первый элемент равен 3. а также! Как вы можете видеть на картинке, 3 не полностью виден, он смещен на некоторое расстояние, мы называем этоrenderOffset. Его формула расчета:renderOffset = virtualOffset - (heightSum - 元素3的高度). Из этого видно, что нам нужен элемент для переноса элемента списка, чтобы можно было достичь общего смещения. По первому вопросу мы знаем, что нам нужно отрендерить 3, 4, 5, 6. Здесь нам нужно обратить внимание на вычитание вычисления.renderOffset.

  1. Как отображать элементы списка так, как я хочу?

Для каждого элемента списка мы вызываемitemElementGeneratorфункция для создания DOM, которая принимает соответствующий элемент списка в качестве параметра и возвращает элемент DOM. Элемент DOM будет загружен в элемент окна просмотра как элемент списка.

Хорошо, давайте наберем код напрямую!

1. Конструктор, мы сначала определяем нужные нам параметры.

class VirtualScroll {
  constructor({ el, list, itemElementGenerator, itemHeight }) {
    this.$list = el // 视口元素
    this.list = list // 需要展示的列表数据
    this.itemHeight = itemHeight // 每个列表元素的高度
    this.itemElementGenerator = itemElementGenerator // 列表元素的DOM生成器
  }
}

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

2. Операция инициализации

class VirtualScroll {
  constructor({ el, list, itemElementGenerator, itemHeight }) {
    // ...
    this.mapList()
    this.initContainer()
  }
  initContainer() {
    this.containerHeight = this.$list.clientHeight
    this.$list.style.overflow = "hidden"
  }
  mapList() {
    this._list = this.list.map((item, i) => ({
      height: this.itemHeight,
      index: i,
      item: item,
    }))
  }
}

Мы записываем высоту элемента вьюпорта, а затем преобразуем поступающие данные списка в структуру данных, удобную нам для расчета.

3. Слушайте события

class VirtualScroll {
  constructor(/* ... */) {
    // ...
    this.bindEvents()
  }
  bindEvents() {
    let y = 0
    const updateOffset = (e) => {
      e.preventDefault()
      y += e.deltaY
    }
    this.$list.addEventListener("wheel", updateOffset)
  }
}

Мы слушаем событие колеса прокрутки области просмотра, объект события имеет свойство, называемоеdeltaY, который записывает направление и количество прокрутки колеса прокрутки. Вниз положительный, вверх отрицательный.

4. Отрисовка списка

class VirtualScroll {
  render(virtualOffset) {
    const headIndex = findIndexOverHeight(this._list, virtualOffset)
    const tailIndex = findIndexOverHeight(this._list, virtualOffset + this.containerHeight)

    this.renderOffset = offset - sumHeight(this._list, 0, headIndex)

    this.renderList = this._list.slice(headIndex, tailIndex + 1)

    const $listWp = document.createElement("div")
    this.renderList.forEach((item) => {
      const $el = this.itemElementGenerator(item)
      $listWp.appendChild($el)
    })
    $listWp.style.transform = `translateY(-${this.renderOffset}px)`
    this.$list.innerHTML = ''
    this.$list.appendChild($listWp)
  }
}
// 找到第一个累加高度大于指定高度的序号
export function findIndexOverHeight(list, offset) {
  let currentHeight = 0
  for (let i = 0; i < list.length; i++) {
    const { height } = list[i]
    currentHeight += height

    if (currentHeight > offset) {
      return i
    }
  }

  return list.length - 1
}

// 获取列表中某一段的累加高度
export function sumHeight(list, start = 0, end = list.length) {
  let height = 0
  for (let i = start; i < end; i++) {
    height += list[i].height
  }

  return height
}

Здесь наш метод рендеринга в основном зависит от общего количества прокрутки пользователем.virtualOffset,КаждыйvirtualOffsetВсе они соответствуют фиксированному кадру рендеринга. Сначала мы вычисляем видимый подсписок, а затем вычисляем смещение. Наконец, DOM генерируется в соответствии с подсписком, и DOM в элементе окна просмотра заменяется.

5. Посмотреть обновление

Запись прокрутки и метод рендеринга были реализованы, поэтому последний шаг очень прост: выполнение метода рендеринга при изменении записи прокрутки.

class VirtualScroll {
  constructor(/* ... */) {
    // ...
    this._virtualOffset = 0
    this.virtualOffset = this._virtualOffset
  }
  set virtualOffset(val) {
    this._virtualOffset = val
    this.render(val)
  }
  get virtualOffset() {
    return this._virtualOffset
  }
  initContainer($list) {
    // ...
+   this.contentHeight = sumHeight(this._list)
  }
  bindEvents() {
    let y = 0
+   const scrollSpace = this.contentHeight - this.containerHeight
    const updateOffset = (e) => {
      e.preventDefault()
      y += e.deltaY
+     y = Math.max(y, 0)
+     y = Math.min(y, scrollSpace)
+     this.virtualOffset = y
    }
    this.$list.addEventListener("wheel", updateOffset)
  }
}

Хорошо, на данный момент наша виртуальная прокрутка уже реализовала основные функции. Спасибо за просмотр, увидимся в следующий раз👋!

Тестирование производительности

Во-первых, давайте посмотрим, могут ли базовые функции решить проблему загрузки больших объемов данных.

截屏20200608 下午4.36.59.png

Еще раз мы используем тест производительности Chrome Page. Лянжи дети на шелковистых! С рисунности мы можем видеть, что потребляемое время рендеринга сброшено из оригинала 4,5с 5 мс !!! Тогда мы стараемся использовать открытый сафари, успех! Оригинальное количество контраста, 100 000 данных, сафари, но не открываются ах.

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

截屏20200608 下午5.25.45.png

Мы проверили его производительность прокрутки после непрерывной прокрутки в течение 10 секунд и обнаружили, что время выполнения его скрипта было слишком большим, достигая 40%. Время рендеринга/отрисовки также значительно увеличивается. Но есть преимущество в том, что при потреблении такого количества ресурсов FPS страницы действительно хороший, между 50-70, так что картинка не застревает и очень плавная.

оптимизация производительности

Узнав о производительности при прокрутке, я думаю, вы также знаете проблему. каждый раз, когда мы запускаемwheelсобытие повторно отобразит весь список. а такжеwheelЧастота срабатывания тачпада довольно высока!

Итак, давайте посмотрим, как оптимизировать эти проблемы.

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

регулирование событий

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

class VirtualScroll {
   bindEvents() {
    let y = 0
    const scrollSpace = this.contentHeight - this.containerHeight
    const recordOffset = (e) => {
      e.preventDefault()
      y += e.deltaY
      y = Math.max(y, 0)
      y = Math.min(y, scrollSpace)
    }
    const updateOffset = () => {
      this.virtualOffset = y
    }
    const _updateOffset = throttle(updateOffset, 16)

    this.$list.addEventListener("wheel", recordOffset)
    this.$list.addEventListener("wheel", _updateOffset)
  }
}

видно будем обновлятьvirtualOffsetОперация исключена, так как она потребуетrenderработать.但是记录偏移量我们可以一直触发。 所以我们把更新virtualOffsetРабочая частота через потоковую функциюthrottleУменьшенный.

Когда мы установили интервал в 16 мс, мы снова протестировали и получили следующие результаты:

截屏20200608 下午6.19.27.png

Можно обнаружить, что время выполнения скрипта сокращается вдвое, и соответственно уменьшается время рендеринга/перерисовки. Видно, что эффект очень очевиден, но когда FPS страницы падает примерно до 30, беглость прокрутки страницы становится не такой плавной. Но явного отставания нет.

кеш списка

Даже если мы уменьшим частоту срабатывания события, интервал рендеринга все равно будет слишком коротким, чтобы обеспечить плавную прокрутку. Так как же увеличить интервал рендеринга? Другими словами, между двумя повторными рендерингами потребности пользователя в прокрутке могут быть удовлетворены без повторного рендеринга.

Решение состоит в том, что мы продвигаемся вперед более чем на несколько элементов списка до и после рендеринга визуальных элементов списка. Таким образом, мы можем сместить небольшое количество прокрутки, когда эти элементы были отрендерены, вместо повторного рендеринга, когда прокрутка превышает объем элемента кеша, а затем повторно отрендерил.

По сравнению с повторным рендерингом изменение свойств стиля списка обходится намного дешевле.

virtualscroll1.jpg

Розовая область — это наша буферная область, при прокрутке в этой области нам нужно только изменить списокtranslateYДостаточно. Обратите внимание, что здесь мы неyа такжеmargin-topдва свойства, потому чтоtransformНаслаждайтесь анимацией.

class VirtualScroll {
  constructor(/* ... */) {
    // ...
    this.cacheCount = 10
    this.renderListWithCache = []
  }
  render(virtualOffset) {
    const headIndex = findIndexOverHeight(this._list, virtualOffset)
    const tailIndex = findIndexOverHeight(this._list, virtualOffset + this.containerHeight)

    let renderOffset
    
    // 当前滚动距离仍在缓存内
    if (withinCache(headIndex, tailIndex, this.renderListWithCache)) {
      // 只改变translateY
      const headIndexWithCache = this.renderListWithCache[0].index
      renderOffset = virtualOffset - sumHeight(this._list, 0, headIndexWithCache)
      this.$listInner.style.transform = `translateY(-${renderOffset}px)`
      return
    }

    // 下面的就和之前做法基本一样,但是列表增加了前后缓存元素
    const headIndexWithCache = Math.max(headIndex - this.cacheCount, 0)
    const tailIndexWithCache = Math.min(tailIndex + this.cacheCount, this._list.length)

    this.renderListWithCache = this._list.slice(headIndexWithCache, tailIndexWithCache)

    renderOffset = virtualOffset - sumHeight(this._list, 0, headIndex)

    renderDOMList.call(this, renderOffset)

    function renderDOMList(renderOffset) {
      this.$listInner = document.createElement("div")
      this.renderListWithCache.forEach((item) => {
        const $el = this.itemElementGenerator(item)
        this.$listInner.appendChild($el)
      })
      this.$listInner.style.transform = `translateY(-${renderOffset}px)`
      this.$list.innerHTML = ""
      this.$list.appendChild(this.$listInner)
    }

    function withinCache(currentHead, currentTail, renderListWithCache) {
      if (!renderListWithCache.length) return false

      const head = renderListWithCache[0]
      const tail = renderListWithCache[renderListWithCache.length - 1]
      const withinRange = (num, min, max) => num >= min && num <= max

      return withinRange(currentHead, head.index, tail.index) && withinRange(currentTail, head.index, tail.index)
    }
  }
}

Мы установили размер кеша примерно в два раза больше, чем видимый элемент, и после тестирования получили следующее изображение:

截屏20200608 下午9.22.05.png

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

Результаты оптимизации

Мы увеличили время выполнения скрипта с 40% в начале до 13% сейчас. Эффект весьма примечателен, конечно, есть еще простор для оптимизации.Например, мы сейчас заменяем все списки.На самом деле существует много одинаковых или похожих DOM.Мы можем переиспользовать некоторые из DOM для сократить время создания DOM.

индикатор

Индикатор выполнения на самом деле очень прост. Вот несколько замечаний.

  1. Поскольку полоса прогресса пропорционально слишком мала, нам нужно указать минимальную высоту.
  2. Когда индикатор выполнения перетаскивается, его нужно только обновить пропорциональноvirtualOffsetВот и все.
  3. Конечно, перетаскивание индикатора выполнения также требует регулирования событий.

расположение идей

  1. Слушайте события колеса/касания и записывайте общее смещение списка.
  2. Вычисляет начальный индекс видимого элемента списка на основе общего смещения.
  3. Визуализирует элемент из начального индекса в нижнюю часть области просмотра.
  4. Перерисовывает список визуальных элементов при обновлении общего смещения.
  5. Визуальные элементы до и после добавления списка элементов буфера.
  6. Когда количество прокрутки относительно невелико, непосредственно измените смещение списка визуальных элементов.
  7. Когда количество прокрутки велико (например, при перетаскивании полосы прокрутки), весь список будет перерисован.
  8. Дросселирование событий.

Оригинальный текст -- My Xiaopo Station (не адаптировано для ПК)

Исходный код - VirtualScroll.js

Исторические выборы

  1. Как заполнить бизнес-страницу в течение 10 минут - Vue Package Art
  2. Анализ исходного кода Axios