SSR для Bitrix с Vue.js без Nuxt.js: как не потерять поисковую индексацию
Привет, Хабр!
Я Саша Шутай, backend-тимлид в компании AGIMA. Сейчас расскажу, что делать, если на проекте Bitrix сожительствует с Vue.js и поисковые боты не видят контента вашего сайта. Рассмотрим технологию серверного рендеринга страниц с помощью Puppeteer, как это всё настроить и быстро запустить для любого веб-приложения.
А для начала немного поговорим, зачем используются реактивные фреймворки и какие трудности приносит их использование.
Подводный камень реактивных веб-приложений
Для разработки современных интерактивных веб-приложений, как известно, используют реактивные JS-фреймворки. Они дают возможность бесшовно переходить между страницами и автоматически перерисовывать контент при его изменении. При этом рендеринг происходит уже на устройстве у пользователя, и это довольно тяжелая операция.То есть, если использовать классическую архитектуру, каждая новая страница сайта для пользователя будет отрисовываться как бы заново — это увеличивает время загрузки. А вот реактивные фреймворки позволяют зарендерить страницу один раз, а потом в нее уже просто подгружается нужный контент.
Но представим следующую ситуацию. Вы интегрировали на сайт реактивный фреймворк, пользователям нравится скорость работы веб-приложения и его быстрые интерфейсы, но теперь ваш сайт не попадает в поисковую выдачу либо имеет низкие позиции. Что не так?Происходит это потому, что ваш сервер отдает пустой html с базовым <div id=”app”> без контента. А то, что видят ваши пользователи, создает JS-приложение в браузере. При этом далеко не все поисковые системы будут запускать ваш JS. Чаще всего боты заходят на страницу, видят отсутствие контента и помечают сайт как пустой.
Google bot умеет выполнять JS, но это имеет смысл, если полный рендеринг занимает не больше 3 секунд — иначе страница будет в красной зоне и про высокие позиции в поисковой выдаче можно будет забыть. А еще в сети популярна мысль, что Google bot для просмотра страниц может использовать Chrome 41, а в этой версии ещё не реализована полная поддержка ES6 и стрелочных функций.
Короче, при разработка фронтовой части сайта как приложения на JS-фреймворке надо использовать технологии серверного рендеринга — для будущей SEO-доступности контента в поисковиках.
Одно «но»
На проекте, где я работаю, был проведён редизайн и впервые опробованы технологии Vue.js и его связь с Bitrix. Мы не хотели уходить от предлагаемого вендором способа интеграции верстки в компоненты, лишать контент-менеджера возможности править страницы в визуальном редакторе и при этом хотели оставить роутинг страниц на бекенде (urlrewrite.php).
После пробы пера реактивном фреймоврке нам нужно было в короткие сроки реализовать механизм отдачи контента для поисковых систем, потому что Google начал помечать страницы красным, а Яндекс выкидывал их из выдачи. Когда проект подразумевает полное разделение фронта и бэка и их взаимодействие происходит через API, можно использовать удобные решения: Nuxt.js или Vue SSR.
Но не было бы этой статьи, если бы не одно «но».
На бэкенде стоял Bitrix, роутинг приложения был разработан тоже в нем. В общем команда оставила стандартные Bitrix-компоненты. Только в их template появился вызов собранных Vue-компонентов и в их Store сразу начал складываться JSON-массив со всеми данными для контента.
Исходя из того, что я описал выше, интеграции Vue.js в Bitrix-компоненты и использование Nuxt.js стали недоступны. И перед нами встала серьезная задача: нужно было спроектировать систему отдачи поисковым системам уже заранее отрендеренного контента страниц. Сейчас расскажу, как нам это удалось.
Дисклеймер. Тот вариант генерации реактивных страниц на стороне сервера, о котором сейчас пойдет речь, это не универсальное средство от всех проблем. И наверняка есть другие, не менее или даже более удачные способы связать архитектуры Bitrix и Vue.js без использование Nuxt.js. Но зато точно могу сказать, что это работает. Это позволяет быстро вернуть индексацию сайту и вывести его на высокие позиции в рейтинге за счёт хороших оценок в Google PageSpeed Insights. И в целом наше решение нужно рассматривать исключительно как интересный опыт и необычный способ поисковой оптимизации.
Архитектура рендеринга
После всех попыток найти простое решение мы поняли, что в нашей ситуации таким решением будет построить серверный рендеринг по-своему. Были варианты обратиться к платным сервисам, которые могли бы частично избавить нас от необходимости что-то изобретать, но мы хотели во что бы то ни стало найти выход из ситуации самостоятельно и бесплатно (об этом ниже).
Скоро мы его нашли. Таким выходом стало как бы отдельное приложение (Server Side Rendering — SSR), которое занималось пререндерингом. Чтобы понять, как оно работает, посмотрим на схему. В обычной ситуации, когда пользователь отправляет запрос на получение страницы, запрос сразу направляется в App. Но мы добавили еще одно звено — Balancer. Он по ряду условий определяет, отдать поисковому боту уже отрендеренную страницу из SSR или направить трафик на App. При этом SSR взаимодействует с сервером приложения (App) и генерирует готовые страницы постоянно.
При такой реализации нам нужно было решить ряд задач
Задача №1 (главная)
Пререндеринг страниц, чтобы в момент обращения внешнего запроса, отдавался уже готовый html+css и клиент не ожидал фонового рендеринга.
Решение
Мы рассуждали так: если у нас не будет изображения, заранее заготовленного в кэше, то при каждом обращении пользователя машина будет бегать на Bitrix, запрашивать информацию, рендерить, а только потом отдавать. Это занимает не менее 5 секунд. Долго.Поэтому мы поняли, что хорошо бы иметь уже готовую страницу — собранную, html-ую. И придумали вот такой фоновый процесс: страницы в кэше актуализируются сами по себе раз в промежуток времени. Чтобы запустить пререндеринг, мы используем cron-задачу. Модуль принимает автогенерируемый xml-файл со списком актуальных адресов.
Задача №2
Оперативная актуализация готовых страниц при реальном их изменении.
Решение
Нашему SSR нужно было оперативно понимать, что на страницах Bitrix что-то поменялось. При этом у Bitrix есть технология «Умный кэш», которая позволяет оперативно актуализировать данные в кэше.
Мы подписались на эту технологию и немного расширили ее, чтобы Bitrix не только сбрасывал кэш, но еще и отправлял в наше приложение информацию о том, что нужно пересобрать страницу.
Задача №3
Облегчение страницы.
Решение
Страницы генерировались на html и JS. Причем у JS в рендеринге была главная роль. Но грузить страницу с JS было тяжело, и для поискового бота он был не нужен. Поэтому нам надо было из итоговой странице JS удалить.
Собственно, это и была наша идея. Теперь подробнее о том, как мы технически решили каждую из этих задач.
Как мы сделали приложение для рендеринга
Как я и говорил, когда мы только придумали верхнюю схему, начали искать технологию, которая бы так работала. Одной из них оказалась PhantomJS. Затем мы нашли сервис Prerender. Он тоже делал примерно то же самое, но был платным. Так что для нас изобрести свой вариант такого приложения было делом чести.Мы какое-то время поломали голову, как это сделать, но потом вышли на Node-библиотеку Google Puppeteer. Она актуальная и делается самим Google. Мы предположили, что это значит, что Google оптимизировал ее под потребности поисковых ботов. Сразу собрали быстрый образ, увидели, что всё работает, и остановились на ней. Библиотека предоставляет API для работы с Headless Chrome по протоколу DevTools. Она быстро разворачивается на Express web server и сразу готова для рендеринга страниц сайта.
Инструкция по применению
Если вы столкнетесь с такой же задачей, как мы, и захотите решить ее так же, то вот ссылка на github, а ниже небольшая инструкция, как это сделать.
Во-первых, установите следующие библиотеки:
express;
puppeteer;
node-cron (для запуска фоновых событий: актуализация кэша, актуализация страниц и сброс кэша);
dotenv (для работы с переменными).
Еще для разработки понадобится:
@babel/cli;
@babel/core;
@babel/node;
@babel/plugin-transform-runtime;
@babel/preset-env;
eslint;
eslint-plugin-promise;
nodemon.
Дальше создаем файл server.js.
import express from 'express';
const app = express();
app.listen(3000, () => console.log(`http://localhost:3000`))
Добавляем роут для обработки команды рендеринга, принимающий на вход адрес страницы.
app.get('/ssr', async (req, res, next) => {
const {url} = req.query;
const {html, status} = await ssr(url);
res.status(status).send(html);
})
Дальше переходим к самой функции SSR, предварительно подключив её в server.js.
import {ssr} from './functions/ssr.js'
ssr.js
import puppeteer from 'puppeteer';
export async function ssr(url) {
const browser = await puppeteer.launch({
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-accelerated-2d-canvas',
'--disable-gpu',
'--window-size=1920x1080',
'--single-process',
'--no-zygote'
],
});
const page = await browser.newPage();
page.setViewport({ width: 1280, height: 926 });
try {
const response = await page.goto(url, {
timeout: 25000,
waitUntil: 'networkidle2'
});
if (response.status() < 400) {
let status = response.status();
let html = await page.content();
await page.close();
browser.close();
return {html, status: status}
}
let html = null;
await page.close();
browser.close();
return {html, status: response.status()}
} catch (e) {
let html = e.toString();
console.warn({message: `URL: ${url} Failed with message: ${html}`})
await page.close();
browser.close();
return {html, status: 500}
}
}
При каждом новом обращении запускается новый экземпляр const browser = await puppeteer.launch
и завершается после попытки рендеринга страницы.
Часто встречаются рекомендации, что экземпляр лучше запустить один раз при первом обращении и в дальнейшем все последующие генерации запускать в нём. Мы убедились в этом на собственном опыте: на новый запуск виртуального браузера отводится существенное время всего процесса рендеринга. Однако были проблемы: количество процессов росло экспоненциально, быстро заканчивалась оперативная память, экземпляр Headless Chrome зависал и переставал принимать новые команды.
После добавления аргументов:
'--single-process',
'--no-zygote'
...и запуска / завершения процесса Headless Chrome при каждом обращении, проблема с высокой загрузкой серверного железа ушла. Если вы знаете, как победить неконтролируемый расход памяти при использовании одного экземпляра, поделитесь в комментариях.
Теперь давайте разберем содержимое ssr.js
Открываем страницу const page = await browser.newPage();
Регулируем событие, когда страница считается готовой и можно её сохранять. Помимо таймаута и счётчика количества открытых соединений, можно использовать более гибкие признаки, как, например, появление селектора page.waitForSelector('#posts');
const response = await page.goto(url, {
timeout: 25000,
waitUntil: 'networkidle2'
});
Дожидаемся отрисовки контента, закрываем страницу, экземпляр браузера и возвращаем готовый html со статус-кодом.
let status = response.status();
let html = await page.content();
await page.close();
browser.close();
return {html, status: status}
Добавим в команды сборки в package.json.
"scripts": {
"dev": "nodemon --exec babel-node ./src/server.js",
"build": "NODE_ENV=production babel ./src --out-dir dist",
"server": "node ./dist/index.js"
},
Теперь можно запустить полученное приложение npm run dev
, обратиться по адресу http://localhost:3000/ssr?url=https://example.com и получить готовую страницу!
Как мы оптимизировали рендеринг
Кэширование.
Как я и говорил, генерировать страницу при каждом обращении долго, поэтому мы решили, что нужно хранить уже готовые страницы в кэше.
Чтобы этого добиться, нужно добавить в проект cache.js с методами установки и получения страниц из хранилища:
export class Cache {
constructor() {
this.cache = {};
}
getPageByURL(url) {
if (this.cache.hasOwnProperty(url)) {
return this.cache[url];
}
return null;
}
setPage(url, html, status) {
this.cache[url] = {
html,
status,
'date_create': Date.now(),
'live_time': Date.now() + this.time
}
}
clear() {
Object.keys(this.cache).forEach(key => {
delete this.cache[key];
})
}
}
Теперь добавляем в приложение библиотеку Node-cron и реализуем метод очистки устаревших страниц из хранилища.
cache.js
check() {
for (const url in this.cache) {
if (Date.now() > this.cache[url]['live_time']) {
delete this.cache[url];
}
}
}
cron.js
export class Cron {
constructor(CACHE) {
this.cron = require('node-cron');
this.cache = CACHE;
}
initTasks() {
this.cron.schedule('0 0 */1 * * *', () => {
this.cache.check();
});
}
}
server.js
import {Cache} from "./functions/cache";
import {Cron} from "./cron";
const CACHE = new Cache();
const CRON = new Cron(CACHE);
CRON.initTasks();
...
APP.get('/ssr', async (req, res, next) => {
const {url} = req.query;
/**
* Отдача страницы из кеша (при наличии)
*/
let page = CACHE.getPageByURL(url);
if (page) {
return res.status(page.status).send(page.html);
}
const {html, status} = await ssr(url);
if (status === 200) {
CACHE.setPage(url, html, status)
}
res.status(status).send(html);
})
Облегчение страниц
Почти последний пункт. Если заранее отрендеренные страницы отдаются только поисковым ботам для индексации контента, то имеет смысл удалить с них подключения JS, чтобы страница была легче. Поэтому добавим в ssr.js
await page.evaluate(() => {
const elements = document.querySelectorAll('script, link[rel="import"]');
elements.forEach(e => e.remove());
});
Сохранение статусов редиректов
Когда 301 и 302 редиректы производятся на уровне приложения, то статус, полученный Puppeteer будет 200. Например, при LocalRedirect('index.php', true, 301);
в Bitrix, важно передать поисковому боту правильный статус произведенного редиректа.
const chain = response.request().redirectChain();
for ( let num in chain ) {
if(chain[num].response().headers().p3p)
{
let chainStatus = chain[num].response().headers().status;
if ((chainStatus === '301') || (chainStatus === '302')) {
status = chainStatus;
redirect = chain[num].response().headers().location;
}
}
}
server.js
...
APP.get('/ssr', async (req, res, next) => {
...
/**
* Обработка редиректов
*/
if (redirect) {
return res.set('Redirect', redirect).status(status).send();
}
res.status(status).send(html);
})
Фоновая генерация кэша
Удобно заранее иметь страницы в хранилище, чтобы при реальном запросе не тратить время на вызов Puppeteer. Поэтому запустите фоновую задачу рендеринга страниц для адресов из sitemap.xml вашего сайта, повесив актуализацию на cron. Мы использовали библиотеку sitemapper, корректно парсящующую карту сайта и учитывающую вложенные ссылки. И результат её работы скормили методу ssr.
И как это запустить на любом сервере?
Обернем приложение в Docker, чтобы запускать образ одной командой на любой машине. Начнем строить Dockerfile.
Первой инструкцией указываем, что будем строить образ на основе node 14.16.0
FROM node:14.16.0
Обновляем и устанавливаем пакеты, в том числе и Chrome
RUN apt-get update \
&& apt-get install -y wget gnupg ca-certificates procps libxss1 \
&& wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - \
&& sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' \
&& apt-get update \
&& apt-get install -y google-chrome-stable \
&& rm -rf /var/lib/apt/lists/* \
&& wget --quiet https://raw.githubusercontent.com/vishnubob/wait-for-it/master/wait-for-it.sh -O /usr/sbin/wait-for-it.sh \
&& chmod +x /usr/sbin/wait-for-it.sh \
&& echo 'kernel.unprivileged_userns_clone=1' > /etc/sysctl.d/userns.conf
Если запускаете Docker-образ из-под Root, то создайте раздел и настройте права на пользователя Node
RUN mkdir -p /home/node/app/node_modules && chown -R node:node /home/node/app
Дальше указываем директорию для приложения
WORKDIR /home/node/app
Копируем package.json и package-lock.json, присваивая права пользователю Node
COPY --chown=node:node package*.json ./
Переключаемся на пользователя Node
USER node
Устанавливаем NPM-зависимости
RUN npm install
Копируем код приложения в Docker-образ
COPY --chown=node:node . .
Запускаем сборку приложения
RUN npm run build
Поскольку мы настроили наше приложение на порт 3000, то указываем это
EXPOSE 3000
И финальной командой запускаем приложение
CMD [ "npm", "run", "server" ]
Разделение трафика поисковых ботов и пользователей
После настройки модуля рендеринга мы получаем 3 вида каждой страницы сайта:
отрендеренная страница с JS;
отрендеренная страница без JS;
неотрендеренная страница.
Нам нужно сегментировать трафик так, чтобы готовые облегченные страницы отдать поисковым ботам, готовые полноценные страницы отдать неавторизованным пользователям, а на неотрендеренные страницы, функциональные и закрытые, направлять авторизованных пользователей.
Внимание! Следите за актуализацией отрендеренных страниц в кэше и за их наполнением. Контент страниц, отдаваемый ботам, должен соответствовать контенту, возвращаемому пользователю. Иначе есть риск попасть в бан за подмену страниц.
Рекомендую перенаправлять трафик поисковых ботов до загрузки основного приложения. Например, такие варианты предлагает сервис prerender.io: https://docs.prerender.io/article/12-middlewares. Либо, если вы хотите управлять трафиком на уровне приложения Bitrix, подпишитесь на событие OnPageStart
Всё работает. Что дальше?
Такой базовой настройки достаточно, чтобы опробовать Puppeteer для рендеринга динамических страниц на любых JS-фреймворках, если в вашей архитектуре невозможно использовать Nuxt и его аналоги. Расширяя функциональность, можно добавлять роуты для управления кэшем, сбора статистики и т. д.
Как я и говорил в дисклеймере, этот инструмент позволяет в короткие сроки для любого сайта без СМС и регистрации подключить технологию серверного рендеринга страниц и кормить поискового бота контентом. Стоит ли на этом останавливаться?
Если вам хочется за короткие сроки попробовать фишки реактивных фреймворков, не интегрируя Nuxt.js, не разрабатывая API для связи бэкенда и фронтенда и не теряя SEO, Puppeteer — идеальный инструмент. После тестирования вашего MVP с реактивными технологиями, я всё-таки рекомендую переходить на классические способы сайтостроения. В нашем случае мы движемся в сторону связки: Bitrix + GraphQL + Nuxt.js. Историю этой крайне интересной интеграции я расскажу в следующей статье. До новых встреч!
P.S. Если задачу, которая стояла перед нами, вы на своих проектах решали иначе, то интересно узнать, как именно. Напишите, пожалуйста, в комментариях!
Материалы и полезные ссылки:
Headless Chrome: an answer to server-side rendering JS sites.
Server side rendering with Puppeteer and Headless Chrome — a practical guide.
Tips and Tricks for Web Scraping with Puppeteer.
Опыт 2 миллионов headless-сессий.