Наводим порядок в конфигах Webpack

6ba1b8a575e3bec0616e8885592044ba.jpg

Всем привет. Меня зовут Евгений Чернышев, и я возглавляю фронтенд-разработку в одном из направлений деятельности Домклик. Хочу поделиться своими мыслями о том, как управлять сложными конфигурациями Webpack. Сразу «проведу черту», чтобы предотвратить возможные холивары: сравнение Webpack с другими бандлерами (Rollup, Vite и прочими) выходит за рамки статьи.

Де-факто, Webpack является основным сборщиком фронтенд-проектов. Это зрелый продукт, который до сих пор развивается и повсеместно используется. Но, как и любой инструмент, он имеет свои слабые стороны. Я считаю что основной недостаток Webpack — это сложность его конфигурации. На крупных долгоживущих проектах конфигурационные файлы становятся слишком большими и нечитаемыми, превращаясь в мешанину вложенных объектов и spread-операторов. Чтобы показать, что я имею в виду, рассмотрим стадии развития проекта.

Рождение

Наверное, каждый из нас ощущал то воодушевляющее волнение, когда приходил тимлид и говорил: «У нас будет новый проект». Помним мы и свои мысли в тот момент: «Вот с сегодняшнего дня я буду писать код аккуратно, буду применять лучшие практики и теперь уж точно-точно не стану плодить тоннами техдолг!» Ну что же, готов и простенький конфигурационный файл Webpack:

Первый вариант конфигурации

const path = require('path');
const { TsconfigPathsPlugin } = require('tsconfig-paths-webpack-plugin');
const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin');
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');
const htmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
  mode: 'development',
  entry: path.resolve('public', 'index.tsx'),
  output: {
    path: path.resolve('dist'),
    clean: true
  },
  resolve: {
    modules: ['node_modules'],
    extensions: ['.js', '.ts', '.tsx'],
    plugins: [new TsconfigPathsPlugin()]
  },
  stats: {
    preset: 'normal',
    modules: false
  },
  performance: {
    hints: false
  },
  devtool: 'eval-cheap-module-source-map',
  devServer: {
    historyApiFallback: true,
    port: 3000,
    allowedHosts: 'all'
  },
  module: {
    rules: [
      {
        test: /\.(ts|tsx)$/,
        loader: 'ts-loader',
        options: {
          transpileOnly: true
        },
        exclude: /node_modules/
      },
      {
        test: /\.css$/,
        use: ['style-loader', 'css-loader']
      },
      {
        test: /\.(svg|gif|png|jpg|jpeg|webp)$/i,
        type: 'asset',
        exclude: /node_modules/
      }
    ]
  },
  plugins: [
    new ReactRefreshWebpackPlugin(),
    new ForkTsCheckerWebpackPlugin()
    new htmlWebpackPlugin({
      filename: 'index.html',
      template: path.resolve('public', 'index.html')
    })
  ]
};

Всё чистенько, понятно, удобочитаемо. Сразу видно, где загрузчик для React-компонентов, какой плагин отвечает за настройку псевдонимов (aliases) для путей модулей проекта. Разве может что-то пойти не так?

Развёртывание в эксплуатацию

Спустя месяц интенсивной разработки, соблюдения лучших практик, применения самых новых версий ${packageName} и самого React наш проект готов к промышленной эксплуатации. Мы всё проверили, написали приличное количество unit-тестов, даже заморочились на пару-тройку e2e-тестов. Нас переполняет чувство гордости за достойно проделанную работу. А ещё мы испытываем лёгкое волнение перед первым развёртыванием нашего newawesomerealestateservice.domclick.ru.

Если внимательно присмотреться, то можно обнаружить, что бессмысленно оставлять для prod-сборки ReactRefreshWebpackPlugin. А ведь есть ещё стандарты компании и требования бизнеса. Бизнес хочет добавить на сайт Яндекс-метрику, а стандарты компании требуют использование Sentry в проектах. Кроме того, у нас есть CI/CD, а также несколько контуров приложения (тестовый и эксплуатационный). По сути, нам требуется передавать в сборку некоторый набор переменных окружения отдельно для каждого контура. Webpack, конечно же, это делать умеет. Нужно взять тот же DefinePlugin и чуть докрутить конфигурационный файл:

Вариант конфигурации во время первой публикации сервиса

const path = require('path');
const { TsconfigPathsPlugin } = require('tsconfig-paths-webpack-plugin');
const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin');
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');
const htmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
  mode: process.env.NODE_ENV === 'production' ? 'production' : 'development',
  entry: path.resolve('public', 'index.tsx'),
  output: {
    path: path.resolve('dist'),
    clean: true
  },
  resolve: {
    modules: ['node_modules'],
    extensions: ['.js', '.ts', '.tsx'],
    plugins: [new TsconfigPathsPlugin()]
  },
  stats: {
    preset: 'normal',
    modules: false
  },
  performance: {
    hints: false
  },
  devtool: 'eval-cheap-module-source-map',
  devServer: {
    historyApiFallback: true,
    port: 3000,
    allowedHosts: 'all'
  },
  module: {
    rules: [
      {
        test: /\.(ts|tsx)$/,
        loader: 'ts-loader',
        options: {
          transpileOnly: true
        },
        exclude: /node_modules/
      },
      {
        test: /\.css$/,
        use: ['style-loader', 'css-loader']
      },
      {
        test: /\.(svg|gif|png|jpg|jpeg|webp)$/i,
        type: 'asset',
        exclude: /node_modules/
      }
    ]
  },
  plugins: [
    process.env.NODE_ENV === 'development' && new ReactRefreshWebpackPlugin(),
    new ForkTsCheckerWebpackPlugin(),
    new htmlWebpackPlugin({
      filename: 'index.html',
      template: path.resolve('public', 'index.html')
    }),
    process.env.NODE_ENV === 'production' && new webpack.DefinePlugin({
			YM_ID: process.env.YANDEX_METRICA_ID,
			SENTRY_DSN: process.env.SENTRY_DSN,
    }),
  ].filter(Boolean),
};

Код усложнился, но совсем не критично. Одним условием больше, один условием меньше — принципиальной разницы тут нет.

Жизнь в эксплуатации

Долгожданный релиз состоялся, всё прошло даже лучше, чем мы ожидали. Никаких падений, хотфиксов и прочих неприятных вещей: просто раскатили и оно заработало. Опытные разработчики тут усмехнутся, но на самом деле так оно и было. Мы с уважаемыми коллегами отметили успешный релиз в баре, потом в караоке, потом… Но это уже совсем другая история.

Мало по малу, конфигурация Webpack начала усложняться. Добавили поддержку SVGR, потом мы затащили CSS-модули, до кучи докинули Bundle Analyzer. Добавили в конфигурационный файл забытую впопыхах секцию optimization. А ещё много раз приходил бизнес и просил новые фичи, менял требования.

Спустя год конфигурация Webpack претерпела сильные изменения:

Конфигурация спустя год жизни проекта

const path = require('path');
const { TsconfigPathsPlugin } = require('tsconfig-paths-webpack-plugin');
const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin');
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');
const htmlWebpackPlugin = require('html-webpack-plugin');
const TerserPlugin = require('terser-webpack-plugin');

const config = {
  mode: process.env.NODE_ENV === 'production' ? 'production' : 'development',
  entry: path.resolve('public', 'index.tsx'),
  output: {
    path: path.resolve('dist'),
    clean: true
  },
  resolve: {
    modules: ['node_modules'],
    extensions: ['.js', '.ts', '.tsx'],
    plugins: [new TsconfigPathsPlugin()]
  },
  stats: {
    preset: 'normal',
    modules: false
  },
  performance: {
    hints: false
  },
  devtool: 'eval-cheap-module-source-map',
  devServer: {
    historyApiFallback: true,
    port: 3000,
    allowedHosts: 'all'
  },
  module: {
    rules: [
      {
        test: /\.(ts|tsx)$/,
        loader: 'ts-loader',
        options: {
          transpileOnly: true
        },
        exclude: /node_modules/
      },
      {
        test: /\.css$/,
        use: ['style-loader', 'css-loader']
      },
      {
        test: /\.module\.scss$/,
        sideEffects: true,
        use: [
          'style-loader',
          {
            loader: 'css-loader',
            options: {
              importLoaders: 3,
              modules: {
                localIdentName: '[local]_[hash:base64:5]'
              }
            }
          },
          'postcss-loader',
          'resolve-url-loader',
          'sass-loader'
        ],
        exclude: /node_modules/
      },
			{
        test: /\.scss$/,
        sideEffects: true,
        use: [
          'style-loader',
          {
            loader: 'css-loader',
            options: {}
          },
          'postcss-loader',
          'resolve-url-loader',
          'sass-loader'
        ],
        exclude: [/node_modules/, /\.module\.scss$/]
      },
			{
        test: /\.svg$/i,
        issuer: /\.[jt]sx?$/,
        use: ['@svgr/webpack'],
      },
      {
        test: /\.(gif|png|jpg|jpeg|webp)$/i,
        type: 'asset',
        exclude: /node_modules/
      }
    ]
  },
  plugins: [
    process.env.NODE_ENV === 'development' && new ReactRefreshWebpackPlugin(),
    new ForkTsCheckerWebpackPlugin(),
    new htmlWebpackPlugin({
      filename: 'index.html',
      template: path.resolve('public', 'index.html')
    }),
    process.env.NODE_ENV === 'production' && new webpack.DefinePlugin({
			YM_ID: process.env.YANDEX_METRICA_ID,
			SENTRY_DSN: process.env.SENTRY_DSN,
    }),
    process.env.NODE_ENV === 'development' && new BundleAnalyzerPlugin({
		  analyzerMode: 'static',
		  reportFilename: 'bundle-analysis.html',
		  generateStatsFile: true,
		  openAnalyzer: false,
		}),
  ].filter(Boolean),
};

module.exports = process.env.NODE_ENV === 'development' ? config : { 
  ...config,
	...{ optimization: {
      minimize: true,
      moduleIds: 'deterministic',
      minimizer: [
        new TerserPlugin({
          terserOptions: {
            warnings: false,
            compress: {
              comparisons: false,
            },
            parse: {},
            mangle: true,
            output: {
              comments: false,
              ascii_only: true,
            },
          },
          parallel: true,
        }),
      ],
      nodeEnv: process.env.NODE_ENV,
      sideEffects: true,
      concatenateModules: true,
      runtimeChunk: 'single',
      splitChunks: {
        chunks: 'all',
        cacheGroups,
      },
    },
  },
};

Прочитать такой код и разобраться в нём уже непросто, но мы можем взять хотя бы тот же console.log или пройтись отладчиком, если сходу не поймём что тут происходит.

А не прикрутить ли нам SSR?

Использование SSR (Server Side Rendering) позволит нам улучшить клиентский опыт за сайта в выдаче поисковиков. Изначально на проекте мы не использовали фреймворки серверного рендеринга вроде Next.js, а всё решили сделать сами. По сути, сборка для SSR — это та же самая production-сборка, только с определёнными изменениями. Нужно учесть, что здесь сборщик собирает серверное node.js-приложение. Попробуем отразить доработку в файле конфигурации Webpack:

Конфигурация после дальнейшего усложнения

const path = require('path');
const { TsconfigPathsPlugin } = require('tsconfig-paths-webpack-plugin');
const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin');
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');
const htmlWebpackPlugin = require('html-webpack-plugin');
const TerserPlugin = require('terser-webpack-plugin');

const config = {
  mode: process.env.NODE_ENV === 'production' ? 'production' : 'development',
  entry: path.resolve('public', 'index.tsx'),
  output: {
    path: path.resolve('dist'),
    clean: true
  },
  resolve: {
    modules: ['node_modules'],
    extensions: ['.js', '.ts', '.tsx'],
    plugins: [new TsconfigPathsPlugin()]
  },
  stats: {
    preset: 'normal',
    modules: false
  },
  performance: {
    hints: false
  },
  devtool: 'eval-cheap-module-source-map',
  devServer: {
    historyApiFallback: true,
    port: 3000,
    allowedHosts: 'all'
  },
  module: {
    rules: [
      {
        test: /\.(ts|tsx)$/,
        loader: 'ts-loader',
        options: {
          transpileOnly: true
        },
        exclude: /node_modules/
      },
      {
        test: /\.css$/,
        use: ['style-loader', 'css-loader']
      },
      {
        test: /\.module\.scss$/,
        sideEffects: true,
        use: [
          'style-loader',
          {
            loader: 'css-loader',
            options: {
              importLoaders: 3,
              modules: {
                localIdentName: '[local]_[hash:base64:5]'
              }
            }
          },
          'postcss-loader',
          'resolve-url-loader',
          'sass-loader'
        ],
        exclude: /node_modules/
      },
			{
        test: /\.scss$/,
        sideEffects: true,
        use: [
          'style-loader',
          {
            loader: 'css-loader',
            options: {}
          },
          'postcss-loader',
          'resolve-url-loader',
          'sass-loader'
        ],
        exclude: [/node_modules/, /\.module\.scss$/]
      },
			{
        test: /\.svg$/i,
        issuer: /\.[jt]sx?$/,
        use: ['@svgr/webpack'],
      },
      {
        test: /\.(gif|png|jpg|jpeg|webp)$/i,
        type: 'asset',
        exclude: /node_modules/
      }
    ]
  },
  plugins: [
    process.env.NODE_ENV === 'development' && new ReactRefreshWebpackPlugin(),
    new ForkTsCheckerWebpackPlugin(),
    new htmlWebpackPlugin({
      filename: 'index.html',
      template: path.resolve('public', 'index.html')
    }),
    process.env.NODE_ENV === 'production' && new webpack.DefinePlugin({
			YM_ID: process.env.YANDEX_METRICA_ID,
			SENTRY_DSN: process.env.SENTRY_DSN,
    }),
    process.env.NODE_ENV === 'development' && new BundleAnalyzerPlugin({
		  analyzerMode: 'static',
		  reportFilename: 'bundle-analysis.html',
		  generateStatsFile: true,
		  openAnalyzer: false,
		}),
  ].filter(Boolean),
};

module.exports = process.env.NODE_ENV === 'development' ? config : { 
  ...config,
	...{ optimization: process.env.IS_SSR === 'true' ? {
    minimize: false,
  } : {
      minimize: true,
      moduleIds: 'deterministic',
      minimizer: [
        new TerserPlugin({
          terserOptions: {
            warnings: false,
            compress: {
              comparisons: false,
            },
            parse: {},
            mangle: true,
            output: {
              comments: false,
              ascii_only: true,
            },
          },
          parallel: true,
        }),
      ],
      nodeEnv: process.env.NODE_ENV,
      sideEffects: true,
      concatenateModules: true,
      runtimeChunk: 'single',
      splitChunks: {
        chunks: 'all',
        cacheGroups,
      },
    },
  },
};

Хьюстон, ви хэв э проблем

Когда с коллегой сели отлаживать конфиг Webpack.

Когда с коллегой сели отлаживать конфиг Webpack.

Вот и настал момент осознания неприятного факта: наш некогда новый проект, сотканный из последних версий библиотек и best practices, почему-то превратился в тухлую легасятину. Конфигурация Webpack стала крайне запутанной и совершенно неподдерживаемой. Релизы проходят с проблемами, участники команды выгорают.

И бизнес, и разработка понимают, что с этим срочно нужно что-то сделать. Пришло время рефакторинга. Но куда же двигаться?

Возможное решение проблемы

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

0e810a6c8be8eabcbaa357b37ce3af3d.jpg

Под нашу задачу хорошо подходит порождающий паттерн «Строитель» (builder). Определение:

Строитель — порождающий паттерн, отделяет конструирование сложного объекта от его представления, так что в результате одного и того же процесса конструирования могут получаться разные представления.

Как это всё соотносится с нашей задачей? Как раз использование паттерна «Строитель» позволит нам «разложить по полочкам» все загрузчики, плагины и ветвления. Итак, приступим. У нас есть некая начальная заготовка конфигурации Webpack (я взял за основу Webpack 5-й версии).

Hidden text

const config = {
    mode: 'development',
    name: 'awesome-real-estate-config',
    devtool: false,
    bail: false,
    entry: [],
    output: {},
    optimization: {
      minimize: false,
      moduleIds: 'named',
    },
    performance: false,
    plugins: [],
    module: {
      strictExportPresence: true,
      rules: [],
    },
    resolve: {
      extensions: ['.tsx', '.jsx', '.ts', '.js'],
      mainFields: ['browser', 'es2015', 'module', 'main'],
      modules: ['node_modules'],
      plugins: [new TsconfigPathsPlugin()],
    },
  };

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

  • addLoader — добавляет в конфигурацию загрузчик;

  • addPlugin — добавляет в конфигурацию плагин;

  • build — выдаёт готовый конфигурационный объект Webpack.

Все эти публичные методы (кроме build) способны объединяться в цепочки вызовов. Код строителя конфигурации выглядит как-то так:

Основной код строителя

const path = require('path');
const fs = require('fs');
const TerserPlugin = require('terser-webpack-plugin');
const { TsconfigPathsPlugin } = require('tsconfig-paths-webpack-plugin');

const publicUrlOrPath = process.env.STATIC_PATH || '/';
const outputPath = path.resolve(`${__dirname}/dist/`);

class DefaultBuilder {
  buildMode = 'development';

  useSourcemap = false;

  #loaders = [];

  #plugins = [];

  config = {
    mode: 'development',
    name: 'awesome-real-estate-config',
    devtool: false,
    bail: false,
    entry: [],
    output: {},
    optimization: {
      minimize: false,
      moduleIds: 'named',
    },
    performance: false,
    plugins: [],
    module: {
      strictExportPresence: true,
      rules: [],
    },
		resolve: {
      extensions: ['.tsx', '.jsx', '.ts', '.js'],
      mainFields: ['browser', 'es2015', 'module', 'main'],
      modules: ['node_modules'],
      plugins: [new TsconfigPathsPlugin()],
    },
  };

  /**
   * Конструктор сборщика
   * @param {string} buildMode - режим сборки ('development', 'production')
   *
   */
  constructor(buildMode) {
    this.config.mode = buildMode;
    this.buildMode = buildMode;
    this.config.devtool =
      buildMode === 'development' ? false : 'eval-cheap-module-source-map';
    this.config.bail = buildMode === 'production';
    this.#configureOutput();
  }

  /**
   * Добавляет загрузчик в список загрузчиков Webpack
   * @param {Object|Function} loaderConfig - конфигурация лоадера
   *
   * @returns {WebpackBuilder}
   *
   */
  addLoader(loaderConfig) {
    let loader;
    if (typeof loaderConfig === 'function') {
      loader = loaderConfig(this.buildMode, this.#isDevelopment());
    } else {
      loader = loaderConfig;
    }

    this.#loaders = [...this.loaders, loader];

    return this;
  }

  /**
   * Добавляет плагин в список плагинов Webpack
   * @param {Object|Function} pluginConfig - конфигурация плагина
   *
   * @returns {WebpackBuilder}
   *
   */
  addPlugin(pluginConfig) {
    let plugin;
    if (typeof pluginConfig === 'function') {
      plugin = pluginConfig(this.buildMode, this.#isDevelopment());
    } else {
      plugin = pluginConfig;
    }

    this.#plugins = [...this.#plugins, plugin];

    return this;
  }

  /**
   * Собирает конфигурацию Webpack, основной метод
   *
   * @returns {Object}
   *
   */
  build() {
    const loadersConfig = [{ oneOf: this.#loaders }];
    this.config.module.rules = [...this.config.module.rules, ...loadersConfig];
    this.config.plugins = [...this.config.plugins, ...this.#plugins];

    return this.config;
  }
 
  /**
   * Настраивает выходную конфигурацию
   *
   * @returns {Object}
   *
   */
  #configureOutput() {
    const outputConfig = {
      path: this.#isProduction() ? outputPath : undefined,
      pathinfo: this.#isDevelopment(),
      filename: this.#isDevelopment()
        ? '[name].js'
        : '[name].[contenthash:8].js',
      publicPath: publicUrlOrPath,
    };

    this.config.output = { ...this.config.output, ...outputConfig };
  }
  
  /**
   * Очевидно определяет, является ли текущий режим работы
   * "Строителя" development-режимом
   *
   * @returns {boolean}
   *
   */
  #isDevelopment() {
    return this.buildMode === 'development';
  }

  /**
   * Определяет, является ли текущий режим работы
   * "Строителя" production-режимом
   *
   * @returns {boolean}
   *
   */
  #isProduction() {
    return this.buildMode === 'production';
  }
}

class DevelopmentBuilder extends DefaultBuilder {
  #port;

  #host;

  /**
   * Конструктор сборщика. Устанавливает хост и порт
   * для webpack-dev-server
   *
   * @param {string} host - хост для dev-сервера
   * @param {number} port - порт для dev-сервера
   *
   */
	constructor(host, port) {
    super('development');

    this.#host = host;
    this.#port = port;
    this.#configureWebServer();
  }

  #configureWebServer() {
    this.config.devServer = {
      port: this.#port,
      host: this.#host,
      historyApiFallback: true,
      open: true,
      liveReload: true,
      hot: true,
      server: {
        type: 'spdy',
        options: {
          key: fs.readFileSync(path.join(__dirname, '.ssl/key.key')),
          cert: fs.readFileSync(path.join(__dirname, '.ssl/cert.cert')),
        },
      },
    };

    return this;
  }
}

class ProductionBuilder extends DefaultBuilder {
  #useSourcemap;

  constructor(useSourcemap = false) {
    super('production');
		// иногда бывает полезно собрать с сорсмапами для отладки
    this.#useSourcemap = useSourcemap;
    this.#configureDevtool();
    this.#configureOptimization();
  }

	/**
   * Настраиваем минификацию, а также разбиение бандла на чанки
   */
  #configureOptimization() {
    const vendorCacheGroups = {
      axios: 'axios',
      'date-fns': 'date-fns',
      lodash: 'lodash.*?',
      react: '(react|react-dom)',
      rxjs: 'rxjs',
      sentry: '@sentry',
    };
    const cacheGroups = {
      ...Object.fromEntries(
        Object.entries(vendorCacheGroups).map(([name, libPath]) => [
          name,
          {
            name,
            test: new RegExp(`/node_modules/${libPath}/.*\\.js$`),
            priority: 1,
            enforce: true,
          },
        ]),
      ),

      vendors: {
        name: 'vendors',
        test: /\/node_modules\//,
        priority: 0,
        enforce: true,
      },
    };

    const optimization = {
      minimize: true,
      moduleIds: 'deterministic',
      minimizer: [
        new TerserPlugin({
          terserOptions: {
            warnings: false,
            compress: {
              comparisons: false,
            },
            parse: {},
            mangle: true,
            output: {
              comments: false,
              ascii_only: true,
            },
          },
          parallel: true,
        }),
      ],
      nodeEnv: this.buildMode,
      sideEffects: true,
      concatenateModules: true,
      runtimeChunk: 'single',
      splitChunks: {
        chunks: 'all',
        cacheGroups,
      },
    };

    this.config.optimization = { ...this.config.optimization, ...optimization };
  }

	/**
   * Включаем в бандл генерацию сорсмапов, если это нужно
   */
  #configureDevtool() {
    if (this.#useSourcemap) {
      this.config.devtool = 'eval-cheap-module-source-map';
    }
  }
}

class ProductionSSRBuilder extends DefaultBuilder {
  constructor() {
    super('production');
  }
}

module.exports = {
  DevelopmentBuilder,
  ProductionBuilder,
  ProductionSSRBuilder
};

Кажется, что кода много, но если вглядеться, то у нас есть три строителя: DevelopmentBuilder — для локальной разработки, ProductionBuilder — для создания эксплуатационных сборок, а также ProductionSSRBuilder для сборки SSR-проекта. Сложность скрывается внутри этих классов, но их использование довольно простое и лаконичное. Сами конфигурации загрузчиков и плагинов также положим в отдельные модули:

Конфигурация загрузчиков Webpack

module.exports = {
  tsLoader: {
    test: /\.(ts|tsx)$/,
    loader: 'ts-loader',
    options: { transpileOnly: true },
    exclude: /node_modules/
  },
  svgLoader: {
    test: /\.svg$/,
    use: [{
        loader: '@svgr/webpack',
        options: { babel: false }
      }]
  },
  cssLoader: {
    test: /\.css$/,
    use: [
      'style-loader',
      'css-loader'
    ]
  },
  scssModuleLoader: {
    test: /\.module\.scss$/,
    sideEffects: true,
    use: [
      'style-loader',
      {
        loader: 'css-loader',
        options: {
          importLoaders: 3,
          modules: { localIdentName: '[local]_[hash:base64:5]' }
        }
      },
      'postcss-loader',
      'resolve-url-loader',
      'sass-loader'
    ],
    exclude: /node_modules/
  },
  scssLoader: {
    test: /\.scss$/,
    sideEffects: true,
    use: [
      'style-loader',
      {
        loader: 'css-loader',
        options: {}
      },
      'postcss-loader',
      'resolve-url-loader',
      'sass-loader'
    ],
    exclude: [
      /node_modules/,
      /\.module\.scss$/
    ]
  },
  assets: {
    test: /\.(gif|png|jpg|jpeg|webp)$/i,
    type: 'asset',
    exclude: /node_modules/
  }
};

Конфигурация плагинов Webpack

const path = require('path');
const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin');
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');
const htmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
  reactRefreshPlugin: new ReactRefreshWebpackPlugin(),
  forkTsCheckerPlugin: new ForkTsCheckerWebpackPlugin(),
  htmlWebpackPlugin: new htmlWebpackPlugin({
    filename: 'index.html',
    template: path.resolve('public', 'index.html')
  }),
  definePlugin: new webpack.DefinePlugin({
    YM_ID: process.env.YANDEX_METRICA_ID,
    SENTRY_DSN: process.env.SENTRY_DSN
  }),
  bundleAnalyzerPlugin: new BundleAnalyzerPlugin({
    analyzerMode: 'static',
    reportFilename: 'bundle-analysis.html',
    generateStatsFile: true,
    openAnalyzer: false
  })
};

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

Пример development-конфигурации

const { DevelopmentBuilder } = require('./webpackBuilder');
const {
  tsLoader,
  svgLoader,
  cssLoader,
  scssModuleLoader,
  scssLoader,
  assets,
} = require('./loaders');
const {
  reactRefreshPlugin,
  forkTsCheckerPlugin,
  htmlWebpackPlugin,
  bundleAnalyzerPlugin,
} = require('./plugins');

const builder = new DevelopmentBuilder('localhost', 8282);

const webpackConfig = builder
  .addLoader(tsLoader)
  .addLoader(svgLoader)
  .addLoader(cssLoader)
  .addLoader(scssModuleLoader)
  .addLoader(scssLoader)
  .addLoader(assets)
  .addPlugin(reactRefreshPlugin)
  .addPlugin(forkTsCheckerPlugin)
  .addPlugin(htmlWebpackPlugin)
  .addPlugin(bundleAnalyzerPlugin)
  .build();

module.exports = webpackConfig;

Пример production-конфигурации

const { ProductionBuilder } = require('./webpackBuilder');
const {
  tsLoader,
  svgLoader,
  cssLoader,
  scssModuleLoader,
  scssLoader,
  assets,
} = require('./loaders');
const {
  forkTsCheckerPlugin,
  htmlWebpackPlugin,
  definePlugin,
} = require('./plugins');

const builder = new ProductionBuilder();

const webpackConfig = builder
  .addLoader(tsLoader)
  .addLoader(svgLoader)
  .addLoader(cssLoader)
  .addLoader(scssModuleLoader)
  .addLoader(scssLoader)
  .addLoader(assets)
  .addPlugin(forkTsCheckerPlugin)
  .addPlugin(htmlWebpackPlugin)
  .addPlugin(definePlugin)
  .build();

module.exports = webpackConfig;

Пример production-ssr-конфигурации

const { ProductionSSRBuilder } = require('./webpackBuilder');
const {
  tsLoader,
  svgLoader,
  cssLoader,
  scssModuleLoader,
  scssLoader,
  assets,
} = require('./loaders');
const {
  forkTsCheckerPlugin,
  htmlWebpackPlugin,
  definePlugin,
} = require('./plugins');

const builder = new ProductionSSRBuilder();

const webpackConfig = builder
  .addLoader(tsLoader)
  .addLoader(svgLoader)
  .addLoader(cssLoader)
  .addLoader(scssModuleLoader)
  .addLoader(scssLoader)
  .addLoader(assets)
  .addPlugin(forkTsCheckerPlugin)
  .addPlugin(htmlWebpackPlugin)
  .addPlugin(definePlugin)
  .build();

module.exports = webpackConfig;

Теперь вся конфигурация у нас «разложена по полочкам», такой код легко читать. Мы избавились от ветвлений, spread-операторов и прочего визуального мусора. Любой разработчик в команде (в том числе и новичок) легко разберётся, как устроена конфигурация Webpack и как в неё вносить изменения.

Вывод

86b96a3adcd6b02ab8a0e7e48e12f17f.jpg

Хоть JS и не является классическим ООП-языком, однако многие шаблоны объектно-ориентированного проектирования вполне к нему применимы. Закончу очевидной мыслью: знание фундаментальных дисциплин, таких как шаблоны проектирования, алгоритмы и структуры данных всегда полезно и стоит потраченного на изучение времени.

© Habrahabr.ru