Реализовать компонент виртуальной прокрутки с помощью React + Rxjs

React.js RxJS
Реализовать компонент виртуальной прокрутки с помощью React + Rxjs

Оригинальный текст также был опубликован в колонке Zhihu.

zhuanlan.zhihu.com/p/54327805

Зачем использовать виртуальные списки

Мы столкнулись с такой проблемой в нашем бизнес-сценарии.Для продавцов есть выпадающий список выбора.Мы просто используем компонент выбора antd и обнаруживаем, что каждый раз, когда мы нажимаем раскрывающийся список, будет серьезное зависание от нажмите, чтобы всплыло Во время теста в базе данных всего около 370 единиц данных, и данные такого масштаба могут чувствовать себя явно застрявшими (среда разработки составляет около 700+ мс), не говоря уже о данных 2000 + онлайн. Производительность Antd при выборе просто невероятна.Он просто сопоставляет все данные, инициализирует и сохраняет их в узле DOM под document.body при щелчке по нему и кэширует их, что создает еще одну проблему.Наша В сцене много модулей используется список выбора продавца.После каждого клика будет генерироваться более 2000 узлов DOM.Если эти узлы будут храниться в документе, количество узлов DOM резко возрастет.

Виртуальные списки существуют для решения этой проблемы.

принцип виртуального списка

Суть виртуальных списков заключается в использовании небольшого количества DOM-узлов для имитации длинного списка. Как показано слева на рисунке ниже, каким бы длинным ни был список, в поле нашего зрения на самом деле появляется только его часть. показан слева на рис. в п. 5 этот элемент). Даже если убрать пункт 5 (как показано справа), пользователь увидит точно то же самое.

Разберем пошагово, конкретный код можно посмотретьOnline Demo.

Вот библиотека, которую я реализовал с помощью этой идеи, функция будет более совершенной.

github.com/musicq/vist

Создайте элемент DOM, который соответствует высоте контейнера.

В качестве примера возьмем приведенную выше картинку, представьте себе список из 1000 элементов.Если вы используете метод слева на приведенной выше картинке, вам нужно создать 1000 DOM-узлов и добавить их в документ.На самом деле элементов всего 4. которые появляются в поле зрения каждый раз, то оставшиеся 996 элементов тратятся впустую. И если создаются только 4 узла DOM, это экономит 996 узлов DOM.

Идеи решения проблем

реальный счетчик DOM = Math.ceil (высота контейнера / высота элемента)

Компонент определения имеет следующий интерфейс

interface IVirtualListOptions {
  height: number
}

interface IVirtualListProps {
  data$: Observable<string[]>
  options$: Observable<IVirtualListOptions>
}

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

 private containerHeight$ = new BehaviorSubject<number>(0)

Истинную высоту контейнера можно измерить только после монтажа компонента. Элементы контейнера могут быть связаны ссылкой, вcomponentDidMountПосле этого получите высоту контейнера и уведомитеcontainerHeight$.

this.containerHeight$.next(virtualListContainerElm.clientHeight)

Получив высоту контейнера, рассчитайте количество DOM, которые должны отображаться в окне просмотра, по приведенной выше формуле.

const actualRows$ = combineLatest(this.containerHeight$, this.props.options$).pipe(
    map(([ch, { height }]) => Math.ceil(ch / height))
)

комбинируяactualRows$а такжеdata$Два потока для получения фрагментов данных, которые должны появиться в окне просмотра.

const dataInViewSlice$ = combineLatest(this.props.data$, actualRows$).pipe(
    map(([data, actualRows]) => data.slice(0, actualRows))
)

Таким образом получается источник данных текущего момента, подписываемся на него для рендеринга списка

dataInViewSlice$.subscribe(data => this.setState({ data }))

Эффект

Учитывая 1000 фрагментов данных, отображаются только первые 7 фрагментов данных, как и ожидалось.

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

открыть контейнер

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

Чтобы контейнер выглядел так, будто в нем действительно 1000 элементов данных, вам нужно растянуть высоту контейнера до высоты, которую должны иметь 1000 элементов. Этот шаг прост, обратитесь к формуле ниже

Идеи решения проблем

Истинная высота контейнера = общие данные * высота каждого элемента

Замените приведенную выше формулу кодом

const scrollHeight$ = combineLatest(this.props.data$, this.props.options$).pipe(
    map(([data, { height }]) => data.length * height)
)

Эффект

Это больше похоже на список из 1000 элементов.

Но после прокрутки я обнаружил, что все следующее пустое.Так как в списке всего 7 элементов, пустое нормально. И мы ожидаем, что элемент будет правильно отображаться при прокрутке.

список прокрутки

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

Полностью обновить список

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

Чтобы сделать список еще лучше, мы увеличили количество отображаемых реальных DOM еще на 3.

const actualRows$ = combineLatest(this.containerHeight$, this.props.options$).pipe(
    map(([ch, { height }]) => Math.ceil(ch / height) + 3)
)

Сначала определите поток событий прокрутки окна

const scrollWin$ = fromEvent(virtualListElm, 'scroll').pipe(
    startWith({ target: { scrollTop: 0 } })
)

Вычислять индекс текущего состояния каждый раз, когда вы прокручиваете

const shouldUpdate$ = combineLatest(
    scrollWin$.pipe(map(() => virtualListElm.scrollTop)),
    this.props.options$,
    actualRows$
).pipe(
    // 计算当前列表中最顶部的索引
    map(([st, { height }, actualRows]) => {
        const firstIndex = Math.floor(st / height)
        const lastIndex = firstIndex + actualRows - 1
        return [firstIndex, lastIndex]
    })
)

Таким образом, вы можете получать начальный и конечный индекс данных в окне каждый раз при прокрутке, а затем вам нужно только вычислить срез данных в соответствии с индексом.

const dataInViewSlice$ = combineLatest(this.props.data$, shouldUpdate$).pipe(
	map(([data, [firstIndex, lastIndex]]) => data.slice(firstIndex, lastIndex + 1))
);

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

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

const dataInViewSlice$ = combineLatest(
    this.props.data$,
    this.props.options$,
    shouldUpdate$
).pipe(
    map(([data, { height }, [firstIndex, lastIndex]]) => {
        return data.slice(firstIndex, lastIndex + 1).map(item => ({
            origin: item,
            // 用来定位元素的位置
            $pos: firstIndex * height,
            $index: firstIndex++
        }))
    })
);

Затем измените структуру HTML и добавьте смещение каждого элемента.

this.state.data.map(data => (
  <div
    key={data.$index}
    style={{
      position: 'absolute',
      width: '100%',
      // 定位每一个 item
      transform: `translateY(${data.$pos}px)`
    }}
  >
    {(this.props.children as any)(data.origin)}
  </div>
))

Это завершает основную форму и функцию виртуального списка.

Эффект следующий

Но эта версия виртуального списка не идеальна, у нее есть следующие проблемы

  1. вычислительные отходы
  2. Создание и удаление узла DOM

вычислительные отходы

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

На самом деле нам действительно следует запускать синхронизацию обновлений — это изменение индекса текущего списка, когдаЭто было начало моего списка индекса[0, 1, 2], после прокрутки индекс становится[1, 2, 3], это время, когда нам нужно обновить представление. С помощью операторов rxjs это можно сделать легко, просто поставьтеshouldUpdate$Поток можно отфильтровать один раз.

const shouldUpdate$ = combineLatest(
  scrollWin$.pipe(map(() => virtualListElm.scrollTop)),
  this.props.options$,
  actualRows$
).pipe(
  // 计算当前列表中最顶部的索引
  map(([st, { height }, actualRows]) => [Math.floor(st / height), actualRows]),
  // 如果索引有改变,才触发重新 render
  filter(([curIndex]) => curIndex !== this.lastFirstIndex),
  // update the index
  tap(([curIndex]) => this.lastFirstIndex = curIndex),
  map(([firstIndex, actualRows]) => {
    const lastIndex = firstIndex + actualRows - 1
    return [firstIndex, lastIndex]
  })
)

Эффект

Создание и удаление узла DOM

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

И идеальное состояние, которое я ожидаю, — это иметь возможность повторно использовать DOM, не удаляя и не создавая их, и это реализация второй версии.

Повторное использование списка обновления DOM

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

this.state.data.map((data, i) => <div key={i}>{data}</div>)

Просто нужно это небольшое изменение и увидеть эффект

Видно, что данные изменились, но DOM не удаляется, а используется повторно, чего я и хочу.

Обратите внимание, чем реализация этой версии отличается от предыдущей версии.

Да, в этой версии каждый рендер будет менять стиль всего списка, и есть еще одна проблема, то есть при пролистывании списка до конца DOM будет уменьшаться.Хотя на отображение это не влияет, но есть по-прежнему создание DOM.и проблема удаления существует.

Повторное использование DOM + список обновлений по запросу

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

Предположим, что когда пользователь прокручивает список вниз, DOM-узел элемента 1 перемещается из поля зрения, тогда мы можем переместить его на позицию элемента 5, чтобы завершить непрерывную прокрутку,Здесь мы только меняем положение элемента, а не создаем и удаляем DOM.

dataInViewSlice$зависимость потокаprops.data$,props.options$,shouldUpdate$Три потока для расчета среза данных в текущий момент, а данные представления полностью основаны наdataInViewSlice$для рендеринга, поэтому, если мы хотим обновить список по запросу, нам нужно начать с этого потока.

В процессе прокрутки контейнера есть несколько сценариев:

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

Но на самом деле эти два сценария можно свести к одной ситуации.Найдите разницу индексов между предыдущим состоянием и текущим состоянием.

выполнить

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

Давайте сначала реализуем первый шаг, только нужно немного изменить оригиналdataInViewSlice$Реализация карты потока может завершить заполнение исходных данных

const dataSlice = this.stateDataSnapshot;

if (!dataSlice.length) {
  return this.stateDataSnapshow = data.slice(firstIndex, lastIndex + 1).map(item => ({
    origin: item,
    $pos: firstIndex * height,
    $index: firstIndex++
  }))
}

Затем выполните часть обновления массива по запросу. Во-первых, вам нужно знать разницу индексов между двумя состояниями до и после прокрутки. Например, индекс до прокрутки равен[0,1,2], индекс после прокатки[1,2,3], то их разность[0], указывающий, что первый элемент в старом массиве был перемещен из поля зрения, то этот первый элемент нужно добавить в конец списка, чтобы он стал последним элементом.

Сначала найдите разность массива

// 获取滚动前后索引差集
const diffSliceIndexes = this.getDifferenceIndexes(dataSlice, firstIndex, lastIndex);

С установленной разницей можно рассчитать новый состав массива. Взяв за пример эту картинку, пользователь прокручивает вниз, и при удалении элемента из поля зрения первый элемент (индекс 0) становится последним элементом (индекс 4), то есть oldSlice[0,1,2,3] -> newSlice [1,2,3,4].

В процессе трансформации,[1,2,3]Три элемента всегда не нужно перемещать, поэтому нам нужно только перехватить неизменный[1,2,3]С новым индексом 4 становится[1,2,3,4].

// 计算视窗的起始索引
let newIndex = lastIndex - diffSliceIndexes.length + 1;

diffSliceIndexes.forEach(index => {
  const item = dataSlice[index];
  item.origin = data[newIndex];
  item.$pos = newIndex * height;
  item.$index = newIndex++;
});

return this.stateDataSnapshot = dataSlice;

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

Но это только для прокрутки вниз, при прокрутке вверх этот код пойдет не так. Причина тоже очевидна, массив при прокрутке вниз дополняет элементы вниз, а при прокрутке вверх должен дополнять элементы вверх. Такие как[1,2,3,4] -> [0,1,2,3], операция над ним[1,2,3]остается прежним, а элемент 4 становится элементом 0, поэтому нам нужно пополнять массив в соответствии с разными направлениями прокрутки.

Сначала создайте поток, который получает направление прокруткиscrollDirection$

// scroll direction Down/Up
const scrollDirection$ = scrollWin$.pipe(
  map(() => virtualListElm.scrollTop),
  pairwise(),
  map(([p, n]) => n - p > 0 ? 1 : -1),
  startWith(1)
);

БудуscrollDirection$поток присоединиться кdataInViewSlice$в зависимости

const dataInViewSlice$ = combineLatest(this.props.data$, this.options$, shouldUpdate$).pipe(
  withLatestFrom(scrollDirection$)
)

С направлением прокрутки нам просто нужно изменить newIndex

// 向下滚动时 [0,1,2,3] -> [1,2,3,4] = 3
// 向上滚动时 [1,2,3,4] -> [0,1,2,3] = 0
let newIndex = dir > 0 ? lastIndex - diffSliceIndexes.length + 1 : firstIndex;

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

Здесь что-то не так?

Все верно, мы не решили проблему создания и удаления DOM при прокрутке списка до конца.

Проанализируйте причину проблемы, вы должны быть в состоянии думать о нейshouldUpdate$Здесь на последнем экране разница между рассчитанным индексом и последним индексом меньше чемactualRows$Число вычисляется в списке, поэтому приводит к изменению количества списков.Проблему легко решить, если знать причину.

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

При вычислении индекса последнего экрана вам нужно знать длину данных, поэтому сначала вытащите зависимость данных

const shouldUpdate$ = combineLatest(
  scrollWin$.pipe(map(() => virtualListElm.scrollTop)),
  this.props.data$,
  this.props.options$,
  actualRows$
)

Затем для расчета индекса

// 计算当前列表中最顶部的索引
map(([st, data, { height }, actualRows]) => {
  const firstIndex = Math.floor(st / height)
  // 在维持 DOM 数量不变的情况下计算出的索引
  const maxIndex = data.length - actualRows < 0 ? 0 : data.length - actualRows;
  // 取二者最小作为起始索引
  return [Math.min(maxIndex, firstIndex), actualRows];
})

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


Github

github.com/musicq/vist

Подробную информацию о приведенном выше коде см. в онлайн-демонстрационной версии.

Online Demo.