Децентрализованная конфигурация webpack или как упростить сборку проекта

image-loader.svg

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

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

Но если говорить о конфигурации сборки, то такая декомпозиция скорее редкость, и в больших проектах часто можно встретить огромные 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-конфига. Поэтому для условного домашнего проекта такой подход может стать только усложнением.

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

© Habrahabr.ru