Как стать автором
Обновить

Webpack: параллельная сборка изоморфного приложения с перезагрузкой браузера

Время на прочтение11 мин
Количество просмотров4.2K

Несмотря на кажущуюся простоту темы, мне не довелось поучаствовать ни в одном проекте, где сборка фронтовой и серверной частей была бы настроена удобно, то есть обладала бы следующими характеристиками:


  • эти части собираются параллельно (в разных процессах)
  • после пересборки серверной части перезапускается сервер, исходя из новых файлов
  • после пересборки фронтовой части обновляется текущая страница в браузере
  • изоморфные файлы вызывают обе пересборки, а неизоморфные — только соответствующую
  • необходимые параметры (порт watch-сервера, https-режим) настраиваются через env-переменные

Коллеги настраивали лишь последовательную сборку этих частей при изменении любых файлов, что приводило к обязательному перезапуску сервера и нескольким перезагрузкам страницы (т.к. для этого использовалась либо middleware, отслеживающая запуск сервера, либо watch-сервер webpack, но видел и что используются они одновременно). Поэтому тема показалась актуальной, разберем все по полочкам.


Условия задачи указаны выше, соответственно можно спроектировать последовательность воплощения.


1. Определить схему подключения изменяемых параметров


На мой взгляд, наиболее удобно использовать .env файлы:


  • .env — используется непосредственно для сборки и запуска, исключен из git-репозитория;
  • example.dev.env — пример для локальной сборки;
  • example.prod.env — пример для production сборки.

Для текущей задачи будет достаточно включить в них настройки для основного сервера, для сервера, перезагружающего браузер, и параметры Webpack, которые будут меняться в зависимости от среды запуска или для удобства разработчика:


HOT_RELOAD=true
HOT_RELOAD_PORT=401

# Webpack config
# @docs: https://webpack.js.org/configuration/devtool
DEV_TOOL=
DEV_TOOL_SERVER=
DROP_CONSOLE=false
FILENAME_HASH=false
CIRCULAR_CHECK=true
MINIMIZE_CLIENT=false
MINIMIZE_SERVER=false
AGGREGATION_TIMEOUT=800

NODE_ENV=development
NODE_PATH=./src
EXPRESS_PORT=80
HTTPS_BY_NODE=false

Для того, чтобы смерджить реальное env-окружение машины и данный файл, можно использовать dotenv, но я предпочитаю better-npm-run, который заодно решает проблему кроссплатформенной передачи параметров из рецептов package.json. В код эти переменные будут передаваться с помощью следующей утилиты:


env.ts
type Devtool =
  | 'eval'
  | 'source-map'
  | 'eval-source-map'
  | 'cheap-source-map'
  | 'inline-source-map'
  | 'hidden-source-map'
  | 'nosources-source-map'
  | 'cheap-eval-source-map'
  | 'cheap-module-source-map'
  | 'inline-cheap-source-map'
  | 'cheap-module-eval-source-map'
  | 'inline-cheap-module-source-map'
  | boolean;

class Env {
  constructor(params: Record<string, any>) {
    Object.entries(params).forEach(([envKey, envValue]) => {
      switch (typeof this[envKey]) {
        case 'boolean':
          this[envKey] = envValue === true || envValue === 'true';
          break;
        case 'string':
          this[envKey] = (envValue || '').replace(/"/g, '').trim();
          break;
        case 'number':
          this[envKey] = Number(envValue || 0);
          break;
        default:
          break;
      }
    });
  }

  HOT_RELOAD = false;
  HOT_RELOAD_PORT = 0;

  DEV_TOOL: Devtool = 'cheap-module-eval-source-map';
  DEV_TOOL_SERVER: Devtool = 'cheap-module-eval-source-map';

  DROP_CONSOLE = false;
  FILENAME_HASH = false;
  CIRCULAR_CHECK = false;
  MINIMIZE_CLIENT = false;
  MINIMIZE_SERVER = false;
  AGGREGATION_TIMEOUT = 0;
  START_SERVER_AFTER_BUILD = false;

  NODE_ENV: `development` | `production` = `development`;
  NODE_PATH = '';
  EXPRESS_PORT = 0;
  HTTPS_BY_NODE = false;
}

// eslint-disable-next-line no-process-env
export const env = new Env(process.env);

В реальном приложении здесь нужно будет также проверять согласованность всех .env-файлов.


2. Создать webpack-конфиги для серверной и фронтовой сборки


Результат одной будет использоваться в целевых браузерах и выполняться на машине клиента, второй — в установленной в системе версии Node.js. По большей части они должны быть одинаковы, кроме следующих моментов (F — фронтендовый конфиг, B — бэкендовый):


F: target: 'web'
B: target: 'node'




F: entry: { client: path.resolve(paths.sourcePath, 'client.js') }
B: entry: { server: path.resolve(paths.serverPath, 'server.js') }




F: webpack-custom/loaders/loaderBabel.ts
/**
 * @docs: https://github.com/babel/babel-loader
 *
 */

import webpack from 'webpack';

import { env } from '../../env';
import babelConfigServer from '../../babel.config';

const presetEnvOptions = env.POLYFILLING
  ? {
      corejs: 3,
      useBuiltIns: 'usage',
    }
  : undefined;

export const loaderBabel: webpack.RuleSetLoader = {
  loader: 'babel-loader',
  options: {
    presets: [['@babel/preset-env', presetEnvOptions]],
    plugins: [...babelConfigServer.plugins, '@babel/plugin-transform-react-jsx', 'lodash'],
  },
};

B: webpack-custom/loaders/loaderBabelServer.ts
/**
 * @docs: https://github.com/babel/babel-loader
 *
 */

import webpack from 'webpack';

import babelConfigServer from '../../babel.config';

export const loaderBabelServer: webpack.RuleSetLoader = {
  loader: 'babel-loader',
  options: {
    presets: [
      [
        '@babel/preset-env',
        {
          targets: {
            node: 'current',
          },
        },
      ],
    ],
    plugins: [...babelConfigServer.plugins, '@babel/plugin-transform-react-jsx'],
  },
};

В данном случае фронтенд-сборка транспилируется в код, понятный браузерам из browserslist в package.json с автоматическим анализом файлов и внедрением полифиллов, а серверная — в код, понятный установленной в системе версии Node.js.




F: webpack-custom/loaders/loaderFiles.ts
/**
 * @docs: https://github.com/webpack-contrib/file-loader
 *
 */

import webpack from 'webpack';

export const loaderFiles: webpack.RuleSetLoader = {
  loader: 'file-loader',
  options: {
    name: '[contenthash].[ext]',
    outputPath: 'images',
    emitFile: true,
  },
};

B: webpack-custom/loaders/loaderFilesServer.ts
/**
 * @docs: https://github.com/webpack-contrib/file-loader
 *
 */

import webpack from 'webpack';

export const loaderFilesServer: webpack.RuleSetLoader = {
  loader: 'file-loader',
  options: {
    name: '[contenthash].[ext]',
    outputPath: 'images',
    emitFile: false,
  },
};

Здесь подразумевается, что серверная сборка не должна перемещать файлы из src в build с соответствующим переименованием, этим будет заниматься фронтовая.




F: webpack-custom/rules/ruleSass.ts
const loaderCss: webpack.RuleSetLoader = {
  loader: 'css-loader',
  options: {
    importLoaders: 1,
    modules: {
      localIdentName: '[folder]__[local]',
    },
  },
};

export const ruleSass: webpack.Rule = {
  test: /\.s?css$/,
  include: [paths.sourcePath],
  use: [loaderExtractCss, loaderCss, loaderPostcss],
};

B: webpack-custom/rules/loaderCssServer.ts
const loaderCssServer: webpack.RuleSetLoader = {
  loader: 'css-loader',
  options: {
    importLoaders: 1,
    onlyLocals: true,
    modules: {
      localIdentName: '[folder]__[local]',
    },
  },
};

export const ruleSassServer: webpack.Rule = {
  test: /\.s?css$/,
  include: [paths.sourcePath],
  use: [loaderCssServer, loaderPostcss],
};

Таким образом, фронтенд-сборка будет записывать стили в css-файлы, а бэкенд-сборка только преобразует их в js-объекты вида { myClass: "FolderName__myClass" } благодаря параметру onlyLocals: true.




И последнее различие между двумя сборками — в настройке оптимизации. Бэкендовая сборка должна сгенерировать один главный файл, который будет обращаться к дополнительным пакетам из node_modules напрямую, не включая в себя. Для этого служит настройка externals: [nodeExternals()]. Во фронтовой же будет генерироваться неограниченное количество файлов (асинхронные чанки + группы из cacheGroups).


3. Создать рецепт для параллельной сборки


Для базовой версии достаточно иметь возможность запустить функцию, как только обе сборки завершились в первый раз. С этим справится небольшая обертка над node-worker-farm под названием parallel-webpack. Этот пакет собирает сигналы от параллельных процессов через node-ipc и может выполнить необходимый коллбэк. К сожалению, напрямую конфиги в виде массива передать нельзя, поэтому придется создать дополнительный файл webpackParallel.config.ts с реэкспортом:


webpack-custom/webpackParallel.config.ts
import webpackClientConfig from './webpackClient.config';
import webpackServerConfig from './webpackServer.config';

export default [webpackClientConfig, webpackServerConfig];

В итоге получится подобная структура файлов:


.
|-- webpack-custom
|   |-- configs
|   |-- loaders
|   |-- plugins
|   |-- rules
|   |-- utils
|   `-- package.json
|   `-- webpackBuider.ts
|   `-- webpackClient.config.ts
|   `-- webpackParallel.config.ts
|   `-- webpackServer.config.ts

Непосредственно билдер будет содержать следующий функционал:


  • очищение папки билда (это нельзя делать плагином Webpack в одном из конфигов ввиду параллельной сборки);
  • запуск параллельной сборки;
  • по завершении всех сборок запуск в отдельном процессе node.js сервера + запуск в отдельном процессе сервера, перезапускающего браузер, с выведением в главный процесс информации из консоли для обоих.

webpack-custom/webpackBuider.ts
/**
 * @docs: https://github.com/trivago/parallel-webpack
 *
 */

import path from 'path';
import { exec } from 'child_process';

import { run } from 'parallel-webpack';

import { env } from '../env';
import { paths } from '../paths';
import { clearFolder } from '../server/serverUtils/clearFolder';

function afterFirstBuild() {
  /**
   * Start server & proxy it's stdout/stderr to current console
   *
   */

  if (!env.START_SERVER_AFTER_BUILD) return false;

  const serverProcess = exec('better-npm-run -s start');

  serverProcess.stdout.on('data', msg => console.log('[server]', msg.trim()));
  serverProcess.stderr.on('data', msg => console.error('[server]', msg.trim()));

  /**
   * Start watch server & proxy it's stdout/stderr to current console
   *
   */

  if (!env.HOT_RELOAD) return false;

  const reloadServerProcess = exec('better-npm-run -s reload-browser');

  reloadServerProcess.stdout.on('data', msg => console.log('[reload-browser]', msg.trim()));
  reloadServerProcess.stderr.on('data', msg => console.error('[reload-browser]', msg.trim()));
}

const parallelOptions = {
  stats: true,
  watch: env.HOT_RELOAD,
  colors: true,
  maxRetries: 1,
  maxConcurrentWorkers: 2,
};

Promise.resolve()
  .then(() => clearFolder(paths.buildPath))
  .then(() =>
    run(path.resolve(__dirname, 'webpackParallel.config.ts'), parallelOptions, afterFirstBuild)
  )
  .catch(console.error);

4. Создать рецепты для запуска


package.json
{
  "scripts": {
    "dev": "better-npm-run -s dev",
    "build": "better-npm-run build",
    "start": "better-npm-run start"
  },
  "betterScripts": {
    "dev": {
      "command": "better-npm-run -s build",
      "env": {
        "START_SERVER_AFTER_BUILD": true
      }
    },
    "build": {
      "command": "babel-node --extensions .ts,.tsx ./webpack-custom/webpackBuider.ts"
    },
    "reload-browser": {
      "command": "babel-node --extensions .ts,.tsx ./server/watchServer.ts"
    },
    "start": {
      "command": "nodemon -q ./build/server.js"
    }
  }
}

Как видно из файла, dev-режим будет отличаться только тем, что в консоль не будет выводиться информация о команде запуска (параметр -s в двух местах) и передачей переменной START_SERVER_AFTER_BUILD, все остальные настройки контролируются env-файлом.


Для того, чтобы babel-node позволил использовать Typescript в файлах сборщика, нужно добавить дефолтный конфиг для бабеля в корне проекта, который трансформирует код в понятный для установленной версии node.js:


babel.config.js
module.exports = {
  presets: [
    [
      '@babel/preset-env',
      {
        targets: {
          node: 'current',
        },
      },
    ],
  ],
  plugins: [
    [
      '@babel/plugin-transform-typescript',
      { isTSX: true, allExtensions: true, allowDeclareFields: true },
    ],
    ['@babel/plugin-proposal-decorators', { legacy: true }],
    ['@babel/plugin-proposal-class-properties', { loose: true }],
  ],
};

Плагины в данном случае (кроме @babel/plugin-transform-typescript) не нужны, но я вынес их в этот файл для переиспользования в babel-loader'ах (во втором разделе статьи).


5. Настроить перезапуск сервера


Для слежения за изменением файла server.js и рестарта я буду использовать nodemon, как записано выше в рецепте "start": "nodemon -q ./build/server.js". Для watch-режима достаточно в корень проекта поместить файл:


nodemon.json
{
  "verbose": false,
  "watch": ["build/server.js"]
}

6. Настроить обновление браузера


Включать этот функционал в node.js сервер неэффективно, так как он будет вызывать ложные перезагрузки и может не вызвать нужных ввиду разного времени завершения билдов серверного и клиентского кода. Поэтому сделал отдельный рецепт "reload-browser": "babel-node --extensions .ts,.tsx ./server/watchServer.ts", который будет выполняться в отдельном процессе. Цель этого скрипта — запустить WebSocket сервер + file watcher для папки билда, чтобы при изменении определенных файлов подавать сигнал браузеру.


server/watchServer.ts
/**
 * @docs: https://github.com/websockets/ws
 * @docs: https://github.com/yuanchuan/node-watch
 *
 */

import fs from 'fs';
import path from 'path';
import http from 'http';
import https from 'https';

import ws from 'ws';
import watch from 'node-watch';
import express from 'express';

import { env } from '../env';
import { paths } from '../paths';
import { configEntryServer } from '../webpack-custom/configs/configEntryServer';

function startReloadServer() {
  const sslOptions = {
    key: fs.readFileSync(path.resolve(paths.rootPath, 'ssl-local/cert.key')),
    cert: fs.readFileSync(path.resolve(paths.rootPath, 'ssl-local/cert.pem')),
  };

  const app = express();

  app.get('/reload/reload.js', (req, res) => {
    res.type('text/javascript');
    res.send(`
(function refresh() {
  let socketUrl = window.location.origin;
  if (!socketUrl.match(/:[0-9]+/)) {
    socketUrl = socketUrl + ':80';
  }
  socketUrl = socketUrl.replace(/(^http(s?):\\/\\/)(.*:)(.*)/,${`'ws$2://$3${env.HOT_RELOAD_PORT}`}');

  function websocketWaiter() {
    const socket = new WebSocket(socketUrl);

    socket.onmessage = function socketOnMessage(msg) {
      if (msg.data === 'reload') {
        socket.close();
        window.location.reload();
      }
    };

    socket.onclose = function socketOnClose() {
      setTimeout(function reconnectSocketDelayed() {
        websocketWaiter();
      }, 1000);
    };
  }

  window.addEventListener('load', websocketWaiter);
})();
`);
  });

  const server = env.HTTPS_BY_NODE ? https.createServer(sslOptions, app) : http.createServer(app);

  return new ws.Server({ server: server.listen(env.HOT_RELOAD_PORT) });
}

function startFileWatcher({ onFilesChanged }: { onFilesChanged: () => void }) {
  const excludedFilenames = Object.keys(configEntryServer);
  let changedFiles = [];
  let watchDebounceTimeout = null;

  const watchOptions = {
    recursive: false,
    filter: filePath => !excludedFilenames.some(fileName => filePath.indexOf(fileName) !== -1),
  };

  watch(paths.buildPath, watchOptions, function fileChanged(event, filePath) {
    const { base: fileName } = path.parse(filePath);

    changedFiles.push(fileName);

    clearTimeout(watchDebounceTimeout);
    watchDebounceTimeout = setTimeout(() => {
      console.log(`triggered by`, changedFiles);

      changedFiles = [];

      onFilesChanged();
    }, 50);
  });
}

const wss = startReloadServer();

startFileWatcher({
  onFilesChanged() {
    wss.clients.forEach(client => {
      if (client.readyState === ws.OPEN) client.send('reload');
    });
  },
});

Таким образом, основному серверу достаточно будет запросить HOT_RELOAD_URL/reload/reload.js, а полученный скрипт настроит соединение и будет реагировать на сигналы. Также если процесс прерван, он будет пытаться заново подключиться — и если получится, то бесшовно продолжит перезагружать браузер при новых изменениях. Так как данная схема используется только при локальной разработке, никаких деградаций до long polling предусматривать не нужно.


При слежении за папкой build я исключил вложенные папки и файл server.js, так как при работе чисто с серверными файлами перезагрузка браузера и новые запросы создадут лишь неудобство. Заметьте, что filePath.indexOf(fileName) !== -1 в реальном проекте для этих целей не подойдет — проигнорируются в том числе файлы, в части имени или в названии папки которых присутствует строка "server". Оставил это решение только для того, чтобы читатели не забывали внимательно относиться к тому, что пишут в интернетах.


Небольшой дебаунс сделан для единой перезагрузки браузера при обновлении вебпаком сразу нескольких файлов, магическая цифра 50 не имеет какого-то сакрального смысла, думаю, без проблем будет работать и меньшая, но не 0 ввиду задержек срабатывания node-watch и неодновременной записи файлов вебпаком.


Так как локальная разработка должна вестись на приближенной к стендовой среде, то включил вариант работы по HTTPS, чтобы браузером не выдавались ошибки из разряда mixed content.


Подключение этого скрипта в основном сервере сделать очень просто:


server/routeMiddlewares/handlePageRoutes.ts
import fs from 'fs';
import path from 'path';

import { injectAppMarkup } from 'serverUtils';

import { env } from '../../env';
import { paths } from '../../paths';

const template = fs.readFileSync(path.resolve(paths.buildPath, 'template.html'), 'utf-8');

export function handlePageRoutes(app) {
  app.get('*', (req, res) => {
    Promise.resolve()
      .then(() => injectAppMarkup(template))
      .then(modTemplate => injectBrowserReload(modTemplate))
      .then(modTemplate => res.send(modTemplate))
      .catch(error => {
        console.error(error);

        res.status(500);
        res.send('Unpredictable error');
      });
  });
}

const hotReloadUrl = `${env.HTTPS_BY_NODE ? 'https' : 'http'}://localhost:${
  env.HOT_RELOAD_PORT
}/reload/reload.js`;

export function injectBrowserReload(str) {
  if (!env.HOT_RELOAD) return str;

  return str.replace('</body>', `<script src="${hotReloadUrl}"></script></body>`);
}

Все, система, соответствующая критериям создана — теперь можно спокойно заниматься сервером и получать быструю пересборку с перезапуском, либо заниматься стилями и получать только фронтовую пересборку (но при добавлении нового класса изменится в том числе серверный js-объект, что приведет к обеим пересборкам). При этом file watcher гибко настраивается под любой проект.


Последнее, на что хотел бы обратить внимание — что parallel-webpack не имеет функционала выполнения логики перед пересборками, а этот функционал необходим для системы автоматической генерации файлов (отдельная тема, по которой планирую еще одну статью), поэтому скорее всего придется создавать более гибкое решение.


Репозиторий


Комфортного всем кодинга.

Теги:
Хабы:
Рейтинг0
Комментарии0

Публикации

Истории

Работа

Ближайшие события