Всем привет!
Начну с небольшой предыстории.
Свой новый проект я решил попробовать сделать на Vue.js. Мне нужен был серверный рендеринг (SSR), CSS модули, code-splitting и прочие прелести. Разумеется, для повышения производительности разработки нужна была горячая перезагрузка (HMR).
Я не хотел использовать готовые решения, типа Nuxt.js, т.к. при разрастании проекта важно иметь возможность кастомизации. А любые высокоуровневые решения, как правило, не дают этого делать, или дают, но с большими усилиями (был похожий опыт с использованием Next.js для React).
Основная проблема локальной разработки при использовании серверного рендеринга и горячей перезагрузки состояла в том, что мало запустить один webpack-dev-server. Мы должны также что-то сделать с исходниками, которые запускает Node.js, иначе при следующей перезагрузке страницы мы получим код, который не был обновлен на сервере, но обновился на клиенте.
Погрузившись в документацию и интернет, я, к сожалению, не нашел готовых адекватно работающих примеров и шаблонов. Поэтому я создал свой.

Я определил, из чего должен состоять мой шаблон, чтобы можно было вести комфортную разработку:
При локальной разработке все это должно обновляться в браузере на лету, также должен обновляться серверный код.
В продакшн режиме бандлы должны минифицироваться, должен добавляться хэш для кэширования статики, пути к бандлам должны автоматически проставляться в html-шаблоне.
Все это реализовано в репозитории на GitHub, я буду приводить код и описывать решения.
Стоит отметить, что у Vue.js есть довольно исчерпывающая документация для настройки серверного рендеринга, поэтому есть смысл туда заглянуть.
Итак, в качестве сервера для Node.js мы будем использовать Express, также нам потребуется vue-server-renderer. Этот пакет нам позволит срендерить код в html-строку, на основании серверного бандла, html-шаблона и клиентского манифеста, в котором указаны названия и путь к ресурсам.
Файл server.js в итоге будет выглядеть так:
Как видим, у нас используются 2 файла: vue-ssr-server-bundle.json и vue-ssr-client-manifest.json.
Они генерируются при сборке приложения; в первом находится код, который будет выполняться на сервере, второй содержит названия и пути к ресурсам.
Также, в опциях createBundleRenderer мы указали параметр inject: false. Это означает, что не будет происходить автоматическая генерация html кода для загрузки ресурсов и прочего, т.к. нам нужна полная гибкость. В шаблоне мы самостоятельно пометим те места, в которые хотим выводить данный код.
Сам шаблон будет выглядеть так:
Рассмотрим подробнее.
Вместо комментария будет подставлена разметка нашего приложения. Он обязателен.
Входной точкой в наше Vue приложение со стороны сервера является файл entry-server.js.
Входной точкой со стороны клиента является файл entry-client.js.
В app.js создается наш экземпляр Vue, который далее используется как на сервере, так и на клиенте.
Мы всегда создаем новый экземпляр, чтобы избежать ситуации, когда несколько запросов использует один экземпляр.
App.vue — это корневой компонент, в котором содержится директива <router-view></router-view>, которая будет подставлять нужные компоненты, в зависимости от роута.
Сам роутер выглядит так
Через Vue.use мы подключаем два плагина: Router и VueMeta.
В роутах сами компоненты мы указываем не непосредственно, а через
Это нужно для разделения кода (code-splitting).
Что касается управления состоянием (осуществляется Vuex), то его настройка ничем особенным не выделяется. Единственное, я разделил стор на модули и использую константы с названием, чтобы было легче ориентироваться по коду.
Теперь рассмотрим несколько нюансов в самих Vue компонентах.
Свойство metaInfo отвечает за отрисовку мета-данных, используя пакет vue-meta. Можно указать большое количество всевозможных параметров (подробнее).
В компонентах есть метод, который выполняется только на серверной стороне.
Также, я хотел использовать CSS модули. Мне приятна идея, когда ты не обязан заботиться о наименовании классов, чтобы не пересекаться между компонентами. Используя CSS модули, результирующий класс будет выглядеть, как <название класса>_<хэш>.
Чтобы это сделать нужно в компоненте указать style module.
И в шаблоне указать атрибут :class
Также, необходимо в настройках вебпака указать, что мы будем использовать модули.
Перейдем к самим настройкам вебпака.
У нас есть базовый конфиг, который наследуют конфиг для серверной и клиентской частей.
Конфиг для сборки серверного кода ничем не отличается от того, который в документации. За исключением обработки CSS.
Сначала вся обработка CSS у меня была вынесена в базовый конфиг, т.к. она нужна как на клиенте, так и на сервере. Там же и происходила минификация для продакшн режима.
Однако я столкнулся с проблемой, что на стороне сервера оказался document, и, соответственно, возникала ошибка. Это оказалось ошибкой mini-css-extract-plugin, которая починилась путем разделения обработки CSS для сервера и клиента.
VueSSRServerPlugin генерирует файл vue-ssr-server-bundle.json, в котором указан код, который выполняется на сервере.
Теперь рассмотрим клиентский конфиг.
Из примечательного, при локальной разработке мы указываем publicPath, ссылающийся на webpack-dev-server и генерируем имя файла без хэша. Также, для devServer мы указываем параметр writeToDisk:true.
Тут необходимо пояснение.
По умолчанию, webpack-dev-server раздает ресурсы из оперативной памяти, не записывая их на диск. В таком случае мы сталкиваемся с проблемой, что в клиентском манифесте (vue-ssr-client-manifest.json), который размещен на диске, будут указаны неактуальные ресурсы, т.к. он не будет обновлен. Чтобы обойти это, мы говорим дев-серверу записывать изменения на диск, в таком случае клиентский манифест будет обновлен, и подтянутся нужные ресурсы.
На самом деле, в будущем хочется избавиться от этого. Одно из решений — в дев. режиме в server.js подключать манифест не из каталога /dist, а с урла дев-сервера. Но в таком случае это становится асинхронной операцией. Буду рад красивому варианту решения проблемы в комментариях.
За релоадинг серверной части отвечает Nodemon, который наблюдает за двумя файлами: dist/vue-ssr-server-bundle.json и app/server.js и при их изменении рестартует приложение.
Чтобы иметь возможность рестартовать приложение при изменении server.js, мы не указываем этот файл как входную точку в nodemon, а создаем файл nodemon.js, в который подключаем server.js. И входной точкой становится файл nodemon.js.
В продакшн режиме входной точкой становится app/server.js.
Итого, мы имеем репозиторий с настройками и несколькими командами.
Для локальной разработки:
С клиентской стороны: запускает webpack-dev-server, который наблюдает за изменением Vue компонентов и просто кода, генерирует клиентский манифест с путями к дев-серверу, сохраняет это на диск и обновляет код, стили на лету в браузере.
С серверной стороны: запускает webpack в режиме наблюдения, собирает серверный бандл (vue-ssr-server-bundle.json) и при его изменении рестартует приложение.
В таком случае код консистентно изменяется на клиенте и сервере автоматически.
При первом запуске может возникнуть ошибка, что серверный бандл не найден. Это нормально. Просто нужно перезапустить команду.
Для продакшн сборки:
С клиентской стороны: собирает и минифицирует js и css, добавляя хэш к названию и генерирует клиентский манифест с относительными путями к ресурсам.
С серверной стороны: собирает серверный бандл.
Также, я создал еще команду yarn run start-node, которая запускает server.js, однако это сделано только для примера, в продакшн-приложении для запуска стоит использовать менеджеры процессов, например, PM2.
Я надеюсь, что описанный опыт поможет быстро настроить экосистему для комфортной работы и сосредоточиться на разработке функционала.
Начну с небольшой предыстории.
Свой новый проект я решил попробовать сделать на Vue.js. Мне нужен был серверный рендеринг (SSR), CSS модули, code-splitting и прочие прелести. Разумеется, для повышения производительности разработки нужна была горячая перезагрузка (HMR).
Я не хотел использовать готовые решения, типа Nuxt.js, т.к. при разрастании проекта важно иметь возможность кастомизации. А любые высокоуровневые решения, как правило, не дают этого делать, или дают, но с большими усилиями (был похожий опыт с использованием Next.js для React).
Основная проблема локальной разработки при использовании серверного рендеринга и горячей перезагрузки состояла в том, что мало запустить один webpack-dev-server. Мы должны также что-то сделать с исходниками, которые запускает Node.js, иначе при следующей перезагрузке страницы мы получим код, который не был обновлен на сервере, но обновился на клиенте.
Погрузившись в документацию и интернет, я, к сожалению, не нашел готовых адекватно работающих примеров и шаблонов. Поэтому я создал свой.

Я определил, из чего должен состоять мой шаблон, чтобы можно было вести комфортную разработку:
- VueJS
- SSR
- Vuex
- CSS модули
- Code-splitting
- ESLint, Prettier
При локальной разработке все это должно обновляться в браузере на лету, также должен обновляться серверный код.
В продакшн режиме бандлы должны минифицироваться, должен добавляться хэш для кэширования статики, пути к бандлам должны автоматически проставляться в html-шаблоне.
Все это реализовано в репозитории на GitHub, я буду приводить код и описывать решения.
Стоит отметить, что у Vue.js есть довольно исчерпывающая документация для настройки серверного рендеринга, поэтому есть смысл туда заглянуть.
Серверная часть
Итак, в качестве сервера для Node.js мы будем использовать Express, также нам потребуется vue-server-renderer. Этот пакет нам позволит срендерить код в html-строку, на основании серверного бандла, html-шаблона и клиентского манифеста, в котором указаны названия и путь к ресурсам.
Файл server.js в итоге будет выглядеть так:
const path = require('path'); const express = require('express'); const { createBundleRenderer } = require('vue-server-renderer'); const template = require('fs').readFileSync( path.join(__dirname, './templates/index.html'), 'utf-8', ); const serverBundle = require('../dist/vue-ssr-server-bundle.json'); const clientManifest = require('../dist/vue-ssr-client-manifest.json'); const server = express(); const renderer = createBundleRenderer(serverBundle, { // с этим параметром код сборки будет выполняться в том же контексте, что и серверный процесс runInNewContext: false, template, clientManifest, inject: false, }); // в боевом проекте имеет смысл раздавать статику с nginx server.use('/dist', express.static(path.join(__dirname, '../dist'))); server.get('*', (req, res) => { const context = { url: req.url }; renderer.renderToString(context, (err, html) => { if (err) { if (+err.message === 404) { res.status(404).end('Page not found'); } else { console.log(err); res.status(500).end('Internal Server Error'); } } res.end(html); }); }); server.listen(process.env.PORT || 3000);
Как видим, у нас используются 2 файла: vue-ssr-server-bundle.json и vue-ssr-client-manifest.json.
Они генерируются при сборке приложения; в первом находится код, который будет выполняться на сервере, второй содержит названия и пути к ресурсам.
Также, в опциях createBundleRenderer мы указали параметр inject: false. Это означает, что не будет происходить автоматическая генерация html кода для загрузки ресурсов и прочего, т.к. нам нужна полная гибкость. В шаблоне мы самостоятельно пометим те места, в которые хотим выводить данный код.
Сам шаблон будет выглядеть так:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> {{{ meta.inject().title.text() }}} {{{ meta.inject().meta.text() }}} {{{ renderResourceHints() }}} {{{ renderStyles() }}} </head> <body> <div id="app"><!--vue-ssr-outlet--></div> {{{ renderState() }}} {{{ renderScripts() }}} </body> </html>
Рассмотрим подробнее.
- meta.inject().title.text() и meta.inject().meta.text() нужны для вывода заголовков и мета-описаний. За это отвечает пакет vue-meta, про который я расскажу ниже
- renderResourceHints() — возвратит ссылки rel=«preload/prefetch» на ресурсы, указанные в клиентском манифесте
- renderStyles() — возвратит ссылки на стили, указанные в клиентском манифесте
- renderState() — возвратит стейт, положенный по умолчанию в window.__INITIAL_STATE__
- renderScripts() — возвратит скрипты, необходимые для работы приложения
Вместо комментария будет подставлена разметка нашего приложения. Он обязателен.
Входной точкой в наше Vue приложение со стороны сервера является файл entry-server.js.
import { createApp } from './app'; export default context => new Promise((resolve, reject) => { // на каждый запрос создается экземпляр Vue const { app, router, store } = createApp(); // $meta - метод, добавляемый пакетом vue-meta в экземпляр Vue const meta = app.$meta(); // пушим текущий путь в роутер router.push(context.url); // записываем мета-данные в контекст, чтобы потом отрендерить в шаблоне context.meta = meta; router.onReady(() => { context.rendered = () => { // записываем стейт в контекст, в шаблоне он будет сгенерирован, как window.__INITIAL_STATE__ context.state = store.state; }; const matchedComponents = router.getMatchedComponents(); // если ничего не нашлось if (!matchedComponents.length) { return reject(new Error(404)); } return resolve(app); }, reject); });
Клиентская часть
Входной точкой со стороны клиента является файл entry-client.js.
import { createApp } from './app'; const { app, router, store } = createApp(); router.onReady(() => { if (window.__INITIAL_STATE__) { // заменяет стейт на тот, что пришел с сервера store.replaceState(window.__INITIAL_STATE__); } app.$mount('#app'); }); // этот код активирует HMR и сработает, когда webpack-dev-server будет запущен со свойством hot if (module.hot) { const api = require('vue-hot-reload-api'); const Vue = require('vue'); api.install(Vue); if (!api.compatible) { throw new Error( 'vue-hot-reload-api is not compatible with the version of Vue you are using.', ); } module.hot.accept(); }
В app.js создается наш экземпляр Vue, который далее используется как на сервере, так и на клиенте.
import Vue from 'vue'; import { sync } from 'vuex-router-sync'; import { createRouter } from './router'; import { createStore } from './client/store'; import App from './App.vue'; export function createApp() { const router = createRouter(); const store = createStore(); sync(store, router); const app = new Vue({ router, store, render: h => h(App), }); return { app, router, store }; }
Мы всегда создаем новый экземпляр, чтобы избежать ситуации, когда несколько запросов использует один экземпляр.
App.vue — это корневой компонент, в котором содержится директива <router-view></router-view>, которая будет подставлять нужные компоненты, в зависимости от роута.
Сам роутер выглядит так
import Vue from 'vue'; import Router from 'vue-router'; import VueMeta from 'vue-meta'; import routes from './routes'; Vue.use(Router); Vue.use(VueMeta); export function createRouter() { return new Router({ mode: 'history', routes: [ { path: routes.pages.main, component: () => import('./client/components/Main.vue') }, { path: routes.pages.about, component: () => import('./client/components/About.vue') }, ], }); }
Через Vue.use мы подключаем два плагина: Router и VueMeta.
В роутах сами компоненты мы указываем не непосредственно, а через
() => import('./client/components/About.vue')
Это нужно для разделения кода (code-splitting).
Что касается управления состоянием (осуществляется Vuex), то его настройка ничем особенным не выделяется. Единственное, я разделил стор на модули и использую константы с названием, чтобы было легче ориентироваться по коду.
Теперь рассмотрим несколько нюансов в самих Vue компонентах.
Свойство metaInfo отвечает за отрисовку мета-данных, используя пакет vue-meta. Можно указать большое количество всевозможных параметров (подробнее).
metaInfo: { title: 'Main page', }
В компонентах есть метод, который выполняется только на серверной стороне.
serverPrefetch() { console.log('Run only on server'); }
Также, я хотел использовать CSS модули. Мне приятна идея, когда ты не обязан заботиться о наименовании классов, чтобы не пересекаться между компонентами. Используя CSS модули, результирующий класс будет выглядеть, как <название класса>_<хэш>.
Чтобы это сделать нужно в компоненте указать style module.
<style module> .item { padding: 3px 0; } .controls { margin-top: 12px; } </style>
И в шаблоне указать атрибут :class
<div :class="$style.item"></div>
Также, необходимо в настройках вебпака указать, что мы будем использовать модули.
Сборка
Перейдем к самим настройкам вебпака.
У нас есть базовый конфиг, который наследуют конфиг для серверной и клиентской частей.
const webpack = require('webpack'); const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin'); const UglifyJsPlugin = require('uglifyjs-webpack-plugin'); const merge = require('webpack-merge'); const VueLoaderPlugin = require('vue-loader/lib/plugin'); const isProduction = process.env.NODE_ENV === 'production'; let config = { mode: isProduction ? 'production' : 'development', module: { rules: [ { test: /\.vue$/, loader: 'vue-loader', }, { test: /\.js$/, loader: 'babel-loader', exclude: file => /node_modules/.test(file) && !/\.vue\.js/.test(file), }, { test: /\.(png|jpe?g|gif|svg)(\?.*)?$/, use: { loader: 'url-loader', options: { limit: 10000, name: 'images/[name].[hash:8].[ext]', }, }, }, ], }, plugins: [ new VueLoaderPlugin(), new webpack.DefinePlugin({ 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV), }), ], }; if (isProduction) { config = merge(config, { optimization: { minimizer: [new OptimizeCSSAssetsPlugin(), new UglifyJsPlugin()], }, }); } module.exports = config;
Конфиг для сборки серверного кода ничем не отличается от того, который в документации. За исключением обработки CSS.
const merge = require('webpack-merge'); const nodeExternals = require('webpack-node-externals'); const VueSSRServerPlugin = require('vue-server-renderer/server-plugin'); const baseConfig = require('./webpack.base.js'); module.exports = merge(baseConfig, { entry: './app/entry-server.js', target: 'node', devtool: 'source-map', output: { libraryTarget: 'commonjs2', }, externals: nodeExternals({ whitelist: /\.css$/, }), plugins: [new VueSSRServerPlugin()], module: { rules: [ { test: /\.css$/, loader: 'css-loader', options: { modules: { localIdentName: '[local]_[hash:base64:8]', }, }, }, ], }, });
Сначала вся обработка CSS у меня была вынесена в базовый конфиг, т.к. она нужна как на клиенте, так и на сервере. Там же и происходила минификация для продакшн режима.
Однако я столкнулся с проблемой, что на стороне сервера оказался document, и, соответственно, возникала ошибка. Это оказалось ошибкой mini-css-extract-plugin, которая починилась путем разделения обработки CSS для сервера и клиента.
VueSSRServerPlugin генерирует файл vue-ssr-server-bundle.json, в котором указан код, который выполняется на сервере.
Теперь рассмотрим клиентский конфиг.
const webpack = require('webpack'); const merge = require('webpack-merge'); const VueSSRClientPlugin = require('vue-server-renderer/client-plugin'); const MiniCssExtractPlugin = require('mini-css-extract-plugin'); const path = require('path'); const baseConfig = require('./webpack.base.js'); const isProduction = process.env.NODE_ENV === 'production'; let config = merge(baseConfig, { entry: ['./app/entry-client.js'], plugins: [new VueSSRClientPlugin()], output: { path: path.resolve('./dist/'), filename: '[name].[hash:8].js', publicPath: '/dist/', }, module: { rules: [ { test: /\.css$/, use: [ isProduction ? MiniCssExtractPlugin.loader : 'vue-style-loader', { loader: 'css-loader', options: { modules: { localIdentName: '[local]_[hash:base64:8]', }, }, }, ], }, ], }, }); if (!isProduction) { config = merge(config, { output: { filename: '[name].js', publicPath: 'http://localhost:9999/dist/', }, plugins: [new webpack.HotModuleReplacementPlugin()], devtool: 'source-map', devServer: { writeToDisk: true, contentBase: path.resolve(__dirname, 'dist'), publicPath: 'http://localhost:9999/dist/', hot: true, inline: true, historyApiFallback: true, port: 9999, headers: { 'Access-Control-Allow-Origin': '*', }, }, }); } else { config = merge(config, { plugins: [ new MiniCssExtractPlugin({ filename: '[name].[hash:8].css', }), ], }); } module.exports = config;
Из примечательного, при локальной разработке мы указываем publicPath, ссылающийся на webpack-dev-server и генерируем имя файла без хэша. Также, для devServer мы указываем параметр writeToDisk:true.
Тут необходимо пояснение.
По умолчанию, webpack-dev-server раздает ресурсы из оперативной памяти, не записывая их на диск. В таком случае мы сталкиваемся с проблемой, что в клиентском манифесте (vue-ssr-client-manifest.json), который размещен на диске, будут указаны неактуальные ресурсы, т.к. он не будет обновлен. Чтобы обойти это, мы говорим дев-серверу записывать изменения на диск, в таком случае клиентский манифест будет обновлен, и подтянутся нужные ресурсы.
На самом деле, в будущем хочется избавиться от этого. Одно из решений — в дев. режиме в server.js подключать манифест не из каталога /dist, а с урла дев-сервера. Но в таком случае это становится асинхронной операцией. Буду рад красивому варианту решения проблемы в комментариях.
За релоадинг серверной части отвечает Nodemon, который наблюдает за двумя файлами: dist/vue-ssr-server-bundle.json и app/server.js и при их изменении рестартует приложение.
Чтобы иметь возможность рестартовать приложение при изменении server.js, мы не указываем этот файл как входную точку в nodemon, а создаем файл nodemon.js, в который подключаем server.js. И входной точкой становится файл nodemon.js.
В продакшн режиме входной точкой становится app/server.js.
Заключение
Итого, мы имеем репозиторий с настройками и несколькими командами.
Для локальной разработки:
yarn run dev
С клиентской стороны: запускает webpack-dev-server, который наблюдает за изменением Vue компонентов и просто кода, генерирует клиентский манифест с путями к дев-серверу, сохраняет это на диск и обновляет код, стили на лету в браузере.
С серверной стороны: запускает webpack в режиме наблюдения, собирает серверный бандл (vue-ssr-server-bundle.json) и при его изменении рестартует приложение.
В таком случае код консистентно изменяется на клиенте и сервере автоматически.
При первом запуске может возникнуть ошибка, что серверный бандл не найден. Это нормально. Просто нужно перезапустить команду.
Для продакшн сборки:
yarn run build
С клиентской стороны: собирает и минифицирует js и css, добавляя хэш к названию и генерирует клиентский манифест с относительными путями к ресурсам.
С серверной стороны: собирает серверный бандл.
Также, я создал еще команду yarn run start-node, которая запускает server.js, однако это сделано только для примера, в продакшн-приложении для запуска стоит использовать менеджеры процессов, например, PM2.
Я надеюсь, что описанный опыт поможет быстро настроить экосистему для комфортной работы и сосредоточиться на разработке функционала.
