Предыдущая статья https://habr.com/ru/articles/923120/ была посвящена инструменту 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 и проект будет портирован с полной функциональностью оригинала!