В классических одностраничных приложениях (Single Page Applications, SPA) на базе React браузер клиента изначально получает минималистичный HTML-документ: пустой контейнер <div id="root"></div> и ссылку на JavaScript-бандл. Для пользователей рендеринг интерфейса происходит на клиенте (Client-Side Rendering, CSR). Однако для поисковых систем и краулеров социальных сетей эта архитектура создает фундаментальный барьер индексации.
Поисковый робот Googlebot использует подсистему Web Rendering Service (WRS). Индексация CSR-сайтов в Google происходит в две фазы:
Более того, поисковые боты Yandex, Baidu, Naver, а также парсеры социальных сетей (OpenGraph-краулеры Telegram, Twitter/X, LinkedIn, Discord, WhatsApp) обладают существенно более ограниченными возможностями исполнения JS или вовсе не запускают скрипты. Если не отдать готовый HTML в первом HTTP-ответе, ссылки лишаются превью, а страницы выпадают из поискового индекса.
Архитектура Pre-rendering (динамический рендеринг) решает эту проблему без полного переписывания кодовой базы SPA на SSR-фреймворки вроде Next.js или Remix. Reverse Proxy перехватывает входящие запросы: обычные пользователи получают стандартный SPA-бандл, а запросы от идентифицированных поисковых ботов прозрачно перенаправляются на headless-браузер (Puppeteer, Playwright или сервис Prerender.io), который генерирует и кэширует готовый HTML-снапшот страницы.
В основе пререндеринга лежит разделение потоков обработки трафика на уровне сетевого шлюза (Reverse Proxy, CDN или Edge Worker). Система определяет тип клиента по заголовку User-Agent и внутренним эвристикам, после чего направляет запрос по соответствующему контуру.
+-----------------------------------------------------------------------+
| Входящий HTTP-запрос |
+-----------------------------------------------------------------------+
|
v
+-----------------------------------------------------------------------+
| Reverse Proxy / CDN (Cloudflare / Nginx) |
| Анализ User-Agent и флага предварительного кэша |
+-----------------------------------------------------------------------+
| |
[Обычный пользователь] [Поисковый бот / Краулер]
| |
v v
+---------------------------+ +---------------------------------+
| Статический хостинг (S3) | | Кэш HTML-снапшотов (Redis/S3) |
| Отдача index.html + JS | +---------------------------------+
+---------------------------+ |
| +---------+---------+
v | |
+---------------------------+ [Cache Hit] [Cache Miss]
| Клиентский браузер (CSR) | | |
| Гидратация и выполнение JS| v v
+---------------------------+ +----------------+ +----------------+
| Отдача готового| | Headless Pool |
| HTML (TTFB<80ms| | (Puppeteer) |
+----------------+ | Рендер DOM |
+----------------+
|
+---------+
v
[Запись в Redis/S3]
|
v
[Отдача HTML боту]Ключевые сущности архитектуры:
Reverse Proxy сверяет User-Agent с базой краулеров. При совпадении инициируется контур пререндеринга.
Проверяется наличие актуального HTML-снапшота по хэшу URL. При Cache Hit ответ отдается мгновенно с TTFB менее 60 мс.
При Cache Miss запрос встает в очередь к пулу Headless Chromium. Блокируются медиаресурсы и трекеры.
Страница загружается в браузере, исполняются скрипты, React монтирует DOM и выставляет window.__PRERENDER_READY__ = true.
HTML извлекается из document.documentElement.outerHTML, отправляется в Redis с заданным TTL и возвращается боту.
В чем заключается фундаментальное различие между архитектурой Pre-rendering и классическим Server-Side Rendering (SSR)?
Маршрутизатор анализирует заголовок User-Agent. Список сигнатур включает ботов Google, Yandex, Bing, соцсетей и AI-поисковиков (Perplexity, GPTBot). Критически важно передавать заголовок Vary: User-Agent, чтобы CDN не отдавала закэшированный бот-снапшот живому пользователю.
interface Env {
PRERENDER_SERVICE_URL: string;
PRERENDER_TOKEN: string;
CACHE_KV: KVNamespace;
}
const BOT_USER_AGENTS: readonly RegExp[] = [
/googlebot/i,
/yandex/i,
/bingbot/i,
/baiduspider/i,
/twitterbot/i,
/facebookexternalhit/i,
/linkedinbot/i,
/slackbot/i,
/telegrambot/i,
/whatsapp/i,
/chatgpt-user/i,
/gptbot/i,
/perplexitybot/i,
];
const IGNORED_EXTENSIONS: readonly string[] = [
'.js', '.css', '.xml', '.less', '.png', '.jpg', '.jpeg',
'.gif', '.pdf', '.svg', '.ico', '.woff', '.woff2', '.ttf',
];
function isBot(userAgent: string | null): boolean {
if (!userAgent) return false;
return BOT_USER_AGENTS.some((pattern) => pattern.test(userAgent));
}
function isStaticAsset(url: URL): boolean {
const pathname = url.pathname.toLowerCase();
return IGNORED_EXTENSIONS.some((ext) => pathname.endsWith(ext));
}
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const url = new URL(request.url);
const userAgent = request.headers.get('User-Agent');
// Пропуск статических ассетов мимо пререндеринга
if (isStaticAsset(url) || !isBot(userAgent)) {
const response = await fetch(request);
const newHeaders = new Headers(response.headers);
newHeaders.append('Vary', 'User-Agent');
return new Response(response.body, {
status: response.status,
headers: newHeaders,
});
}
// Проверка наличия закэшированного снапшота в Edge KV
const cacheKey = `snapshot:${url.origin}${url.pathname}${url.search}`;
const cachedHtml = await env.CACHE_KV.get(cacheKey);
if (cachedHtml) {
return new Response(cachedHtml, {
status: 200,
headers: {
'Content-Type': 'text/html; charset=utf-8',
'X-Prerender-Cache': 'HIT',
'Vary': 'User-Agent',
'Cache-Control': 'public, max-age=3600, s-maxage=86400, stale-while-revalidate=604800',
},
});
}
// Маршрутизация на Headless Prerender сервис
const prerenderUrl = `${env.PRERENDER_SERVICE_URL}?url=${encodeURIComponent(url.toString())}`;
const prerenderResponse = await fetch(prerenderUrl, {
headers: {
'X-Prerender-Token': env.PRERENDER_TOKEN,
'User-Agent': userAgent || 'Prerender-Forwarder',
},
});
if (!prerenderResponse.ok) {
// Fallback на оригинальный SPA-сервер при сбое воркера
return fetch(request);
}
const html = await prerenderResponse.text();
// Асинхронное сохранение снапшота в Edge KV
ctx.waitUntil(
env.CACHE_KV.put(cacheKey, html, {
expirationTtl: 86400, // 24 часа
})
);
return new Response(html, {
status: prerenderResponse.status,
headers: {
'Content-Type': 'text/html; charset=utf-8',
'X-Prerender-Cache': 'MISS',
'Vary': 'User-Agent',
'Cache-Control': 'public, max-age=3600, s-maxage=86400',
},
});
},
};Для поддержания TTFB менее 300 мс headless-браузер должен быть предварительно запущен, а второстепенные сетевые запросы (загрузка тяжелых изображений, внешних аналитических трекеров, шрифтов) должны перехватываться и блокироваться на уровне Chrome DevTools Protocol.
import puppeteer, { Browser, Page } from 'puppeteer';
import { createClient, RedisClientType } from 'redis';
interface RenderOptions {
url: string;
timeoutMs?: number;
waitForSelector?: string;
}
export class PrerenderCluster {
private browser: Browser | null = null;
private redis: RedisClientType;
private readonly blockedResourceTypes = new Set<string>([
'image',
'media',
'font',
'websocket',
]);
private readonly blockedDomains: readonly string[] = [
'google-analytics.com',
'googletagmanager.com',
'mc.yandex.ru',
'facebook.net',
'hotjar.com',
];
constructor(redisUrl: string) {
this.redis = createClient({ url: redisUrl });
}
public async initialize(): Promise<void> {
await this.redis.connect();
this.browser = await puppeteer.launch({
headless: true,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-accelerated-2d-canvas',
'--disable-gpu',
'--single-process',
'--no-zygote',
],
});
}
public async renderPage(options: RenderOptions): Promise<string> {
if (!this.browser) {
throw new Error('PrerenderCluster не инициализирован');
}
const { url, timeoutMs = 10000, waitForSelector = '#root > *' } = options;
const page: Page = await this.browser.newPage();
try {
// Оптимизация сети: блокировка несущественных ресурсов
await page.setRequestInterception(true);
page.on('request', (req) => {
const resourceType = req.resourceType();
const reqUrl = req.url().toLowerCase();
const isDomainBlocked = this.blockedDomains.some((d) => reqUrl.includes(d));
if (this.blockedResourceTypes.has(resourceType) || isDomainBlocked) {
req.abort();
} else {
req.continue();
}
});
// Эмуляция десктопного Googlebot
await page.setUserAgent(
'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)'
);
// Переход на страницу с ожиданием завершения сетевой активности
await page.goto(url, {
waitUntil: 'networkidle0',
timeout: timeoutMs,
});
// Ожидание готовности кастомного события или селектора в React
await page.waitForFunction(
() => Boolean(window.__PRERENDER_READY__) || document.querySelector('#root > *') !== null,
{ timeout: timeoutMs }
);
// Извлечение полного HTML-документа
const html = await page.content();
return html;
} finally {
await page.close();
}
}
public async shutdown(): Promise<void> {
if (this.browser) {
await this.browser.close();
}
await this.redis.disconnect();
}
}
declare global {
interface Window {
__PRERENDER_READY__?: boolean;
}
}Чтобы Chromium не делал снапшот в момент отображения скелетона загрузки, React-приложение должно выставить глобальный флаг window.__PRERENDER_READY__ = true строго после завершения всех критических асинхронных запросов и обновления мета-тегов.
import { useEffect } from 'react';
interface PrerenderOptions {
isDataLoaded: boolean;
hasErrors?: boolean;
}
export function usePrerenderSignal({ isDataLoaded, hasErrors = false }: PrerenderOptions): void {
useEffect(() => {
if (typeof window === 'undefined') return;
if (isDataLoaded || hasErrors) {
// Даем React завершить фазу commit и обновление DOM
const rafId = requestAnimationFrame(() => {
window.__PRERENDER_READY__ = true;
});
return () => {
cancelAnimationFrame(rafId);
window.__PRERENDER_READY__ = false;
};
}
window.__PRERENDER_READY__ = false;
}, [isDataLoaded, hasErrors]);
}Как предотвратить возникновение ошибки Soft 404 при рендеринге несуществующих страниц SPA через Puppeteer?
Если обратный прокси не добавляет заголовок Vary: User-Agent, промежуточный кэш CDN (Cloudflare, Fastly, Akamai) закэширует статический HTML-снапшот, сгенерированный для поискового бота, и вернет его обычному пользователю. В результате живой пользователь получит страницу без интерактивности или с поломанными клиентскими скриптами.
Решение: На всех уровнях маршрутизации гарантируйте передачу Vary: User-Agent во всех ответах (как для ботов, так и для обычных клиентов).
Headless-браузер завершает работу по событию load или networkidle2, когда на странице еще отображается лоадер или пустой скелетон, поскольку фоновый запрос к API через fetch еще не вернул данные. Поисковый бот индексирует пустую страницу или текст «Загрузка данных...».
Решение: Используйте двухфакторную готовность: ожидание селектора контента плюс проверка флага window.__PRERENDER_READY__ через page.waitForFunction().
Каждый вызов puppeteer.launch() на входящий запрос приводит к запуску отдельного процесса ОС. Под нагрузкой в 50 запросов в секунду сервер исчерпывает RAM, а упавшие процессы превращаются в зомби, парализуя сервер.
Решение: Эксплуатируйте фиксированный пул предварительно созданных инстансов через библиотеки управления пулами (например, generic-pool), изолируйте рендеринг в отдельных вкладках browser.newPage() с обязательным закрытием в блоке finally и перезапускайте инстанс браузера после каждых 500 отрендеренных страниц.
SPA по умолчанию отдает index.html со статусом 200 для любых путей. Если headless-браузер рендерит страницу 404 внутри React, поисковый бот получает статус 200 OK с текстом «Страница не найдена» — возникает критическая ошибка Soft 404, разрушающая поисковую видимость сайта.
Решение: Внедряйте метатег <meta name="prerender-status-code" content="404"> внутри React-компонента NotFound и инструктируйте прокси считывать этот тег для выставления корректного HTTP-кода ответа.