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

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

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

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

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

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

Источник: https://habr.com/ru/company/funcorp/blog/538982/


Интересные статьи

Интересные статьи

Меня зовут Борзов Олег, я техлид команды разработки CRM-системы для менеджеров ипотечного кредитования крупного банка. Сегодня я хочу рассказать, как наша команда разработки упрощает часть рабочих про...
.NET Foundation – независимая организация, основанная Microsoft с целью поддержки open source проектов на платформе DotNet. Под их крылом на данный момент собралось множество библиотек, некоторые из к...
Добавляйте счётчики для флагов ошибок.Используйте хеши или контрольную сумму (если хеши не могут быть использованы) для проверки целостности кода и выдачи предупреждения, если он был изменён.Разбираем...
Привет, Хабр! Меня зовут Александр. 17 лет в КРОК. В основном я занимаюсь разработкой и внедрением заказного ПО, хранилищ данных, решений Big Data для бизнеса и госсектор...
В пятой версии сборщика Webpack появился набор плагинов для обмена модулями между Javascript приложениями. Эта статья — краткое описание и пример на основе двух небольших приложени...