Skip to content

Latest commit

 

History

History
438 lines (295 loc) · 24.2 KB

15. Серверный рендеринг.md

File metadata and controls

438 lines (295 loc) · 24.2 KB

Серверный рендеринг

Приходим к бэкэнду фронтэнда

В прошлом уроке мы разобрались с билдами и деплоями, но есть одна проблема: а как же нам быть с поисковыми системами?

Они то умеют отрабатывать Джаваскрипт, то не умеют, но нам же нужно надёжное решение, мы не можем надеяться на волю Гугла или Яндекса. Для этого мы будем рендерить приложение на сервере.

Как было раньше, лет 10 назад? Помните, я говорил про MVC? Был проект, который делал запрос к базе данных, затем его рендерил в шаблон и отдавал готовую вёрстку в браузер.

Псевдокодом это выглядит так:

// подготовленные данные из БД
const data = {
  title: "2-комнатная квартира в центре Хамовников",
  area: 142.2
};

// шаблон с местами под данные
<template>
  <h1>{{data.title}}</h1>
  <p>{{data.area}} м²</p>
</template>

// рендер в готовую вёрстку,
// render не из ReactDOM,
// а из какого-нибудь шаблонизатора
const html = render(data, template);

По этой причине поисковики (и люди) получали готовую вёрстку и могли её парсить, изучать.

Сейчас всё по-другому: поисковикам (и людям) отдаётся небольшой ХТМЛ с <div id="root"></div>, куда Реакт рендерит приложение.

Но Реакт-то написан на Джаваскрипте, а поисковики его не исполняют! Всё, что они знают о вашем сайте — это <div id="root"></div>. Не очень круто.

Серверный рендеринг

Серверный рендеринг это процесс рендера приложения на сервере и отдача готовой вёрстки. Как раньше, только у нас не большой проект с бэкэндом и фронтэндом вместе, а небольшой сервер, который занимается только рендером фронтэнда на сервере.

Для чего нужен ССР (server-side rendering)?

В первую очередь для людей: сервера намного мощнее чем ноутбуки и планшеты людей, поэтому проще подготовить готовую вёрстку и отдать её, чем ждать пока выполнится ваш Джаваскрипт-код. Почитайте про Ресурс Тайминг в блоге Гугла — очень большой простор для оптимизации.

Во вторую очередь для тех самых поисковиков. Поисковый трафик до сих пор остаётся самым сильным, поэтому компании в него вкладываются и будут вкладываться и вы должны очень чутко следить за тем, чтобы ваш новейший фронтэнд работать хорошо.

Да, если сеошники начинают ныть, что Реакт не приспособлен для СЕО и они не возьмутся за проект — смело им показывайте мой кейс (29 июня 2017 и полгода спустя, 24 декабря 2017 >


Окей, нужда понятна, как это всё реализовать?

ХТТП-серверы и Нода

Нам же нужно отдавать готовую вёрстку на запрос браузера? Для этого нам нужно написать сервер, который будет работать с ХТТП-запросами.

Сервер мы будем писать на Джаваскрипте, а запускать — Нодой, которую вы ставили в начале курса.

Нода это специальное окружение, где выполняется джс. Нода дарит свой АПИ (а браузеры — свой), например, для работы с файловой системой, криптографией или операционной системой и путями в ней.

Нас интересует ХТТП, но нативный АПИ слишком низкоуровневый и многосложный, поэтому мы возьмём Экспресс — удобную библиотеку.

Такие прокладки дают более удобный АПИ, чем нативный, но за это нужно расплачиваться скоростью работы: если бы мы использовали http Ноды, у нас бы приложение работало быстрее, потому что Экспресс тоже затрачивает время на выполнение своего кода; это называется оверхедом. К счастью, речь идёт о совсем крошечных значениях и в реальной жизни редко кто находит Экспресс узким местом в приложении.

Пока мы в браузерах с помощью Бейбеля и Вебпака пришли к import/export, модули в Ноде делались (и пока что делаются) через функцию require().

Давайте соберём небольшую демку на экспрессе, которая будет на двух разных страницах отдавать разный контент. Код будем писать в /server.js

const express = require("express");

// создаём приложение
const app = express();

const text = "Hello World!";
const json = { ok: true };
const html = '<html><p style="color: red">test</p></html>';

// при ГЕТ-запросах на разные адреса
// отдадим разный контент через res.send
app.get("/", function(req, res) {
  return res.send(text));
}

app.get("/json", function(req, res) {
  return res.send(json));
}

app.get("/html", function(req, res) {
  return res.send(html));
}

// запускаем сервер на порту 3000
app.listen(3000, function() {
  console.log("Example app listening on port 3000!");
});

Экспресс работает на уже знакомых вам коллбеках, в которые приходят два параметра: req[uest] и res[ponse]. В реквесте хранится информация о реквесте (например, хедеры, тело, куки и проч), в респонсе — методы и информация о респонсе.

Через метод res.send() мы посылаем ХТТП-ответ. Перед этим мы можем через методы res.cookie(),res.set(), res.status() поставить куки, ХТТП-хедеры или ХТТП-статус-код.

Да, этим мы мутируем объект res, ну а что поделать — такой вот АПИ у Экспресса. Мутабельность это всё ещё плохо, потому что явное лучше неявного.

Как запустить наш сервер? Командой node ./server.js.


Окей, что нам дают эти знания? Теперь мы немного умеем работать с ХТТП, понимаем что для этого нужно написать сервер на Ноде с помощью Экспресса.

Серверный рендеринг — теория

Наша задача отрендерить Нодой Реакт-приложение и затем отдать готовую вёрстку.

Псевдокодом это выглядит примерно так:

const express = require("express");
const { render } = require("react-dom/server"); // например
const ReactApp = require("./src/App");

// создаём приложение
const app = express();

// на любом адресе рендерим Реакт-приложение
// потому что Реакт-роутер разберётся что именно нужно рендерить
app.get("/*", function(req, res) {
  // на каждом запросе заново строим Реакт-приложение
  // потому что у каждого клиента свой запрос
  const html = render(React.createElement(ReactApp));

  return res.send(html);
});

// запускаем сервер на порту 3000
app.listen(3000, function() {
  console.log("Example app listening on port 3000!");
});

Что характерно, этот псевдокод почти рабочий: разве что вместо render() из react-dom/server мы импортим renderToString() оттуда же. Фиксанём.

const express = require("express");
const { renderToString } = require("react-dom/server");
const ReactApp = require("./src/App");

// создаём приложение
const app = express();

// на любом адресе рендерим Реакт-приложение
// потому что Реакт-роутер разберётся что именно нужно рендерить
app.get("/*", function(req, res) {
  // на каждом запросе заново строим Реакт-приложение
  // потому что у каждого клиента свой запрос
  const html = renderToString(React.createElement(ReactApp));

  return res.send(html);
});

// запускаем сервер на порту 3000
app.listen(3000, function() {
  console.log("Example app listening on port 3000!");
});

Если мы теперь каждый запрос будем пропускать через ССР-прокси, то второе, что нам нужно сделать — заменить в src/index.js ReactDOM.render() на ReactDOM.hydrate().

Но если мы запустим теперь node server.js, то получим кучу ошибок: от Unexpected token import.

Есть два способа это решить: использовать babel-node (который будет на лету преобразовывать — но это грозит неплохим оверхедом) и настраивать Вебпак чтобы при билде он собирал отдельный бандл (конечный файл), который вы подключите в Ноду как обычный модуль через require().

Вообще, почему так сложно? Почему Нода не поддерживает современный Джаваскрипт? Чисто технически — поддерживает, но есть три нюанса.

Различие Ноды и браузерного Джса

Модули

Первый — модули.

Раньше в Джсе вообще никаких модулей не было, потом появился RequireJS который их эмулировал и использовал подход AMD (Asynchronous Module Definition).

Был ещё CommonJS (он же ServerJS) — спецификация, идея которой была в использовании Джса на серверной стороне, в том числе с модулями. Как вы понимаете, победила Нода, но систему модулей себе она забрала именно оттуда.

После — комитет TC39, развивающий Экмаскрипт (стандарт, на котором реализован Джаваскрипт) вернулся к своей работе и каждый год обновляет Экмаскрипт. Самый громкий релиз был ES2015 (он же ES6), частью которого и были модули, или ES modules. Это уже знакомые вам import/export.

Поддержка ес-модулей будет, но, скорее всего, через расширение .mjs. Во всяком случае, сейчас её нет.

Поддержка ES2015 и выше

Вторая проблема — поддержка этого самого Экмаскрипта.

Если мы делаем фронтэнд, благодаря Бейбелю мы можем писать современный Джс даже если он недоступен в браузерах. С Нодой современный Джс всё ещё недоступен.

Да, можно компилировать тем же Бейбелем, но плата за это — сложно читаемые исходники. Зайдите на сайт Бейбеля, попробуйте вбить какой-нибудь свой Реакт-код туда — вывод будет сложно понять.

Невозможность импорта картинок и прочего

Вебпак работает с любым типом файлов — был бы нужный лоадер, хоть markdown-loader, хоть пдф через file-loader.

В Ноде, конечно же, такого нет (как и в ес-модулях, в общем-то) — каждый реквайр трактуется как Джс, а не как пнг.

Решения

Сложный: делать отдельный бандл

В Купибилете ребята подготавливают отдельный CommonJS-бандл с Реакт-приложением, который потом реквайрят в server.js.

Плюсы:

  • нет оверхеда на преобразование Бейбелем на лету

Минусы:

  • сложно настроить

Лёгкий: использовать babel-node вместо node

Плюсы:

  • настроить легче

Минусы:

  • сильный оверхед: код преобразовывается на каждом запросе и на это уходят ресурсы сервера и время

Лично я использую babel-node на этом сайте: у меня не такие большие запросы чтобы думать об оверхеде.

asset-require-hook

Один из вопросов настройки это те самые ресурсы типа .жпг или .свг, которые вы импортите и обрабатываете Вебпаком.

Для этого используется asset-require-hook: специальный модуль, который отлавливает (hook) такие импорты и преобразовывает их в ссылки.

Настройка у него достаточно примитивная:

require("asset-require-hook")({
  extensions: [".jpg", ".jpeg", ".png", ".gif", ".svg"], // нужны точки обязательно
  publicPath: process.env.PUBLIC_URL,
  name: "/static/media/[name].[hash:8].[ext]" // https://github.com/facebookincubator/create-react-app/blob/master/packages/react-scripts/config/webpack.config.prod.js#L167
});
isomorphic-fetch

В Ноде нет встроенного Фетча, поэтому используется node-fetch, но чтобы не заменять весь проект им, существует ещё один хук: isomorphic-fetch.

Просто импортните его:

require("isomorphic-fetch");
window-or-global

В Ноде также нет объекта Window, который существует в браузерах. Для этого используют модуль window-or-global, который импортят в Реакт-приложении когда нужно обратиться к window.

Серверный рендеринг — собираем вместе

Окей, мы разобрались с проблемами и нашли решения, давайте соберём всё вместе.

Нам нужно на сервере рендерить:

Давайте напишем код, который это делает.

// public/index.html

<!doctype html>
<html lang="ru">

<head>
  <meta charset="utf-8">

  // заводим плейсхолдеры
  // куда будем рендерить стили
  // и мета-инфу из helmet
  <meta name="$helmet-placeholder$">
  <meta name="$sc-placeholder$">

  <style>
    // скроем, чтобы люди не видели {ssrData}

    .ssr-placeholder {
      opacity: 0;
    }
  </style>
</head>

<body>
  <noscript>
    Сайт без Джаваскрипта работает плохо
  </noscript>

  <div id="root">
    // плейсхолдер для основной вёрстки
    <span class="ssr-placeholder">{ssrData}</span>
  </div>
</body>

</html>
// server/reactApp.js
// нельзя импортить через require,
// потому что мы там экспортим через `export`,
// а не `module.exports` из CommonJS
import ReactApp from "./src/App";

const path = require("path");
const fs = require("fs");

const React = require("react");
const { StaticRouter } = require("react-router-dom");
const ReactDOMServer = require("react-dom/server");

const { Helmet } = require("react-helmet");
const { ServerStyleSheet } = require("styled-components");

// берём build/index.html и сохраняем весь контент в indexFileContent
const indexFile = path.resolve(__dirname, "..", "build", "index.html");
const indexFileContent = fs.readFileSync(indexFile, { encoding: "utf8" });

// плейсхолдер куда будем рендерить данные
const ssrPlaceholder = '<span class="ssr-placeholder">{ssrData}</span>';

// плейсхолдер для react-helmet
const helmetPlaceholder = '<meta name="$helmet-placeholder$">';

// плейсхолдер для styled-components
const scPlaceholder = '<meta name="$sc-placeholder$">';

module.exports = (req, res) => {
  // https://www.styled-components.com/docs/advanced#server-side-rendering
  // получаем стили
  const sheet = new ServerStyleSheet();

  // https://reacttraining.com/react-router/web/guides/server-rendering
  const context = {};

  // приложение с роутером
  const AppWithRouter = (
    <StaticRouter location={req.url} context={context}>
      <ReactApp />
    </StaticRouter>
  );

  // получим стили для текущей страницы
  const AppWithStyles = sheet.collectStyles(AppWithRouter);

  // рендерим в строку
  const App = ReactDOMServer.renderToString(AppWithStyles);

  // https://github.com/nfl/react-helmet#server-usage
  // все данные из helmet переводим в строку
  // и получаем <style> для стайлед-компонентс
  const helmet = Helmet.renderStatic();
  const styleTags = sheet.getStyleTags();

  // редиректим с http-кодом 301
  // если где-то есть <Redirect /> реакт-роутера
  if (context.url) res.redirect(301, context.url);

  // собираем title, meta, link-теги
  // в одну строку
  const helmetData = `
    ${helmet.title.toString()}
    ${helmet.meta.toString()}
    ${helmet.link.toString()}
  `;

  // вставляем данные через функцию replace
  // в indexFileContent заменяем все плейсхолдеры
  const content = indexFileContent
    .replace(helmetPlaceholder, helmetData)
    .replace(scPlaceholder, styleTags)
    .replace(ssrPlaceholder, App);

  // возвращаем тело запроса
  res.send(content);
};

И пишем небольшой сервер на Экспрессе

// server/index.js

// используем npm.im/debug вместо консольлогов
const debug = require("debug")("erodionov:server");
const express = require("express");
const path = require("path");

// импортим нашу функцию
const handleRenderReactApp = require("./reactApp");

const server = express();

// достаём HOST и PORT энв-параметрами
// если их нет — ставим дефолтные значения
const { HOST = "127.0.0.1", PORT = 8080 } = process.env;

// статикой раздаём директорию `build`
// для картинок и прочего
// и выключаем через { index: false } использование
// файла build/index.html
// иначе Экспресс будет отдавать его на /
server.use(
  express.static(path.resolve(__dirname, "..", "build"), { index: false })
);

// на любой запрос вызываем функцию handleRenderReactApp
server.get("/*", handleRenderReactApp);

server.listen(PORT, HOST, () => debug(`app started at ${HOST}:${PORT}`));

Запускаем как PUBLIC_URL=https://erodionov.ru babel-node server/index.js и вуаля, всё работает!

Итог

Да, серверный рендеринг это и легко и сложно одновременно: что-то могло быть легче, но в целом это не какая-то шаманская магия, всё вполне очевидно решается.

Ну и заодно мы в начале урока разобрались с ХТТП и Нодой!

PS: публичный код этого сервернего рендеринга я храню в gist.github.com