Предыдущая статья была посвящена инструменту 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); }
Помимо этого проект включает в себя различные директивы, сервисы и вспомогательные компоненты.

Полный код проекта см. по ссылке ng‑virtual‑list‑demo
Live demo проекта
Понравился проект и инструмент? Тогда ставьте ⭐ ng‑virtual‑list и инструмент будет дальше развиваться и улучшаться!
А также, если интересен данный инструмент виртуализации списков под React, тоже ставьте ⭐ rcx‑virtual‑list и проект будет портирован с полной функциональностью оригинала!
