Чем больше вы знаете, тем больше вы не знаете
点赞
Посмотри еще раз, аромат остался в руке, и слава
предисловие
На работе мы иногда сталкиваемся с бизнес-ситуациями, которые требуют некоторых бизнес-ситуаций, которые не могут использовать пейджинг для загрузки данных списка.Для этого мы называем этот список именем长列表
. Например, в некоторых системах торговли иностранной валютой внешний интерфейс будет отображать позицию пользователя (прибыль, убыток, размер лота и т. д.) в режиме реального времени.В настоящее время список позиций пользователя обычно не может быть разбит на страницы.
существуетВысокопроизводительный рендеринг 100 000 фрагментов данных (квантование по времени)В статье упоминается, что вы можете использовать时间分片
метод для отображения длинных списков, но этот метод больше подходит для случая, когда структура DOM элементов списка очень проста. В этой статье будет представлено использование虚拟列表
способ одновременной загрузки больших объемов данных.
Почему вам нужно использовать виртуальный список
Предположим, нашему длинному списку нужно отображать 10 000 записей, и мы одновременно отображаем 10 000 записей на странице, давайте посмотрим, сколько времени это займет:
<button id="button">button</button><br>
<ul id="container"></ul>
document.getElementById('button').addEventListener('click',function(){
// 记录任务开始时间
let now = Date.now();
// 插入一万条数据
const total = 10000;
// 获取容器
let ul = document.getElementById('container');
// 将数据插入容器中
for (let i = 0; i < total; i++) {
let li = document.createElement('li');
li.innerText = ~~(Math.random() * total)
ul.appendChild(li);
}
console.log('JS运行时间:',Date.now() - now);
setTimeout(()=>{
console.log('总运行时间:',Date.now() - now);
},0)
// print JS运行时间: 38
// print 总运行时间: 957
})
Когда мы нажмем кнопку, на страницу одновременно добавится 10 000 записей.По выводу консоли мы можем примерно подсчитать, что время работы JS составляет38ms
Однако общее время после рендеринга равно957ms
.
Кратко объясните, почему дваждыconsole.log
Результаты времени сильно разнятся, и как просто их посчитатьJS运行时间
а также总渲染时间
:
- в JS
Event Loop
, когда все события в стеке выполнения, управляемом движком JS, и все события микрозадач выполняются, поток рендеринга будет запущен для рендеринга страницы. - Первый
console.log
Время триггера — до отображения страницы, а интервал времени, полученный в это время, — это время, необходимое для запуска JS. - второй
console.log
Он помещается в setTimeout, и его время срабатывания — когда рендеринг завершен, а в следующий разEvent Loop
казнен в
Подробнее о цикле событий см. в этой статье -->
Затем мы проходимChrome
изPerformance
Инструмент для подробного анализа узких мест в производительности этого кода:
отPerformance
Видно, что код потребляет всего960.8ms
, основные затраты времени следующие:
- Event(click) :
40.84ms
- Recalculate Style :
105.08ms
- Layout :
731.56ms
- Update Layer Tree :
58.87ms
- Paint :
15.32ms
Отсюда мы видим, что два самых трудоемких этапа выполнения нашего кода — этоRecalculate Style
а такжеLayout
.
-
Recalculate Style
: расчет стиля, браузер вычисляет, какие элементы должны применять какие правила в соответствии с селектором CSS, и определяет конкретный стиль каждого элемента. -
Layout
: layout, зная, какие правила применяются к элементу, браузер начинает рассчитывать, сколько места он будет занимать и его положение на экране.
В реальной работе элемент списка не должен состоять только из одного тега li, как в примере, а должен состоять из сложных узлов DOM.
Тогда можно предположить, что, когда элементов списка слишком много и структура элементов списка сложна, при одновременном рендерингеRecalculate Style
а такжеLayout
Этапы занимают много времени.
а также虚拟列表
Это реализация для решения этой проблемы.
Что такое виртуальный список
虚拟列表
По сути, это реализация отображения по запросу, то есть только для可见区域
оказывать, чтобы非可见区域
Данные не визуализируются или визуализируются частично, что приводит к чрезвычайно высокой производительности визуализации.
Предполагая, что одновременно необходимо отобразить 10 000 записей, наш экран可见区域
Высота500px
, а высота элемента списка50px
, то в это время мы можем видеть на экране только до 10 элементов списка, поэтому при первом рендеринге нам нужно загрузить только 10 элементов.
Поговорив о первой загрузке, давайте проанализируем, когда происходит прокрутка.Мы можем рассчитать текущее значение прокрутки, чтобы знать, что находится на экране в это время.可见区域
Элемент списка, который должен отображаться.
Предполагая, что происходит прокрутка, полоса прокрутки располагается сверху на150px
, то мы можем видеть, что в可见区域
Элементы списка внутри第4项
к пункту 13.
выполнить
Реализация виртуального списка на самом деле загружается только при загрузке первого экрана.可视区域
Требуемые элементы списка получаются динамически вычислением при прокрутке可视区域
элемент списка внутри非可视区域
Существующие элементы списка удаляются.
- Рассчитать текущий
可视区域
Индекс начать данные (startIndex
) - Рассчитать текущий
可视区域
конечный индекс данных (endIndex
) - Рассчитать текущий
可视区域的
данные и отображать их на странице - рассчитать
startIndex
Позиция смещения соответствующих данных во всем спискеstartOffset
И установить в список
Потому что просто可视区域
Элементы списка в списке визуализируются, поэтому для сохранения высоты контейнера списка и нормального запуска прокрутки структура HTML разработана следующим образом:
<div class="infinite-list-container">
<div class="infinite-list-phantom"></div>
<div class="infinite-list">
<!-- item-1 -->
<!-- item-2 -->
<!-- ...... -->
<!-- item-n -->
</div>
</div>
-
infinite-list-container
для可视区域
контейнер -
infinite-list-phantom
Для заполнителя в контейнере высота — это общая высота списка, используемая для формирования полосы прокрутки. -
infinite-list
для элементов списка渲染区域
Затем следитеinfinite-list-container
изscroll
событие, получить положение прокруткиscrollTop
- предполагаемый
可视区域
фиксированная высота, называемаяscreenHeight
- предполагаемый
列表每项
фиксированная высота, называемаяitemSize
- предполагаемый
列表数据
назови этоlistData
- предполагаемый
当前滚动位置
назови этоscrollTop
Можно сделать вывод, что:
- общая высота списка
listHeight
= listData.length * itemSize - Количество отображаемых элементов списка
visibleCount
= Math.ceil(screenHeight / itemSize) - начальный индекс данных
startIndex
= Math.floor(scrollTop / itemSize) - конечный индекс данных
endIndex
= startIndex + visibleCount - В списке данные отображаются как
visibleData
= listData.slice(startIndex,endIndex)
После прокрутки из-за渲染区域
относительно可视区域
Смещение уже произошло, и в этот момент мне нужно получить смещениеstartOffset
, элемент управления стилем будет渲染区域
смещение к可视区域
середина.
- Компенсировать
startOffset
= scrollTop - (scrollTop % itemSize);
окончательный简易代码
следующим образом:
<template>
<div ref="list" class="infinite-list-container" @scroll="scrollEvent($event)">
<div class="infinite-list-phantom" :style="{ height: listHeight + 'px' }"></div>
<div class="infinite-list" :style="{ transform: getTransform }">
<div ref="items"
class="infinite-list-item"
v-for="item in visibleData"
:key="item.id"
:style="{ height: itemSize + 'px',lineHeight: itemSize + 'px' }"
>{{ item.value }}</div>
</div>
</div>
</template>
export default {
name:'VirtualList',
props: {
//所有列表数据
listData:{
type:Array,
default:()=>[]
},
//每项高度
itemSize: {
type: Number,
default:200
}
},
computed:{
//列表总高度
listHeight(){
return this.listData.length * this.itemSize;
},
//可显示的列表项数
visibleCount(){
return Math.ceil(this.screenHeight / this.itemSize)
},
//偏移量对应的style
getTransform(){
return `translate3d(0,${this.startOffset}px,0)`;
},
//获取真实显示列表数据
visibleData(){
return this.listData.slice(this.start, Math.min(this.end,this.listData.length));
}
},
mounted() {
this.screenHeight = this.$el.clientHeight;
this.start = 0;
this.end = this.start + this.visibleCount;
},
data() {
return {
//可视区域高度
screenHeight:0,
//偏移量
startOffset:0,
//起始索引
start:0,
//结束索引
end:null,
};
},
methods: {
scrollEvent() {
//当前滚动位置
let scrollTop = this.$refs.list.scrollTop;
//此时的开始索引
this.start = Math.floor(scrollTop / this.itemSize);
//此时的结束索引
this.end = this.start + this.visibleCount;
//此时的偏移量
this.startOffset = scrollTop - (scrollTop % this.itemSize);
}
}
};
Нажмите, чтобы просмотреть онлайн-демонстрацию и полный код
Окончательный эффект выглядит следующим образом:
Динамическая высота элемента списка
В предыдущей реализации высота элемента списка была фиксированной.Поскольку высота фиксирована, можно легко получить общую высоту элемента списка и отображаемых данных, а также соответствующее смещение во время прокрутки. В практических приложениях, когда список содержит переменное содержимое, такое как текст, высота элементов списка будет разной.
Например этот случай:
Обычно существует три решения для применения динамической высоты в виртуальных списках:
1. О свойствах компонента
itemSize
Расширен для поддержки типов передачи как数字
,数组
,函数
- Может быть фиксированным значением, например 100, и в этом случае элемент списка будет постоянно высоким.
- Может быть данными, содержащими высоту всех элементов списка, например [50, 20, 100, 80, ...]
- Может быть функцией, которая возвращает высоту элемента списка на основе его индекса: (индекс: число): число
Хотя этот метод обладает хорошей гибкостью, он подходит только для ситуации, когда высота элемента списка может быть известна заранее или может быть рассчитана вычислением, и он все же не может решить ситуацию, когда высота элемента списка растягивается на содержимое.
2. Поместите элемент списка
渲染到屏幕外
, измерить и кэшировать его высоту перед визуализацией в видимой области.
Это удваивает стоимость рендеринга из-за предварительного рендеринга за кадром, а затем на экране, что нецелесообразно для продукта, который миллионы пользователей используют на недорогих мобильных устройствах.
3. Возьми
预估高度
Сначала визуализируйте, затем получите истинную высоту и кэшируйте ее.
Эту реализацию я выбрал, чтобы избежать недостатков первых двух вариантов.
Далее, давайте посмотрим, как это легко реализовать:
Определить свойства компонентаestimatedItemSize
, для приема预估高度
props: {
//预估高度
estimatedItemSize:{
type:Number
}
}
определениеpositions
Список элементов для отображения после хранения每一项的高度以及位置
Информация,
this.positions = [
// {
// top:0,
// bottom:100,
// height:100
// }
];
и первоначально согласноestimatedItemSize
правильноpositions
для инициализации.
initPositions(){
this.positions = this.listData.map((item,index)=>{
return {
index,
height:this.estimatedItemSize,
top:index * this.estimatedItemSize,
bottom:(index + 1) * this.estimatedItemSize
}
})
}
Поскольку высота элемента списка является переменной, и мы сохраняемpositions
, используемый для записи положения каждого элемента, и列表高度
На самом деле он равен положению нижней части последнего элемента в списке от вершины списка.
//列表总高度
listHeight(){
return this.positions[this.positions.length - 1].bottom;
}
из-за необходимости渲染完成
После этого получите информацию о позиции каждого элемента в списке и кэшируйте ее, поэтому используйте функцию ловушкиupdated
реализовать:
updated(){
let nodes = this.$refs.items;
nodes.forEach((node)=>{
let rect = node.getBoundingClientRect();
let height = rect.height;
let index = +node.id.slice(1)
let oldHeight = this.positions[index].height;
let dValue = oldHeight - height;
//存在差值
if(dValue){
this.positions[index].bottom = this.positions[index].bottom - dValue;
this.positions[index].height = height;
for(let k = index + 1;k<this.positions.length; k++){
this.positions[k].top = this.positions[k-1].bottom;
this.positions[k].bottom = this.positions[k].bottom - dValue;
}
}
})
}
получить список после прокрутки开始索引
Метод изменен для прохождения缓存
Получать:
//获取列表起始索引
getStartIndex(scrollTop = 0){
let item = this.positions.find(i => i && i.bottom > scrollTop);
return item.index;
}
Поскольку наши кешированные данные по своей природе являются последовательными, получите开始索引
Метод можно считать二分查找
способ сократить время поиска:
//获取列表起始索引
getStartIndex(scrollTop = 0){
//二分法查找
return this.binarySearch(this.positions,scrollTop)
},
//二分法查找
binarySearch(list,value){
let start = 0;
let end = list.length - 1;
let tempIndex = null;
while(start <= end){
let midIndex = parseInt((start + end)/2);
let midValue = list[midIndex].bottom;
if(midValue === value){
return midIndex + 1;
}else if(midValue < value){
start = midIndex + 1;
}else if(midValue > value){
if(tempIndex === null || tempIndex > midIndex){
tempIndex = midIndex;
}
end = end - 1;
}
}
return tempIndex;
},
после прокрутки偏移量
Изменения в способе получения:
scrollEvent() {
//...省略
if(this.start >= 1){
this.startOffset = this.positions[this.start - 1].bottom
}else{
this.startOffset = 0;
}
}
пройти черезfaker.jsсоздать некоторые随机数据
let data = [];
for (let id = 0; id < 10000; id++) {
data.push({
id,
value: faker.lorem.sentences() // 长文本
})
}
Нажмите, чтобы просмотреть онлайн-демонстрацию и полный код
Окончательный эффект выглядит следующим образом:
Демонстрационный эффект с точки зрения, мы достигли на основе文字内容动态撑高列表项
в сложившейся ситуации虚拟列表
, но мы можем обнаружить, что при слишком быстрой прокрутке будет краткий白屏现象
.
Чтобы страница прокручивалась плавно, нам также нужно可见区域
отображает дополнительные элементы над и под прокруткой, давая некоторые缓冲
, поэтому экран делится на три области:
- Над видимой областью:
above
- Видимая область:
screen
- Ниже видимой области:
below
Определить свойства компонентаbufferScale
, для приема缓冲区数据
а также可视区数据
из比例
props: {
//缓冲区比例
bufferScale:{
type:Number,
default:1
}
}
Количество баров, отображаемых над видимой областьюaboveCount
Способ его получения следующий:
aboveCount(){
return Math.min(this.start,this.bufferScale * this.visibleCount)
}
Количество баров, отображаемых ниже видимой областиbelowCount
Способ его получения следующий:
belowCount(){
return Math.min(this.listData.length - this.end,this.bufferScale * this.visibleCount);
}
реальные данные рендерингаvisibleData
Способ его получения следующий:
visibleData(){
let start = this.start - this.aboveCount;
let end = this.end + this.belowCount;
return this._listData.slice(start, end);
}
Нажмите, чтобы просмотреть онлайн-демонстрацию и полный код
Окончательный эффект выглядит следующим образом:
На основе этой схемы отдельные лица разработали виртуальный список компонентов на базе Vue2.x:vue-virtual-listview,МогуНажмите, чтобы просмотреть полный код.
лицом к будущему
В предыдущей статье мы использовали监听scroll事件
Способ запуска обновления данных в видимой области, когда происходит прокрутка, событие прокрутки будет запускаться часто, много раз вызовет重复计算
Нет никаких сомнений в том, что производительность является пустой тратой времени.
можно использоватьIntersectionObserverВместо того, чтобы слушать событие прокрутки,IntersectionObserver
Вы можете отслеживать, появляется ли целевой элемент в видимой области, выполнять обновление данных видимой области в отслеживаемом событии обратного вызова иIntersectionObserver
Обратный вызов прослушивателя запускается асинхронно, а не при прокрутке целевого элемента, а потребление производительности чрезвычайно низкое.
Оставшаяся проблема
Хотя мы реализовали виртуальный список на основе динамической высоты элемента списка, если элемент списка содержит изображения, а высота списка увеличивается за счет изображения, поскольку изображение отправит сетевой запрос, мы не можем гарантировать получение изображения при его получении. реальная высота элемента списка. Был ли он загружен, что привело к неточным вычислениям.
В этом случае, если мы сможем отслеживать изменение размера элемента списка, мы сможем получить его реальную высоту. мы можем использоватьResizeObserverотслеживать изменение высоты области содержимого элемента списка, чтобы получать высоту каждого элемента списка в режиме реального времени.
К сожалению, на момент написания только несколькоПоддержка браузераResizeObserver
.
Ссылаться на
- Говоря о принципе реализации виртуального списка
- Реализация виртуального списка для реактивно-виртуализированных компонентов
- Реагировать и неограниченный список
- Обсуждение внедрения внешнего виртуального списка
напиши в конце
- Если в статье есть ошибки, исправьте их в комментариях, если статья вам поможет, добро пожаловать
点赞
а также关注
- Эта статья была впервые опубликована одновременно сgithub, доступны наgithubНайдите больше отличных статей в
Watch
&Star ★
- Для последующих статей см.:строить планы
Добро пожаловать в публичный аккаунт WeChat
【前端小黑屋】
, 1–3 высококачественные высококачественные статьи публикуются каждую неделю, чтобы помочь вам в продвижении вперед.
Также приглашаю добавить меня в друзья, ответить
加群
, беру тебя в группу и изучай фронтенд со мной~