Децентрализованная конфигурация 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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *