company_banner

3 способа рендеринга больших списков в Angular

Автор оригинала: Giancarlo Buomprisco
  • Перевод
В 2020 году фронтенд-фреймворки стали лучше, эффективнее и быстрее. Но, даже учитывая это, рендеринг больших списков без «замораживания» браузера всё ещё может оказаться сложной задачей даже для самых быстрых из существующих фреймворков.

Это — один из тех случаев, когда «фреймворк является быстрым, а код — медленным».



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

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

Хотя этот материал направлен на Angular, то, о чём здесь пойдёт речь, применимо к другим фреймворкам и к проектам, которые написаны на чистом JavaScript. В частности, здесь будут рассмотрены следующие подходы к рендерингу больших списков:

  • Виртуальный скроллинг (с использованием Angular CDK).
  • Ручной рендеринг.
  • Прогрессивный рендеринг.

Виртуальный скроллинг


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

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

Воспользуемся модулем scrolling из Angular CDK, который предназначен для организации виртуального скроллинга. Для этого сначала нужно установить CDK:

npm i @angular/cdk

Затем нужно импортировать модуль:

import { ScrollingModule } from '@angular/cdk/scrolling';
@NgModule({
 ...
 imports: [ ScrollingModule, ...]
})
export class AppModule {}

После этого в компонентах можно использовать cdk-virtual-scroll-viewport:

<cdk-virtual-scroll-viewport itemSize="50">
 <div *cdkVirtualFor="let item of items">
   {{ item }}
 </div>
</cdk-virtual-scroll-viewport>

Вот пример проекта, в котором используется такой подход к организации виртуального скроллинга.

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

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

  • То, как именно будет работать виртуальный скроллинг, сильно зависит от его реализации. Непросто перекрыть все возможные сценарии вывода больших списков, пользуясь единственной реализацией этой идеи. Например, мой компонент зависит от поля Autocomplete (созданного той же командой разработчиков). К сожалению, то, что у меня получилось, не работало так, как ожидалось. Чем сложнее элементы списка — тем больше неожиданностей может возникнуть при их выводе.
  • Дополнительный модуль для организации виртуального скроллинга — это большой фрагмент кода, добавляемый к коду приложения.
  • В сфере доступности и простоты использования содержимого списка с виртуальным скроллингом имеются некоторые проблемы. Скрытые элементы не рендерятся — это значит, что они не будут доступны для средств чтения с экрана, и то, что их нельзя найти на странице, пользуясь стандартными механизмами браузера.

Виртуальный скроллинг идеален в ряде ситуаций (при условии, что он работает):

  • В том случае, если нужно выводить списки, размер которых заранее неизвестен, или такие, которые могут иметь огромные размеры (по приблизительной оценке — списки, в состав которых входит более 5 тысяч элементов, но это сильно зависит от сложности каждого элемента).
  • В том случае, если нужно организовать бесконечный скроллинг.

Ручной рендеринг


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

У нас имеется простой шаблон, в котором используется цикл, организуемый с помощью директивы *ngFor

<tr 
*ngFor="let item of data; trackBy: trackById; let isEven = even; let isOdd = odd"
    class="h-12"
    [class.bg-gray-400]="isEven"
    [class.bg-gray-500]="isOdd"
>
  <td>
    <span class="py-2 px-4">{{ item.id }}</span>
  </td>

  <td>
    <span>{{ item.label }}</span>
  </td>

  <td>
    <a>
      <button class="py-2 px-4 rounded (click)="remove(item)">x</button>
    </a>
  </td>
</tr>

Для измерения производительности рендеринга 10000 простых элементов я воспользовался бенчмарком, основанным на js-frameworks-benchmark.

Сначала я исследовал производительность списка, при выводе которого используется обычный цикл *ngFor. В результате оказалось, что выполнение кода (Scripting) заняло 1099 мс., на рендеринг (Rendering) ушло 1553 мс., а на рисование (Painting) — 3 мс.


Исследование производительности списка, при выводе которого используется *ngFor

Элементы списка можно рендерить вручную, воспользовавшись API Angular:

<tbody>
  <ng-container #itemsContainer></ng-container>
</tbody>
<ng-template #item let-item="item" let-isEven="isEven">
  <tr class="h-12 "
      [class.bg-gray-400]="isEven"
      [class.bg-gray-500]="!isEven"
  >
    <td>
      <span class="py-2 px-4">{{ item.id }}</span>
    </td>

    <td>
      <span>{{ item.label }}</span>
    </td>

    <td>
      <a>
        <button class="py-2 px-4 rounded" (click)="remove(item)">x</button>
      </a>
    </td>
  </tr>
</ng-template>

Поговорим о том, как изменился код контроллера.

Мы объявили шаблон и контейнер:

@ViewChild('itemsContainer', { read: ViewContainerRef }) container: ViewContainerRef;
@ViewChild('item', { read: TemplateRef }) template: TemplateRef<any>;

При формировании данных мы рендерим их с использованием метода createEmbeddedView сущности ViewContainerRef:

private buildData(length: number) {
  const start = this.data.length;
  const end = start + length;

  for (let n = start; n <= end; n++) {
    this.container.createEmbeddedView(this.template, {
      item: {
        id: n,
        label: Math.random()
      },
      isEven: n % 2 === 0
    });
  }
}

В результате показатели, характеризующие производительность списка, удалось немного улучшить. А именно — на выполнение кода ушло 734 мс., на рендеринг — 1443 мс., на рисование — 2 мс.


Исследование производительности списка, при выводе которого используется API Angular

Правда, на практике это означает, что список всё ещё работает крайне медленно. При нажатии на соответствующую кнопку браузер «замерзает» на несколько секунд. Если бы такое появилось в продакшне, пользователям это точно не понравилось бы.

Вот как это выглядит (тут я, с помощью мыши, имитирую индикатор загрузки ).


Медленная работа списка

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

Прогрессивный рендеринг


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

Код, приведённый ниже, реализующий прогрессивный рендеринг списка, устроен совсем несложно:

  • Сначала мы, пользуясь setInterval, налаживаем регулярный, выполняемый каждые 10 мс., вызов функции, выполняющей при вызове рендеринг 500 элементов.
  • После того, как все элементы будут выведены, что мы определяем, основываясь на анализе индекса текущего элемента, мы останавливаем регулярный вызов функции и прерываем цикл.

private buildData(length: number) {
  const ITEMS_RENDERED_AT_ONCE = 500;
  const INTERVAL_IN_MS = 10;

  let currentIndex = 0;

  const interval = setInterval(() => {
    const nextIndex = currentIndex + ITEMS_RENDERED_AT_ONCE;

    for (let n = currentIndex; n <= nextIndex ; n++) {
      if (n >= length) {
        clearInterval(interval);
        break;
      }
      const context = {
        item: {
          id: n,
          label: Math.random()
        },
        isEven: n % 2 === 0
      };
      this.container.createEmbeddedView(this.template, context);
    }

    currentIndex += ITEMS_RENDERED_AT_ONCE;
  }, INTERVAL_IN_MS);

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

Измерив производительность этого решения, я получил результаты, которые выглядят хуже, чем те, что получал раньше. Выполнение кода — 907 мс., рендеринг — 2555 мс., рисование — 16 мс.


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

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

Тут могут возникнуть некоторые проблемы, связанные с тем, что в ходе рендеринга контейнер меняет размеры, или с тем, что меняется позиция скроллинга содержимого. Если это случится — с подобными проблемами придётся бороться.

Вот как выглядит работа с таким списком.


Список работает быстро

Итоги


Методики ручного и прогрессивного рендеринга больших списков, безусловно, полезны в некоторых ситуациях. Я использовал их в тех случаях, когда виртуальный скроллинг по каким-то причинам мне не подходил.

Учитывая вышесказанное, можно сказать, что чаще всего виртуальный скроллинг, построенный на базе хорошей библиотеки, вроде Angular CDK, это — лучший способ вывода больших списков. Но если виртуальным скроллингом почему-то воспользоваться нельзя — у разработчика есть и другие возможности.

Уважаемые читатели! Как вы выполняете вывод больших списков в своих веб-проектах?

RUVDS.com
RUVDS – хостинг VDS/VPS серверов

Комментарии 10

    0
    Я использовал что-то типа прогрессивного рендеринга, только триггером был скроллинг. Плюс в том, что не нужно тратить ресурсы на элементы, которые возможно не будут смотреть. Минус — возможно, на некоторых устройствах, скроллинг будет на мгновения подмерзать перед выводом очередной порции данных. Из подводных камней: нужно отлавливать события изменения размеров экрана и проверять размеры контейнера списка, на лету корректировать количество элементов которые нужно вывести. Иначе можно наткнуться на ситуацию когда скроллинг пропал, а все элементы еще не выведены.
      0
      Тогда если фантазировать, то можно сделать гибрид по типу рендерим за кадром N элементов, а если пользователь начал скролить, то опять за кадром подгружаем N элементов. Всё равно могут быть проблемы при агрессивном скроллинге, но тоже как решение
        0
        Да проблемы особо нет. Скроллинг просто упрется и продолжится когда новая пачка отрисуется. Если такое поведение нормально.
          0
          если подгрузка будет занимать больше времени для плавного скрола, то это очень будет злить. Встречал такие решения. Для меня лучше уже будут пустые элементы с надписью Loading чем постоянно натыкаться на блокировку скрола :)
            0
            Не, данные уже как правило получены. Откладывать именно отрисовку.
      0
      Навскидку — а если скрестить *ngFor и прогрессивный рендеринг? Ведь без этого мы теряем привязку данных к состоянию элемента массива и следить за его обновлением при необходимости нужно самим.
        +1

        Я примерно так и делал недавно. Воспользовался тем, что можно довольно дешево пере-рендеривать один и тот же элемент списка, если он представлен компонентом с OnPush.
        Код выглядит примерно так


        @Component({
          template: `<item *ngFor="let item of displayedItems$ | async" [item]="item"></item>`
        })
        class ListComponent {
          // делаем observable input,
          // позволит отменять незаконченный рендеринг при получении нового массива items
          private _items$ = new ReplaySubject<Item[]>(1);
          @Input set items(value: Item[]) { this._items$.next(value) }
        
          displayedItems$ = this._items$.pipe(switchMap(emitIncrementally))
        }
        
        // реализация довольно тривиальна
        // пример вывода: emitIncrementally([0, 1, 2, 3, 4], {batchSize: 2})
        // - [0, 1] - сразу же
        // - [0, 1, 2, 3] - после таймаута
        // - [0, 1, 2, 3, 4] - после очередного таймаута
        function emitIncrementally() { ... }
        0
        Небольшое уточнение по Angular CDK — он не умеет работать с элементами которые идут колонками (плиткой). В случае если нужна «плитка» можно использовать ngx-virtual-scroller
          +1
          Как вы выполняете вывод больших списков в своих веб-проектах?

          У Вас не будет проблем с большими списками если не будет больших списков) Давайте будем честными, никто в здравом уме не захочет скроллить 3 часа чтобы выбрать 345684132489751324 элемент. Поэтому я пошел по принципу вот вам 10/50/100 первых опции и возможность поиска по списку, как только пользователь начинает вводить что он ищет — подсовываем ему подходящие опции в количестве 10/50/100.
            0
            Не во всех случаях возможен такой подход. У нас сотни элементов в коллекции из-за infinity scroll и поменять этот метод на другой не получится — пользователи не оценят от слова вообще

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

          Самое читаемое