perf(messenger): оптимизация загрузки initial messages

Проблема: При открытии чата сначала загружались сообщения,
затем происходила видимая прокрутка вниз - плохой UX

Решение:
1. Скрываем контейнер (visibility: hidden) перед рендерингом
2. Используем DocumentFragment в renderMessages для батчевой вставки
3. Устанавливаем scrollTop ДО показа контейнера
4. Показываем контейнер - пользователь сразу видит конец диалога

Преимущества:
- Нет видимой прокрутки - сразу показывается конец чата
- Меньше reflow (1 вместо N при forEach)
- Быстрее рендеринг больших чатов (все элементы вставляются за раз)
- Лучший UX - нет "прыжков" при загрузке

Изменения:
- userWebSocket.js: добавлено скрытие/показ контейнера, прямая установка scrollTop
- messageRenderer.js: renderMessages использует DocumentFragment вместо множественных insertAdjacentHTML
This commit is contained in:
Игорь Надеин 2026-01-12 10:56:59 +03:00
parent 1be2f3cdd4
commit 17f818656d
2 changed files with 44 additions and 8 deletions

View File

@ -22,19 +22,48 @@ export class MessageRenderer {
return;
}
// ОПТИМИЗАЦИЯ: Используем DocumentFragment для батчевой вставки
const fragment = document.createDocumentFragment();
let lastDay = null;
messages.forEach(msg => {
// Проверяем на дубликаты
const existingMessage = container.querySelector(`[data-message-id="${msg.id}"]`);
if (existingMessage) {
console.log('[MessageRenderer] Message already exists, skipping:', msg.id);
return;
}
// Добавляем разделитель дня если нужно
const msgDate = new Date(msg.created_ts);
const msgDay = this.formatDay(msgDate);
if (msgDay !== lastDay) {
this.addDayDivider(msgDay, container);
const dividerEl = document.createElement('div');
dividerEl.className = 'day-divider text-center small text-muted my-3';
dividerEl.innerHTML = `<span class="px-3 py-1 rounded-pill bg-light">${msgDay}</span>`;
fragment.appendChild(dividerEl);
lastDay = msgDay;
}
this.renderMessage(msg, container);
// Создаем элемент сообщения и добавляем во fragment
const isOwn = msg.author_id === this.currentUserId;
const messageEl = document.createElement('div');
messageEl.className = `d-flex mb-3 msg ${isOwn ? 'justify-content-end' : 'justify-content-start'}`;
messageEl.setAttribute('data-id', String(msg.id));
messageEl.setAttribute('data-message-id', String(msg.id));
messageEl.setAttribute('data-ts', String(msg.created_ts));
messageEl.setAttribute('data-author-id', String(msg.author_id || ''));
messageEl.setAttribute('data-is-edited', String(msg.is_edited || false));
messageEl.setAttribute('data-edited-at', String(msg.edited_at || ''));
messageEl.setAttribute('data-reactions', JSON.stringify(msg.reactions_summary || {}));
messageEl.innerHTML = this.buildMessageInnerHtml(msg, isOwn);
fragment.appendChild(messageEl);
});
// Одна вставка всех элементов - минимизируем reflow
container.appendChild(fragment);
}
/**

View File

@ -273,12 +273,19 @@ export function initUserWebSocket(options = {}) {
}
console.log('[UserWS] Rendering %d initial messages via MessageRenderer', messages.length);
messages.forEach(msg => {
options.messageRenderer.renderMessage(msg);
});
// Скроллим вниз
scrollToBottom(true);
// ОПТИМИЗАЦИЯ: Скрываем контейнер перед рендерингом для предотвращения reflow
const wasVisible = state.scrollEl.style.visibility;
state.scrollEl.style.visibility = 'hidden';
// Используем батчевый renderMessages вместо forEach для лучшей производительности
options.messageRenderer.renderMessages(messages);
// Устанавливаем scroll в конец ДО показа контейнера
state.scrollEl.scrollTop = state.scrollEl.scrollHeight;
// Показываем контейнер - пользователь сразу видит конец диалога
state.scrollEl.style.visibility = wasVisible || '';
// Инициализируем голосования после загрузки сообщений
if (window.chatPoll) {