24 ноября 2022

⚡ Как занять первое место в поисковой выдаче: добавляем SSR в Vue 3 + Vite приложение

Технический директор компании vverh.digital. JavaScript программист, любитель Kotlin и Swift.
При разработке на реактивных фреймворках многие забывают о том, что итоговое приложение – это что-то ближе к SPA, а не классический сайт как «на Wordpress». И когда дело доходит до SEO-продвижения, многие хватаются за голову, потому что поисковые системы плохо работают с такими ресурсами. Поэтому давайте сегодня познакомимся с технологией SSR, которая решит данную проблему.
⚡ Как занять первое место в поисковой выдаче: добавляем SSR в Vue 3 + Vite приложение

Что такое SSR

SSR – (с англ. Server Side Rendering) технология, позволяющая выполнять на сервере JavaScript код для достижения каких-либо целей.

Зачем нужен SSR и что такое SEO

SSR в первую очередь необходим для продвижения сайта в интернете. Есть такое направление в маркетинге как SEO. И чаще всего, SSR необходим именно для этого.

SEO – (с англ. Search Engine Optimization) это оптимизация сайта под нужды поисковой системы. Само по себе, SEO продвижение – это целое самостоятельное направление в маркетинге с большой концентрацией капитала бизнеса, поэтому для многих это очень важная тема. Особенно если бизнес генерирует деньги в интернете.

Видите ли, когда поисковый робот делает запрос к сайту, сделанному на реактивных фреймворках по типу: Vue, React, Angular, то он видит примерно это:

⚡ Как занять первое место в поисковой выдаче: добавляем SSR в Vue 3 + Vite приложение

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

А вот та же самая страница, но с уже включенным SSR:

⚡ Как занять первое место в поисковой выдаче: добавляем SSR в Vue 3 + Vite приложение

Как видите, тут контент есть. Что вообще происходит?

Все просто – сайты, сделанные на JavaScript, обычно, инициализируются в реальном времени на стороне клиента. То есть, в браузере у пользователя. Поэтому поисковая система просто не может грамотно считать контент сайта, и значит, дать ему корректное место в поисковой выдаче. Тут вряд ли можно рассчитывать на первое место.

Больше полезных материалов вы найдете на нашем телеграм-канале «Библиотека фронтендера»

Автор не прав, поисковые системы индексируют JavaScript сайты…

Раздел для тех, кто где-то видел или читал какие-то новости на этому тему. Да, вы в целом правы. Но есть огромное но

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

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

А теперь представьте классический сайт на PHP, C#, Python. Сделал запрос – получил контент. Все.

Как рендерить JavaScript на сервере

С помощью Node.js. Не любите Node.js? Извините, других способов у нас для вас нет.

Хотя внутри Node.js за исполнение JavaScript отвечает движок V8, можете его скачать с GitHub и засунуть в свой проект. Только учтите: V8 написан на С++. Как вы свяжите между собой кучу инструментов, мы представляем лишь примерно, но точно можем сказать что вам будет очень «весело».

Технически, возможно добавить SSR и в Laravel + Vue проект (помним, Laravel это PHP), но это будет выглядеть как-то так. Сомнительный монолит получится. Да и вам все равно потребуется Node.js, как ни крути. Так что, будем работать с Node.js.

Добавляем SSR во Vue приложение

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

Создаем Vue приложение

Примечание
Автор использует Node.js 16.15.0.

Инициализируем Vue приложение с помощью команды:

        npm init vue@latest
    

Далее нам зададут некоторые вопросы, отвечаем на них:

  1. Project name. Пишите любое.
  2. Add TypeScript? Нет.
  3. Add JSX Support? Нет.
  4. Add Vue Router for Single Page Application development? Обязательно да.
  5. Add Pinia for state management? Нет, если надо, позже сами добавите.
  6. Add Vitest for Unit Testing? Нет.
  7. Add an End-to-End Testing Solution? Нет.
  8. Add ESLint for code quality? Как хотите, автор использует всегда.
  9. Add Prettier for code formatting? Как хотите.

Теперь переходим в папку с проектом, устанавливаем пакеты и запускаем приложение в режиме разработки (команды вводите по порядку):

        cd “ваше название”
npm install
npm run dev
    

У нас с вами появился такой проект, который нужен:

⚡ Как занять первое место в поисковой выдаче: добавляем SSR в Vue 3 + Vite приложение

Мы имеем App.vue как шаблон и несколько страниц добавленных через router/index.js: HomeView.vue и AboutView.vue.

⚡ Как занять первое место в поисковой выдаче: добавляем SSR в Vue 3 + Vite приложение

Если мы сейчас нажмем в браузере «Посмотреть код страницы», то не увидим никакого текста в нашем базовом приложении:

⚡ Как занять первое место в поисковой выдаче: добавляем SSR в Vue 3 + Vite приложение

Хотя в компонентах текст есть:

Компонент TheWelcome.vue, он вызывается внутри HomeView.vue.
Компонент TheWelcome.vue, он вызывается внутри HomeView.vue.

Создаем сервер для рендеринга JavaScript

Для начала идем в package.json и добавляем туда строчку:

        "type": "module",
    

Должно получится как-то так:

⚡ Как занять первое место в поисковой выдаче: добавляем SSR в Vue 3 + Vite приложение

Это для того чтобы в Node.js файлах использовать конструкцию import.

Теперь надо создать сервер. Пусть будет Express:

        npm install express
    

В папке src создайте файл server.js со следующим содержимым:

server.js
        // Node.js utility
import path from 'path'
import fs from 'fs'
import { fileURLToPath } from 'url'

// Vite
import { createServer } from 'vite'

// Express
import express from 'express'

// Helpers
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const resolve = (p) => path.resolve(__dirname, p)

const getIndexHTML = async () => {
  const indexHTML = resolve('../index.html')
  const html = await fs.promises.readFile(indexHTML, 'utf-8')
  return html
}

async function start() {
  const manifest = null
  const ssrServer = resolve('./main-server.js')

  const app = express()
  const router = express.Router()

  const vite = await createServer({
    server: { middlewareMode: true },
    appType: 'custom'
  })

  app.use(vite.middlewares)

  // Ловим все запросы, а вообще можно продублировать тут
  // логику из src/router.js
  router.get('/*', async (req, res, next) => {
    try {
      const url = req.url
      let template = await getIndexHTML()
      template = await vite.transformIndexHtml(url, template)

      let render = (await vite.ssrLoadModule(ssrServer)).render
      
      const [appHtml, preloadLinks] = await render(url, manifest)
      const html = template
        .replace(`<!--preload-links-->`, preloadLinks)
        .replace('<!--app-html-->', appHtml)

      res.status(200).set({ 'Content-Type': 'text/html' }).end(html)
    } catch (e) {
      vite.ssrFixStacktrace(e)
      next(e)
    }
  })

  // Routes
  app.use('/', router)

  app.listen(3000, () => {
    console.log('Сервер запущен')
  })
}

start()
    

Давайте обсудим, что же здесь написано. Это очень важно.

На 22 строке функция start() запускает Express-сервер, предварительно запуская внутри себя Vite-сервер на 29 строке. Сам Vite-сервер – это некое дополнительное приложение, которое умеет компилировать Vue-файлы.

По идее, если через Node.js вызвать файл:

        node index.js
    

в котором будем import файла с расширением .vue, то произойдет ошибка, так как нам нужно заранее предсобрать наше приложение особым способом через Vite (что мы и делаем).

Вообще, вся магия происходит с 40 по 51 строчку. В первую очередь, с помощью функции getIndexHTML(), которую мы чуть выше реализовали. Мы берем наш index.html из корня проекта, для того чтобы через регулярные выражения в нужное место установить отрендеренный контент. Да, нам нужно немного модернизировать index.html. Для этого вставьте под тег title конструкцию:

        <!--preload-links-->
    

И между <div id="app"></div> конструкцию:

        <!--app-html-->
    

Должно выйти так:

index.html
index.html

В preload-links полетят стили и еще всякие полезные ссылки, собираемые Vite. А в app-html, собранное с помощью SSR, – приложение.

Кого-то может смутить пустая переменная manifest. Все так и должно быть. Это не конечный вид файла, и чтобы вас не запутать, мы даем информацию постепенно.

За сам рендер JavaScript отвечает функция vite.ssrLoadModule(). В нее мы передаем путь до нашей специальной версии приложения – entry point для SSR. Да, мы сейчас говорим про файл main-server.js, которого у вас еще нету.

В папке src создайте еще один файл main-server.js с таким содержимым:

 main-server.js
        // Node.js
import { basename } from 'node:path'

// Vue SSR
import { createSSRApp } from 'vue'
import { renderToString } from 'vue/server-renderer'

// App
import App from './App.vue'
import router from './router/index.js'

export async function render(url, manifest = null) {
  const app = createSSRApp(App)
  app.use(router)

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

  // ctx - context. Плагин @vitejs/plugin-vue
  // https://vitejs.dev/guide/ssr.html#generating-preload-directives
  const ctx = {
    modules: []
  }
  const html = await renderToString(app)
  let preloadLinks = ''
  if (manifest) {
    renderPreloadLinks(ctx.modules, manifest)
  }

  return [html, preloadLinks]
}

function renderPreloadLinks(modules, manifest) {
  let links = ''
  const seen = new Set()
  modules.forEach((id) => {
    const files = manifest[id]
    if (files) {
      files.forEach((file) => {
        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) {
  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">`
  } else {
    return ''
  }
}
    

По документации Vite, функция vite.ssrLoadModule() возвращает другие экспортируемые функции из передаваемого файла. Поэтому внутри main-server.js мы объявляем функцию render() и в ней напишем классический SSR сервер из документации Vue.js.

Сама функция render() будет вызываться из файла server.js.

Внутри main-server.js у многих могут вызывать вопросы две функции: renderPreloadLinks() и renderPreloadLink(). Хотя они и выглядят страшно, но на самом деле выполняют простую роль: они помогают нам и подготавливают ссылки на .css файлы. Все ссылки на чанки стилей будут находиться в манифесте. Мы его просто тут читаем. Понимаем, вопросов много, но пока у нас нет манифеста, мы его сделаем чуть позже, и все станет сразу в разы понятней.

К сожалению, это еще не все (хотя уже финишная прямая). Даже если мы сейчас попытаемся запустить сервер, то ничего хорошего не произойдет. Нам надо еще перенастроить наш router/index.js. Для этого откройте этот файл.

Смотрите на 5 строку, раздел history. Тут используется функция createWebHistory(). Под капотом у этой функции есть использование глобальных переменных document и window. Только вот беда: когда мы будем собирать наше приложение через SSR-мод с помощью Node.js, мы не сможем обратиться к этим переменным. Просто потому, что в Node.js нет их. Вместо window в Node.js есть global и process, но там совсем другое содержимое. А document вообще является DOM API, которого тем более там нет… это же не браузер.

Поэтому мы должны поменять createWebHistory() на сreateMemoryHistory(), но только для SSR, дабы в обычном режиме приложение не сломалось. Поэтому модернизируйте файл router/index.js таким способом:

router/index.js
        import { createRouter, createWebHistory, createMemoryHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'

const baseUrl = import.meta.env.BASE_URL
const history = import.meta.env.SSR ? createMemoryHistory(baseUrl) : createWebHistory(baseUrl)

const router = createRouter({
  history,
  routes: [
    {
      path: '/',
      name: 'home',
      component: HomeView
    },
    {
      path: '/about',
      name: 'about',
      component: () => import('../views/AboutView.vue')
    }
  ]
})

export default router
    

Теперь можете запустить наше творение командой:

        node ./src/server.js
    

Во-первых, сервер запустился под адресом localhost:3000, и если вы перейдете на него и откроете исходный код, то увидите результат своего труда:

Можете проскролить вправо и там будет еще контент из компонентов
Можете проскролить вправо и там будет еще контент из компонентов

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

Во-вторых, если нажимать F5, то как-то некрасиво встают стили. Мы это исправим за счет манифеста. В режиме разработки мы поработаем и так, а для production сделаем все чуть красивее.

В-третьих, если вы меняете файлы, Vite подхватывает изменения и делает Hot Reload. Ну, кроме файла server.js… тут, если хотите, то же самое – надо поставить nodemon и запускать server.js уже через него. Как-то так:

        npm install -g nodemon
nodemon ./src/server.js
    

P.S: Это по желанию.

Финал: сборка для production

Замените содержимое server.js на новое:

server.js
        // Node.js utility
import path from 'path'
import fs from 'fs'
import { fileURLToPath } from 'url'

// Vite
import { createServer } from 'vite'

// Express
import express from 'express'

// eslint-disable-next-line no-undef
const isProd = process.env.NODE_ENV === 'production'

// Helpers
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const resolve = (p) => path.resolve(__dirname, p)

const getIndexHTML = async () => {
  const indexHTML = isProd
    ? resolve('../dist/client/index.html')
    : resolve('../index.html')
  const html = await fs.promises.readFile(indexHTML, 'utf-8')
  return html
}

async function start() {
  const manifest = isProd 
    ? JSON.parse(fs.readFileSync(resolve('../dist/client/ssr-manifest.json'), 'utf-8'))
    : null

  const app = express()
  const router = express.Router()

  let vite = null
  if (isProd) {
    app.use(express.static('dist/client', { index: false }))
  } else {
    vite = await createServer({
      // eslint-disable-next-line no-undef
      root: process.cwd(),
      server: { middlewareMode: true },
      appType: 'custom'
    })

    app.use(vite.middlewares)
  }

  // Ловим все запросы, а вообще можно продублировать тут
  // логику из src/router.js
  router.get('/*', async (req, res, next) => {
    try {
      const url = req.url
      let template = await getIndexHTML()

      let render = null
      if (isProd) {
        render = (await import('../dist/server/main-server.js')).render
      } else {
        template = await vite.transformIndexHtml(url, template)
        render = (await vite.ssrLoadModule(resolve('./main-server.js'))).render
      }
      
      const [appHtml, preloadLinks] = await render(url, manifest)
      const html = template
        .replace(`<!--preload-links-->`, preloadLinks)
        .replace('<!--app-html-->', appHtml)

      res.status(200).set({ 'Content-Type': 'text/html' }).end(html)
    } catch (e) {
      if (vite) {
        vite.ssrFixStacktrace(e)
      }
      next(e)
    }
  })

  // Routes
  app.use('/', router)

  app.listen(3000, () => {
    console.log('Сервер запущен')
  })
}

start()

    

Тут не так много правок, как может показаться. В самом вверху мы добавили переменную isProd.

С помощью этой переменной мы будем понимать в каком режиме мы сейчас функционируем. По-хорошему, для production нужно заранее собрать наше приложение через Vite и больше не использовать Vite сервер (там ведь много лишнего под капотом). После сборки наше приложение помещается в папку dist и мы будем просто тянуть файлы оттуда. Посмотрите на строки 20, 28, 36 и 57. Тут как раз у нас еще и манифест появился.

Теперь давайте соберем наше приложение в боевом режиме. Давайте внесем корректировки в package.json:

Добавить в  package.json
        "dev": "node ./src/server.js",
"serve": "NODE_ENV=production node ./src/server.js",
"build": "npm run build:client && npm run build:server",
"build:client": "vite build --ssrManifest --outDir dist/client",
"build:server": "vite build --ssr src/main-server.js --outDir dist/server",

    

Должно получится так:

package.json
package.json

Теперь вместо:

        node ./src/server.js
    

Можно использовать:

        npm run dev
    

А для production есть команда:

        npm run serve
    

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

        npm run build
    

Ибо без сборки нечего «обслуживать».

В целом на этом все, можем поздравить вас с реализацией своего SSR без всяких фреймворков.

Бонус: альтернативные способы внедрения SSR

Вообще, если лень проходить по этому туториалу и все кажется слишком сложным, то можно рассмотреть готовые инструменты для внедрения SSR.

Например, в рамках Vue 3 существуют такие инструменты как Nuxt и Quasar. Данные инструменты позволяют не создавать всякие Express сервера, а просто работать с Vue, как привыкли. Минусы такого подхода лишь в том, что не вы сами настраиваете Express сервер, а разработчик фреймворка. Поэтому, вы, как программист, придерживаетесь чужой логики (но это не всегда плохо).

***

Итог

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

Материалы по теме

Комментарии

ВАКАНСИИ

Добавить вакансию
Разработчик C++
Москва, по итогам собеседования

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ