Обновление страницы без JavaScript
Передавайте обновления HTML без JavaScript с помощью template for и инструкций обработки. Изучите пример на Node, поддержку браузерами и ограничения.
Элемент <template for="name"> заполняет плейсхолдер, отмеченный ранее в том же документе инструкцией обработки с таким же именем. Сервер отправляет медленные фрагменты в конце того же HTML-ответа, а парсер браузера сам перемещает их на нужное место. Никакой скрипт при этом не выполняется.
На большинстве страниц с серверным рендерингом есть одна медленная область, например счётчик заказов или проверка статуса. Обычно проблему решают так: выводят div с индикатором загрузки и добавляют скрипт, который затем подгружает фрагмент. Именно от этого скрипта многие команды хотели бы избавиться.
В статье показаны разметка, готовый к запуску Node-сервер, который передаёт обновления потоком, место этого подхода рядом с htmx, а также то, что отображают браузеры без поддержки.
Ключевые выводы
<template for>заменяет диапазон<?start name="x">…<?end>или заполняет точку вставки<?marker name="x">, как только HTML-парсер до него доходит.- Сервер должен сначала сразу отправить каркас страницы, а шаблоны дописать позже в том же ответе. Любой слой, буферизующий ответ целиком, доставит плейсхолдер и его замену одновременно.
- В Chrome 150 потоковая передача вне порядка документа включена по умолчанию. Сопутствующие JavaScript-методы, например
streamHTML, появятся в Chrome 155. В более ранних версиях нужен флаг. - Браузеры без поддержки разбирают инструкции обработки как комментарии, поэтому резервное содержимое остаётся на экране.
Проблема: загрузить, найти, заменить
До сих пор обновление одной области страницы с серверным рендерингом требовало трёх шагов на клиенте: загрузить фрагмент, найти целевой узел и заменить его содержимое. Даже минимальная реализация требует скрипта, второго запроса и поиска по DOM:
<div id="status">Loading…</div>
<script type="module">
const res = await fetch('/status.html');
document.querySelector('#status').innerHTML = await res.text();
</script>
Сервер уже знал статус, когда рендерил первый ответ. Дополнительный круговой запрос нужен лишь потому, что HTML всегда разбирался в порядке следования документа.
Как декларативные частичные обновления работают в разметке
Декларативные частичные обновления используют инструкции обработки как именованные якоря, а <template for> — как патч. Анонс декларативных частичных обновлений в блоге Chrome определяет три якоря. <?marker name="x"> — это точка вставки. <?start name="x"> и <?end> обрамляют заменяемый диапазон.
Если плейсхолдер показывает резервное содержимое, используйте пару <?start>…<?end>. Когда приходит соответствующий шаблон, всё, что находится между двумя инструкциями, заменяется. Одиночный <?marker> лишь отмечает точку, поэтому размещённый рядом с ним резервный текст никогда не удаляется.
<div id="status"><?start name="status">Loading…<?end></div>
<!-- later in the same response -->
<template for="status">All systems <strong>normal</strong></template>
После разбора шаблона DOM выглядит так:
<div id="status">All systems <strong>normal</strong></div>
В документации Chrome объясняется, почему выбраны инструкции обработки, а не элементы: патч через них не оставляет лишних узлов. Благодаря этому он работает там, где новые элементы всё бы сломали, например в <head> (скажем, чтобы изменить <title>) или внутри <table>.
Что отправляет сервер?
Сервер сразу записывает весь каркас, держит ответ открытым, пока ждёт данные, а затем записывает каждый <template for> перед закрытием документа. В примере ниже используются только node:http и ES-модули. Сохраните его как server.mjs и запустите командой node server.mjs:
// server.mjs — run with: node server.mjs, then open http://localhost:3000
import { createServer } from 'node:http';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const esc = (s) => String(s).replace(/[&<>"']/g, (c) =>
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
async function getStatus() { await sleep(1000); return 'All systems normal'; }
async function* getOrders() {
for (const o of ['Order #1001', 'Order #1002', 'Order #1003']) {
await sleep(700);
yield o;
}
}
createServer(async (req, res) => {
if (req.url !== '/') { res.writeHead(404).end(); return; }
res.writeHead(200, {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store',
});
// 1. Flush the shell immediately.
res.write(`<!doctype html>
<html><head><meta charset="utf-8"><title>Orders</title></head>
<body>
<h1>Your orders</h1>
<p id="status"><?start name="status">Checking status…<?end></p>
<ul id="orders"><?start name="orders"><li>Loading orders…</li><?end></ul>
`);
// 2. Templates are direct children of <body>, so they can reach the placeholders above.
const status = await getStatus();
res.write(`<template for="status">${esc(status)}</template>\n`);
// 3. Each template appends one item and leaves a fresh marker for the next.
for await (const order of getOrders()) {
res.write(`<template for="orders"><li>${esc(order)}</li><?marker name="orders"></template>\n`);
}
// 4. Close the document only after the last template.
res.end('</body></html>');
}).listen(3000);
Каждый вызов response.write() отправляет фрагмент тела, не дожидаясь end(). Подробнее о потреблении фрагментированного вывода читайте в статье о веб-потоках. Вызовы sleep имитируют медленные запросы и не являются бенчмарком.
Вспомогательная функция esc() нужна потому, что содержимое шаблона — это обычный HTML. Любые подставляемые данные могут внедрить разметку, если их не экранировать.
Когда поток завершается, #orders содержит три элемента <li>, за которыми следует завершающий <?marker name="orders">. Откройте страницу в Chrome 150 или новее, чтобы увидеть, как список заполняется.
Буферизация сводит на нет потоковую передачу вне порядка
Потоковая передача вне порядка документа полезна, только если байты доходят до браузера постепенно. Три распространённых слоя задерживают ответ целиком: middleware сжатия, ожидающее заполнения буфера; обратные прокси или CDN с включённой буферизацией ответов; фреймворки, которые рендерят всю страницу, прежде чем что-либо отправить. Выполните curl -N http://localhost:3000 через каждый из этих слоёв, чтобы убедиться, что фрагменты приходят по отдельности. Флаг -N отключает буферизацию вывода curl. Запись сессий (session replay) показывает результат глазами пользователя — например, пустой экран, который остаётся пустым, пока страница не загрузится целиком разом, потому что прокси задержал поток.
Сценарии использования и их ограничения
Декларативные патчи работают, только пока сервер ещё записывает документ, поэтому окно для обновлений равно времени жизни ответа.
- Живой список. Если каждый шаблон заканчивается новым
<?marker>с тем же именем, последующие шаблоны могут продолжать добавлять элементы в эту точку. Список остаётся «живым» лишь до тех пор, пока открыто соединение. - Строка статуса. Один диапазон
<?start>/<?end>и один шаблон, как в примере выше. - Результат отправки формы. Обычная навигация через
POSTвозвращает страницу, каркас которой рендерится сразу, а результат подгружается потоком по завершении обработки.
Всё, что реагирует на клики после загрузки страницы (фильтры, inline-редактирование, оптимистичный UI), по-прежнему требует клиентского состояния, JavaScript или библиотеки.
Декларативные частичные обновления и htmx
htmx отправляет новые HTTP-запросы в ответ на пользовательские события и вставляет полученные фрагменты в страницу. <template for> работает только внутри ответа, который уже передаётся потоком. Как только сервер завершает этот ответ, для дальнейших обновлений нужен JavaScript или новая навигация.
htmx остаётся JavaScript-библиотекой. В htmx 4.0 появился тег <hx-partial>, позволяющий обновлять несколько элементов из одного ответа. Такие паттерны, как бесконечная прокрутка с htmx, где появление элемента-загрузчика в области видимости заставляет htmx запросить следующую страницу с вашего сервера, и валидация форм с htmx, основаны на запросах, инициируемых пользователем.
| htmx | <template for> | |
|---|---|---|
| Триггер | Пользовательское событие | Порядок записи на сервере |
| Запросы | Один на каждое взаимодействие | Один ответ |
| Где выполняется | JavaScript-библиотека | HTML-парсер |
| Цели | Атрибуты с CSS-селекторами | Именованные инструкции обработки |
| Обновления после завершения ответа | Да | Нет |
Когда без JavaScript всё же не обойтись
Для контента, поступающего после загрузки, в Chrome добавляются сопутствующие методы — streamHTML и appendHTML, а также их Unsafe-варианты, — и расширяется существующий setHTML. Разработчики могут тестировать их начиная с Chrome 148 с флагом chrome://flags/#enable-experimental-web-platform-features, а в Chrome 155, вышедшем в бета-версии 16 сентября 2026 г., они включены по умолчанию.
const el = document.querySelector('#feed');
const response = await fetch('/feed-fragment.html');
await response.textStream().pipeTo(el.streamHTMLUnsafe());
response.textStream() доступен начиная с Chrome 151. В более старых версиях вместо него используйте response.body.pipeThrough(new TextDecoderStream()).
Когда разметка передаётся через streamHTMLUnsafe, любой <template for> внутри неё патчит живую страницу. setHTML и innerHTML работают иначе: сначала они разбирают разметку в отдельный фрагмент, поэтому шаблоны внутри них могут патчить только узлы этого фрагмента, но не существующую страницу.
Поддержка браузерами, резервный вариант и ограничения
Потоковая передача вне порядка документа с <template for> и инструкциями обработки <?marker>, <?start> и <?end> включена по умолчанию начиная с Chrome 150, который вышел в стабильной версии 30 июня 2026 г. Функция отслеживается на ChromeStatus, а атрибут for входит в WHATWG HTML Living Standard. Ни один браузер не на базе Chromium её пока не поддерживает. В обсуждении позиции WebKit по стандартам инженер WebKit предложил зафиксировать позицию WebKit как «поддержка».
Intent to Ship подтверждает, что браузеры без поддержки разбирают инструкции обработки как комментарии, поэтому любое резервное содержимое остаётся на экране. Содержимое стандартного элемента <template> не отображается, так что в таких браузерах переданные потоком данные так и не появятся.
| Chrome 150+ | Без поддержки | С template-for-polyfill | |
|---|---|---|---|
| Резервный текст | Заменяется | Остаётся видимым | Заменяется |
| Контент из потока | Встраивается | Скрыт в <template> | Встраивается, с ограничениями парсера |
Chrome публикует template-for-polyfill в npm. Он справляется с типичными случаями, но не имеет доступа к собственному парсеру браузера, поэтому некоторые ограничения сохраняются. Кроме того, это JavaScript, так что страница «без JS», которая должна работать во всех браузерах, всё равно зависит от скрипта. Отдельный html-setters-polyfill повторяет имена методов новых API, но сначала собирает весь контент и вставляет его в конце, то есть потоковой передачи не обеспечивает.
Ограничения <template for>, о которых стоит знать:
- Шаблон может обращаться только к инструкциям обработки с тем же родительским элементом. Размещайте шаблоны непосредственно в
<body>— тогда им доступен весь документ, включая<head>. <?end>можно опустить. В этом случае замена выполняется от<?start>до закрытия родительского элемента.- Если переместить инструкцию обработки, пока в неё ещё передаётся шаблон, оставшийся контент может продолжать попадать в старое место.
Заключение
Потоковая передача вне порядка документа переносит цикл «загрузить — найти — заменить» в HTML-парсер, но только в пределах одного открытого ответа. Где бы вы её ни применяли, размещайте реальные данные внутри резервных диапазонов <?start>/<?end>, чтобы браузеры без поддержки всё равно показывали что-то осмысленное. Затем запустите server.mjs за вашими реальными прокси и CDN и проверьте, приходят ли фрагменты по отдельности, прежде чем полагаться на этот подход в продакшене.
Часто задаваемые вопросы
Чем template for отличается от потоковой передачи с React Suspense?
Потоковый SSR в React отправляет каждую разрешённую границу Suspense в виде скрытого div, за которым следует встроенный скрипт, подменяющий им резервное содержимое. Если скрипты отключены или Content Security Policy блокирует встроенные скрипты без nonce, этот контент так и не встанет на место. С template for подмену выполняет сам HTML-парсер, поэтому никакой скрипт не запускается и странице не требуется разрешение на встроенные скрипты.
Есть ли кросс-браузерный способ передавать HTML потоком вне порядка документа без JavaScript?
Да. Декларативный Shadow DOM с именованными слотами работает в Chrome, Firefox и Safari. Каркас представляет собой template с shadowrootmode='open', содержащий элементы slot с резервным содержимым. Элементы с соответствующим атрибутом slot поступают потоком позже и заполняют эти слоты. Недостаток в том, что shadow root обычно размещается на body, поэтому CSS уровня документа не стилизует разметку каркаса внутри теневого дерева.
Что произойдёт, если медленный запрос завершится ошибкой уже после отправки каркаса страницы?
HTTP-статус и заголовки фиксируются, как только записан первый фрагмент, поэтому сервер уже не может переключиться на ошибку 500, редирект или установить новые cookie. Вместо этого обработайте сбой в разметке: запишите шаблон с тем же именем, содержащий сообщение об ошибке, и он заменит резервное содержимое с индикатором загрузки. В Node вызов res.setHeader после отправки заголовков выбрасывает ошибку ERR_HTTP_HEADERS_SENT.
Complete picture for complete understanding
Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.
Star on GitHub12k