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