Речь пойдет конечно же о списках с виртуализацией.
Немного теории
В классическом исполнении, списки включают в себя все элементы из коллекции. Другими словами, те элементы, которые не видны пользователю (находятся за пределами вьюпорта) все равно присутствуют в DOM дереве. А теперь представим, если список состоит допустим из 1 000 000 элементов, как это повлияет на производительность и ресурсоемкось? Ответ очевиден, пропорционально объёму коллекции будет расти ресурсопотребление и снижаться общая производительность.
Но к счастью для нас есть методы и алгоритмы позволяющие существенно ускорить работу таких «исполинских» списков.

Концепция виртуализации списков очень проста. Вычисляется количество элементов, которое умещается в границах вьюпорта плюс зоны буфера, создается соответствующее количество компонентов элементов списка и далее все манипуляции будут происходить именно с уже созданными компонентами. При получении события скролла вызываются методы перерасчета элементов входящих в список визуализации. Тот компонент, который выходит за область буфера, будет использоваться в момент смещения списка заново. Ему будут назначены новые координаты и переданы новые данные из соответствующего элемента коллекции.
Процесс назначения новых координат и данных элемента коллекции "свободному" компоненту называется трекингом. Трекинг элементов списка обязательно должен осуществляться по уникальному идентификатору, т.к. создается мэпинг соответствия элемента коллекции по заданному идентификатору и компоненту экранной области.

Как видно из изображения выше, в дереве DOM количество элементов не превышает рассчитанный и заданный буфером предел (т.е. количество компонентов +- постоянное).
Если говорить о виртуализированных списках с фиксированным размером элементов, то это весьма простая задача с линейными расчетами.
Виртуализированные списки с адаптивными по размеру контента элементами уже не такая и простая задача. Чтобы корректно высчитывать позиции, необходимо вводить понятие кэша "размеченной" и "не размеченной" областей, далее вычислять метрики и корректно визуализировать компоненты с помощью рекурсивно "цепного" обновления. Но и это еще не все. При скроллинге возникнут "проблемы" с позициями, чтобы их решить нужно будет вводить дельту смещения, которая высчитывается исходя из разницы "размеченной" и "не размеченной" областей.
Кто из Вас хочет попрактиковаться в написании подобного решения, это отличный челлендж!
А далее речь пойдёт о готовом к работе инструменте.
NgVirtualList

Далее мы рассмотрим пример создания виртуализированного списка с входящими и исходящими сообщениями, сгруппированные например по дате. Размер коллекции будет равен 100000 сообщений.
Нам потребуется версия Angular от 14.x до 20.x На момент написания статьи последняя релизная версия пакета 20.0.12, которая соответствует версии Angular 20.x
Установка cli angular:
npm i -g @angular/cli@20
Создание нового Angular проекта:
ng new [Your project name]
Переходим в директорию созданного проекта:
cd [Your project name]
Установка инструмента ng-virtual-list:
npm i ng-virtual-list@20
В src/app/app.component.ts прописываем следующий код:
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { NgVirtualListComponent, IVirtualListCollection, IVirtualListStickyMap } from 'ng-virtual-list';
///////////// Генерация коллекции сообщений с произвольным текстом /////////////
const MAX_ITEMS = 100000;
const CHARS = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z'];
const generateLetter = () => {
return CHARS[Math.round(Math.random() * CHARS.length)];
}
const generateWord = () => {
const length = 5 + Math.floor(Math.random() * 50), result = [];
while (result.length < length) {
result.push(generateLetter());
}
return `${result.join('')}`;
};
const generateText = () => {
const length = 2 + Math.floor(Math.random() * 10), result = [];
while (result.length < length) {
result.push(generateWord());
}
let firstWord = '';
for (let i = 0, l = result[0].length; i < l; i++) {
const letter = result[0].charAt(i);
firstWord += i === 0 ? letter.toUpperCase() : letter;
}
result[0] = firstWord;
return `${result.join(' ')}.`;
};
const GROUP_DYNAMIC_ITEMS: IVirtualListCollection = [],
GROUP_DYNAMIC_ITEMS_STICKY_MAP: IVirtualListStickyMap = {};
let groupDynamicIndex = 0;
for (let i = 0, l = MAX_ITEMS; i < l; i++) {
const id = i + 1, type = i === 0 || Math.random() > .895 ? 'group-header' : 'item', incomType = Math.random() > .5 ? 'in' : 'out';
if (type === 'group-header') {
groupDynamicIndex++;
}
GROUP_DYNAMIC_ITEMS.push({ id, type, name: type === 'group-header' ? `Group ${groupDynamicIndex}` : `${id}. ${generateText()}`, incomType });
GROUP_DYNAMIC_ITEMS_STICKY_MAP[id] = type === 'group-header' ? 1 : 0;
}
////////////////////////////////////////////////////////////////////////////////
@Component({
selector: 'app-root',
standalone: true,
imports: [CommonModule, NgVirtualListComponent],
templateUrl: './app.component.html',
styleUrl: './app.component.scss'
})
export class AppComponent {
groupDynamicItems = GROUP_DYNAMIC_ITEMS;
groupDynamicItemsStickyMap = GROUP_DYNAMIC_ITEMS_STICKY_MAP;
}
Настраиваем шаблон src/app/App.component.html:
<div class="wrapper">
<div class="vl-section block cap">
<h1 class="center">ng-virtual-list demo</h1>
</div>
<div class="container">
<ng-virtual-list #dynamicList class="list" [items]="groupDynamicItems" [itemRenderer]="groupItemRenderer"
[itemsOffset]="10" [stickyMap]="groupDynamicItemsStickyMap" [dynamicSize]="true" [snap]="true"></ng-virtual-list>
</div>
<ng-template #groupItemRenderer let-data="data" let-config="config">
@if (data) {
@switch (data.type) {
@case ("group-header") {
<div class="list__group-container" [ngClass]="{'snapped': config.snapped, 'snapped-out': config.snappedOut}">
<span>{{data.name}}</span>
</div>
}
@default {
@let isIn = data.incomType === 'in';
@let isOut = data.incomType === 'out';
@let class = {'in': isIn, 'out': isOut};
<div class="list__container" [ngClass]="class" [ngStyle]="{}">
<div class="message" [ngClass]="class">
<span>{{data.name}}</span>
</div>
</div>
}
}
}
</ng-template>
</div>
Редактируем src/app/app.component.scss:
// reset ng-virtual-list-item styles
.list::part(item) {
background-color: unset;
}
.wrapper {
display: flex;
flex-direction: column;
align-items: center;
display: grid;
grid-template-rows: auto 1fr;
overflow: hidden;
justify-content: center;
width: 100%;
height: 100%;
}
.vl-section {
padding: 0 20px;
margin-bottom: 24px;
width: calc(100% - 40px);
max-width: 640px;
&.cap {
margin-bottom: 24px;
}
&>h1 {
background: -webkit-linear-gradient(0deg, #6f00e2 0%0%, #6f00e2 30%, #f90058 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
font-size: 19px;
font-weight: 800;
text-transform: uppercase;
margin: 0;
padding: 0;
letter-spacing: 0.5px;
margin-bottom: 9px;
&.center {
width: 100%;
text-align: center;
}
}
&.block {
padding: 20px 20px 4px;
}
}
.container {
display: grid;
height: calc(100%);
width: auto;
min-width: 480px;
overflow: hidden;
}
.list {
background: linear-gradient(180deg, rgb(80, 42, 155) 0%, rgb(53, 147, 184) 100%);
border-radius: 4px;
&::part(scroller) {
scroll-behavior: auto;
}
&__container {
height: 100%;
width: 100%;
padding: 6px 20px;
background-color: transparent;
box-sizing: border-box;
display: flex;
align-items: center;
&>* {
height: auto;
}
&.in {
justify-content: start;
}
&.out {
justify-content: end;
}
.message {
display: flex;
max-width: 55%;
background-color: rgb(210, 220, 221);
border-radius: 12px;
box-shadow: 1px 2px 8px 4px rgba(0, 0, 0, 0.075);
border: 1px solid rgba(0, 0, 0, 0.1);
padding: 10px 14px;
word-break: break-all;
&.in {
border-bottom-left-radius: 0px;
}
&.out {
border-bottom-right-radius: 0px;
}
}
}
&__group-container {
&.snapped,
&.snapped-out {
background-color: rgb(80, 45, 156);
}
height: 100%;
width: 100%;
padding: 6px 12px;
background-color: transparent;
color: rgb(241 246 255);
box-sizing: border-box;
font-weight: 800;
font-size: 12px;
text-transform: uppercase;
display: flex;
align-items: center;
justify-content: center;
&>* {
height: auto;
}
}
&.vertical {
max-height: unset;
height: 100%;
width: 100%;
}
}
Редактируем src/style.scss
body {
font-family: Arial, Helvetica, sans-serif;
background-color: #0f0f11;
padding: 0 40px 40px;
margin: 0;
width: calc(100% - 80px);
height: calc(100vh - 80px);
}
Запускаем проект:
ng serve
Переходим в браузере на localhost:4200

Итог
Мы реализовали "экстремально" оптимизированный сгруппированный виртуальный список с динамическими по размеру контента элементами, за считанные минуты.