В последнее время во время разработки и итерации определенной платформы я столкнулся с ситуацией, что сверхдливый список вложен в модальный анталь, а нагрузка медленная и застряла. Так что на прихотите, я решил реализовать виртуальный список прокрутки с нуля, чтобы оптимизировать общий опыт.
Трансформация прежнего:
Мы видим, что перед преобразованием будет небольшая задержка при открытии окна редактирования Modal, и после нажатия кнопки «Отмена», чтобы закрыть, оно не отвечает сразу, а закрывается после небольшого колебания.
После преобразования:
После завершения преобразования мы можем заметить, что открытие всего модального окна стало намного более плавным, чем раньше, и оно может немедленно реагировать на событие щелчка пользователя, чтобы вызвать/закрыть модальное окно.
- Демонстрация сравнения производительности:Код Sandbox.io/yes/ah-v-list-...
0x0 Основы
Так что же такое виртуальная прокрутка/список?
Виртуальный список означает, что когда у нас есть тысячи фрагментов данных для отображения, но пользовательское «окно» (одноразовый видимый контент) невелико, мы можем использовать хитрый метод для отображения только максимального количества видимых фрагментов данных. user + "BufferSize" и динамически обновлять содержимое каждого элемента по мере прокрутки пользователем для достижения того же эффекта, что и прокрутка длинного списка, но с очень небольшими ресурсами.
(Из приведенного выше рисунка видно, что фактический пользователь может видеть только элемент-4 ~ элемент-13, то есть каждый раз по 9 элементов.)
0x1 реализует виртуальный список фиксированной высоты.
-
Сначала нам нужно определить несколько переменных/имен.
- Из приведенного выше рисунка видно, что начальным элементом фактической видимой области пользователя является Item-4, поэтому его соответствующий индекс в массиве данных является нашим.
startIndex
- Точно так же индекс массива, соответствующий Item-13, должен быть нашим.
endIndex
- Таким образом, Item-1, Item-2 и Item-3 скрыты операцией пользователя по прокрутке вверх, поэтому мы называем это
startOffset(scrollTop)
- Из приведенного выше рисунка видно, что начальным элементом фактической видимой области пользователя является Item-4, поэтому его соответствующий индекс в массиве данных является нашим.
Поскольку мы визуализируем только содержимое видимой области, чтобы поведение всего контейнера было похоже на длинный список (прокрутка), мы должны поддерживать высоту исходного списка, поэтому мы разрабатываем структуру HTML следующим образом.
<!--ver 1.0 -->
<div className="vListContainer">
<div className="phantomContent">
...
<!-- item-1 -->
<!-- item-2 -->
<!-- item-3 -->
....
</div>
</div>
- в:
-
vListContainer
Для контейнера видимой областиoverflow-y: auto
Атрибуты. - существует
phantom
Каждая часть данных должна иметьposition: absolute
Атрибуты -
phantomContent
Это наша «фантомная» часть, основная цель которой — восстановить высоту содержимого реального списка, чтобы имитировать поведение обычной прокрутки длинного списка.
-
-
Тогда у нас есть
vListContainer
связать одинonScroll
Функция отклика , и в функции вычислить нашstartIndex
а такжеendIndex
- Прежде чем начать расчет, нам нужно определить несколько значений:
- Нам нужна фиксированная высота элемента списка:
rowHeight
- Нам нужно знать, сколько частей данных в текущем списке:
total
- Нам нужно знать высоту области просмотра текущего пользователя:
height
- Нам нужна фиксированная высота элемента списка:
- Имея вышеуказанные данные, мы можем рассчитать следующие данные:
- Список общей высоты:
phantomHeight = total * rowHeight
- Количество элементов, отображаемых в видимом диапазоне:
limit = Math.ceil(height/rowHeight)
- Список общей высоты:
- Прежде чем начать расчет, нам нужно определить несколько значений:
(обратите внимание, что мы используем округление здесь)
- Таким образом, мы можем сделать следующие вычисления в обратном вызове onScroll:
onScroll(evt: any) {
// 判断是否是我们需要响应的滚动事件
if (evt.target === this.scrollingContainer.current) {
const { scrollTop } = evt.target;
const { startIndex, total, rowHeight, limit } = this;
// 计算当前startIndex
const currentStartIndex = Math.floor(scrollTop / rowHeight);
// 如果currentStartIndex 和 startIndex 不同(我们需要更新数据了)
if (currentStartIndex !== startIndex ) {
this.startIndex = currentStartIndex;
this.endIndex = Math.min(currentStartIndedx + limit, total - 1);
this.setState({ scrollTop });
}
}
}
- Когда у нас есть startIndex и endIndex, мы можем отобразить их соответствующие данные:
renderDisplayContent = () => {
const { rowHeight, startIndex, endIndex } = this;
const content = [];
// 注意这块我们用了 <= 是为了渲染x+1个元素用来在让滚动变得连续(永远渲染在判断&渲染x+2)
for (let i = startIndex; i <= endIndex; ++i) {
// rowRenderer 是用户定义的列表元素渲染方法,需要接收一个 index i 和
// 当前位置对应的style
content.push(
rowRenderer({
index: i,
style: {
width: '100%',
height: rowHeight + 'px',
position: "absolute",
left: 0,
right: 0,
top: i * rowHeight,
borderBottom: "1px solid #000",
}
})
);
}
return content;
};
Онлайн-демонстрация:код sandbox.io/yes/ah-naive-V…
принцип:
- Так как же достигается этот эффект прокрутки? Сначала мы визуализируем «фантомный» контейнер с фактической высотой списка в vListContainer, чтобы пользователь мог прокручивать его. Во-вторых, мы слушаем событие onScroll и динамически вычисляем начальный индекс (индекс), соответствующий текущему смещению прокрутки (насколько прокручивается и скрыто) каждый раз, когда пользователь запускает прокрутку. Когда мы обнаруживаем, что новое дно не совпадает с тем, которое мы сейчас показываем, мы назначаем его, и setState запускает перерисовку. Когда текущее смещение прокрутки пользователя не запускает обновление нижнего индекса, виртуальный список имеет ту же возможность прокрутки, что и обычный список, из-за длины самого фантома. Когда запускается перерисовка, поскольку мы вычисляем startIndex, пользователь не может воспринимать перерисовку страницы (поскольку следующий кадр текущей прокрутки согласуется с перерисованным содержимым).
оптимизация:
- Для виртуального списка, который мы реализовали выше, нетрудно обнаружить, что после выполнения быстрого смахивания список будет мерцать/рендериться будет слишком поздно, и он будет пустым. Помните, что мы говорили в начале **Отрисовка максимального количества видимых баров + "BufferSize"Какой? Для фактического контента, который мы рендерим, мы можем добавить к нему концепцию буферизации вверх и вниз (то есть рендеринг некоторых элементов вверх и вниз для быстрого перехода и скольжения, когда уже слишком поздно для рендеринга). Оптимизированная функция onScroll выглядит следующим образом:
onScroll(evt: any) {
........
// 计算当前startIndex
const currentStartIndex = Math.floor(scrollTop / rowHeight);
// 如果currentStartIndex 和 startIndex 不同(我们需要更新数据了)
if (currentStartIndex !== originStartIdx) {
// 注意,此处我们引入了一个新的变量叫originStartIdx,起到了和之前startIndex
// 相同的效果,记录当前的 真实 开始下标。
this.originStartIdx = currentStartIndex;
// 对 startIndex 进行 头部 缓冲区 计算
this.startIndex = Math.max(this.originStartIdx - bufferSize, 0);
// 对 endIndex 进行 尾部 缓冲区 计算
this.endIndex = Math.min(
this.originStartIdx + this.limit + bufferSize,
total - 1
);
this.setState({ scrollTop: scrollTop });
}
}
Онлайн-демонстрация:код sandbox.IO/is/A-better-…
0x2 адаптивная высота элемента списка
Теперь, когда мы реализовали реализацию виртуального списка элементов «фиксированной высоты», что, если мы столкнемся с бизнес-сценарием сверхдлинного списка с переменной высотой?
- Как правило, существует три метода реализации виртуального списка при столкновении с элементами списка переменной высоты:
-
Измените входные данные, передайте высоту, соответствующую каждому элементу dynamicHeight[i] = x x — высота строки элемента i
Нужно знать высоту каждого элемента (непрактично)
-
Нарисуйте текущий элемент за пределами экрана и измерьте его высоту, прежде чем отображать его в видимой области пользователя.
Этот подход эквивалентен удвоению стоимости рендеринга (непрактично).
-
Передайте свойство AssessmentHeight, чтобы сначала оценить и отобразить высоту строки, а затем получить реальную высоту строки после рендеринга, обновления и кэширования.
В нем будут представлены избыточные преобразования (приемлемо), а о том, зачем нужны избыточные преобразования, я расскажу позже...
- Давайте пока вернемся к части HTML
<!--ver 1.0 -->
<div className="vListContainer">
<div className="phantomContent">
...
<!-- item-1 -->
<!-- item-2 -->
<!-- item-3 -->
....
</div>
</div>
<!--ver 1.1 -->
<div className="vListContainer">
<div className="phantomContent" />
<div className="actualContent">
...
<!-- item-1 -->
<!-- item-2 -->
<!-- item-3 -->
....
</div>
</div>
-
Когда мы реализуем виртуальный список «фиксированной высоты», мы используем элементы рендеринга в
phantomContent
контейнер, и установив для каждого элементаposition
дляabsolute
плюс определениеtop
свойство равноi * rowHeight
Чтобы достичь этого, независимо от того, как вы прокручиваете, отображаемый контент всегда находится в поле зрения пользователя. В случае, когда высота списка не может быть определена, мы не можем точно передатьestimateHeight
Чтобы вычислить позицию y текущего элемента, нам нужен контейнер, который поможет нам сделать это абсолютное позиционирование. -
actContent — это наш недавно представленный контейнер рендеринга содержимого списка, установив этот контейнер
position: absolute
свойство, чтобы не устанавливать его для каждого элемента. -
Одно отличие состоит в том, что вместо этого мы используем контейнер factContent. Когда мы скользим, нам нужно динамически менять положение контейнера.y-transformЧтобы контейнер всегда был в окне пользователя:
getTransform() {
const { scrollTop } = this.state;
const { rowHeight, bufferSize, originStartIdx } = this;
// 当前滑动offset - 当前被截断的(没有完全消失的元素)距离 - 头部缓冲区距离
return `translate3d(0,${
scrollTop -
(scrollTop % rowHeight) -
Math.min(originStartIdx, bufferSize) * rowHeight
}px,0)`;
}
Онлайн-демонстрация:код sandbox.io/yes/ah-V-list-…
(Примечание. Когда нет требований высокой адаптивности и повторное использование ячеек не реализовано, производительность рендеринга элементов в фантомном через абсолютный будет лучше, чем через преобразование. Поскольку каждый раз при рендеринге содержимое будет переупорядочиваться, но если вы используете преобразование эквивалентно выполнению (перекомпоновка + преобразование) > перекомпоновка)
-
Возвращаясь к проблеме самоадаптирующейся высоты элементов списка, теперь у нас есть контейнер рендеринга элементов (actualContent), который может выполнять обычную блочную компоновку внутри, теперь мы можем рендерить содержимое напрямую, не задавая высоту. В тех местах, где раньше нам нужно было использовать rowHeight для вычисления высоты, мы единообразно заменяем его на AssessmentHeight для вычисления.
limit = Math.ceil(height / estimateHeight)
phantomHeight = total * estimateHeight
-
В то же время, чтобы избежать повторного вычисления высоты каждого элемента после рендеринга (getBoundingClientReact().height) нам нужен массив для хранения этих высот
interface CachedPosition {
index: number; // 当前pos对应的元素的下标
top: number; // 顶部位置
bottom: number; // 底部位置
height: number; // 元素高度
dValue: number; // 高度是否和之前(estimate)存在不同
}
cachedPositions: CachedPosition[] = [];
// 初始化cachedPositions
initCachedPositions = () => {
const { estimatedRowHeight } = this;
this.cachedPositions = [];
for (let i = 0; i < this.total; ++i) {
this.cachedPositions[i] = {
index: i,
height: estimatedRowHeight, // 先使用estimateHeight估计
top: i * estimatedRowHeight, // 同上
bottom: (i + 1) * estimatedRowHeight, // same above
dValue: 0,
};
}
};
- Когда мы закончим вычисление (инициализацию) cachedPositions, поскольку мы вычисляем верхнюю и нижнюю часть каждого элемента, высота фантома является нижним значением последнего элемента в cachedPositions.
this.phantomHeight = this.cachedPositions[cachedPositionsLen - 1].bottom;
- После того, как мы визуализируем элементы в пользовательском окне в соответствии с оценкойHeight, нам нужно обновить фактическую высоту отображаемых элементов.В это время мы можем использовать хук жизненного цикла componentDidUpdate для расчета, оценки и обновления:
componentDidUpdate() {
......
// actualContentRef必须存在current (已经渲染出来) + total 必须 > 0
if (this.actualContentRef.current && this.total > 0) {
this.updateCachedPositions();
}
}
updateCachedPositions = () => {
// update cached item height
const nodes: NodeListOf<any> = this.actualContentRef.current.childNodes;
const start = nodes[0];
// calculate height diff for each visible node...
nodes.forEach((node: HTMLDivElement) => {
if (!node) {
// scroll too fast?...
return;
}
const rect = node.getBoundingClientRect();
const { height } = rect;
const index = Number(node.id.split('-')[1]);
const oldHeight = this.cachedPositions[index].height;
const dValue = oldHeight - height;
if (dValue) {
this.cachedPositions[index].bottom -= dValue;
this.cachedPositions[index].height = height;
this.cachedPositions[index].dValue = dValue;
}
});
// perform one time height update...
let startIdx = 0;
if (start) {
startIdx = Number(start.id.split('-')[1]);
}
const cachedPositionsLen = this.cachedPositions.length;
let cumulativeDiffHeight = this.cachedPositions[startIdx].dValue;
this.cachedPositions[startIdx].dValue = 0;
for (let i = startIdx + 1; i < cachedPositionsLen; ++i) {
const item = this.cachedPositions[i];
// update height
this.cachedPositions[i].top = this.cachedPositions[i - 1].bottom;
this.cachedPositions[i].bottom = this.cachedPositions[i].bottom - cumulativeDiffHeight;
if (item.dValue !== 0) {
cumulativeDiffHeight += item.dValue;
item.dValue = 0;
}
}
// update our phantom div height
const height = this.cachedPositions[cachedPositionsLen - 1].bottom;
this.phantomHeight = height;
this.phantomContentRef.current.style.height = `${height}px`;
};
-
Когда теперь у нас есть точные значения высоты и положения всех элементов, наш метод получения начального элемента, соответствующего текущему scrollTop (Offset), модифицируется для получения его через cachedPositions:
Поскольку наш cachedPositions представляет собой упорядоченный массив, мы можем использовать бинарный поиск, чтобы уменьшить временную сложность при поиске.
getStartIndex = (scrollTop = 0) => {
let idx = binarySearch<CachedPosition, number>(this.cachedPositions, scrollTop,
(currentValue: CachedPosition, targetValue: number) => {
const currentCompareValue = currentValue.bottom;
if (currentCompareValue === targetValue) {
return CompareResult.eq;
}
if (currentCompareValue < targetValue) {
return CompareResult.lt;
}
return CompareResult.gt;
}
);
const targetItem = this.cachedPositions[idx];
// Incase of binarySearch give us a not visible data(an idx of current visible - 1)...
if (targetItem.bottom < scrollTop) {
idx += 1;
}
return idx;
};
onScroll = (evt: any) => {
if (evt.target === this.scrollingContainer.current) {
....
const currentStartIndex = this.getStartIndex(scrollTop);
....
}
};
- Реализация бинарного поиска:
export enum CompareResult {
eq = 1,
lt,
gt,
}
export function binarySearch<T, VT>(list: T[], value: VT, compareFunc: (current: T, value: VT) => CompareResult) {
let start = 0;
let end = list.length - 1;
let tempIndex = null;
while (start <= end) {
tempIndex = Math.floor((start + end) / 2);
const midValue = list[tempIndex];
const compareRes: CompareResult = compareFunc(midValue, value);
if (compareRes === CompareResult.eq) {
return tempIndex;
}
if (compareRes === CompareResult.lt) {
start = tempIndex + 1;
} else if (compareRes === CompareResult.gt) {
end = tempIndex - 1;
}
}
return tempIndex;
}
- Наконец, метод получения преобразования после прокрутки преобразуется следующим образом:
getTransform = () =>
`translate3d(0,${this.startIndex >= 1 ? this.cachedPositions[this.startIndex - 1].bottom : 0}px,0)`;
Онлайн-демонстрация:код sandbox.io/yes/ah-V-list-…