[Из песочницы] Как я сделал удобной разработку на Vue.js с server-side рендерингом

Всем привет!

Начну с небольшой предыстории.

Свой новый проект я решил попробовать сделать на Vue.js. Мне нужен был серверный рендеринг (SSR), CSS модули, code-splitting и прочие прелести. Разумеется, для повышения производительности разработки нужна была горячая перезагрузка (HMR).

Я не хотел использовать готовые решения, типа Nuxt.js, т.к. при разрастании проекта важно иметь возможность кастомизации. А любые высокоуровневые решения, как правило, не дают этого делать, или дают, но с большими усилиями (был похожий опыт с использованием Next.js для React).

Основная проблема локальной разработки при использовании серверного рендеринга и горячей перезагрузки состояла в том, что мало запустить один webpack-dev-server. Мы должны также что-то сделать с исходниками, которые запускает Node.js, иначе при следующей перезагрузке страницы мы получим код, который не был обновлен на сервере, но обновился на клиенте.

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

frynfqwfukcvkxd1a673bleocno.jpeg
Я определил, из чего должен состоять мой шаблон, чтобы можно было вести комфортную разработку:

  • 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.

Я надеюсь, что описанный опыт поможет быстро настроить экосистему для комфортной работы и сосредоточиться на разработке функционала.

Полезные ссылки


© Habrahabr.ru