[Из песочницы] Как я сделал удобной разработку на Vue.js с server-side рендерингом
Всем привет!
Начну с небольшой предыстории.
Свой новый проект я решил попробовать сделать на 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 кода для загрузки ресурсов и прочего, т.к. нам нужна полная гибкость. В шаблоне мы самостоятельно пометим те места, в которые хотим выводить данный код.
Сам шаблон будет выглядеть так:
{{{ meta.inject().title.text() }}}
{{{ meta.inject().meta.text() }}}
{{{ renderResourceHints() }}}
{{{ renderStyles() }}}
{{{ renderState() }}}
{{{ renderScripts() }}}
Рассмотрим подробнее.
- 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 — это корневой компонент, в котором содержится директива
Сам роутер выглядит так
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.
И в шаблоне указать атрибут : class
Также, необходимо в настройках вебпака указать, что мы будем использовать модули.
Сборка
Перейдем к самим настройкам вебпака.
У нас есть базовый конфиг, который наследуют конфиг для серверной и клиентской частей.
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.
Я надеюсь, что описанный опыт поможет быстро настроить экосистему для комфортной работы и сосредоточиться на разработке функционала.