Пару лет назад, работая над своим продуктом Cleverbrush мой друг и я столкнулись с проблемой SEO оптимизации. Созданный нами сайт, который по идее должен был продавать наш продукт, а это был обычный Single Page React Application, не выводился в Google выборке даже по ключевым словам! В ходе детального разбора данной проблемы родилась библиотека iSSR, а также наш сайт начал появляться на первой странице Google. Итак, давайте по порядку!
Главной проблемой Single Page приложений является то, что сервер отдает клиенту пустую HTML страницу. Её формирование происходит только после того как весь JS будет скачан (это весь ваш код, библиотеки, фреймверк). Это в большинстве случаев более 2-х мегабайт размера + задержки на обработку кода.
Даже если Google-бот умеет выполнять JS, он получает контент только спустя некоторое время, критичное для ранжирования сайта. Google-бот попросту видит пустую страницу несколько секунд! Это плохо!
Google начинает выдавать красные карты если ваш сайт рендерится более 3-х секунд. First Contentful Paint, Time to Interactive — это метрики которые будут занижены при Single Page Application. Подробнее читайте здесь.
Также есть менее продвинутые поисковые системы, которые попросту не умеют работать с JS. В них Single Page Application не будут индексироваться.
На ранжирование сайта еще влияет множество факторов, часть из них мы разберем далее в этой статье.
Существует несколько путей как решить проблему пустой странички при загрузке, рассмотрим несколько из них:
Static Site Generation (SSG). Сделать пререндер сайта перед тем как его загрузить на сервер. Очень простое и эффективное решение. Отлично подходит для простых веб страничек, без взаимодействия с backend API.
Server-Side Rendering (SSR). Рендерить контент в рантайме на сервере. При таком подходе мы сможем делать запросы backend API и отдавать HTML вместе с необходимым содержимым.
Рассмотрим подробнее, как работает SSR:
Схематично SSR приложение выглядит вот так:
Из вышеописанной работы SSR приложения мы можем выделить проблемы:
Это маленькая библиотека которая способна решить проблемы асинхронной обработки запросов к данным а также синхронизации состояния с сервера на клиент. Это не “очередной убийца Next.JS”, нет! Next.JS прекрасный фреймверк имеющий множество возможностей, но чтобы его использовать вам придется почти полностью переписать ваше приложение и следовать правилам Next.JS.
Посмотрим на примере, как просто перенести обычное SPA приложение на SSR.
К примеру, у нас есть простейшее приложение с асинхронной логикой.
import React, { useState, useEffect } from 'react';
import { render } from 'react-dom';
const getTodos = () => {
return fetch('https://jsonplaceholder.typicode.com/todos')
.then(data => data.json())
};
const TodoList = () => {
const [todos, setTodos] = useState([]);
useEffect(() => {
getTodos()
.then(todos => setTodos(todos))
}, []);
return (
<div>
<h1>Hi</h1>
<ul>
{todos.map(todo => (
<li key={todo.id} style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>{todo.title}</li>
))}
</ul>
</div>
)
}
render(
<TodoList />,
document.getElementById('root')
);
Данный код рендерит список выполненных задач, используя сервис jsonplaceholder для эмуляции взаимодействия с API.
Для установки iSSR нужно выполнить:
npm install @issr/core --save
npm install @issr/babel-plugin --save-dev
Для настройки базовой билд системы установим:
npm install @babel/core @babel/preset-react babel-loader webpack webpack-cli nodemon-webpack-plugin --save-dev
Один из неочевидных моментов разработки SSR приложений является то, что некоторые API и библиотеки могут работать на клиенте но не работать на сервере. Одним из таких API является fetch. Данный метод отсутствует в nodejs где будет выполняться серверная логика нашего приложения. Для того, чтобы у нас приложение работало одинаково установим пакет:
npm install node-fetch --save
Для сервера будем использовать express, но это не важно, можно использовать любой другой фреймверк:
npm install express --save
Добавим модуль для сериализации состояния приложения на сервере:
npm install serialize-javascript --save
const path = require('path');
const NodemonPlugin = require('nodemon-webpack-plugin');
const commonConfig = {
module: {
rules: [
{
test: /.jsx$/,
exclude: /node_modules/,
use: [
{
loader: 'babel-loader',
options: {
presets: [
'@babel/preset-react'
],
plugins: [
'@issr/babel-plugin'
]
}
}
]
}
]
},
resolve: {
extensions: [
'.js',
'.jsx'
]
}
}
module.exports = [
{
...commonConfig,
target: 'node',
entry: './src/server.jsx',
output: {
path: path.resolve(__dirname, './dist'),
filename: 'index.js',
},
plugins: [
new NodemonPlugin({
watch: path.resolve(__dirname, './dist'),
})
]
},
{
...commonConfig,
entry: './src/client.jsx',
output: {
path: path.resolve(__dirname, './public'),
filename: 'index.js',
}
}
];
В babel-loader необходимо добавить плагин:
@issr/babel-plugin
Это вспомогательный модуль @issr/babel-plugin позволяющий отследить асинхронные операции в приложении. Замечательно работает с babel/typescript-preset, и прочими babel плагинами.
Вынесем общую логику нашего приложения в отдельный файл App.jsx. Это нужно для того, чтобы в файлах client.jsx и server.jsx осталась только логика рендеринга, ничего больше. Таким образом весь код приложения у нас будет общий.
import React from 'react';
import fetch from 'node-fetch';
import { useSsrState, useSsrEffect } from '@issr/core';
const getTodos = () => {
return fetch('https://jsonplaceholder.typicode.com/todos')
.then(data => data.json())
};
export const App = () => {
const [todos, setTodos] = useSsrState([]);
useSsrEffect(async () => {
const todos = await getTodos()
setTodos(todos);
});
return (
<div>
<h1>Hi</h1>
<ul>
{todos.map(todo => (
<li key={todo.id} style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>{todo.title}</li>
))}
</ul>
</div>
);
};
import React from 'react';
import { hydrate } from 'react-dom';
import { App } from './App';
hydrate(
<App />,
document.getElementById('root')
);
Мы поменяли стандартный render метод React на hydrate, который работает для SSR приложений.
import React from 'react';
import express from 'express';
import { renderToString } from 'react-dom/server';
import { App } from './App';
const app = express();
app.use(express.static('public'));
app.get('/*', async (req, res) => {
const html = renderToString(<App />);
res.send(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<div id="root">${html}</div>
</body>
</html>
`);
});
app.listen(4000, () => {
console.log('Example app listening on port 4000!');
});
В коде сервера обратите внимание, что мы должны расшаривать папку с собранным SPA приложением webpack:
app.use(express.static(‘public’));
Таким образом, полученный с сервера HTML будет работать далее как обычный SPA
Мы разделили логику вынеся общую часть, подключили компилятор для клиент и сервер частей приложения. Теперь решим остальные проблемы связанные с асинхронными вызовами и состоянием.
Для обработки асинхронных операций их нужно обернуть в хук useSsrEffect из пакета @issr/core:
import React from 'react';
import fetch from 'node-fetch';
import { useSsrEffect } from '@issr/core';
const getTodos = () => {
return fetch('https://jsonplaceholder.typicode.com/todos')
.then(data => data.json())
};
export const App = () => {
const [todos, setTodos] = useState([]);
useSsrEffect(async () => {
const todos = await getTodos()
setTodos(todos);
});
return (
<div>
<h1>Hi</h1>
<ul>
{todos.map(todo => (
<li key={todo.id} style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>{todo.title}</li>
))}
</ul>
</div>
);
};
В server.jsx заменим стандартный renderToString на serverRender из пакета @issr/core:
import React from 'react';
import express from 'express';
import { serverRender } from '@issr/core';
import serialize from 'serialize-javascript';
import { App } from './App';
const app = express();
app.use(express.static('public'));
app.get('/*', async (req, res) => {
const { html } = await serverRender(() => <App />);
res.send(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<div id="root">${html}</div>
<script src="/index.js"></script>
</body>
</html>
`);
});
app.listen(4000, () => {
console.log('Example app listening on port 4000!');
});
Если запустить приложение сейчас, то ничего не произойдет! Мы не увидим результата выполнения асинхронной функции getTodos. Почему так происходит? Мы забыли синхронизировать состояние. Давайте исправим это.
В App.jsx заменим стандартный setState на useSsrState из пакета @issr/core:
import React from 'react';
import fetch from 'node-fetch';
import { useSsrState, useSsrEffect } from '@issr/core';
const getTodos = () => {
return fetch('https://jsonplaceholder.typicode.com/todos')
.then(data => data.json())
};
export const App = () => {
const [todos, setTodos] = useSsrState([]);
useSsrEffect(async () => {
const todos = await getTodos()
setTodos(todos);
});
return (
<div>
<h1>Hi</h1>
<ul>
{todos.map(todo => (
<li key={todo.id} style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>{todo.title}</li>
))}
</ul>
</div>
);
};
Внесем изменения в client.jsx для синхронизации состояния переданного с сервера на клиент:
import React from 'react';
import { hydrate } from 'react-dom';
import createSsr from '@issr/core';
import { App } from './App';
const SSR = createSsr(window.SSR_DATA);
hydrate(
<SSR>
<App />
</SSR>,
document.getElementById('root')
);
window.SSR_DATA — это объект, переданный с сервера с кешированнным состоянием, для синхронизации на клиенте.
Сделаем передачу состояние на сервере:
import React from 'react';
import express from 'express';
import { serverRender } from '@issr/core';
import serialize from 'serialize-javascript';
import { App } from './App';
const app = express();
app.use(express.static('public'));
app.get('/*', async (req, res) => {
const { html, state } = await serverRender(() => <App />);
res.send(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<script>
window.SSR_DATA = ${serialize(state, { isJSON: true })}
</script>
</head>
<body>
<div id="root">${html}</div>
<script src="/index.js"></script>
</body>
</html>
`);
});
app.listen(4000, () => {
console.log('Example app listening on port 4000!');
});
Обратите внимание, что функция serverRender передает не только HTML, но и состояние, которое прошло через useSsrState, мы его передаем на клиент, в качестве глобальной переменной SSR_DATA. На клиенте, данное состояние будет автоматически синхронизировано.
Осталось добавить скрипты запуска в package.json:
"scripts": {
"start": "webpack -w --mode development",
"build": "webpack"
},
iSSR отлично поддерживает разные state management библиотеки. В ходе работы над iSSR я заметил, что React State Management библиотеки делятся на 2 типа:
Рассмотрим пример реализации SSR для приложения с Redux Saga.
Мы не будем рассматривать этот пример так детально как предыдущий. Ознакомится с полным кодом можно по ссылке.
*Для лучшего понимания происходящего, читайте предыдущую главу
Сервер запускает наше приложение через serverRender, код выполняется последовательно, выполняя все операции useSsrEffect.
Концептуально Redux работая с сагами не выполняет никаких асинхронных операций. Наша задача отправить action для старта асинхронной операции в слое Саг, отдельных от нашего react-flow. В примере по ссылке выше, в контейнере Redux мы выполняем
useSsrEffect(() => {
dispatch(fetchImage());
});
Это не асинхронная операция! Но iSSR понимает, что что то произошло в системе. iSSR будет идти по остальным React компонентам выполняя все useSsrEffect если таковые будут и по завершению iSSR вызовет каллбек:
const { html } = await serverRender(() => (
<Provider store={store}>
<App />
</Provider>
), async () => {
store.dispatch(END);
await rootSaga.toPromise();
});
Таким образом мы можем обрабатывать асинхронные операции не только на уровне с React но и на других уровнях, в данном случае мы в начале поставили на выполнение нужные нам саги, после чего в callback serverRender запустили и дождались их окончания.
Я подготовил много примеров использования iSSR, вы можете их найти по ссылке.
На пути в разработке SSR приложений существуют множество проблем. Проблема асинхронных операций это только одна из них. Давайте посмотрим на другие распространенные проблемы.
Немаловажным аспектом в разработке SSR является использование правильных HTML meta tags. Они сообщают поисковому боту ключевую информацию на странице.
Для реализации данной задачи рекомендую использовать один из модулей:
React-Helmet-Async
React-Meta-Tags
Я подготовил несколько примеров:
React-Helmet-Async
React-Meta-Tags
Чтобы снизить размер финального бандла приложения, принято приложение делить на части. Например dynamic imports webpack позволяет автоматически разбить приложение. Мы можем вынести отдельные страницы в чанки или блоки. При SSR мы должны уметь обрабатывать данные фрагменты приложения как одно целое. Для этого рекомендую использовать замечательный модуль @loadable
Некоторые компоненты или фрагменты страницы можно не рендерить на сервере. Например, если у вас есть пост и комментарии, не целесообразно обрабатывать обе асинхронные операции. Данные поста более приоритетны чем комментарии к нему, именно эти данные формируют SEO нагрузку вашего приложения. По этому мы можем исключать не важные части при помощи проверок типа:
if (typeof windows === 'undefined') {
}
NodeJS не поддерживает localStorage. Для хранения сессионных данных мы используем cookie вместо localStorage. Файлы cookie отправляются автоматически по каждому запросу. Файлы cookie имеют ограничения, например:
Некоторые данные должны быть переданы в URL. Например, если мы используем локализацию на сайте, то текущий язык будет частью URL. Такой подход улучшит SEO, поскольку у нас будут разные URL-адреса для разных локализаций приложения и обеспечить передачу данных по запросу.
React Server Components возможно будет хорошим дополнением для SSR. Его идеей является снижение нагрузки на Bundle за счет выполнения компонент на сервере и выдачи готового JSON React дерева. Нечто подобное мы видели в Next.JS. Читайте подробнее по ссылке
React Router из коробки поддерживает SSR. Отличие в том, что на server используется StaticRouter с переданным текущим URL, а на клиенте Router определяет URL автоматически при помощи location API. Пример
Дебаг на сервере может выполняться также как и любой дебаг node.js приложений через inpsect.
Для этого нужно добавить в webpack.config для nodejs приложения:
devtool: ‘source-map’
А в настройки NodemonPlugin:
new NodemonPlugin({
watch: path.resolve(__dirname, './dist'),
nodeArgs: [
'--inspect'
]
})
Также, для улучшения работы с source map можно добавить модуль
npm install source-map-support --save-dev
В nodeArgs опций NodemonPlugin добавить:
‘–require=«source-map-support/register»’
Пример
Если вы создаете приложение с нуля, рекомендую обратить внимание на данный фреймверк. Это самое популярное решение на данный момент для создания с нуля приложений с поддержкой SSR. Из плюсов можно выделить то, что все идет из коробки (билд система, роутер). Из минусов — необходимо переписывать существующее приложение, использовать подходы Next.JS.
Критерии Google бота для SEO оптимизации включают множество метрик. Рендер данных, получение первого байта и т.д. это лишь часть метрик! При SEO оптимизации приложения необходимо минимизировать вес картинок, бандла, грамотно использовать HTML теги и HTML мета теги и прочее.
Для проверки вашего сайта при SEO оптимизации можно воспользоваться:
lighthouse
sitechecker
pagespeed
В данной статье я описал основные проблемы, но далеко не все, для разработки SSR приложений. Но цель данной статьи показать вам, что SSR это не так страшно. С данным подходом мы можем жить и делать отличные приложения! Всем, кто дочитал до конца желаю успешных и интересных проектов, меньше багов и крепкого здоровья в это нелегкое для всех нас время!
Apple возобновила переговоры с OpenAI о возможности внедрения ИИ-технологий в iOS 18, на основе данной операционной системы будут работать новые…
Конкурсный управляющий российской «дочки» Google подготовил 23 иска к участникам рекламного рынка. Общая сумма исков составляет 16 млрд рублей –…
Google завершил обновление основного алгоритма March 2024 Core Update. Раскатка обновлений была завершена 19 апреля, но сообщил об этом поисковик…
У частных продавцов на Авито появилась возможность составлять текст объявлений с помощью нейросети. Новый функционал доступен в категории «Обувь, одежда,…
24 апреля 2024 года в Москве состоялась церемония вручения наград международного конкурса Workspace Digital Awards. В этом году участниками стали…
27 июня Яндекс проведет гик-фестиваль Young Con для студентов и молодых специалистов, которые интересуются технологиями и хотят работать в IT.…