«Передний расширенный» высокопроизводительный рендеринг 100 000 элементов данных (виртуальный список)

оптимизация производительности Vue.js
«Передний расширенный» высокопроизводительный рендеринг 100 000 элементов данных (виртуальный список)

Чем больше вы знаете, тем больше вы не знаете
点赞Посмотри еще раз, аромат остался в руке, и слава

предисловие

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

существуетВысокопроизводительный рендеринг 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运行时间а также总渲染时间:

  • в JSEvent 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 высококачественные высококачественные статьи публикуются каждую неделю, чтобы помочь вам в продвижении вперед.

Также приглашаю добавить меня в друзья, ответить加群, беру тебя в группу и изучай фронтенд со мной~