Довольно частая ситуация, когда с ростом проекта растёт и сложность его сборки. Широкий зоопарк технологий, сторонние компоненты, библиотеки, линтеры, серверный рендеринг и нюансы, связанные с конкретным проектом, — всё это в итоге приводит к тому, что конфигурация сборки достигает более тысячи строк.
Если провести аналогию с обычным кодом, то достижение таких объёмов в рамках одного модуля/класса/компонента/сущности становится сигналом, чтобы заняться декомпозицией и разделить ответственность по более мелким и независимым составляющим.
Но если говорить о конфигурации сборки, то такая декомпозиция скорее редкость, и в больших проектах часто можно встретить огромные 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)
DmitryKazakov8
02.02.2022 17:24+1Вот здесь https://habr.com/ru/post/506636/ описывал намного более продвинутый вариант конфигурирования - конфиг вебпака протипизирован TS, разбит по папкам, файлы в которых имеют семантичные имена. Также там описан отличный вариант проброса env-параметров, рецепт параллельной сборки для фронта и для сервера, запуск сервера после билда.
Но даже тот вариант уже подустарел. А непротипизированное решение с webpack --build вместо билд-файла и продуманности системы файлов, колбеков и переменных - это просто самый первый и очевидный шаг к построению вебпакового конфига.
Но за статью плюс - часто маленькие шаги лучше принимаются и применяются сообществом, что вдохновляет задумываться и улучшать свои подходы к кодированию.
vasyakolobok77
02.02.2022 23:27Когда вы выносите часть код в отдельную библиотеку вы только добавляете +Х к сложности своего проекта. Эту новую зависимость нужно поддерживать, версионировать, делать регресс при изменениях. Для части проектов этот конфиг избыточен, для части он недостаточек. И вообще, мы не серьезно говорим о том, чтобы вынести конфиг в отдельный репо, Карл? :-)
ameli_anna_kate Автор
03.02.2022 01:59+3Выносить в отдельную библиотеку имеет смысл если у вас много проектов, имеющих примерно один и тот же стек и условия сборки. Понятное дело, что если проект один или несколько это не имеет большого смысла и пойдёт только во вред, но когда их количество приближается к десятку или переваливает за это значение, то такой подход более чем имеет право на существование. Для нового проекта не придётся копипастить конфиги на старте - достаточно будет установить одну зависимость. Также не придётся вносить изменения во все проекты, если вдруг необходимо будет обновить одну из зависимостей. При этом, если на каком-либо из проектов не захочется вот прямо сейчас обновлять и поддерживать новую версию (если она вдруг потребует дополнительных изменений), то всегда можно зафиксироваться на текущей версии. Все издержки по версионированию ложатся на npm. А поддержка и регресс ничем не будут отличаться от того как если бы эти конфиги были непосредственно в самом проекте.
lexey111
03.02.2022 10:37+1Тут есть одна ловушка. На одном проекте был похожий сетап, скрипты сборки, подключаемые как сабмодули + локальный конфиг, но не суть, идея та же. Было десятка два-три репозиториев микросервисов (условно), которые пользовались этим централизованным решением.
Так вот, после определённого порога накопленные специфические для конкретного проекта требования становятся комбинаторным взрывом. Слишком много опций сборки, поддержки специальных параметров конфига, эдж-кейсов, комбинаций настроек. Особенно когда есть просто сборки, дев-сборки, тестовые, а/б, условные фича-тоглы, экспериментальные проекты с нечеловеческими конфигами и т.п.
Ну то есть идея имеет право на жизнь, но ниша её не очень широка и обставлена дополнительными условиями, типа длительного неизменного мейнстрима.DmitryKazakov8
03.02.2022 11:49Никто не мешает вынести общее ядро, а кастомные плагины и лоадеры дописывать уже в конкретных проектах, расширяя дефолтный конфиг. Не обязательно в него запихивать все, если конкретная фича нужна только в одном из десяти проектов.
Если проект экспериментальный - то делать полностью свой конфиг.
vasyakolobok77
03.02.2022 21:30-1Никто не мешает вынести общее ядро, а кастомные плагины и лоадеры дописывать уже в конкретных проектах, расширяя дефолтный конфиг
В итоге вы тратите время на анализ и поддержку этого общего / частного. Вы занимаетесь чем угодно, но не разработкой проекта.
Никто вам не мешает начинать проекты на основе шаблона, т.н. scaffolding, иметь некую базу шаблонов сборки. Это более здравая мысль, нежели искать общее и частное, поддерживать непонятный репо и версионировать его.
uyrij
03.02.2022 09:27можно
webpack.config.mjs и отказаться от require в пользу import. Если в проекте модули. Конфиг из статьи похож на легаси webpack4
eshimischi
Меня давно уже стала напрягать вся эта портянка в конфиге webpack, я просто отказался от него в пользу Rollup, а теперь Vite. Проще стало конфигурировать, много полезных дополнений и будь то Vue, React или просто приложение на vanilla сборка проходит быстро и четко.
pavelsc
Приходишь на проект, а там вместо православного вебпака какой-то Витя. Пилителям лендосов конечно не сидится ровно, дело жизни вместо двух строчек конфига написать одну, но все остальные вебпак конфигурируют раз в 2-3 года, а то и реже.
Alexufo
да дело то не только в количестве настроек, но и в скорости
dimti
Не, знаю, насчёт лендосов. Они там неплохо и на jQuery силами самих верстальщиков собираются, с scss-ом.
Но насчёт vite для среднестастияеского интернет-магазина с грудой typoscript, неплохой сборщик.
Стартует на дев-машине быстро. Все что нужно - лоадит. SSR к Vite скоро начнем постигать на проде. Это явно речь не о лендосе уже.
Webpack Encore от Symphony ещё куда ни шло. И портянки перестали быть чем-то необходимым. Скорость: на дев-машине - проект среднего размера пересобирался очень долго. С Vite эти проблемы перестали существовать. Кмк, может я не разбираюсь, и webpack тоже может за мгновение стартовать сборку всю на дев.
Ulibka
Вы не пробовали storybook - к Vite подключается ?
Можете конфигурацией поделиться?
emotion или styled components под Vite работают ?
Eslint - там тоже есть ?
dimti
В текущей реализации проекта (без SSR) конфиг выглядит так: https://paste.slave.dimti.ru/?0e723a34823f8d4e#57wQbHT6bCMaggTb31F6th1N7AfF9z9MXET4LDjgCBVy. Мой коллега Александр подключил туда Vue и небольшой минификатор изображений.
Eslint лежит рядом: https://paste.slave.dimti.ru/?8abe3f83b6f8a93e#DdswMt6FgQdYc9894JpsEieKK9nDk7vsQ6sFJGJZWDiL
Видимо Eslint сам по себе цепляется (у меня к IDE прицеплен, но насчет сборщика я не уверен). Позову помощь.
Ulibka
Большое спасибо!
Конфигурация выглядит не намного меньше такой же webpack :)
dimti
Это да. По сравнению с ними - webpack encore выглядет минималистично: https://paste.slave.dimti.ru/?9b7c06089ea6c5b6#3b4WyKt8hNn1KVAEoQPM9dfpTTfKre5fXHw9gBuY7RVS
Начали смотреть в сторону storybook, говорят у него родная интеграция с Vite. Только до конца не уверены нужно ли это нам. Я во фронтенде не очень силен (как показала череда недавних собеседований на позицию Fullstack: я, оказывается, не знаю ES6 и flex-wrap) - поэтому искренне не могу вовлечся во все новые техники, применимые к проекту по части nodejs. Может это конечно и есть "проклятие" фуллстеков, что они не могут как следует вовлечся в какую-то одну область.
lexey111
Я для PoCов и мелких проектов ушёл на esbuild. Скорость сборки в дев-режиме оооочень приятная, как и размер зависимостей. Конечно, когда подключаешь линтинг, tsc-чек, компрессию, tailwind постпроцессинг и всё прочее, то падает, но всё равно остаётся в разы быстрее вебпака.
Тем не менее, когда уходит в прод - делаю вебпак-конфиг, из соображений, что народ уже писал: чтоб стандартный дев разобрался.