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