Пишем высокопроизводительный вьюпорт для мессенджера

от автора

Предыдущая статья была посвящена инструменту ng‑virtual‑list. С тех пор инструмент обрел богатый функционал, внесен ряд существенных улучшений в алгоритмы виртуализации и трекинга, улучшена стабильность и производительность. Также был реализован порт на React. Кому интересны тесты, бенчмарки и цифры, чем отличается данный инструмент от аналогов и коробочных решений для Angular CDKVirtualFor, смотрите комменты к предыдущей статье.

Хочется отметить, что ng‑virtual‑list не просто виртуализированный список, он опционально может работать как виртуализированный select и multi‑select; умеет работать с группированными списками и в дальнейшем будет добавлена возможность collapsableGroups и работа в многопоточном режиме.

Проектирование

Теперь настало время опробовать всю силу виртуализации списков на практике.

Определимся с условиями для проектирования вьюпорта:

  • Список сообщений. Сообщения будут автоматически создаваться в начале и конце списка; реализовать возможность редактирования и удаления сообщений.

  • Поиск сообщения по подстроке.

  • Боковой список поставщиков сообщений, который будет отображаться в левом доке. Док открывается по клику на соответствующую кнопку. При выборе поставщика, отображается сгенерированный список сообщений и док закрывается.

  • Заголовок вьюпорта в котором будет отображаться название поставщика сообщений и элементы управления поиском сообщений и кнопка открытия дока поставщиков.

Макет будущего вьюпорта для мессенджера

Макет будущего вьюпорта для мессенджера

Будем использовать Angular 19.x и ng‑virtual‑list@19

Реализация

В данной статье не буду приводить весь код проекта, т. к. Вы сможете его найти ниже по ссылке. Сконцентрирую лишь на основных моментах.

Детальное описание шаблона:

Скрытый текст
<div class="container">    <!-- Панель инструментов -->    <div class="toolbar">      <div>        <!-- Кнопка открывающая док с поставщиками сообщений -->        <app-menu-button (click)="onOpenMenuHandler()" [opened]="menuOpened()" />      </div>      <!-- Заголовок поставщика сообщений -->      <div class="title">{{title()}}</div>      <!-- Элемент управления для поиска сообщений по подстроке -->      <app-search (search)="onSearchHandler($event)" />    </div>    <div class="list-container">      @let doc = dockMode();      <app-drawer [dock]="doc" [dockLeftSize]="240">        <!-- Док поставщика сообщений -->        <dock-left>          <div class="list-rooms__container">            <!-- Виртуальный список поставщиков сообщений -->            <ng-virtual-list class="list rooms" [items]="items" [itemRenderer]="itemRenderer" [trackBy]="'id'"              [itemSize]="40" [dynamicSize]="true" [bufferSize]="60"              (onItemClick)="onRoomClickHandler($event)"></ng-virtual-list>          </div>        </dock-left>        <!-- Вьюпорт сообщений -->        <div class="list-wrapper">          <!-- Виртуальный список сообщений -->          <ng-virtual-list #dynamicList class="list" [items]="groupDynamicItems" [itemRenderer]="groupItemRenderer"            [trackBy]="'id'" [itemSize]="40" [bufferSize]="30" [bufferSize]="120"            [itemConfigMap]="groupDynamicItemsConfigMap" [dynamicSize]="true" [snap]="true"            (onScroll)="onScrollHandler($event)" snappingMethod="advanced" methodForSelecting="multi-select"            (onScrollEnd)="onScrollEndHandler($event)" [enabledBufferOptimization]="false"            (onItemClick)="onClickHandler($event)"></ng-virtual-list>        </div>      </app-drawer>    </div>  </div><!-- Шаблон сообщения -->  <ng-template #groupItemRenderer let-data="data" let-measures="measures" let-config="config">    @if (data) {      @switch (data.type) {        <!-- Индикатор формирования сообщения -->        @case ("write-indicator") {          <div class="list__windicator-container">            <!-- Тут располагается пиктограмма для индикатора формирования сообщения -->          </div>        }        <!-- Заголовок группы сообщений -->        @case ("group-header") {          <div class="list__group-container" [ngClass]="{'snapped': config.snapped, 'snapped-out': config.snappedOut}">            <span>{{data.name}}</span>          </div>        }        <!-- Cобщение -->        @default {          @let isIn = data.incomType === 'in';          @let isOut = data.incomType === 'out';          @let class = {'in': isIn, 'out': isOut, 'edited': data.edited, 'selected': config.selected, focused: config.focus};          <div class="list__container" [ngClass]="class" [longPress]="1000">            <div class="message__container" [ngClass]="class">              <div class="message" [ngClass]="class">                @if (data.edited) {                  <!-- Редактированние сообщения с изображением -->                  @if (data.image) {                    <div class="complex-message">                      <img [src]="data.image" />                      <textarea clickOutside [clickOutsideItem]="data" (keydown)="onKeyDownHandler($event)"                    (onClickOutside)="onOutsideClickHandler($event, data, config.selected)"                    [ngStyle]="{height: getContentHeight(measures.height, true) + 'px'}" [value]="data.name"                    (click)="onTAClickHandler($event)" (onOutsideClose)="onEditingCloseHandler($event)"                    (change)="onEditedHandler($event, data)"></textarea>                    </div>                  } @else {                    <!-- Редактированние сообщения без изображения -->                    <textarea clickOutside [clickOutsideItem]="data" (keydown)="onKeyDownHandler($event)"                    (onClickOutside)="onOutsideClickHandler($event, data, config.selected)"                    [ngStyle]="{height: getContentHeight(measures.height) + 'px'}" [value]="data.name"                    (click)="onTAClickHandler($event)" (onOutsideClose)="onEditingCloseHandler($event)"                    (change)="onEditedHandler($event, data)"></textarea>                  }                } @else {                  <!-- Сообщение с изображением -->                  @if (data.image) {                    <div class="complex-message">                      <img [src]="data.image" />                      <span searchHighlight substringClass="search-substring" [text]="data.name" [searchedWords]="searchedWords()"                        (click)="onEditItemHandler($event, data, config.selected)">{{data.name}}</span>                    </div>                  } @else {                    <!-- Сообщение без изображения -->                    <span searchHighlight substringClass="search-substring" [text]="data.name" [searchedWords]="searchedWords()"                      (click)="onEditItemHandler($event, data, config.selected)">{{data.name}}</span>                  }                }              </div>                <!-- При выборе сообщения отображается элемент управления для его удаления -->                @if (config.selected) {                  <div class="flex"></div>                  <div class="message__controls">                    <div class="ctrl__button del-icon" (click)="onDeleteItemHandler($event, data)">                      <!-- Иконка для кнопки удаления -->                    </div>                  </div>                }              </div>            </div>          }        }      }  </ng-template>  <!-- Шаблон элемента списка поставщика сообщений -->  <ng-template #itemRenderer let-data="data" let-config="config">    @if (data) {      @switch (data.type) {        @case ("group-header") {          <div class="list__item-container">            <div class="content">              <span>{{data.name}}</span>            </div>          </div>        }        @default {          <div class="list__item-container">            <div class="message">              <span>                <!-- Тут располагается пиктограмма для поставщика -->              </span>              <span>{{data.name}}</span>            </div>          </div>        }      }    }  </ng-template></div>

Детальное описание компонента App:

Скрытый текст
constructor(private _service: ClickOutsideService) {    const list = this._listContainerRef;    this.dockMode = computed(() => {      const menuOpened = this.menuOpened();      return menuOpened ? DockMode.LEFT : DockMode.NONE;    });    const $virtualList = toObservable(list).pipe(      filter(list => !!list),      switchMap(list => combineLatest([of(list), list?.$initialized])),      filter(([, init]) => !!init),      map(([list]) => list),    );    // В момент инициализации и обновления списка сообщений    // проверяет, если нужно проскроллить до конца списка, то выполняет скролл    combineLatest([this.$version, $virtualList]).pipe(      map(([version, list]) => ({ version, list })),      filter(({ list }) => !!list),      debounceTime(50),      tap(({ version, list }) => {        if (version === 0) {          list!.scrollToEnd('instant');        }        if (this._$isEndOfListPosition.getValue()) {          list!.scrollToEnd('instant');        }      }),    ).subscribe();    // Поиск и скролл до искомого сообщения во вьюпорте    combineLatest([$virtualList, toObservable(this.search)]).pipe(      map(([list, search]) => ({ list, search })),      filter(({ list }) => !!list),      debounceTime(0),      tap(({ list, search }) => {        this.searchedWords.set(search.split(' '));        for (let i = 0, l = this.groupDynamicItems.length; i < l; i++) {          const item = this.groupDynamicItems[i], name: string = item['name'];          if (name) {            const index = name?.indexOf(search);            if (index > -1) {              list!.scrollTo(item.id, 'instant');              break;            }          }        }      }),    ).subscribe();    // При инициализации генерируется первое сообщение    $virtualList.pipe(      delay(100),      mergeMap(() => this.write()),    ).subscribe();    // Далее генерация новых сообщений выполняется с интервалом в 2сек    from(interval(2000)).pipe(      mergeMap(() => this.write()),    ).subscribe();    // Вычисление, является ли позиция скролла конечной    combineLatest([toObservable(this._scrollParams), $virtualList, this.$version]).pipe(      delay(10),      switchMap(([{ viewportEndY, scrollWeight }, list]) => {        let bounds: ISize | undefined;        if (list) {          bounds = list.getItemBounds(this.groupDynamicItems[this.groupDynamicItems.length - 1].id);        }        const height = (bounds?.height ?? 0);        return of((viewportEndY + height + SNAP_HEIGHT) >= scrollWeight);      }),      tap(v => {        this._$isEndOfListPosition.next(v);      }),    ).subscribe();    const appHeightHandler = () => document.documentElement.style.setProperty('--app-height', `${window.innerHeight}px`);    window.addEventListener('resize', appHeightHandler);    $virtualList.pipe(      tap(() => {        appHeightHandler();      }),      delay(100),      tap(() => {        document.documentElement.style.setProperty('--viewport-alpha', '1');      }),    ).subscribe();  }  /**   * Вычисляет высоту для редактируемого текстового поля   */  getContentHeight(v: number, hasImage: boolean = false) {    return Math.ceil(v) - 34 - (hasImage ? 72 : 0);  }  /**   * Обработчик поиска по подстроке   */  onSearchHandler(pattern: string) {    this.search.set(pattern);  }  /**   * Сброс списка в начальное состояние   * Вызывается после выбора поставщика сообщений   */  private resetList() {    this.groupDynamicItems = [...GROUP_DYNAMIC_ITEMS];    this.groupDynamicItemsConfigMap = { ...GROUP_DYNAMIC_ITEMS_STICKY_MAP };  }  /** Поток формирования сообщения */  private write() {    const msg = generateMessage(this._nextIndex);    this._nextIndex++;    return of(msg).pipe(      tap(() => {        const writeIndicator = generateWriteIndicator(this._nextIndex);        this._nextIndex++;        this.groupDynamicItems = [...this.groupDynamicItems, writeIndicator];        this.groupDynamicItemsConfigMap[writeIndicator.id] = {          sticky: 0,          selectable: false,        };        const writeIndicatorShift = generateWriteIndicator(this._nextIndex);        this._nextIndex++;        this.groupDynamicItems = [writeIndicatorShift, ...this.groupDynamicItems];        this.groupDynamicItemsConfigMap[writeIndicatorShift.id] = {          sticky: 0,          selectable: false,        };        this.increaseVersion();      }),      delay(500),      tap(() => {        const items = [...this.groupDynamicItems];        items.pop();        items.push(msg);        this.groupDynamicItemsConfigMap[msg.id] = {          sticky: 0,          selectable: true,        };        items.shift();        for (let i = 0, l = 1; i < l; i++) {          const msgStart = generateMessage(this._nextIndex);          this._nextIndex++;          this.groupDynamicItemsConfigMap[msgStart.id] = {            sticky: 0,            selectable: true,          };          items.unshift(msgStart);        }        this.groupDynamicItems = items;        this.increaseVersion();      }),    );  }  /**   * Записывает метрики скролла   */  onScrollHandler(e: IScrollEvent & { [x: string]: any; }) {    this._scrollParams.set({      viewportEndY: e.scrollSize + e.size,      scrollWeight: e.scrollWeight,    });  }  /**   * Записывает метрики скролла   */  onScrollEndHandler(e: IScrollEvent & { [x: string]: any; }) {    this._scrollParams.set({      viewportEndY: e.scrollSize + e.size,      scrollWeight: e.scrollWeight,    });  }  /**   * Трэйс клика по сообщению   */  onClickHandler(item: IRenderVirtualListItem | undefined) {    if (item) {      console.info(`Click: (ID: ${item.id}) Item ${item.data.name}`);    }  }  /**   * Блокировка распространения события `keydown` при нажатии `Space`   * Это необходимо, чтобы предотвратить снятие выделения с сообщения.   * Т.к. за выбор сообщения в списке отвечает именно клавиша `Space`   */  onKeyDownHandler(e: KeyboardEvent) {    if (e.key === ' ') {      e.stopImmediatePropagation();    }  }  /**   * Обработчик переключения режима редактирования   */  onEditItemHandler(e: Event, item: IRenderVirtualListItem | undefined, selected: boolean) {    if (selected) {      e.stopImmediatePropagation();    }    const index = this.groupDynamicItems.findIndex(({ id }) => id === item?.id);    if (index > -1) {      const items = [...this.groupDynamicItems], item = items[index];      items[index] = { ...item, edited: selected ? !item.edited : false };      this.groupDynamicItems = items;      this.increaseVersion();    }  }  onTAClickHandler(e: Event) {    e.stopImmediatePropagation();  }  /**   * Завершение редактирования сообщения по срабатыванию `outside click`   */  onOutsideClickHandler(e: Event, item: IRenderVirtualListItem<any> | undefined, selected: boolean) {    const index = this.groupDynamicItems.findIndex(({ id }) => id === item?.id);    if (index > -1) {      const items = [...this.groupDynamicItems], item = items[index];      items[index] = { ...item, edited: false };      this.groupDynamicItems = items;      this.increaseVersion();    }    this._service.activeTarget = null;  }  /**   * Обработчик завершения редактирования сообщения   */  onEditingCloseHandler(data: { target: any; item: IItemData & { id: Id }; }) {    const index = this.groupDynamicItems.findIndex(({ id }) => id === data.item.id);    if (index > -1) {      const items = [...this.groupDynamicItems], _item = items[index];      items[index] = { ..._item, edited: false, name: data.target.value };      this.groupDynamicItems = items;      this.increaseVersion();    }  }  /**   * Обработчик перехода сообщения из просмотра в режим редактирования и наооборот   */  onEditedHandler(e: any, item: IRenderVirtualListItem<any> | undefined) {    const index = this.groupDynamicItems.findIndex(({ id }) => id === item?.id);    if (index > -1) {      const items = [...this.groupDynamicItems], _item = items[index];      items[index] = { ..._item, edited: !_item.edited, name: e.target.value };      this.groupDynamicItems = items;      this.increaseVersion();    }  }  /**   * Обработчик удаления сообщения   */  onDeleteItemHandler(e: Event, item: IRenderVirtualListItem | undefined) {    e.stopImmediatePropagation();    const index = this.groupDynamicItems.findIndex(({ id }) => id === item?.id);    if (index > -1) {      const items = [...this.groupDynamicItems];      items.splice(index, 1);      this.groupDynamicItems = items;      this.increaseVersion();    }  }  /**   * Обработчик клика по поставщику сообщений   */  onRoomClickHandler(item: IRenderVirtualListItem | undefined) {    this.menuOpened.set(false);    if (item) {      this.title.set(item.data['name']);      this.resetList();      this._listContainerRef()?.scrollToEnd('instant');      setTimeout(() => {        this._listContainerRef()?.scrollToEnd('instant');      }, 150);    }  }  /**   * Открытие/закрытие дока с поставщиками сообщений   */  onOpenMenuHandler() {    this.menuOpened.update(v => !v);  }

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

Preview вьюпорта

Preview вьюпорта

Полный код проекта см. по ссылке ng‑virtual‑list‑demo

Live demo проекта

Понравился проект и инструмент? Тогда ставьте ⭐ ng‑virtual‑list и инструмент будет дальше развиваться и улучшаться!

А также, если интересен данный инструмент виртуализации списков под React, тоже ставьте ⭐ rcx‑virtual‑list и проект будет портирован с полной функциональностью оригинала!

ссылка на оригинал статьи https://habr.com/ru/articles/947256/