From 17f818656d3be8c06293df0349a4f9b403e5fba2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=B3=D0=BE=D1=80=D1=8C=20=D0=9D=D0=B0=D0=B4=D0=B5?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Mon, 12 Jan 2026 10:56:59 +0300 Subject: [PATCH] =?UTF-8?q?perf(messenger):=20=D0=BE=D0=BF=D1=82=D0=B8?= =?UTF-8?q?=D0=BC=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20=D0=B7=D0=B0=D0=B3?= =?UTF-8?q?=D1=80=D1=83=D0=B7=D0=BA=D0=B8=20initial=20messages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Проблема: При открытии чата сначала загружались сообщения, затем происходила видимая прокрутка вниз - плохой UX Решение: 1. Скрываем контейнер (visibility: hidden) перед рендерингом 2. Используем DocumentFragment в renderMessages для батчевой вставки 3. Устанавливаем scrollTop ДО показа контейнера 4. Показываем контейнер - пользователь сразу видит конец диалога Преимущества: - Нет видимой прокрутки - сразу показывается конец чата - Меньше reflow (1 вместо N при forEach) - Быстрее рендеринг больших чатов (все элементы вставляются за раз) - Лучший UX - нет "прыжков" при загрузке Изменения: - userWebSocket.js: добавлено скрытие/показ контейнера, прямая установка scrollTop - messageRenderer.js: renderMessages использует DocumentFragment вместо множественных insertAdjacentHTML --- .../static/js/components/messageRenderer.js | 33 +++++++++++++++++-- backend/static/js/components/userWebSocket.js | 19 +++++++---- 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/backend/static/js/components/messageRenderer.js b/backend/static/js/components/messageRenderer.js index ddb3f9a9..e7797b7d 100644 --- a/backend/static/js/components/messageRenderer.js +++ b/backend/static/js/components/messageRenderer.js @@ -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 = `${msgDay}`; + 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); } /** diff --git a/backend/static/js/components/userWebSocket.js b/backend/static/js/components/userWebSocket.js index b6c3fa19..57f7c1a7 100644 --- a/backend/static/js/components/userWebSocket.js +++ b/backend/static/js/components/userWebSocket.js @@ -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) {