Vue SSR — 7 шагов для Vue.js приложения на сервере

Всем доброго времени суток. Сегодня поговорим про великий и ужасный Server-side rendering. Недавно я задался вопросом как внедрить Vue SSR в уже существующий проект на Vue.js и чтобы при этом было удобно вести разработку. Сделать Vue SSR задача довольно нетривиальная. Пришлось прочитать не только официальную документацию, но и перелопатить все существующие статьи которые были в Google на тот момент. Результат, который у нас получится вы можете посмотреть в репозитории на GitHub.

В статье мы сделаем следующее:

  • добавим SSR в проект,
  • разберемся с плагином Vue meta для того, чтобы мы могли управлять метаданными нашего приложения,
  • запустим production сервер на Express.js,
  • настроим dev-окружение c hot module replacement для удобной разработки

1. Отличия  разработки с Vue SSR

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

2. Структура проекта

Посмотрим на структуру проекта.

Структура проекта Vue SSR

Структура файлов и папок довольно распространенная. В глаза бросаются три webpack конфигурации, о которых мы поговорим ниже. Так же можно увидеть файлы entry-client.js и entry-server.js — это точки входа конфигураций webpack для клиента и сервера соответственно.

3. Конфигурации Webpack для клиента и сервера

Начнем с конфигурации для клиента. Здесь нет ничего необычного, за исключением того, что мы вынесли общую часть из двух конфигураций webpack отдельно и назвали её webpack.base.config.js. Для того чтобы использовать base-конфиг в конфигурациях для клиента и сервера нам понадобится плагин 'webpack-merge'.

// webpack.client.config.js

const path = require('path');
const merge = require('webpack-merge');
const baseConfig = require('./webpack.base.config.js');
const webpack = require('webpack')

module.exports = merge(baseConfig, {
    entry: './src/entry-client.js',
    output: {
        filename: '[name].js',
        sourceMapFilename: '[name].js.map',
        path: path.resolve(__dirname, './dist'),
        publicPath: '/dist/',
    }
})

В этой конфигурации настройки все стандартные, мы лишь указали точку входа для клиента entry-client.js.

Теперь посмотрим на конфигурацию для сервера. На выходе этой конфигурации мы получим  JSON файл, с помощью которого и будет генерироваться статичный HTML для сервера. Для этого мы воспользовались плагином vue-server-renderer/server-plugin. Заметьте, что плагин vue-server-renderer должен быть такой же версии, что и vue.

// webpack.server.config.js

const merge = require('webpack-merge');
const baseConfig = require('./webpack.base.config.js');
const nodeExternals = require('webpack-node-externals');
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin');

module.exports = merge(baseConfig, {
    // Здесь мы указываем точку входа серверной части приложения
    entry: './src/entry-server.js',

    // Это позволяет Webpack обрабатывать динамические импорты в Node-стиле,
    // а также сообщает `vue-loader` генерировать серверно-ориентированный код
    // при компиляции компонентов Vue.
    target: 'node',

    // Поддержка sourcemap
    devtool: 'source-map',

    // Это сообщает что в серверной сборке следует использовать экспорты в стиле Node
    output: {
        libraryTarget: 'commonjs2'
    },

    // https://webpack.js.org/configuration/externals/#function
    // https://github.com/liady/webpack-node-externals
    // Внешние зависимости приложения. Это значительно ускоряет процесс
    // сборки серверной части и уменьшает размер итогового файла сборки.
    externals: nodeExternals({
        // не выделяйте зависимости, которые должны обрабатываться Webpack.
        // здесь вы можете добавить больше типов файлов, например сырые *.vue файлы
        // нужно также указывать белый список зависимостей изменяющих `global` (например, полифиллы)
        whitelist: /\.css$/
    }),

    // Этот плагин преобразует весь результат серверной сборки
    // в один JSON-файл. Имя по умолчанию будет
    // `vue-ssr-server-bundle.json`
    plugins: [
        new VueSSRServerPlugin()
    ]
})

Так же взглянем на базовый конфиг, который мы вынесли в отдельный файл. В общем то здесь тоже ничего нового.

// webpack.base.config.js

const VueLoaderPlugin = require('vue-loader/lib/plugin');
const autoprefixer = require('autoprefixer');

const IS_DEV = process.env.NODE_ENV === 'development';

module.exports = {
    mode: process.env.NODE_ENV,
    module: {
        rules: [
            {
                test: /\.vue$/,
                loader: 'vue-loader'
            },
            {
                test: /\.js$/,
                loader: "babel-loader",
                exclude: /node_modules/,
                options: {
                    sourceMap: IS_DEV
                }
            },
            {
                test: /\.s?css$/,
                use: [
                    {
                        loader: 'vue-style-loader',
                        options: {
                            sourceMap: IS_DEV
                        }
                    },
                    {
                        loader: 'css-loader',
                        options: {
                            sourceMap: IS_DEV
                        }
                    },
                    {
                        loader: 'postcss-loader',
                        options: {
                            plugins: () => [autoprefixer]
                        }
                    },
                    {
                        loader: 'sass-loader',
                        options: {
                            sourceMap: IS_DEV
                        }
                    },
                ],
            },
        ],
    },
    plugins: [
        new VueLoaderPlugin()
    ]
}

Если вы хотите вынести css-стили в отдельный файл, то используйте плагин 'mini-css-extract-plugin'.

4. Разбираемся с точками входа

Для начал разберемся с файлом где создается наше приложение — app.js.

// app.js

import Vue from 'vue'
import App from './components/App/App.vue'
import VueRouter from 'vue-router'
import {createRouter} from './components/router.js'
import Meta from 'vue-meta'

Vue.use(VueRouter)
Vue.use(Meta, {
    ssrAppId: 1
});

export const createApp = context => {

    // Создаём экземпляр маршрутизатора
    const router = createRouter();
    const app = new Vue({
        // внедряем маршрутизатор в корневой экземпляр Vue
        router,
        render: h => h(App)
    })
    // возвращаем и приложение и маршрутизатор
    return {
        app,
        router
    }
}

Как мы и говорили в начале статьи, мы экспортируем функцию-фабрику createApp() и передаем в нее context, для того чтобы каждый раз при запросе получать с сервера новый экземпляр  Vue в контексте запроса. Для маршрутизатора как и для createApp нужно тоже каждый раз создавать новый экземпляр роутера. Тоже самое касается и хранилища на vuex. В нашем примере vuex не используется. Также мы объявили использование плагина vue-meta и указали дополнительную опцию ssrAppId: 1. В ней мы указали id приложения для приложения, которое получается на сервере после рендеринга. Более подробно для чего нужен этот параметр можно почитать здесь.

Клиентская точка входа:

// entry-client.js

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

const { app, router } = createApp({state:window.__INITIAL_STATE__});

router.onReady(() => {
    app.$mount('#app')
})

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

Точка входа для сервера:

// entry-server.js

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

export default context => {
    // поскольку могут быть асинхронные хуки маршрута или компоненты,
    // мы будем возвращать Promise, чтобы сервер смог дожидаться
    // пока всё не будет готово к рендерингу.
    return new Promise((resolve, reject) => {
        const {app, router} = createApp()        
        // metadata is provided by vue-meta plugin
        const meta = app.$meta();
        // устанавливаем маршрут для маршрутизатора серверной части
        router.push(context.url);
        context.meta = meta;

        // ожидаем, пока маршрутизатор разрешит возможные асинхронные компоненты и хуки
        router.onReady(() => {
            const matchedComponents = router.getMatchedComponents()
            // нет подходящих маршрутов, отклоняем с 404

            if (!matchedComponents.length) {
                return reject({code: 404})
            }

            // Promise должен разрешиться экземпляром приложения, который будет отрендерен
            resolve(app)
        }, reject)
    })
}

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

<script>
    export default {
        name: "About",
        data: function () {
            return {}
        },
        metaInfo: {
            title: 'About',
            titleTemplate: '%s - Webpack!',
            meta: [
                {
                    hid: 'og:title',
                    name: 'og:title',
                    content: 'Hello World'
                },
                {
                    hid: 'description',
                    name: 'description',
                    content: 'Hello World'
                }
            ]
        }
    }
</script>

Теперь допишем скрипты в файл package.json для запуска сборки для сервера и для клиента и запустим их.

  "scripts": {
    "build:client": "cross-env NODE_ENV=production webpack --config webpack.client.config.js",
    "build:server": "cross-env NODE_ENV=production webpack --config webpack.server.config.js",
  },

О NODE_ENV и переменных сред вы узнаете из этой статьи — NODE ENV в Node JS или что такое переменные окружения.

Если вы всё сделали правильно, то после запуска этих скриптов в папке dist у вас появятся два файла: main.js  после запуска клиентской конфигурации и vue-ssr-server-bundle.json после запуска серверной.

5. Пишем конфигурацию сервера с Vue SSR

Теперь мы детально разберем конфигурацию для сервера на Express.js

const express = require('express');
const server = express();
const webpack = require('webpack');
const config = require('./webpack.client.config');
const path = require('path');
const fs = require('fs');

const {createBundleRenderer} = require('vue-server-renderer');
const template = fs.readFileSync(path.join(__dirname, 'index.html'), 'utf-8');

const createRenderer = bundle => createBundleRenderer(bundle, {
    runInNewContext: false,
    template
});

let renderer = createRenderer(require('./dist/vue-ssr-server-bundle.json'));

server.use(express.static(path.join(__dirname, '/dist')));

server.get('*', async (req, res) => {
    const context = {
        url: req.url || '/',
    }

    let html;

    try {
        html = await renderer.renderToString(context);
    } catch (err) {
        if (err.code === 404) {
            return res.status(404).send('404 | Page Not Found');
        }
        return res.status(500).send('500 | Internal Server Error');
    }

    res.end(html)
})

server.listen(5000, () => {
    console.log(`Server started`)
});

Здесь нам потребуется пакет vue-server-renderer, а именно функция createBundleRenderer из этого пакета. Она будет принимать bundle — это как раз и есть тот json-файл, который получился в процессе сборки серверной части. Так же функция принимает объект настроек:  runInNewContext: false — эта опция означает, что код сборки будет выполнятся в том же контексте, что и серверный процесс; template — это наш index.html модифицированный для Vue SSR.

Далее мы запускаем функцию createRenderer, в качестве параметра bundle мы передаём ей json файл серверной сборки и записываем результат в переменную renderer. На строке 18 мы говорим, чтобы express брал статические ресурсы из папки dist. Затем идет обработка запросов на сервер. Символ '*' говорит о том,  что мы будем обрабатывать любой запрос, который поступит на сервер. Мы создаем переменную context и присваиваем ей объект, в который записываем url запроса если он есть. На строке 28 мы рендерим наш экземпляр приложения в строку при помощи метода renderToString и если все прошло успешно, то передаем отрендеренный экземпляр приложения в ответ.

Теперь запустим наш сервер! Добавим в package.json следующий скрипт.

"server": "cross-env NODE_ENV=production node server"

Этот скрипт будет обращаться к файлу server.js и запускать сервер. После запуска сайт будет доступен по адресу http://localhost:5000

Если вы сделали всё правильно, то увидите в браузере следующее.

6. index.html для Vue SSR

Отдельно хотелось бы взглянуть на файл index.html, который мы подготовили для SSR.

<!DOCTYPE html>
<html lang="en">
<head>
    {{{ meta.inject().title.text() }}}
    {{{ meta.inject().meta.text() }}}
<!--    <link rel="stylesheet" href="./main.css">-->
</head>
<body>
<div id="app">
    <!--vue-ssr-outlet-->
</div>
<script async src="./main.js"></script>
</body>
</html>

На строчках 4 и 5 будет вставляться тайтл и другая мета-информация, которую мы оставляли в компонентах. Так же здесь присутствует специальный комментарий <!--vue-ssr-outlet--> — он будет заменен на отрендеренную разметку.

7. Настраиваем dev-окружение с Vue.js SSR и HMR

Теперь мы настроим dev-окружение, с которым будет удобно работать, при этом у нас  будет поддержка Vue SSR и HMR (hot-module replacement). В этом нам помогут два пакета webpack-dev-middleware и webpack-hot-middleware. Устанавливаем их из npm. Затем нам нужно немного модифицировать файлы server.js и клиентскую конфигурацию webpack.

// добавляем эти строчки вначале файла server.js

const NODE_ENV = process.env.NODE_ENV;
const config = require('./webpack.client.config');
const webpackDevMiddleware = require('webpack-dev-middleware');
const compiler = webpack(config);

// эти строчки перед обработкой запросов сервером server.get(....)

if (NODE_ENV === 'development') {
    server.use(webpackDevMiddleware(compiler, {
        publicPath: config.output.publicPath,
        logLevel: 'warn'
    }));

    server.use(require("webpack-hot-middleware")(compiler, {
        log: console.log,
        path: '/__webpack_hmr',
        heartbeat: 2000
    }));
}

Ели мы запустим сервер при NODE_ENV=’development’, то у нас запуститься dev-сервер. Он будет доступен по тому же порту, что и production-сервер.

А в файле клиентской конфигурации немного меняем точку входа.

const IS_DEV = process.env.NODE_ENV === 'development';
let entryArray = IS_DEV ? ['./src/entry-client.js', 'webpack-hot-middleware/client'] : ['./src/entry-client.js'];

module.exports = merge(baseConfig, {
    entry: entryArray,
    output: {
        filename: '[name].js',
        sourceMapFilename: '[name].js.map',
        path: path.resolve(__dirname, './dist'),
        publicPath: '/dist/',
    },

   ...

})

Теперь если переменная среды NODE_ENV будет равна development, то в массив точек входа мы добавляем точку входа, которая отвечает за HMR и при изменении какого-либо компонента, этот компонент будет сразу изменятся в браузере без перезагрузки окна. Более подробно с этими расширениями вы можете ознакомиться по этим ссылкам: webpack-dev-middleware,  webpack-hot-middleware.

Заключение

Таким образом, из этой статьи мы узнали как внедрить Vue SSR в готовый проект. Надеюсь, данный материал был вам полезен. Напомним, результат вы можете посмотреть на GitHub.

Cody Maverick: