Pull to refresh
640.48
OTUS
Цифровые навыки от ведущих экспертов

Будущее (и прошлое) веба — это рендеринг на стороне сервера

Reading time11 min
Views13K
Original author: Andy Jiang

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

Теперь же веб-страница может быть полноценным приложением, которое получает данные из различных источников, выполняет манипуляции «на лету» и предоставляет полную интерактивность. Это значительно повысило полезность интернета, но ценой размера, пропускной способности и скорости. За последние 10 лет средний размер веб-страниц для десктопа увеличился с 468 КБ до 2284 КБ, что составляет рост на 388,3%. У мобильных устройств скачок еще более впечатляющий — от 145 КБ до 2010 КБ, то есть больше на 1288,1%.

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

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

Как же нам со всем этим справиться? Ответ — путем возврата на сервер (швейцарский подвал не потребуется).

Краткий экскурс о том, как мы сюда попали

Вначале был PHP, и он был прекрасен — в случае если вам нравились вопросительные знаки.

Интернет начинался как сеть статичного HTML, но CGI скрипты (написанные на Perl или PHP), которые позволяют разработчикам преобразовывать внутренние источники данных в HTML, привнесли идею о том, что веб-сайты могут быть динамическими, основанными на посетителях.

Это означало, что разработчики могли создавать динамические сайты и предоставлять конечному пользователю данные реального времени или данные из БД (при условии, что их ключи #, !, $ и ? работали).

PHP работал на сервере, потому что сервера были мощной частью сети. На сервере можно было получить данные и сгенерировать HTML, а затем отправить всё это браузеру. Задача браузера была ограничена — просто интерпретировать документ и показать страницу. Это хорошо работало, но решение было направлено только на отображение информации, а не на взаимодействие с ней.

Затем JavaScript стал действительно хорош, а браузеры — мощнее. С тех пор можно было делать массу интересных вещей прямо на клиенте. Зачем сначала рисовать всё на сервере и отправлять, если можно просто передать в браузер базовую HTML-страницу вместе с JS и позволить клиенту самому обо всём позаботиться?

Так родились одностраничные приложения (SPA) и рендеринг на стороне клиента (CSR).

Рендеринг на стороне клиента

В CSR (Client-side rendering, рендеринг на клиенте), также известном как динамический рендеринг, код выполняется в основном на стороне клиента, в браузере пользователя. Браузер клиента загружает необходимые HTML, JavaScript и другие ресурсы, а затем запускает код для рендеринга пользовательского интерфейса.

Источник: Walmart

Преимущества такого подхода двояки:

  • Отличный пользовательский опыт. Если у вас сверхбыстрая сеть и вы можете быстро загрузить пакет и данные, то в итоге получится супербыстрый сайт. Вам не придётся возвращаться к серверу для совершения повторных запросов, поэтому каждое изменение страницы или данных происходит немедленно.

  • Кэширование. Поскольку вы не используете сервер, вы можете кэшировать основные пакеты HTML и JS на CDN. Это означает, что пользователи могут быстро получить к ним доступ, и затраты компании останутся низкими.

По мере того, как веб становился все более интерактивным (спасибо JavaScript и браузерам), рендеринг на стороне клиента и SPA стали использоваться по умолчанию. Веб стал очень быстрым... особенно если вы работали на настольном компьютере, используя популярный браузер и проводное подключение к интернету.

Для всех остальных веб замедлялся. По мере своего развития он стал доступен на большем количестве устройств и при различных подключениях. Управлять SPA для стабильного пользовательского опыта стало сложнее. Разработчикам приходилось не только следить за тем, чтобы сайт отображался одинаково как в IE, так и в Chrome, но и учитывать, как он будет отображаться на смартфоне пользователя, который находится в центре оживленного города в автобусе. Если соединение не могло загрузить этот пакет JS из кэша, посетить сайт не удавалось.

Как можно легко обеспечить согласованность в широком диапазоне устройств и пропускной способности? Ответ: вернуться на сервер.

Рендеринг на стороне сервера

Существует множество преимуществ переноса работы браузера по рендерингу веб-сайта на сервер:

  • При использовании сервера выше производительность, поскольку HTML уже сгенерирован и готов к отображению при загрузке страницы.

  • При рендеринге на стороне сервера выше совместимость, поскольку, опять же, HTML генерируется на сервере и не зависит от конечного браузера.

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

С SSR мы делаем все на сервере:

Источник: Walmart

Существует множество изоморфных JavaScript-фреймворков, поддерживающих SSR: они рендерят HTML на сервере с помощью JavaScript и отправляют этот HTML в комплекте с JavaScript для интерактивности на клиент. Написание изоморфного JavaScript обеспечивает меньшую кодовую базу.

Некоторые из этих фреймворков, такие как NextJS и Remix, построены на React. Коробочный React — это фреймворк для рендеринга на стороне клиента, но у него есть возможности SSR — если использовать renderToString и другие рекомендуемые версии, такие как renderToPipeableStream, renderToReadableStream и прочие. NextJS и Remix предлагают более высокие абстракции над renderToString, что упрощает создание SSR сайтов.

SSR сопровождается некоторыми компромиссами. Мы можем контролировать больше и поставлять быстрее, но ограничение SSR заключается в том, что для интерактивных сайтов все еще нужно отправлять JS, который объединяется со статическим HTML в процессе, называемом «гидратация» (добавить пояснение: Процесс добавления сценария JavaScript обратно в скомпилированный HTML-код страницы во время рендеринга HTML-кода в браузере).

Отправка JS для гидратации приводит к некоторым усложнениям:

  • Отправлять ли весь JS при каждом запросе? Или мы основываемся на пути?

  • Делается ли гидратация сверху вниз, и насколько это дорого?

  • Как разработчику организовать кодовую базу?

На стороне клиента большие пакеты могут привести к проблемам с памятью и ощущению для пользователя, что ничего не происходит, поскольку весь HTML есть, но, по сути, его не получается использовать, пока он не гидратируется.

Один из подходов, который нам нравится в Deno, это архитектура островов — подразумевается, что в море статичного SSR'd HTML есть острова интерактивности. Вероятно, вы слышали, что острова использует современный веб-фреймворк Fresh, который по умолчанию отправляет клиенту ноль JavaScript.

Что вы хотите получить с помощью SSR, так это то, чтобы HTML обслуживался и отображался быстро, а затем каждый из отдельных компонентов обслуживался и отображался независимо. Таким образом, вы посылаете меньшие чанки JavaScript и выполняете меньшие чанки рендеринга на клиенте.

Именно так и работают острова.

Острова рендерятся не постепенно, а отдельно. Рендеринг острова не зависит от рендеринга предыдущего компонента, а обновления других частей виртуального DOM не приводят к повторному рендерингу отдельного острова.

Острова сохраняют преимущества общего SSR, но без ущерба в виде больших блоков для  гидратации. Большой успех.

Как выполнять рендеринг с сервера

Не все серверные рендеринги одинаковы. Есть рендеринг на стороне сервера, а есть рендеринг на стороне сервера с большой буквы.

Здесь мы рассмотрим несколько различных примеров рендеринга с сервера в Deno. Мы перенесем замечательное введение Джонаса Гальвеса в серверный рендеринг на Deno, Oak и Handlebars, используя три варианта одного и того же приложения:

  1. Прямой, шаблонный, серверный HTML-пример без какого-либо взаимодействия с клиентом (источник).

  2. Первоначально отрендеренный на стороне сервера пример, который затем обновляется на стороне клиента (источник).

  3. Полностью серверная версия с изоморфным JS и общей моделью данных (источник).

Посмотреть исходники всех примеров можно здесь.

Именно третья версия является SSR в истинном смысле. У нас будет один JavaScript файл, который будет использоваться как сервером, так и клиентом, а любые обновления списка будут производиться путем обновления модели данных.

Но сначала давайте сделаем несколько шаблонов. В этом первом примере мы будем только рендерить список. Вот основной файл server.ts:

import { Application, Router } from "https://deno.land/x/oak@v11.1.0/mod.ts";
import { Handlebars } from "https://deno.land/x/handlebars@v0.9.0/mod.ts";

const dinos = ["Allosaur", "T-Rex", "Deno"];
const handle = new Handlebars();
const router = new Router();

router.get("/", async (context) => {
  context.response.body = await handle.renderView("index", { dinos: dinos });
});

const app = new Application();

app.use(router.routes());
app.use(router.allowedMethods());

await app.listen({ port: 8000 });

Обратите внимание, что нам не нужен файл client.html. Вместо этого с помощью Handlebars создадим следующую структуру файлов:

|--Views
|    |--Layouts
|    |     |
|    |     |--main.hbs
|    |
|    |--Partials
|    |
|    |--index.hbs

main.hbs содержит основной HTML-макет с заполнителем для {{{body}}}:

<html lang="en">
 <head>
   <meta charset="UTF-8" />
   <meta name="viewport" content="width=device-width, initial-scale=1.0" />
   <title>Dinosaurs</title>
 </head>
 <body>
   <div>
     <!--content-->
     {{{body}}}
   </div>
 </body>
</html>

{{{body}}} происходит из index.hbs. В данном случае он использует синтаксис Handlebars для итерации по списку:

<ul>
 {{#each dinos}}
   <li>{{this}}</li>
 {{/each}}
</ul>

Итак, что происходит:

  • Клиент выполняет запрос.

  • Сервер передает список dinos рендереру Handlebars.

  • Каждый элемент этого списка рендерится в списке в файле index.hbs

  • Весь список из index.hbs отображается в main.hbs

  • Весь этот HTML отправляется в теле ответа клиенту.

Рендеринг на стороне сервера! Ну, типа того. Хотя рендеринг действительно осуществляется на сервере, он не интерактивен.

Давайте добавим в список немного интерактивности — возможность добавить элемент. Это классический случай использования рендеринга на стороне клиента, по сути, SPA. Сервер практически не меняется, за исключением добавления эндпоинта /add, чтобы добавить элемент в список:

import { Application, Router } from "https://deno.land/x/oak@v11.1.0/mod.ts";
import { Handlebars } from "https://deno.land/x/handlebars@v0.9.0/mod.ts";

const dinos = ["Allosaur", "T-Rex", "Deno"];
const handle = new Handlebars();
const router = new Router();

router.get("/", async (context) => {
  context.response.body = await handle.renderView("index", { dinos: dinos });
});

router.post("/add", async (context) => {
  const { value } = await context.request.body({ type: "json" });
  const { item } = await value;
  dinos.push(item);
  context.response.status = 200;
});

const app = new Application();

app.use(router.routes());
app.use(router.allowedMethods());

await app.listen({ port: 8000 });

На этот раз код Handlebars существенно изменился. У нас по-прежнему есть шаблон Handlebars для генерации HTML-списка, но main.hbs  включает свой собственный JavaScript для работы с кнопкой Add: событие EventListener, привязанное к кнопке, которое будет:

  • выполнять POST с новым элементом списка в эндоинт /add ,

  • добавлять элемент в список HTML.

[...]
  <input />
  <button>Add</button>
 </body>
</html>

<script>
 document.querySelector("button").addEventListener("click", async () => {
   const item = document.querySelector("input").value;
   const response = await fetch("/add", {
     method: "POST",
     headers: {
       "Content-Type": "application/json",
     },
     body: JSON.stringify({ item }),
   });
   const status = await response.status;
   if (status === 200) {
     const li = document.createElement("li");
     li.innerText = item;
     document.querySelector("ul").appendChild(li);
     document.querySelector("input").value = "";
   }
 });
</script>

Но это не рендеринг на стороне сервера в истинном смысле. В SSR вы запускаете один и тот же изоморфный JS на стороне клиента и сервера, и он просто действует по-разному в зависимости от того, где он запущен. В приведенных выше примерах JS запущен на сервере и клиенте, но они работают независимо друг от друга.

Итак, перейдем к настоящему SSR. Мы откажемся от Handlebars и шаблонизаторов и вместо этого создадим DOM, который мы будем обновлять с помощью Dinosaurs. У нас будет три файла. Первый — server.ts:

import { Application } from "https://deno.land/x/oak@v11.1.0/mod.ts";
import { Router } from "https://deno.land/x/oak@v11.1.0/mod.ts";
import { DOMParser } from "https://deno.land/x/deno_dom@v0.1.36-alpha/deno-dom-wasm.ts";
import { render } from "./client.js";

const html = await Deno.readTextFile("./client.html");
const dinos = ["Allosaur", "T-Rex", "Deno"];
const router = new Router();

router.get("/client.js", async (context) => {
  await context.send({
    root: Deno.cwd(),
    index: "client.js",
  });
});

router.get("/", (context) => {
  const document = new DOMParser().parseFromString(
    "<!DOCTYPE html>",
    "text/html",
  );
  render(document, { dinos });
  context.response.type = "text/html";
  context.response.body = `${document.body.innerHTML}${html}`;
});

router.get("/data", (context) => {
  context.response.body = dinos;
});

router.post("/add", async (context) => {
  const { value } = await context.request.body({ type: "json" });
  const { item } = await value;
  dinos.push(item);
  context.response.status = 200;
});

const app = new Application();

app.use(router.routes());
app.use(router.allowedMethods());

await app.listen({ port: 8000 });

На этот раз многое изменилось. Во-первых, у нас снова есть несколько новых эндпоинтов:

  • эндпоинт GET, который будет обслуживать файл client.js,

  • эндпоинт GET, который будет обслуживать данные.

Но существенное изменение также произошло в корневом эндпоинте. Теперь мы создаем объект документа DOM с помощью DOMParser из deno_dom. Модуль DOMParser работает подобно ReactDOM, позволяя воссоздать DOM на сервере. Затем мы используем созданный документ для рендеринга списка заметок, но вместо того, чтобы использовать шаблонизацию handlebars, мы получаем эту функцию рендеринга из нового файла.

let isFirstRender = true;

// Simple HTML sanitization to prevent XSS vulnerabilities.
function sanitizeHtml(text) {
  return text
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&#039;");
}

export async function render(document, dinos) {
  if (isFirstRender) {
    const jsonResponse = await fetch("http://localhost:8000/data");
    if (jsonResponse.ok) {
      const jsonData = await jsonResponse.json();
      const dinos = jsonData;
      let html = "<html><ul>";
      for (const item of dinos) {
        html += `<li>${sanitizeHtml(item)}</li>`;
      }
      html += "</ul><input>";
      html += "<button>Add</button></html>";
      document.body.innerHTML = html;
      isFirstRender = false;
    } else {
      document.body.innerHTML = "<html><p>Something went wrong.</p></html>";
    }
  } else {
    let html = "<ul>";
    for (const item of dinos) {
      html += `<li>${sanitizeHtml(item)}</li>`;
    }
    html += "</ul>";
    document.querySelector("ul").outerHTML = html;
  }
}

export function addEventListeners() {
  document.querySelector("button").addEventListener("click", async () => {
    const item = document.querySelector("input").value;
    const dinos = Array.from(
      document.querySelectorAll("li"),
      (e) => e.innerText,
    );
    dinos.push(item);
    const response = await fetch("/add", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ item }),
    });
    if (response.ok) {
      render(document, dinos);
    } else {
      // In a real app, you'd want better error handling.
      console.error("Something went wrong.");
    }
  });
}

Этот файл client.js доступен как серверу, так и клиенту — это изоморфный JavaScript, необходимый для истинного SSR. Мы используем функцию render на сервере для первоначального рендеринга HTML, но затем мы также используем render на клиенте для рендеринга обновлений.

Кроме того, при каждом вызове данные берутся напрямую с сервера. Данные добавляются в модель данных с помощью эндпоинта /add . По сравнению со вторым примером, где клиент добавляет элемент в список непосредственно в HTML, в этом примере все данные передаются через сервер.

JS из client.js также используется непосредственно на клиенте, в client.html:

<script type="module">
  import { render, addEventListeners } from "./client.js";
  await render(document);
  addEventListeners();
</script>

Когда клиент вызывает client.js в первый раз, HTML становится гидратированным, где client.js вызывает эндпоинт /data, чтобы получить данные, необходимые для будущих рендеров. Гидратация может стать медленной и сложной для больших страниц SSR; и здесь острова могут быть действительно полезными.

Вот как работает SSR. Дано:

  • DOM, воссозданный на сервере.

  • Изоморфный JS, доступный как на сервере, так и на клиенте для рендеринга данных на любом из них, и гидратация, установленная при начальной загрузке на клиенте для захвата всех данных, необходимых для того, чтобы сделать приложение полностью интерактивным для пользователя.

Упрощение сложного веба с помощью SSR

Мы создаем сложные приложения для любого размера экрана и любой пропускной способности. Этими сайтами можно пользоваться, находясь в поезде в тоннеле. Лучший способ обеспечить согласованность работы во всех этих сценариях, сохраняя при этом небольшую базу кода и легкость в рассуждениях — это SSR.

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

Застряли? Приходите за ответами в наш Discord.


Как создать сайт в блокноте html за час? Получите пошаговую инструкцию на открытом уроке в OTUS. 21 марта на бесплатном вебинаре сверстаем одностраничный сайт о компании или персоне, который вы сможете использовать далее. Разберём современные подходы к созданию HTML-страниц, их оформлению и расположению элементов на экране. Урок подойдет тем, кто только начинает знакомиться с миром веб-разработки. Записаться можно на странице специализации "Fullstack Developer".

Tags:
Hubs:
Total votes 20: ↑14 and ↓6+9
Comments22

Articles

Information

Website
otus.ru
Registered
Founded
Employees
101–200 employees
Location
Россия
Representative
OTUS