Skip to content

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

Обзор

Что такое SSR?

Vue.js — это фреймворк для создания клиентских приложений. По умолчанию компоненты Vue создают и управляют DOM в браузере в качестве выходных данных. Однако также возможно преобразовать те же компоненты в строки HTML на сервере, отправить их непосредственно в браузер и, наконец, «гидратировать» статическую разметку в полностью интерактивное приложение на клиенте.

Приложение Vue.js, отображаемое на сервере, также можно считать «изоморфным» или «универсальным» в том смысле, что большая часть кода вашего приложения выполняется как на сервере, так и на клиенте.

Почему SSR?

По сравнению с клиентским одностраничным приложением (SPA) преимущества SSR заключаются прежде всего в следующем:

  • Более быстрое время до контента: это особенно заметно при медленном интернете или на медленных устройствах. Разметке отрисованной на стороне сервера не нужно ждать, пока загрузится и выполнится весь JavaScript, чтобы отобразиться, поэтому пользователь быстрее увидит полностью отрисованную страницу. Кроме того, при первом посещении страницы получение данных осуществляется на стороне сервера, который, скорее всего, имеет более быстрое соединение с базой данных, чем клиент. Это, как правило, приводит к улучшению показателей Core Web Vitals, повышению качества работы пользователей может иметь решающее значение для приложений, в которых время просмотра контента напрямую связано с коэффициентом конверсии.

  • Единая модель мышления: вы можете использовать один и тот же язык и одну и ту же декларативную, компонентно-ориентированную модель мышления для разработки всего приложения, а не прыгать туда-сюда между внутренней системой шаблонов и внешним фреймворком.

  • Улучшение SEO: поисковые системы сразу видят полностью отрендеренную страницу.

    Совет

    На сегодняшний день Google и Bing прекрасно индексируют синхронные JavaScript-приложения. Синхронные - ключевое слово. Если ваше приложение начинается с отображения спиннера загрузки, а затем получаете содержимое с помощью Ajax, то уже никто не будет ждать окончания этого процесса. Это означает, что если на страницах, где важно SEO, содержимое загружается асинхронно, то SSR может оказаться необходимым.

При использовании SSR необходимо учитывать и некоторые компромиссы:

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

  • Более сложные требования к настройке и развёртыванию сборки. В отличие от полностью статического SPA, которое может быть развернуто на любом статическом файловом сервере, серверно-рендерное приложение требует наличия среды, в которой может работать сервер Node.js.

  • Большая нагрузка на сервер. Рендеринг полноценного приложения на Node.js будет более требователен к процессору, чем при обслуживании статических файлов, поэтому, если вы ожидаете большой трафик, будьте готовы к соответствующей нагрузке на сервер и грамотно используйте стратегии кэширования.

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

SSR vs. SSG

Генерация статического сайта (SSG), также называемая предварительным рендерингом, — еще одна популярная технология создания быстрых сайтов. Если данные, необходимые для серверного рендеринга страницы, одинаковы для каждого пользователя, то вместо того, чтобы рендерить страницу при каждом запросе, мы можем сделать это только один раз, заранее, в процессе сборки. Предварительно отрендеренные страницы генерируются и предоставляются в виде статических HTML-файлов.

SSG сохраняет те же характеристики производительности, что и SSR-приложения: он обеспечивает отличные показатели "time-to-content". В то же время он дешевле и проще в развертывании, чем SSR-приложения, поскольку на выходе получается статический HTML и ресурсы. Ключевое слово здесь - статические: SSG можно применять только к страницам, потребляющим статические данные, т.е. данные, которые известны на момент сборки и не меняются между развертываниями. Каждый раз, когда данные меняются, требуется новое развертывание.

Если вы используете SSR только для улучшения SEO нескольких маркетинговых страниц (например, /, /about, /contact, и т.д.), то, скорее всего, вам нужен SSG, а не SSR. SSG также отлично подходит для сайтов, основанных на контенте, таких как сайты документации или блоги. Фактически, этот сайт, который вы сейчас читаете, статически сгенерирован с помощью VitePress, генератора статических сайтов на базе Vue.

Базовое руководство

Рендеринг приложения

Рассмотрим самый простой пример Vue SSR в действии.

  1. Создайте новый каталог и перейдите в него с помощью cd
  2. Выполните команду npm init -y
  3. Добавьте "type": "module" в package.json, чтобы Node.js запускался в режиме ES-модулей.
  4. Выполните команду npm install vue
  5. Создайте файл example.js:
js
// выполняется в Node.js на сервере.
import { createSSRApp } from 'vue'
// API серверного рендеринга Vue находится в разделе `vue/server-renderer`.
import { renderToString } from 'vue/server-renderer'

const app = createSSRApp({
  data: () => ({ count: 1 }),
  template: `<button @click="count++">{{ count }}</button>`
})

renderToString(app).then((html) => {
  console.log(html)
})

Затем запустите:

sh
> node example.js

В командную строку должно быть выведено следующее:

<button>1</button>

Метод renderToString() принимает экземпляр приложения Vue и возвращает Promise, который разрешается в отрендеренный HTML приложения. Также можно осуществлять потоковый рендеринг с помощью Node.js Stream API или Web Streams API. Более подробную информацию можно найти в справочнике API для SSR.

Затем мы можем перенести код Vue SSR в обработчик запросов к серверу, который обернет разметку приложения полным HTML страницы. Для следующих шагов мы будем использовать express:

  • Запустите команду npm install express
  • Создайте следующий файл server.js file:
js
import express from 'express'
import { createSSRApp } from 'vue'
import { renderToString } from 'vue/server-renderer'

const server = express()

server.get('/', (req, res) => {
  const app = createSSRApp({
    data: () => ({ count: 1 }),
    template: `<button @click="count++">{{ count }}</button>`
  })

  renderToString(app).then((html) => {
    res.send(`
    <!DOCTYPE html>
    <html>
      <head>
        <title>Пример Vue SSR</title>
      </head>
      <body>
        <div id="app">${html}</div>
      </body>
    </html>
    `)
  })
})

server.listen(3000, () => {
  console.log('готово')
})

Наконец, запустите node server.js и зайдите на сайт http://localhost:3000. Вы должны увидеть работающую страницу с кнопкой.

Попробовать на StackBlitz

Клиентская гидратация

Если нажать на кнопку, то можно заметить, что число не меняется. HTML на клиенте полностью статичен, поскольку мы не загружаем Vue в браузер.

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

Чтобы смонтировать приложение в режиме гидратации, необходимо использовать createSSRApp() вместо createApp():

js
// это выполняется в браузере.
import { createSSRApp } from 'vue'

const app = createSSRApp({
  // ...то же приложение, что и на сервере
})

// установка приложения SSR на клиенте предполагает, что
// HTML был предварительно отрендерен, и вместо монтирования
// новых узлов DOM будет выполняться гидратация.
app.mount('#app')

Структура кода

Обратите внимание, что нам необходимо повторно использовать ту же реализацию приложения, что и на сервере. Именно здесь нам необходимо задуматься о структуре кода в SSR-приложении - как мы можем использовать один и тот же код приложения на сервере и клиенте?

Здесь мы продемонстрируем самый простой вариант. Для начала выделим логику создания приложения в отдельный файл app.js:

js
// app.js (используется совместно сервером и клиентом)
import { createSSRApp } from 'vue'

export function createApp() {
  return createSSRApp({
    data: () => ({ count: 1 }),
    template: `<button @click="count++">{{ count }}</button>`
  })
}

Этот файл и его зависимости являются общими для сервера и клиента - мы называем их универсальным кодом. При написании универсального кода необходимо обратить внимание на ряд моментов, о которых мы расскажем ниже.

Наш клиент импортирует универсальный код, создаёт приложение и выполняет монтирование:

js
// client.js
import { createApp } from './app.js'

createApp().mount('#app')

И сервер использует ту же логику создания приложения в обработчике запроса:

js
// server.js (нерелевантный код опущен)
import { createApp } from './app.js'

server.get('/', (req, res) => {
  const app = createApp()
  renderToString(app).then(html => {
    // ...
  })
})

Кроме того, для загрузки клиентских файлов в браузер нам также необходимо:

  1. Раздавать клиентские файлы, добавив server.use(express.static('.')) в server.js.
  2. Загрузить клиент добавив <script type="module" src="/client.js"></script> в шаблон HTML.
  3. Поддержать использование, такое как import * from 'vue' в браузере, добавив Import Map в шаблон HTML.

Попробуйте выполнить пример на StackBlitz. Кнопка теперь интерактивна!

Высокоуровневые решения

Переход от примера к готовому приложению SSR требует гораздо большего. Нам потребуется:

  • Поддерживать Vue SFC и другие требования к шагам сборки. Фактически, нам придётся координировать две сборки одного и того же приложения: одну для клиента, другую для сервера.

    Совет

    Компоненты Vue компилируются по-другому при использовании SSR - шаблоны компилируются в конкатенации строк вместо функций рендеринга виртуального DOM для более эффективной работы рендеринга.

  • В обработчике запросов сервера предоставлять HTML с правильными ссылками ресурсов на стороне клиента и оптимальными подсказками ресурсов. Нам также может понадобиться переключаться между режимами SSR и SSG или даже смешивать оба режима в одном приложении.

  • Универсально управлять маршрутизацией, загрузкой данных и состоянием хранилища.

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

Nuxt

Nuxt - это фреймворк более высокого уровня, построенный поверх экосистемы Vue и обеспечивающий оптимизацию разработки для написания универсальных Vue-приложений. Более того, вы можете использовать его в качестве генератора статических сайтов! Мы настоятельно рекомендуем попробовать.

Quasar

Quasar - это комплексное решение на базе Vue, позволяющее создавать SPA, SSR, PWA, мобильные приложения, десктопные приложения и браузерные расширения на основе одной кодовой базы. Оно не только выполняет настройку сборки, но и предоставляет полную коллекцию UI-компонентов, совместимых с Material Design.

Vite SSR

Vite обеспечивает встроенную поддержку рендеринга на стороне сервера Vue, но она намеренно низкоуровневая. Если вы хотите использовать Vite напрямую, обратите внимание на vite-plugin-ssr, плагин сообщества, который абстрагирует вас от многих сложных деталей.4

Вы можете также найти пример проекта Vue + Vite SSR с ручной настройкой здесь, который может послужить основой для построения. Обратите внимание, что это рекомендуется делать только в том случае, если у вас есть опыт работы с SSR / инструментами сборки и вы действительно хотите иметь полный контроль над архитектурой верхнего уровня.

Написание SSR-совместимого кода

Независимо от настройки сборки или выбора фреймворка более высокого уровня, существуют некоторые принципы, которые применяются во всех приложениях Vue SSR.

Реактивность на сервере

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

Хуки жизненного цикла компонентов

Поскольку динамические обновления отсутствуют, хуки жизненного цикла, такие как mountedonMounted или updatedonUpdated НЕ БУДУТ вызываться во время SSR и будут выполняться только на клиенте. Единственными хуками, вызываемыми во время SSR, являются beforeCreate и created

Следует избегать кода, создающего побочные эффекты, которые требуют очистки в beforeCreate и createdsetup() или в корневой области видимости <script setup>. Примером таких побочных эффектов является установка таймеров с помощью setInterval. В коде, предназначенном только для клиентской части, мы можем установить таймер, а затем удалить его в beforeUnmountonBeforeUnmount или unmountedonUnmounted. Однако, поскольку хуки размонтирования никогда не будут вызваны во время SSR, таймеры останутся работать вечно. Чтобы избежать этого, перенесите код побочных эффектов в mountedonMounted.

Доступ к API, специфичным для платформы

Универсальный код не может предполагать доступ к API для конкретной платформы, поэтому, если ваш код напрямую использует глобальные переменные только для браузера, такие как window или document, то при их выполнении в Node.js будут возникать ошибки, и наоборот.

Для задач, которые разделяются между сервером и клиентом, но имеют разные платформенные API, рекомендуется обернуть специфические для платформы реализации в универсальный API или использовать библиотеки, которые сделают это за вас. Например, можно использовать node-fetch для использования одного и того же API fetch на сервере и клиенте.

Для API, предназначенных только для браузера, распространенным подходом является "ленивый" доступ к ним внутри хуков жизненного цикла, предназначенных только для клиента, например mountedonMounted.

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

Загрязнение состояния при перекрестном запросе

В главе "Управление состояниями" мы представили простой паттерн управления состояниями с использованием API Reactivity. В контексте SSR этот паттерн требует некоторых дополнительных настроек.

В паттерне общее состояние объявляется в корневой области видимости JavaScript-модуля. Это делает их синглтонами - т.е. на протяжении всего жизненного цикла нашего приложения существует только один экземпляр реактивного объекта. Это работает, как и ожидалось, в чисто клиентском приложении Vue, поскольку модули в нашем приложении инициализируются заново при каждом посещении страницы браузера.

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

Технически мы можем заново инициализировать все модули JavaScript при каждом запросе, как это делается в браузерах. Однако инициализация JavaScript-модулей может быть дорогостоящей, поэтому это существенно повлияет на производительность сервера.

Рекомендуемое решение - при каждом запросе создавать новый экземпляр всего приложения, включая маршрутизатор и глобальные хранилища. Затем, вместо того чтобы напрямую импортировать его в компоненты, мы предоставляем общее состояние с помощью app-level provide и внедряем его в компоненты, которым оно необходимо:

js
// app.js (совместно используется сервером и клиентом)
import { createSSRApp } from 'vue'
import { createStore } from './store.js'

// вызывается на каждый запрос
export function createApp() {
  const app = createSSRApp(/* ... */)
  // создание нового экземпляра хранилища по запросу
  const store = createStore(/* ... */)
  // предоставление хранилища на уровне приложений
  app.provide('store', store)
  // также подвергается хранению для целей гидратации
  return { app, store }
}

Библиотеки управления состояниями, такие как Pinia, разработаны с учетом этого. Более подробную информацию можно найти в руководстве по SSR для Pinia..

Несоответствие при гидратации

Если структура DOM предварительно обработанного HTML-кода не соответствует ожидаемому результату клиентского приложения, возникнет ошибка несоответствия при гидратации. Она чаще всего возникает по следующим причинам:

  1. Шаблон содержит недопустимую структуру вложенности HTML, а отображаемый HTML был «исправлен» собственным поведением браузера при синтаксическом анализе HTML. Например, распространённая проблема заключается в том, что <div> нельзя помещать внутрь <p>:

    html
    <p><div>привет</div></p>

    Если мы создадим это в нашем серверном HTML, то браузер при появлении <div> завершит первый <p> и разберет его в следующую DOM-структуру:

    html
    <p></p>
    <div>привет</div>
    <p></p>
  2. Данные, используемые при рендеринге, содержат случайно сгенерированные значения. Поскольку одно и то же приложение будет выполняться дважды - один раз на сервере, другой раз на клиенте, - не гарантируется, что случайные значения будут одинаковыми при обоих запусках. Избежать несовпадений, вызванных случайными значениями, можно двумя способами:

    1. Используйте v-if + onMounted для рендеринга той части, которая зависит от случайных значений, только на клиенте. Возможно, ваш фреймворк также имеет встроенные функции, облегчающие эту задачу, например компонент <ClientOnly> в VitePress.

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

  3. Сервер и клиент находятся в разных часовых поясах. Иногда нам может понадобиться преобразовать метку времени в местное время пользователя. Однако часовой пояс во время работы сервера и часовой пояс во время работы клиента не всегда совпадают, и мы можем не знать точно часовой пояс пользователя во время работы сервера. В таких случаях преобразование местного времени также должно выполняться только для клиента.

Когда Vue сталкивается с несоответствием гидратации, он пытается автоматически восстановить и настроить предварительно отрендеренный DOM в соответствии с состоянием на стороне клиента. Это приведет к некоторому снижению производительности рендеринга из-за отбрасывания некорректных узлов и установки новых, но в большинстве случаев приложение продолжит работать как положено. Тем не менее, лучше всего устранять несоответствия в процессе разработки.

Пользовательские директивы

Поскольку большинство пользовательских директив предполагают прямое манипулирование DOM, они игнорируются при SSR. Однако если вы хотите указать, как должна быть выведена пользовательская директива (т.е. какие атрибуты она должна добавить к выводимому элементу), вы можете воспользоваться хуком директивы getSSRProps:

js
const myDirective = {
  mounted(el, binding) {
    // реализация на стороне клиента:
    // прямое обновление DOM
    el.id = binding.value
  },
  getSSRProps(binding) {
    // реализация на стороне сервера:
    // возврат входных данных для рендеринга..
    // getSSRProps получает только привязку к директиве.
    return {
      id: binding.value
    }
  }
}

Телепорты

Телепорты требуют специальной обработки при SSR. Если приложение содержит телепорты, то содержимое телепорта не будет частью отображаемой строки. Более простым решением является условный рендеринг телепорта при монтировании.

Если все же необходимо отрендерить телепортированное содержимое, то оно отображается в свойстве teleports объекта контекста ssr:

js
const ctx = {}
const html = await renderToString(app, ctx)

console.log(ctx.teleports) // { '#teleported': 'teleported content' }

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

Совет

Избегайте указания на body при совместном использовании Teleports и SSR — обычно, <body> будет содержать другой контент, отображаемый сервером, что делает невозможным для Teleports определение правильного начального местоположения для гидратации.

Вместо этого лучше использовать специальный контейнер, например, <div id="teleported"></div>, который содержит только телепортированный контент.

Рендеринг на стороне сервера (SSR)Уже загружено