2025, Oct 22 13:31

Почему HTMX с WebSocket в Django не обновляет DOM и как это исправить

Разбор проблемы с HTMX и WebSocket в Django Channels: сообщение приходит, но DOM пуст. Причина и два способа исправить замену: обёртка или hx-swap-oob.

Иногда HTMX поверх WebSocket «работает, но ничего не отображает»: в DevTools видно, что сообщение от сервера пришло, однако целевая область остаётся пустой. В этом разборе на конкретном примере с Django Channels показывается, почему DOM не заполняется и как добиться реальной подмены содержимого.

Кратко о проблеме

При подключении WebSocket‑консьюмер отправляет сгенерированный HTML‑фрагмент. Страница с расширением HTMX WebSocket принимает этот фрагмент и должна подменить им содержимое целевого контейнера. Сообщение приходит, но дашборд остаётся пустым.

Код для воспроизведения

Серверный консьюмер, который рендерит счётчики и отправляет их клиенту:

from channels.generic.websocket import WebsocketConsumer
from django.template.loader import render_to_string
from myapp.models import Model1, Model2, Model3, Model4
class MetricsBannerSocket(WebsocketConsumer):
    def push_totals(self):
        figures = [
            {"label": "Model1", "total": Model1.objects.count()},
            {"label": "Model2", "total": Model2.objects.count()},
            {"label": "Model3", "total": Model3.objects.count()},
            {"label": "Model4", "total": Model4.objects.count()},
        ]
        html_payload = render_to_string("dashboard/metrics-fragment.html", {"figures": figures})
        self.send(text_data=html_payload)
    def connect(self):
        self.accept()
        self.push_totals()

HTML‑фрагмент для вывода статистики:

{% for row in figures %}
<div class="col-sm-6 col-xl-3">
    <div class="dashboard-stat rounded d-flex align-items-center justify-content-between p-4">
        <div class="ms-3">
            <p class="mb-2">{{ row.label }}</p>
            <h6 class="mb-0">{{ row.total }}</h6>
        </div>
    </div>
</div>
{% endfor %}

Клиентский шаблон, который поднимает WebSocket‑подключение и задаёт поведение подмены:

{% load static %}
{% block styles %}
    <link rel="stylesheet" href="{% static 'css/dashboard.css' %}">
{% endblock %}
<div class="container-fluid pt-4 px-4">
    <div class="row g-2 mb-2 stats-wrapper"
         hx-ext="ws"
         ws-connect="/ws/dashboard/header/"
         hx-target=".stats-wrapper"
         hx-swap="innerHTML"
    >
    </div>
    <div class="active-tasks-scroll-container">
        <div class="row flex-nowrap g-2">
        </div>
    </div>
</div>

Причина

Сервер отправляет «голый» HTML, в котором нет элемента .stats-wrapper. Цель подмены — именно .stats-wrapper, но во фрагменте ответа он отсутствует, поэтому HTMX не находит соответствующий элемент и ничего не заменяет в DOM. В панели сети сообщение видно, но рендера не происходит, потому что в ответе нет того контейнера, с которым HTMX должен работать.

Как исправить

Есть два простых способа заставить подмену стабильно срабатывать с полезной нагрузкой WebSocket.

Первый вариант — включить обёртку в ответ, чтобы HTMX нашёл .stats-wrapper и выполнил замену. Обновлённый шаблон выглядит так:

<div class="row g-2 mb-2 stats-wrapper">
    {% for row in figures %}
    <div class="col-sm-6 col-xl-3">
        <div class="dashboard-stat rounded d-flex align-items-center justify-content-between p-4">
            <div class="ms-3">
                <p class="mb-2">{{ row.label }}</p>
                <h6 class="mb-0">{{ row.total }}</h6>
            </div>
        </div>
    </div>
    {% endfor %}
</div>

Второй вариант — продолжать отправлять только внутреннее содержимое, но пометить ответ как «вне полосы» (out‑of‑band). Тогда HTMX найдёт и заменит уже существующий .stats-wrapper в DOM, даже если он не является прямой целью подмены для текущего элемента.

<div class="row g-2 mb-2 stats-wrapper" hx-swap-oob="true">
    {% for row in figures %}
    <div class="col-sm-6 col-xl-3">
        <div class="dashboard-stat rounded d-flex align-items-center justify-content-between p-4">
            <div class="ms-3">
                <p class="mb-2">{{ row.label }}</p>
                <h6 class="mb-0">{{ row.total }}</h6>
            </div>
        </div>
    </div>
    {% endfor %}
</div>

В обоих случаях HTMX сможет обнаружить соответствующий элемент .stats-wrapper и выполнить подмену — счётчики отобразятся как задумано.

Почему это важно

При потоковой отправке серверных фрагментов в браузер цель подмены, с которой работает HTMX, должна быть либо доступна в самой полезной нагрузке, либо помечена для внеполосной замены. Если во входящем HTML нет элемента, который HTMX собирается заменить, обновление «тихо» не происходит с точки зрения пользователя. Гарантируя наличие цели в ответе или с помощью механизма out‑of‑band, вы делаете интерфейсы на WebSocket предсказуемыми и удобными для отладки.

Вывод

Если ответ по WebSocket приходит, но ничего не отображается, проверьте, что во фрагменте есть элемент, который HTMX должен заменить, или используйте hx-swap-oob, чтобы явно указать замену. Эта небольшая структурная деталь часто решает всё: вместо пустого дашборда — живой, обновляющийся заголовок.

Материал основан на вопросе на StackOverflow от user31153869 и ответе Mahrez BenHamad.