Довольно частая ситуация, когда с ростом проекта растёт и сложность его сборки. Широкий зоопарк технологий, сторонние компоненты, библиотеки, линтеры, серверный рендеринг и нюансы, связанные с конкретным проектом, — всё это в итоге приводит к тому, что конфигурация сборки достигает более тысячи строк.

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

Но если говорить о конфигурации сборки, то такая декомпозиция скорее редкость, и в больших проектах часто можно встретить огромные webpack.config.js, модификация которых может доставить немало проблем и привести к ошибкам.

Если вам хочется сделать работу со сборкой проще и надёжнее при модификациях, то добро пожаловать под кат.

На старте мы имеем большой файл конфигурации webpack.config.js, в котором описана вся сборка. Примерно такой:

webpack.config.js
const path = require('path');
const webpack = require('webpack');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin');
const TerserPlugin = require('terser-webpack-plugin');
const CopyPlugin = require('copy-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = function (env) {
	env = env || {};

	const isDev = !!env.development;
	const isProd = !!env.production;

	const config = {
		mode: isProd ? 'production' : 'development',

		devtool: 'source-map',

		entry: {
			app: [
				'./src/index.tsx'
			],
		},

		output: {
			filename: isDev ? 'js/[name].js' : 'js/[name]-[chunkhash:7].js',
			path: path.resolve(__dirname, '../dist/static/'),
			publicPath: '/'
		},

		module: {
			rules: [
				{
					test: /\.tsx?$/,
					use: [
						{
							loader: 'thread-loader',
							options: {
								workers: require('os').cpus().length - 2,
							},
						},

						{
							loader: 'ts-loader',
							options: {
								configFile: path.resolve(__dirname, 'tsconfig.json'),
								happyPackMode: true,
								transpileOnly: true,
								onlyCompileBundledFiles: true,
							}
						}
					],
				},

				{
					test: /\.jsx?$/,
					use: [
						{
							loader: 'babel-loader',
							options: {
								presets: ['@babel/preset-env']
							}
						}
					],
				},

				{
					test: /\.css$/,
					use: [
						{
							loader: MiniCssExtractPlugin.loader,
							options: {
								publicPath: './',
							},
						},
						'css-loader',
						'postcss-loader',
					],
				},

				{
					test: /\.scss/,
					use: [
						{
							loader: MiniCssExtractPlugin.loader,
							options: {
								publicPath: './',
							},
						},
						{
							loader: 'css-loader',
							options: {
								importLoaders: 1,
								modules: {
									localIdentName: '[name]_[local]_[hash:base64:5]',
								},
								sourceMap: true,
							},
						},
						{
							loader: 'postcss-loader',
							options: {
								sourceMap: true,
							},
						},
						'sass-loader',
					],
				},

				{
					test: /\.(woff|woff2|eot|ttf)$/,
					use: 'file-loader?name=assets/fonts/[name].[hash].[ext]',
				},

				{
					test: /\.svg$/,
					include: /src\/assets\/icons/,
					use: [
						{
							loader: 'svg-sprite-loader',
							options: {
								symbolId: 'svg-icon-[name]',
							},
						},
						{
							loader: 'svgo-loader',
							options: {
								plugins: [
									{ removeTitle: true },
									{ removeUselessStrokeAndFill: true },
									{ removeComments: true },
									{ convertPathData: false },
								]
							},
						},
					],
				},

				{
					test: /\.svg$/,
					exclude: /src\/assets\/icons/,
					use: [
						{
							loader: 'url-loader',
							options: {
								limit: 10,
								mimetype: 'image/png',
								name: '[name].[hash:base64:5].[ext]',
							},
						},
						{
							loader: 'svgo-loader',
							options: {
								plugins: [
									{ removeTitle: true },
									{ removeUselessStrokeAndFill: true },
									{ removeComments: true },
									{ convertPathData: false },
								]
							},
						},
					],
				},

				{
					test: /\.(png|jpg|gif)$/,
					use: 'url-loader?limit=10&mimetype=image/[ext]&name=images/[name].[hash:base64:5].[ext]',
				},
			],
		},

		resolve: {
			extensions: ['.ts', '.tsx', '.js', '.jsx'],
			alias: {
				'@components': path.resolve(__dirname, '../src/components'),
			},
			plugins: [new TsconfigPathsPlugin()]
		},

		optimization: {
			splitChunks: {
				cacheGroups: {
					vendor: {
						test: (item) => /node_modules\/.*/.test(item.userRequest),
						name: 'vendor',
						chunks: 'initial',
						enforce: true,
					},
					icons: {
						test: (item) => /(base\/)?src\/assets\/icons\/.*/.test(item.userRequest),
						name: 'icons',
						chunks: 'initial',
						enforce: true,
					},
				}
			},

			minimizer: [
				new TerserPlugin({
					parallel: true,
					terserOptions: {
						output: {
							comments: false,
						},
						compress: {
							passes: 3,
							unused: true,
							dead_code: true,
							drop_debugger: true,
							conditionals: true,
							evaluate: true,
							sequences: true,
							booleans: true,
						}
					},
				}),
			],
		},

		plugins: [
			new MiniCssExtractPlugin({
				filename: isDev ? 'css/[name].css' : 'css/[name]-[chunkhash:7].css'
			}),

			new CopyPlugin({
				patterns: [
					{ from: './src/assets/static', to: './static' },
				],
			}),

			new webpack.DefinePlugin({
				'process.env.NODE_ENV': JSON.stringify(isDev ? (process.env.NODE_ENV || 'development') : 'production')
			}),

			new HtmlWebpackPlugin({
				template: './src/index.html',
			}),
		],

		stats: 'errors-only',
	};

	if (isDev) {
		config.devServer = {
			contentBase: path.join(__dirname, '../dist'),
			port: 3000,
			compress: true,
			hot: true,
			historyApiFallback: true,
			disableHostCheck: true,
			proxy: [
				{
					context: [],
					target: 'http://api.example.com',
					changeOrigin: true,
					secure: false,
					onProxyReq: (proxyReq) => {
						proxyReq.setHeader('Origin', 'http://api.example.com');
					},
					cookieDomainRewrite: {
						'*': 'localhost'
					},
				}
			]
		};
	}

	return config;
};

Этот пример что-то вроде «джентльменского набора» для простоты понимания. Он может быть чуть меньше при определённых условиях, но скорее всего будет ощутимо больше в зависимости от сложности самого проекта.

Для начала создадим директорию webpack и файл index.js в ней. И перенесём всё содержимое webpack.config.js в этот файл. В самом же webpack.config.js оставим только подключение этого нового файла:

module.exports = require('./webpack');

Это нужно, чтобы вся конфигурация была сосредоточена внутри директории webpack, и чтобы во всех последующих require не дублировать директорию webpack в пути.

Далее давайте договоримся так: под каждый элемент верхнего уровня объекта конфигурации мы создаём свою директорию и файл index.js, в каждом из которых описываем соответствующую часть конфигурации. Например entry/index.js:

module.exports = {
	app: [ './src/index.tsx' ],
};

Если требуется передавать дополнительные параметры (например isDev), то оборачиваем модуль в функцию, принимающую требуемые нам параметры. Например, output/index.js:

const path = require('path');

module.exports = (isDev) => ({
	filename: isDev ? 'js/[name].js' : 'js/[name]-[chunkhash:7].js',
	path: path.resolve(__dirname, '../../dist/static/'),
	publicPath: '/'
});

В главном файле webpack/index.js просто собираем их вместе:

module.exports = function (env) {
	env = env || {};

	const isDev = !!env.development;
	const isProd = !!env.production;

	const config = {
		mode: isProd ? 'production' : 'development',
		devtool: 'source-map',
		entry: require('./entries'),
		output: require('./output')(isDev),
		module: require('./module'),
		resolve: require('./resolve'),
		optimization: require('./optimization'),
		plugins: require('./plugins')(isDev),
		stats: 'errors-only',
	};

	if (isDev) {
		config.devServer = require('./dev-server');
	}

	return config;
};

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

Например, webpack/module/index.js может выглядеть так:

module.exports = {
	rules: [
		require('./loaders/typescriptLoader'),
		require('./loaders/jsLoader'),
		require('./loaders/cssLoader'),
		require('./loaders/sassLoader'),
		require('./loaders/fontLoader'),
		require('./loaders/svgLoader'),
		require('./loaders/imageLoader'),
	]
};

Для примера webpack/module/loaders/typescriptLoader.js будет таким:

module.exports = {
	test: /\.tsx?$/,
	use: [
		{
			loader: 'thread-loader',
			options: {
				workers: require('os').cpus().length - 2,
			},
		},

		{
			loader: 'ts-loader',
			options: {
				configFile: path.resolve(__dirname, 'tsconfig.json'),
				happyPackMode: true,
				transpileOnly: true,
				onlyCompileBundledFiles: true,
			}
		}
	],
};

Также не забываем, что при необходимости всегда можно обернуть любой из подмодулей конфигурации в функцию и передать туда необходимые опции.

Теперь остаётся только подключить его в webpack.config.js:

require('./webpack');

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

Например, один из распространённых случаев — отдельная сборка для серверного рендеринга. Для этого можно описать две различных сборки, каждая из которых будет содержать свою специфику, но в общих чертах будет выглядеть как webpack/index.js. Например, webpack/client.js и webpack/server.js для клиентской и серверной сборки соответственно.

А webpack/index.js в свою очередь берёт на себя роль «собирателя» этих сборок, то есть на основе тех или иных признаков решает, какую сборку (или все сразу) нужно запустить в тот или иной момент времени.

Например, он может это делать на основании параметров, переданных в команду запуска:

module.exports = function (env, options) {
	const buildParams = options.build.split(',');
	const builds = [];

	if (buildParams.includes('client')) {
		builds.push(require('./client')(env, options));
	}

	if (buildParams.includes('server')) {
		builds.push(require('./server')(env, options));
	}

	return builds;
};

В package.json для различных вариантов сборки можно добавить отдельные команды в секцию scripts:

{
	...
  "scripts": {
		...
		"build-client": "webpack --build client",
		"build-server": "webpack --build server",
		"build": "webpack --build client,server",
  },
  ...
}

Если команда разрабатывает одновременно несколько проектов с примерно одним стеком и сборкой, то можно вынести конфигурацию webpack в отдельный npm-пакет. Его можно хранить как в публичном npm registry, так и во внутреннем, если не хочется выносить вовне конфигурацию своей сборки.

Это даёт несколько приятных преимуществ:

  • Избавление от дублирования конфигурации в разных проектах. При исправлении ошибок, оптимизации сборки и прочего все изменения будут касаться сразу всех проектов. Не придётся делать это руками для каждого.

  • Обновление зависимостей, касающихся сборки, будет проходить централизовано и относиться сразу ко всем проектам.

  • Сами зависимости будут спрятаны за фасадом нашего npm-модуля, что позволит визуально разгрузить package.json конечных проектов.

Всё, что потребуется для подключения сборки к конечному проекту, — проинсталлировать пакет с конфигурацией:

$ npm install my-best-webpack-config --save-dev

И подключить его в webpack.config.js:

require('my-best-webpack-config');

В том случае, если ваши проекты всё-таки имеют небольшие отличия в сборке, то их можно разрулить опциями в webpack.config.js:

const { getConfig } = require('my-best-webpack-config');

module.exports = getConfig({
	option1: 'value1',
	option2: true,
  ...
});

Предварительно проэкспортировав из модуля с конфигурацией функцию getConfig и обработав опции.

Также можно внести локальные изменения в сборку, расширив объект конфигурации:

const config = require('my-best-webpack-config');

module.exports = {
  ...config,
  output: {
    ...config.output,
    publicPath: '/static',
  },
};

Или же скомбинировать передачу опций и расширение объекта конфигурации:

const { getConfig } = require('my-best-webpack-config');

const config = getConfig({
	option1: 'value1',
	option2: true,
  ...
});

module.exports = {
  ...config,
  output: {
    ...config.output,
    publicPath: '/static',
  },
};

Заключение

Мы рассмотрели подход, в котором монолитная конфигурация webpack разделяется на мелкие составляющие, а при необходимости из них комбинируются несколько кастомных конфигураций. Дополнительно, если есть потребность, конфигурацию можно вынести в отдельный npm-модуль и использовать на разных проектах.

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

Если у вас есть свои рецепты для упрощения больших и сложных сборок, то добро пожаловать в комментарии.

Комментарии (17)


  1. eshimischi
    02.02.2022 12:14
    +3

    Меня давно уже стала напрягать вся эта портянка в конфиге webpack, я просто отказался от него в пользу Rollup, а теперь Vite. Проще стало конфигурировать, много полезных дополнений и будь то Vue, React или просто приложение на vanilla сборка проходит быстро и четко.


    1. pavelsc
      02.02.2022 12:51
      +6

      Приходишь на проект, а там вместо православного вебпака какой-то Витя. Пилителям лендосов конечно не сидится ровно, дело жизни вместо двух строчек конфига написать одну, но все остальные вебпак конфигурируют раз в 2-3 года, а то и реже.


      1. Alexufo
        02.02.2022 15:00
        +1

        да дело то не только в количестве настроек, но и в скорости


      1. dimti
        02.02.2022 16:44

        Не, знаю, насчёт лендосов. Они там неплохо и на jQuery силами самих верстальщиков собираются, с scss-ом.

        Но насчёт vite для среднестастияеского интернет-магазина с грудой typoscript, неплохой сборщик.

        Стартует на дев-машине быстро. Все что нужно - лоадит. SSR к Vite скоро начнем постигать на проде. Это явно речь не о лендосе уже.

        Webpack Encore от Symphony ещё куда ни шло. И портянки перестали быть чем-то необходимым. Скорость: на дев-машине - проект среднего размера пересобирался очень долго. С Vite эти проблемы перестали существовать. Кмк, может я не разбираюсь, и webpack тоже может за мгновение стартовать сборку всю на дев.


        1. Ulibka
          03.02.2022 12:16

          Вы не пробовали storybook - к Vite подключается ?
          Можете конфигурацией поделиться?

          emotion или styled components под Vite работают ?

          Eslint - там тоже есть ?


          1. dimti
            03.02.2022 16:19
            +1

            В текущей реализации проекта (без SSR) конфиг выглядит так: https://paste.slave.dimti.ru/?0e723a34823f8d4e#57wQbHT6bCMaggTb31F6th1N7AfF9z9MXET4LDjgCBVy. Мой коллега Александр подключил туда Vue и небольшой минификатор изображений.
            Eslint лежит рядом: https://paste.slave.dimti.ru/?8abe3f83b6f8a93e#DdswMt6FgQdYc9894JpsEieKK9nDk7vsQ6sFJGJZWDiL

            Видимо Eslint сам по себе цепляется (у меня к IDE прицеплен, но насчет сборщика я не уверен). Позову помощь.


            1. Ulibka
              05.02.2022 10:21

              Большое спасибо!

              Конфигурация выглядит не намного меньше такой же webpack :)


              1. dimti
                05.02.2022 10:56

                Это да. По сравнению с ними - webpack encore выглядет минималистично: https://paste.slave.dimti.ru/?9b7c06089ea6c5b6#3b4WyKt8hNn1KVAEoQPM9dfpTTfKre5fXHw9gBuY7RVS

                Начали смотреть в сторону storybook, говорят у него родная интеграция с Vite. Только до конца не уверены нужно ли это нам. Я во фронтенде не очень силен (как показала череда недавних собеседований на позицию Fullstack: я, оказывается, не знаю ES6 и flex-wrap) - поэтому искренне не могу вовлечся во все новые техники, применимые к проекту по части nodejs. Может это конечно и есть "проклятие" фуллстеков, что они не могут как следует вовлечся в какую-то одну область.


    1. lexey111
      02.02.2022 21:59
      +1

      Я для PoCов и мелких проектов ушёл на esbuild. Скорость сборки в дев-режиме оооочень приятная, как и размер зависимостей. Конечно, когда подключаешь линтинг, tsc-чек, компрессию, tailwind постпроцессинг и всё прочее, то падает, но всё равно остаётся в разы быстрее вебпака.

      Тем не менее, когда уходит в прод - делаю вебпак-конфиг, из соображений, что народ уже писал: чтоб стандартный дев разобрался.


  1. DmitryKazakov8
    02.02.2022 17:24
    +1

    Вот здесь https://habr.com/ru/post/506636/ описывал намного более продвинутый вариант конфигурирования - конфиг вебпака протипизирован TS, разбит по папкам, файлы в которых имеют семантичные имена. Также там описан отличный вариант проброса env-параметров, рецепт параллельной сборки для фронта и для сервера, запуск сервера после билда.

    Но даже тот вариант уже подустарел. А непротипизированное решение с webpack --build вместо билд-файла и продуманности системы файлов, колбеков и переменных - это просто самый первый и очевидный шаг к построению вебпакового конфига.

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


  1. vasyakolobok77
    02.02.2022 23:27

    Когда вы выносите часть код в отдельную библиотеку вы только добавляете +Х к сложности своего проекта. Эту новую зависимость нужно поддерживать, версионировать, делать регресс при изменениях. Для части проектов этот конфиг избыточен, для части он недостаточек. И вообще, мы не серьезно говорим о том, чтобы вынести конфиг в отдельный репо, Карл? :-)


    1. ameli_anna_kate Автор
      03.02.2022 01:59
      +3

      Выносить в отдельную библиотеку имеет смысл если у вас много проектов, имеющих примерно один и тот же стек и условия сборки. Понятное дело, что если проект один или несколько это не имеет большого смысла и пойдёт только во вред, но когда их количество приближается к десятку или переваливает за это значение, то такой подход более чем имеет право на существование. Для нового проекта не придётся копипастить конфиги на старте - достаточно будет установить одну зависимость. Также не придётся вносить изменения во все проекты, если вдруг необходимо будет обновить одну из зависимостей. При этом, если на каком-либо из проектов не захочется вот прямо сейчас обновлять и поддерживать новую версию (если она вдруг потребует дополнительных изменений), то всегда можно зафиксироваться на текущей версии. Все издержки по версионированию ложатся на npm. А поддержка и регресс ничем не будут отличаться от того как если бы эти конфиги были непосредственно в самом проекте.


      1. lexey111
        03.02.2022 10:37
        +1

        Тут есть одна ловушка. На одном проекте был похожий сетап, скрипты сборки, подключаемые как сабмодули + локальный конфиг, но не суть, идея та же. Было десятка два-три репозиториев микросервисов (условно), которые пользовались этим централизованным решением.

        Так вот, после определённого порога накопленные специфические для конкретного проекта требования становятся комбинаторным взрывом. Слишком много опций сборки, поддержки специальных параметров конфига, эдж-кейсов, комбинаций настроек. Особенно когда есть просто сборки, дев-сборки, тестовые, а/б, условные фича-тоглы, экспериментальные проекты с нечеловеческими конфигами и т.п.

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


        1. DmitryKazakov8
          03.02.2022 11:49

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

          Если проект экспериментальный - то делать полностью свой конфиг.


          1. lexey111
            03.02.2022 11:54

            Да как и везде - вопрос баланса и здравого смысла.


          1. vasyakolobok77
            03.02.2022 21:30
            -1

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

            В итоге вы тратите время на анализ и поддержку этого общего / частного. Вы занимаетесь чем угодно, но не разработкой проекта.

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


  1. uyrij
    03.02.2022 09:27

    можно webpack.config.mjs и отказаться от require в пользу import. Если в проекте модули. Конфиг из статьи похож на легаси webpack4