Создание клона Nuxt.js с помощью Vue 3 и Vite (Vue Custom Server Side Rendering)

Грамотно писать приложения на JavaScript — задача порой не из легких, но эта задача может значительно усложниться в ситуации, когда однажды вашему приложению понадобится индексация поисковыми системами, такими как Google, DuckDuckGo, Bind и др. И проблема в том, что не все из них умеют правильно индексировать SPA-приложения, и перед вами встанет задача превращения из Client Side Render (CSR) в приложение с поддержкой Server Side Render (SSR).

Эта статья будет полезна для тех, кто хочет написать приложение с Custom Server Side Rendering. Для тех, кто хочет добавить поддержку SSR в существующее приложение. Но и, конечно, тем, кто работает с Nuxt и хочет узнать больше о том, как он работает под капотом.

Весь код из статьи и пример проекта вы можете найти здесь.

P.S. Если вы хотите перейти к коду, прокрутите страницу до раздела «Давайте начнем писать код».

Требования к SSR Framework

Чтобы более точно обрисовать свои требования к идеальному фреймворку для рендеринга на стороне сервера, я подготовил наглядную визуализацию:

Узнайте больше о каждом пункте:

  • Ленивая загрузка — фреймворк должен уметь разбивать маршруты на различные куски, чтобы уменьшить вес пакета, который загружает пользователь, заходя в приложение. Преимущество Vite и плагина для Vue из коробки в процессе рендеринга приложения на стороне сервера заключается в регистрации всех модулей, которые необходимо будет загрузить пользователю, подробнее здесь.
  • Middlewares — фреймворк должен позволять писать Middleware, для того чтобы перенаправлять незарегистрированных пользователей, которые не должны иметь доступ к странице, или сделать некоторые страницы возможными для просмотра только в гостевом режиме.
  • Вложенные маршруты — маршрутизатор должен позволять создавать сложные композиции вложенных страниц.
  • Выборка данных — каждая страница должна иметь возможность загружать данные из API и учитывать вложенные страницы.
  • Интеграция с Pinia — каждая страница выполняет определенные действия во время загрузки данных, но что произойдет, если один из вызовов API вернет ошибку 404? Например, у нас есть действие fetchProduct, которое передает id продукта, и если мы получаем 404, мы должны иметь возможность сказать, что мы должны отдать страницу со статусом 404 пользователю, или даже сделать редирект прямо в действии, не вынося эту логику за пределы действия. Мне кажется, что тесная интеграция магазина с SSR роутером для решения подобных ситуаций — частое упущение в мире современных SSR фреймворков.
  • Работа с Head — И конечно же, какое SEO без мета-тегов, фреймворк должен предоставлять возможность редактировать содержимое тега <head /> как на стороне сервера, так и на стороне клиента.

Зачем нужен пользовательский рендеринг на стороне сервера?

Первая причина, которую я вижу, это интерес к тому, как Nuxt/Next работает под капотом в образовательных целях, и вторая, когда у вас много опыта работы с Nuxt/Next и вы уже точно знаете, зачем это нужно, и вы уже столкнулись с ограничениями в других фреймворках 😄.

Например:
Как настроить дефолтный html шаблон nuxt в Nuxt 3?
В Nuxt 2 есть BODY_SCRIPTS, а в Nuxt 3 (на момент написания статьи) нет.

Или у меня проблема, что я не могу получить ip адрес пользователя, потому что он не определен в Nuxt 3 server-middleware 😕 (На момент написания статьи).

  • Несколько слов Vite

Vite очень быстрый бандлер, ориентированный на модули EcmaScript.
У него есть репозиторий примеров Vue SSR.

  • Маршрутизация
  • Ленивая загрузка
  • Выборка данных
  • Middlewares
  • Работа с
  • Интеграция с Pinia

  • Маршрутизация

Мы используем Vue Router, это очень гибкий API по сравнению с другими маршрутизаторами.

  • Ленивая загрузка

По умолчанию в Vue Router используется механизм ленивой загрузки. Все работает из коробки 😍.

  • Выборка данных

Давайте поговорим о методе выборки данных.
Для выборки данных в Nuxt 3, вы могли видеть, что используется useAsyncData, и я предлагаю взглянуть на то, как это работает внутри, здесь и мы видим, что они используют onServerPrefetch для выборки данных, но как это работает?
На самом деле, как только Vue встречает onServerPrefetch, рендеринг компонента останавливается до выполнения асинхронной операции, и только после этого Vue продолжит процесс рендеринга. Подробнее о onServerPrefetch можно узнать здесь.

Здесь я хочу обратить ваше внимание на две вещи:

  1. Но у этого подхода есть и недостатки, поскольку он является компромиссом между удобством (Development Experience) и производительностью. Поскольку каждый вызов onServerPrefetch будет останавливать процесс рендеринга и ждать, пока рендеринг можно будет продолжить, а в случае вложенных маршрутов у нас есть одна проблема, я предлагаю посмотреть на график водопада:

Здесь мы видим, что в случае, когда у нас есть 3 уровня вложенного маршрута, перед вызовом следующего onServerPrefetch, мы должны дождаться завершения работы предыдущего. И в этом случае мы имеем 3 вызова API, которые разрешаются через ~1.4, ~2.1, ~1.5 секунды:

Это концепция того, как мы можем получать данные.
И поскольку вложенная маршрутизация является важной концепцией в нашем фреймворке, мы выбрали вариант asyncData. И у нас есть одно ограничение, все компоненты уровня страницы, которые используют asyncData должны использовать API defineComponent.

Еще немного о вложенных маршрутах

Например, Next имеет один большой архитектурный недостаток (На момент написания статьи), он не умеет работать с вложенными маршрутами. Для тех, кто не знает, Vue Router позволяет использовать компонент <router-view /> для отображения дочерних страниц, на любом уровне вложенности. Я подготовил наглядную визуализацию того, что такое вложенные маршруты, для тех, кто знаком с React + Remix, думаю, это будет очень знакомо. Потому что вложенные маршруты — это убийственная функция Remix.
Кстати, пока я писал статью, вышел Layouts RFC на блоге Next.js, а это значит, что скоро появится поддержка в Next.js.
Давайте вернемся к фреймворку Remix:

Здесь хорошо видно, что компоненты <Root />, <Sales />, <Invoices />, <Invoice /> — это страницы, каждая из которых имеет свой компонент, точно так же, как это работает с вложенными маршрутами в Vue Router.
Vue Router использует конфигурационный файл для объявления маршрутов, я считаю это наиболее удобным и гибким способом объявления маршрутов. Но этот подход в случае с SSR обременяет нас необходимостью иметь возможность получать данные всех вложенных страниц, которые соответствуют url с нашими маршрутами, подробнее об этом ниже.

Почему Vue 3 является лучшим для Server Side Rendering?

Эван Ю и команда Vue Core Team проделали большую работу по оптимизации компилятора Vue SFC и улучшили поддержку TypeScript.
Но самая важная убийственная особенность Vue заключается в том, что для связки SSR время выполнения компонентов отличается от времени выполнения компонентов для CSR. Существует отдельный компилятор, который преобразует наши шаблоны буквально в строки.
Для наглядности давайте сравним стадии выполнения компонентов Vue 2/3 и React в момент переписывания на сервере:

Как вы можете видеть, Vue 2 и React, во время рендеринга компонента <Component /> сначала создают VDOM, и только потом функция renderToString преобразует вирусные ноды в стоки (Не уверен насчет Vue 2).
С другой стороны у нас есть Vue 3, процесс рендеринга компонентов которого включает нормализацию динамических атрибутов и конкатенацию строк.

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

<script lang="ts">
import { defineComponent } from 'vue'

export default defineComponent({
  props: {
    alt: String,
    png: String,
    webp: String,
  },
})
</script>

<template>
  <picture class="picture">
    <source :srcset="$props.webp" type="image/webp">
    <source :srcset="$props.png" type="image/png">
    <img
      :src="$props.png"
      :alt="$props.alt"
      draggable="false"
      loading="lazy"
    >
  </picture>
</template>

<style>
.picture > img {
  width: 100%;
}
</style>
Вход в полноэкранный режим Выход из полноэкранного режима

Как вы думаете, как будет выглядеть этот шаблон после компиляции?
Вы можете узнать больше о работе Vue SFC Compiler здесь, в SFC Playground (откройте вкладку «SSR»).

/* Analyzed bindings: {} */

import { defineComponent } from 'vue'

const __sfc__ = defineComponent({
  props: {
    alt: String,
    png: String,
    webp: String,
  },
})

import { mergeProps as _mergeProps } from "vue"
import { ssrRenderAttr as _ssrRenderAttr, ssrRenderAttrs as _ssrRenderAttrs } from "vue/server-renderer"
function ssrRender(_ctx, _push, _parent, _attrs, $props, $setup, $data, $options) {
  _push(`<picture${
    _ssrRenderAttrs(_mergeProps({ class: "picture" }, _attrs))
  }><source${
    _ssrRenderAttr("srcset", _ctx.$props.webp)
  } type="image/webp"><source${
    _ssrRenderAttr("srcset", _ctx.$props.png)
  } type="image/png"><img${
    _ssrRenderAttr("src", _ctx.$props.png)
  }${
    _ssrRenderAttr("alt", _ctx.$props.alt)
  } draggable="false" loading="lazy"></picture>`)
}
__sfc__.ssrRender = ssrRender
__sfc__.__file = "App.vue"
export default __sfc__
Вход в полноэкранный режим Выход из полноэкранного режима

И что мы можем здесь увидеть? Опытный разработчик, знакомый с React, будет удивлен, увидев это впервые.
Поскольку процесс выполнения компонента Vue 3 в серверном рендеринге — это конкатенация строк, то это безумно быстрая операция сравнения с созданием VNode для каждого элемента, как в React.

Давайте начнем писать код

Пакетирование SSR приложений с помощью Vite

Для пакетирования мы будем использовать Vite по нескольким причинам:

  • Vite является стандартом де-факто для Vue 3, да, Vue 3 можно собрать с помощью Webpack, но, во-первых, пользовательский конфиг Webpack придется постоянно обновлять по мере обновлений, а это само по себе не такая уж простая задача. Да и найти мануалы для Vue 3 невероятно сложно, по крайней мере на момент середины 2021 года, а в противовес этому у нас есть сборник с максимально простым конфиг файлом (наконец-то 🥹) заточенным в большей степени под Vue.
  • Скорость работы, при правильной настройке, тот же Vita setup будет быстрее Webpack во всем, кроме скорости сборки, особенно радует скорость Hot Module Replacement (HMR), это та штука, которая заставляет перезагружать CSS, файлы Vue и прочее без перезагрузки страницы.
  • Открытость и простота, обсуждение новых возможностей Vita можно найти в issues/RFC и т.д., а изучить кодовую базу Vita, на мой взгляд, гораздо проще, чем понять, как работает Webpack. Например, в этой статье я иногда открыто ссылаюсь на некоторые моменты в исходниках того же vitejs/plugin-vue, и понять это не так сложно, как кажется.

Рекомендую вместо ручной инициализации проекта клонировать официальный SSR-example или мой демо-проект для этой статьи (рекомендую).

И так, давайте перейдем к настройке конфига Vite:

/* vite.config.js */
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  css: {
    modules: {
      generateScopedName: '[hash:base64:5]',
      hashPrefix: ' ',
    },
  },
})
Вход в полноэкранный режим Выход из полноэкранного режима

Главное, что нам нужно сделать, это подключить vitejs/plugin-vue.
Вы также можете обратить внимание, что я использую css.modules.generateScopedName в значении [hash:base64:5], вам это делать не обязательно, но если вы используете CSS модули, то этот параметр позволит вам получить имена CSS классов типа vwGJw, это очень лаконично, и позволяет немного уменьшить размер CSS файлов, JS файлов, а также уменьшить размер HTML документа за счет экономии на длине CSS классов.

В package.json добавьте следующие скрипты:

{
  "scripts": {
    "dev": "node server.js",
    "build": "vue-tsc --noEmit && yarn run build:client && yarn run build:server",
    "build:client": "vite build --ssrManifest --outDir dist/client",
    "build:server": "vite build --ssr src/entry-server.ts --outDir dist/server",
    "serve": "cross-env NODE_ENV=production node server.js"
  },
}
Вход в полноэкранный режим Выход из полноэкранного режима

И создайте файл index.html, и server.js в корне проекта.
Вы можете создать server.ts, и использовать замену скрипта dev на node --experimental-specifier-resolution=node --loader ts-node/esm server.ts, но у этого есть один большой недостаток, скорость запуска приложения, особенно для production это критично, поэтому я не рекомендую использовать TypeScript для файла server.js, но это единственный JavaScript файл, который у нас будет.

Вот минимальные файлы, которые мы должны создать для работы нашего SSR:

Что означает каждый из них?

  • App.vue — входной компонент нашего приложения
  • main.ts — функция для инициализации нашего приложения, здесь мы будем вызывать createSSRApp, createRouter, createPinia, createHead и т.д.
  • entry-server.ts — здесь будет храниться та самая функция render, отвечающая за рендеринг на стороне сервера, который мы подключаем к server.js.
  • entry-client.ts — ну, по аналогии, у нас будет входной файл для клиентской связки, которая не будет запускаться на сервере, а служит для процесса гидирования нашего приложения.

И начнем с содержимого server.js:

// In the polyfills file.js you can connect polyfills for fetch calls in the environment Node.js
// https://github.com/fyapy/vue3-vite-custom-ssr-example/blob/master/src/server/polyfills.ts
require('./server/polyfills')
const fs = require('fs')
const path = require('path')
const express = require('express')

const resolve = p => path.resolve(__dirname, p)

async function createServer(
  root = process.cwd(),
  isProd = process.env.NODE_ENV === 'production'
) {
  const indexProd = isProd
    ? fs.readFileSync(resolve('dist/client/index.html'), 'utf-8')
    : ''

  const manifest = isProd
    ? require('./dist/client/ssr-manifest.json')
    : {}

  const app = express()

  /**
   * @type {import('vite').ViteDevServer}
   */
  let vite
  if (!isProd) {
    vite = await require('vite').createServer({
      root,
      logLevel: 'info',
      server: {
        middlewareMode: 'ssr',
        watch: {
          usePolling: true,
          interval: 100,
        },
      },
    })

    app.use(vite.middlewares)
  } else {
    app.use(require('compression')())
    app.use(
      require('serve-static')(resolve('dist/client'), {
        index: false,
      })
    )
  }

  app.use('*', async (req, res) => {
    // handler...
  })

  return {
    app,
    vite,
  }
}

exports.createServer = createServer
Вход в полноэкранный режим Выход из полноэкранного режима

Это практически базовая настройка сервера Vite программно.
Но самое интересное — это то, где происходит формирование тела ответа сервера нашему пользователю, вся работа с кэшированием и т.д.

app.use('*', async (req, res) => {
  try {
    let template, render
    if (!isProd) {
      // In dev mode, we load on the fly index.html
      template = fs.readFileSync(resolve('index.html'), 'utf-8')
      template = await vite.transformIndexHtml(req.originalUrl, template)
      // And in the same way we load the render function
      render = (await vite.ssrLoadModule('/src/entry-server.ts')).render
    } else {
      template = indexProd
      // In the case of production, we already have a ready bundle that we will connect
      render = require('./dist/server/entry-server.js').render
    }

    const {
      html: appHtml,
      preloadLinks,
      headTags,
    } = await render({
      url: req.originalUrl,
      req,
      res,
      manifest,
    })

    const html = template
      .replace('<!--head-tags-->', headTags)
      .replace('<!--preload-links-->', preloadLinks)
      .replace('<!--app-html-->', appHtml)

    res.set({ 'Content-Type': 'text/html' }).end(html)
  } catch (e) {
    vite && vite.ssrFixStacktrace(e)
    console.log(e.stack)
    res.status(500).end(e.stack)
  }
})
Вход в полноэкранный режим Выход из полноэкранного режима

Содержимое файла App.vue, самого стандартного, внутри которого есть <router-view />, в котором мы будем отображать верхний уровень страниц наших страниц. Вот конфигурация роутера:

import {
  createMemoryHistory,
  createRouter as _createRouter,
  createWebHistory,
  type RouteRecordRaw,
} from 'vue-router'
import Resources from './pages/Resources/Resources.vue'
import Resource from './pages/Resources/Resource.vue'


const routes: RouteRecordRaw[] = [

  {
    path: '/',
    component: Resources,
    children: [
      {
        path: 'resource/:id',
        component: Resource,
        children: [
          {
            path: 'transactions',
            component: () => import('./pages/Resources/Transactions.vue'),
          },
          {
            path: 'requisites',
            component: () => import('./pages/Resources/Requisites.vue'),
          },
        ],
      },
    ],
  },
  {
    path: '/home',
    component: () => import('./pages/Home.vue'),
  },
  {
    path: '/:pathMatch(.*)*',
    component: () => import('./ui/pages/NotFound.vue'),
  },
]

export const createRouter = () => _createRouter({
  history: import.meta.env.SSR ? createMemoryHistory() : createWebHistory(),
  routes,
  scrollBehavior() {
    return {
      top: 0,
    }
  }
})
Войти в полноэкранный режим Выйти из полноэкранного режима

Самое важное здесь то, что создание экземпляра роутера — это функция, в обычном SPA это просто висящая глобальная переменная, но поскольку концепция переписывания серверного приложения подразумевает, что для каждого запроса мы создаем совершенно новый экземпляр приложения, включая маршрутизацию.
И не пропустите это условие:

import.meta.env.SSR ? createMemoryHistory() : createWebHistory()
Войти в полноэкранный режим Выход из полноэкранного режима

Поскольку API History недоступен на нашем сервере, мы создаем MemoryHistory, который является имитацией экземпляра History в браузере.
А import.meta.env — это ECMAScript-версия `process.env`.

Файл ‘main.ts’ отвечает за инициализацию экземпляра Vue приложения.

import App from './App.vue'
import { createSSRApp } from 'vue'
import { createPinia } from 'pinia'
import { createRouter } from './router'

export const createApp = () => {
  const app = createSSRApp(App)
  const router = createRouter()
  // we will create a store instance right away for one thing
  // we'll need it very soon
  const pinia = createPinia()

  app.use(router)
  app.use(pinia)

  return {
    app,
    pinia,
    router,
  }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Файл server-entry.ts:

import type { Request, Response } from 'express'
import { renderToString, SSRContext } from 'vue/server-renderer'
import serialize from 'serialize-javascript'
import { createApp } from './main'


interface AppContext {
  req: Request | null
  res: Response | null
  pinia: Pinia
  router: Router
  query: LocationQuery
  params: Record<string, string>
}

export type Manifest = Record<string, string[]>


export const render = async ({
  url,
  req,
  res,
  manifest,
}: {
  url: string
  manifest: Manifest
  req: Request
  res: Response
}) => {
  const { app, router, pinia } = createApp()

  router.push(url)
  await router.isReady()

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

  const preloadLinks = renderPreloadLinks(ctx.modules, manifest)

  const initialState = serialize(pinia.state.value)

  return {
    html,
    initialState,
    preloadLinks,
  }
}

function renderPreloadLinks(modules: string[], manifest: Manifest) {
  let links = ''
  const seen = new Set()
  modules.forEach((id) => {
    const files = manifest[id]
    if (files) {
      files.forEach((file: string) => {
        if (!seen.has(file)) {
          seen.add(file)
          const filename = basename(file)
          if (manifest[filename]) {
            for (const depFile of manifest[filename]) {
              links += renderPreloadLink(depFile)
              seen.add(depFile)
            }
          }
          links += renderPreloadLink(file)
        }
      })
    }
  })
  return links
}
function renderPreloadLink(file: string) {
  if (file.endsWith('.js')) {
    return `<link rel="modulepreload" crossorigin href="${file}">`
  } else if (file.endsWith('.css')) {
    return `<link rel="stylesheet" href="${file}">`
  } else if (file.endsWith('.woff')) {
    return ` <link rel="preload" href="${file}" as="font" type="font/woff" crossorigin>`
  } else if (file.endsWith('.woff2')) {
    return ` <link rel="preload" href="${file}" as="font" type="font/woff2" crossorigin>`
  } else if (file.endsWith('.gif')) {
    return ` <link rel="preload" href="${file}" as="image" type="image/gif">`
  } else if (file.endsWith('.jpg') || file.endsWith('.jpeg')) {
    return ` <link rel="preload" href="${file}" as="image" type="image/jpeg">`
  } else if (file.endsWith('.png')) {
    return ` <link rel="preload" href="${file}" as="image" type="image/png">`
  }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Здесь нас интересует несколько элементов:

  • renderPreloadLinks и renderPreloadLink — функции, отвечающие за рендеринг CSS/JS ресурсов, которые мы загружаем на стороне клиента, это полностью совместимо с механизмом Lazy-loading в Vue Router, они автоматически регистрируются во время рендеринга страницы в Vue Vite Plugin здесь.
  • serialize — это важная часть, которая обеспечивает защиту от XSS-атак, экранируя содержимое хранилищаserialize(pinia.state.value).

А теперь давайте напишем client-entry.ts:

import { createApp } from './main'

const { app, router, pinia } = createApp()

router.isReady().then(() => app.mount('#app'))
Вход в полноэкранный режим Выход из полноэкранного режима

И эта настройка позволит нам уже запустить приложение и проверить, как все работает.

Контекст

Теперь я предлагаю ввести новое понятие Context, думаю, многие знакомы с ним по фреймворкам Nuxt/Next.
Так вот именно его мы и перенесем в наши промежуточные модули и функции async Data page.

Он состоит из:

  • req/res — доступны только на стороне сервера, я не рекомендую использовать их напрямую.
  • pinia — экземпляр нашего хранилища, он понадобится вам для использования хранилища, потому что на стороне клиента Pinia ссылается на глобальный объект, а на стороне сервера так сделать нельзя, потому что в процессе рендеринга нашего приложения на сервере мы можем одновременно открывать разные страницы с несколькими запросами, которые вызывают свои собственные действия, и в результате весь процесс рендеринга будет ссылаться на один и тот же экземпляр хранилища, а нам нужно изолировать процесс рендеринга для каждого запроса. Здесь видно, как при вызове defineStore Pinia пытается получить «активный» экземпляр.
  • query — запрос от маршрутизатора
  • params — параметры из маршрутизатора
  • маршрутизатор — экземпляр маршрутизатора

И вот, когда мы сформировали Контекст, можно переходить к работе промежуточных устройств и асинхронных Данных.

Выборка данных, промежуточные устройства и реализация Context

Для выборки данных мы выбрали стратегию выборки asyncData, так как она быстрее в случае вложенных маршрутов.
И у нас есть одно ограничение, все компоненты уровня страницы, которые используют asyncData, должны использовать API defineComponent.

В вашем проекте должен быть файл декларации vue.d.ts, и в нем вам нужно добавить новые параметры asyncData, и middleware в Vue ComponentCustomOptions:

import type { AppContext as SSRAppContext } from './ssr'

// Typing for middleware
type RedirectLocation = {
  path: string
  status: number
}
type RedirectTo = void | string | RedirectLocation
type Middleware = (ctx: AppContext) => RedirectTo | PromiseLike<RedirectTo>


declare module '@vue/runtime-core' {
  interface ComponentCustomOptions {
    asyncData?(ctx: SSRAppContext): void
    middleware?: Middleware | Middleware[]
  }
}

export {}
Вход в полноэкранный режим Выход из полноэкранного режима

Мы будем использовать данные ComponentCustomOptions на уровне компонента страницы.

  • asyncData — опция для загрузки данных
  • middleware — служит аналогом Navigation Guards из Vue Router, но в отличие от него будет работать как на стороне клиента, так и на сервере. Вы можете называть ее guard, если вам/вашей команде так привычнее.

А теперь позвольте мне показать вам наглядно, как это будет выглядеть:

<script lang="ts">
import { defineComponent } from 'vue'

export default defineComponent({
  middleware: () => '/redirected-to', // You can write a separate file for middleware, and import it here
})
</script>
Вход в полноэкранный режим Выход из полноэкранного режима

Это очень похоже на Navigation Guards из Vue Router?
Не так ли? Это просто Контекст нашего приложения, и вы можете связаться с Pinia, например, для проверки авторизации

const authMiddleware: Middleware = ({ pinia }) => {
  // !!! Important !!!
  // tell all the stores you are contacting
  // Pinia instance, otherwise you will have problems because Pinia will access the global object
  // https://github.com/vuejs/pinia/blob/8626aac0049243de231401a01fe20092eeaf279c/packages/pinia/src/store.ts#L870
  if (!authStore(pinia).isAuth) {
    return {
      path:'/login',
      status: 401,
    }
  }
}
Вход в полноэкранный режим Выйти из полноэкранного режима

А вот пример использования asyncData:

<script lang="ts">
import { computed, defineComponent } from 'vue'
import { useStore } from '../../store'

export default defineComponent({
  asyncData: ({ pinia, params }) => useStore(pinia).fetchRequisites(params.id),
  setup() {
    const store = useStore()

    const requisites = computed(() => store.requisites)

    return {
      requisites,
    }
  },
})
</script>
Вход в полноэкранный режим Выход из полноэкранного режима

И в то же время есть два важных момента:

  • При вызове хранилища в asyncData, передавайте туда pinia из контекста приложения, иначе будут проблемы при рендеринге на стороне сервера
  • В случае вызова нескольких действий, которые не зависят друг от друга, используйте Promise.all для их параллельного выполнения, это одна из самых распространенных ошибок новичков при работе с SSR, которая очень негативно влияет на время открытия страницы, сделайте это:
export default defineComponent({
  asyncData: ({ pinia, params }) => Promise.all([
    useStore(pinia).fetchRequisites(params.id),
    useStore(pinia).fetchHome(),
  ]),
})
Войдите в полноэкранный режим Выйти из полноэкранного режима

А теперь давайте наконец-то воплотим в жизнь работу data ham и middle vars. И поскольку middleware будет иметь возможность перенаправлять пользователя, что должно работать как на клиенте, так и на сервере, добавим класс с ошибкой RedirectError:

export class RedirectError extends Error {
  redirectTo: string
  status: number
  _isRedirect = true

  constructor(redirectTo: string, status = 302) {
    super()

    this.redirectTo = redirectTo
    this.status = status
  }
}
Вход в полноэкранный режим Выйти из полноэкранного режима

В котором мы передадим url для редиректа и статус кода редиректа, который по умолчанию равен 302.
Многие могут сказать, что редирект с выбросом ошибки — это не «правильно», но он, во-первых, используется только внутри сервисных функций и не затрагивает бизнес-логику, а во-вторых, упрощает механизм редиректов.
А теперь давайте заставим наши промежуточные функции работать:

import { DefineComponent } from 'vue'

export const fireMiddlewares = async (
  components: DefineComponent[],
  context: AppContext
) => {
  // We take out the CustomOption middleware from the components
  const middles = components
    .map(c => c.middleware)
    .filter(Boolean)
    .flat() as Middleware[]

  if (middles.length !== 0) {
    const redirects = (
      await Promise.all(middles.map(m => m(context)))
    ).filter(Boolean)

    if (redirects.length !== 0) {
      for (const to of redirects) {
        if (typeof to === 'string') {
          throw new RedirectError(to)
        } else if (typeof to === 'object') {
          throw new RedirectError(to.path, to.status)
        }
      }
    }
  }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Мы передадим контекст приложения и компоненты страницы в функцию fireMiddlewares, а как их получить, я расскажу позже.
А пока давайте напишем такую же функцию для получения данных:

import { DefineComponent } from 'vue'

export const fireAsyncData = async (
  components: DefineComponent[],
  context: AppContext
) => {
  // We take it out of the components CustomOption asyncData
  const asyncs = components.map(c => c.asyncData).filter(Boolean) as Exclude<ComponentCustomOptions['asyncData'], undefined>[]

  if (asyncs.length !== 0) {
    await Promise.all(asyncs.map(a => a(context)))
  }
}
Вход в полноэкранный режим Выйти из полноэкранного режима

И после того, как у нас появилась возможность запустить async Data и middleware CunstomOptions, перейдем к их вызову в entry-client.ts:

import { createApp } from './main'
import { AppContext, fireAsyncData, fireMiddlewares, matchedComponents, RedirectError } from './ssr'

const { app, router, pinia } = createApp()

router.isReady().then(() => {
  pinia.state.value = window.__pinia

  router.beforeResolve(async (to, _from, next) => {
    try {
      const context: AppContext = {
        req: null,
        res: null,
        pinia,
        router,
        query: to.query,
        params: to.params as AppContext['params'],
      }

      const components = matchedComponents(to.matched)

      await fireMiddlewares(components, context)
      await fireAsyncData(components, context)

      next()
    } catch (e) {
      if (e instanceof RedirectError) {
        return next(e.redirectTo)
      }

      throw e
    }
  })

  app.mount('#app')
})
Вход в полноэкранный режим Выход из полноэкранного режима

И я предлагаю разобраться, как все работает:

  • pines.state.value = window.__pinia — заполняем хранилище сериализованными данными
  • context — Формируем контекст приложения на стороне клиента.
  • router.beforeResolve — Vue Router имеет самый удобный хук, который вызывается после загрузки Lazy-компонентов, но еще не показан пользователю, его можно использовать для работы middlewares и asyncData на клиенте.
  • matchedComponents — Vue Router в параметре to.matched передает все компоненты страниц, именно их мы нормализуем в функции matchedComponents, ее реализация будет ниже.
  • RedirectError — в блоке catch мы обрабатываем ошибки, тут же мы можем перехватить все ошибки RedirectError и перенаправить на стороне клиента.

Вот реализация matchedComponents:

import type { RouteRecordNormalized } from 'vue-router'
import type { DefineComponent } from 'vue'

export const matchedComponents = (matched: RouteRecordNormalized[]) =>
  matched.map((m) => Object.values(m.components)).flat() as DefineComponent[]
Вход в полноэкранный режим Выход из полноэкранного режима

И теперь вы можете спокойно обновлять server-entry.ts:

export const render = async ({
  url,
  req,
  res,
  manifest,
}: {
  url: string
  manifest: Manifest
  req: Request
  res: Response
}) => {
  const { app, router, pinia } = createApp()

  router.push(url)
  await router.isReady()

  const _route = router.currentRoute.value

  const context: AppContext = {
    req,
    res,
    pinia,
    router,
    query: _route.query,
    params: _route.params as AppContext['params'],
  }

  const components = matchedComponents(_route.matched)

  await fireMiddlewares(components, context)
  await fireAsyncData(components, context)

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

  const preloadLinks = renderPreloadLinks(ctx.modules, manifest)

  const initialState = serialize(pinia.state.value)

  return {
    html,
    initialState,
    preloadLinks,
  }
}
Войти в полноэкранный режим Выйти из полноэкранного режима

А также в server.js в блоке catch для работы нужно добавить редиректы на сторонние сервера:

if (e._isRedirect) {
  res.status(e.status)
  res.redirect(e.redirectTo)

  return
}
Вход в полноэкранный режим Выход из полноэкранного режима

И теперь у нас есть полностью рабочий механизм для ограничения доступа к страницам и ветчина дат 🥳.
Но впереди еще много работы по интеграции Pinia и Vue Router, а также работа с Head.

Интеграция маршрутизатора редиректов, кодов состояния HTTP с Pinia

Итак, я думаю многие не знакомы с концепцией интеграции роутера в Store, но на самом деле они скорее всего встречались с библиотекой Connected React Router, она позволяет, например, при успешной аутентификации прямо в экшене перенаправить пользователя на страницу /profile или /dashboard, это позволяет логике аутентификации не распространяться за пределы экшена.
Для начала напишем тайминги для состояния этого магазина.

type HistoryState = {
  _router: Router | null
  _redirectUrl: string | null
  status: number
}
Вход в полноэкранный режим Выход из полноэкранного режима

Давайте рассмотрим, что у нас здесь есть и почему:

  • _router — это экземпляр ссылки нашего Vue Router, чтобы иметь доступ к вызову метода push, replace и т.д., чтобы редиректы работали на стороне клиента.
  • _redirectUrl — в случае редиректа будет указан url, на который нужно перенаправить пользователя. Используется только на стороне сервера.
  • status — код HTTP Status ответа сервера, будь то 200, 302 или 404.

А теперь мы напишем саму реализацию магазина, который будет отвечать за эту интеграцию:

export const useHistory = defineStore('history', {
  state: (): HistoryState => ({
    _router: null,
    _redirectUrl: null,
    status: 200,
  }),
  actions: {
    setStatus(status: number) {
      this.status = status
    },
    _setRouter(_router: HistoryState['_router']) {
      (this._router as unknown as HistoryState['_router']) = _router
    },
    push(path: string, status = 302) {
      this.status = status
      this._redirectUrl = path
      this._router?.push(path)
    },
    replace(path: string, status = 302) {
      this.status = status
      this._redirectUrl = path
      this._router?.replace(path)
    },
    go(delta: number) {
      this._router?.go(delta)
    },
    back() {
      this._router?.go(-1)
    },
    forward() {
      this._router?.go(1)
    },
  },
})
Вход в полноэкранный режим Выход из полноэкранного режима

И так, давайте пройдемся по методам:

  • push, replace, back, go, forward — их назначение просто проксировать обращения к роутеру, а в случае редиректа писать его HTTP Code, и сам redirectUrl.
  • _setRouter — поскольку мы не можем напрямую обращаться к глобальным объектам в случае серверного рендеринга, нам нужен метод для привязки/отвязки экземпляра роутера к магазину.
  • setStatus — возможность вручную установить статус кода ответа, например, когда API возвращает нам код 404, чтобы дать понять поисковым системам, что искомая страница не найдена.

И теперь пример использования этой интеграции понятен, давайте представим, что у нас есть магазин Pinia с действием fetchRubric:

async fetchRubric(rubricSlug: string, citySlug: string) {
  try {
    const {
      city,
      rubric,
      reviews,
    } = await http.get(`/rubrics/page/${rubricSlug}/${citySlug}`)

    this.reviews = reviews
    this.data = {
      city,
      rubric,
    }
  } catch (e: any) {
    this.error = e
    useHistory(this._p).setStatus(e.status === 404
      ? 404
      : 500)
  }
},
Войти в полноэкранный режим Выйти из полноэкранного режима

Здесь мы видим, как в случае ответа API с ошибкой 404, мы говорим, что пользователь также должен получить код 404.

  • Вы могли заметить, что вызов магазина useHistory(this._p) сопровождается параметром this._p, это обязательный параметр, который передает экземпляр Pinia магазину, по аналогии с тем, как мы делали в CustomOption asyncData и middleware.Или вот, например, пример действия аутентификации:
async login(values: LoginValues) {
  this.isSending = true

  try {
    const { accessToken } = await http.post('/auth/login', values)

    setAccessToken(accessToken)
    useHistory(this._p).push('/profile')
  } catch (e) {
    if (e instanceof Response) {
      return (await e.json()).message
    }
  } finally {
    this.isSending = false
  }
}
Войти в полноэкранный режим Выйти из полноэкранного режима

В этом случае, в случае успешного ответа, мы перенаправляем пользователя на url профиля.

А теперь давайте сделаем так, чтобы server-entry.ts и client-entry.ts могли работать с этой интеграцией.
Я предлагаю начать с server-entry.ts:

export const render = async ({
  url,
  req,
  res,
  manifest,
}: {
  url: string
  manifest: Manifest
  req: Request
  res: Response
}) => {
  // ...code

  const context: AppContext = {
    req,
    res,
    pinia,
    router,
    query: _route.query,
    params: _route.params as AppContext['params'],
  }

  const historyStore = useHistory(pinia)
  useHistory(pinia)._setRouter(router)

  const components = matchedComponents(_route.matched)

  await fireMiddlewares(components, context)
  await fireAsyncData(components, context)

  if (historyStore._redirectUrl) {
    throw new RedirectError(historyStore._redirectUrl, historyStore.status)
  }
  if (historyStore.status !== 200) {
    res.status(historyStore.status)
  }

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

  const preloadLinks = renderPreloadLinks(ctx.modules ?? [], manifest)

  useHistory(pinia)._setRouter(null)
  const initialState = serialize(pinia.state.value)

  return {
    html,
    initialState,
    preloadLinks,
  }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Здесь, после создания контекста, мы добавили следующие шаги:

  • Создадим экземпляр нашего магазина с именем historyStore.
  • Привязка к экземпляру historyStore маршрутизатора useHistory(pinia)._setRouter(router).
  • Проверка if (historyStore._redirectUrl) на наличие редиректа.
  • Проверка if (historyStore.status !== 200) на изменение кода ответа.
  • И перед этапом сериализации магазина отвязываем экземпляр роутера useHistory(pinia)._setRouter(null), так как он не нужен во время гидирования сериализованного магазина, d и вообще на стороне сервера больше не понадобится.

С сервером все в порядке.
Следующий на очереди client-entry.ts:

router.isReady().then(() => {
  pinia.state.value = window.__pinia
  useHistory(pinia)._setRouter(router)

  router.beforeResolve(async (to, _from, next) => {
    // no changes
  })

  app.mount('#app')
})
Вход в полноэкранный режим Выход из полноэкранного режима

Здесь нам просто нужно связать экземпляр маршрутизатора с магазином, и все, интеграция будет корректно работать на стороне клиента.
Теперь мы можем хранить всю логику внутри наших действий 😍.

Заголовок и SEO-теги

Думаю, ни для кого не секрет, что к серверному рендерингу обычно прибегают, если поисковым системам нужна корректная индексация.
Для работы с head в Vue 3 уже есть библиотека vueuse/head, при этом она поддерживает SSR. Всю документацию по работе с ней вы можете найти в их репозитории, а я покажу простой пример и помогу вам познакомиться с серверным рендерингом.
И так в коде приложения, вам просто нужно вызвать useHead на любом уровне вложенности компонентов:

<script lang="ts">
import { computed, defineComponent } from 'vue'
import { useHead } from '@vueuse/head'

export default defineComponent({
  setup() {
    const rubricStore = useRubricStore()

    // code...

    useHead({
      title: computed(() => rubricStore.data!.rubric.title),
      meta: [
        {
          name: 'description',
          content: computed(() => rubricStore.data!.rubric.metaDescription),
        },
      ],
    })

    // code...
  }
})
</script>
Войти в полноэкранный режим выйти из полноэкранного режима

Обновите файл main.ts, добавьте туда инициализацию экземпляра VueHead:

import App from './App.vue'
import { createSSRApp } from 'vue'
import { createPinia } from 'pinia'
import { createHead } from '@vueuse/head'
import { createRouter } from './router'

export const createApp = () => {
  const app = createSSRApp(App)
  const router = createRouter()
  const pinia = createPinia()
  const head = createHead()

  app.use(router)
  app.use(pinia)
  app.use(head)

  return {
    app,
    head,
    pinia,
    router,
  }
}
Войдите в полноэкранный режим Выход из полноэкранного режима

А в файле server-entry.ts добавим следующее:

import { renderHeadToString } from '@vueuse/head'

export const render = async ({
  url,
  req,
  res,
  manifest,
}: {
  url: string
  manifest: Manifest
  req: Request
  res: Response
}) => {
  const { app, router, pinia, head } = createApp()

  // code...

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

  const { headTags, htmlAttrs, bodyAttrs } = renderHeadToString(head)

  const preloadLinks = renderPreloadLinks(ctx.modules ?? [], manifest)

  // code...

  return {
    html,
    initialState,
    preloadLinks,
    headTags,
    htmlAttrs,
    bodyAttrs,
  }
}
Войти в полноэкранный режим Выйти из полноэкранного режима

В index.html добавьте комментарий <! --head-tags--> в конце тега <head />, а также рекомендую добавить комментарии для тегов <html /> и <body /> тегов для вставки атрибутов <html${htmlAttrs}> и <body${bodyAttrs}> соответственно, они будут нужны для того, чтобы заменить их на настоящие теги, формируемые vueuse/head. Подробнее читайте в моем домашнем репозитории.

А в server.js достаточно добавить в обработчик следующее:

app.use('*', async (req, res) => {
  try {
    // code...

    const {
      html: appHtml,
      preloadLinks,
      headTags,
      htmlAttrs,
      bodyAttrs,
      initialState,
    } = await render({
      url: req.originalUrl,
      req,
      res,
      manifest,
    })

    const html = template
      .replace('<!--head-tags-->', headTags)
      .replace(' ${htmlAttrs}', htmlAttrs)
      .replace(' ${bodyAttrs}', bodyAttrs)
      .replace('<!--preload-links-->', preloadLinks)
      .replace('<!--app-html-->', appHtml)
      .replace('window.__pinia = {};', `window.__pinia = ${initialState};`)

    res.set({ 'Content-Type': 'text/html' }).end(html)
  } catch (e) {
    // code...
  }
})
Войти в полноэкранный режим Выйти из полноэкранного режима

Здесь нас больше всего интересует использование replace на нашем шаблоне, именно он позволяет нам закинуть данные в index.html .
И теперь у вас есть возможность выводить любые SEO теги, которые вам нравятся.
Но есть еще одна важная тема в деле SSR.

Кэширование

Серверный рендеринг решает некоторые проблемы SPA, но у него есть один существенный недостаток, он НЕВОЗМОЖНО ДОРОГОЙ.
Только подумайте, вам нужно инициализировать целое JS приложение при каждом запросе, несмотря на то, что Vue намного быстрее React в этом деле, выдержать резко возросший поток пользователей в случае успешной рекламной компании не просто. 😅
Также DDOS-атаки быстро положат ваш сервер на лопатки, учитывая, что SSR — это тяжелые синхронные вычисления, которые являются слабым местом Node.js. И кэширование ответа сервера в случае с SSR очень помогает, и положительно влияет на такую метрику как «Time to first byte».

И вот в Vue 2 у нас была такая классная фича как serverCacheKey, именно эта штука позволяла кэшировать на уровне компонентов.
Но в Vue 3 такой фишки нет, здесь возникают проблемы. Однако, учитывая, насколько сильно был оптимизирован SFC-компилятор, польза от serverCacheKey будет не так велика.

Поэтому наиболее удобным способом кэширования будет использование Redis. Есть два способа:

  • Кэшировать с помощью Redis на уровне Nginx, написав LUA-скрипт.
  • Или использовать Redis на уровне процесса Node.js.

Первый вариант должен быть быстрее, но второй проще в реализации.
Однако остается проблема сброса кэша. Здесь я не буду показывать конкретную реализацию, но дам пару советов, включая то, как сбросить кэш:

  • Кэширование ответа сервера для авторизованных пользователей не имеет особого смысла.
  • Для «гостевых» сессий можно использовать url в качестве ключа кэша.
  • При перезапуске Node.the js процесса, отвечающего за SSR, следует сбросить кэш. Также, в случае перезапуска Backend, также стоит сбросить кэш, можно просто отправить сообщение в Redis Pub/Sub, а Node.the js процесс, отвечающий за SSR уже прослушает его, и сам сбросит кэш, так что Backend не нужно знать, как работает кэш.
  • Но на большом проекте для сброса кэша может понадобиться сделать более сложную схему сброса кэша при обновлении данных на Backend, и отправлять на фронт сущность, которая была изменена, а фронт сам решит, какие ключи ему нужно отключить.

Ну и не забывайте про многопоточность, ее можно охватить с помощью Worker threads и модуля Cluster, PM2, Docker.

Заключение

И в заключение позвольте представить вам производственное приложение, использующее Vue 3 и Vite:

Хорошая причина выбрать Vue для SSR — это очень быстрый процесс гидратации.

Достичь 90+ баллов производительности в мобильном тесте Lighthouse не так сложно, как в случае с React.
Да, команда Vue проделала отличную работу 👍.

Весь код из статьи и пример проекта вы можете найти здесь.

Оцените статью
devanswers.ru
Добавить комментарий