Всем привет! Один из проектов на работе у нас изначально создан на create-react-app утилите (кстати у меня есть статья по поводу того, что сейчас происходит с CRA и что его ждет в будущем). Встал вопрос по поводу того, можно ли как-то оптимизировать сборку по скорости и весу сжатого проекта, так как есть большие планы на рост проекта и не хотелось бы, чтобы что-то начало тормозить, и этим соответственно я и занялся. Хочу рассказать о том, как все проходило, какие шаги были пройдены и что в итоге получилось. Также в конце приложу код всей конфигурации.

Сразу хотел бы сказать, что это очередная статья по вебпаку с приведенной его настройкой и некоторые могут не увидеть ничего нового, так как тут будет описана последовательность выполненных действий и настроек. Прошу не кидать камни, так как я просто хочу поделиться опытом, возможно даже получить советы по лучшей настройке и, надеюсь, кому-то это все-таки поможет.

Пора эджектить

Первым делом, конечно же, был выполнен eject проекта. После чего я получил кучу папочек и файлов, которые лежали под капотом CRA.

Чтобы отслеживать скорость сборки и сжатый вес я сразу поставил Statoscope в проект. Ставится он очень просто. Устанавливается плагин в проект, импортируется в файл вебпака и вносится в список плагинов. Его всячески можно настраивать, но в данном случае мне не было это нужно.

npm i --save-dev @statoscope/webpack-plugin
...
const StatoscopeWebpackPlugin = require('@statoscope/webpack-plugin').default;

module.exports = {
  ...
  plugins: [
    ...
    new StatoscopeWebpackPlugin()
  ],
  ...
}

Далее при запуске билда проекта у нас дополнительно откроется страница со статистикой проекта. Вот какая стата была получена после eject проекта.

Статистика без донастройки проекта
Статистика без донастройки проекта

Вес проекта в сжатом виде 371,35кб, скорость сборки 36,4 секунды, 1 чанк.

Удаляем лишнее

После эджекта я начал смотреть установленные плагины и пакеты.

Вот список того, что я почистил:

1) Пакет camelcase (использовался в jest файле)
2) Case sensitive paths webpack plugin, который следит за чувствительностью регистра в импортах
3) Пакет prompts
4) Identity obj proxy (в основном нужен для тестирования css модулей, так как надо определять имя объекта как класс, а у нас в проекте Styled-components)
5) Sass-loader
6) Tailwind
7) Semver (для сравнения версии реакт, нужен был только чтобы проверить выше ли 16 версии, в проекте я поднимал версию до 18 и проверка уже точно не понадобится)
8) Sevents, так как это для подписки на уведомления в MacOS
9) Удалил кучу плагинов react-dev-utils, но пакет оставил, так как некоторые вещи, включая отображение в консоли cmd надписей разных цветов не работали (информативные сообщения при хот релоаде, которые настроены в create react app в консоли)

В итоге статистика показала мне уменьшение сжатой сборки на 0,02кб, скорость сборки не изменилась.

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

И я принял волевое решение полностью снести эту сборку и поставить свою..

Начинаем сначала

На моем гитхабе где-то год назад я создал проект со сборкой вебпака, настройками линта и т.п. Сборка вебпака там достаточно стандартная, с настройкой для sass, css-modules и т.п., но без настройки под React Router и сильной оптимизации. Ее я взял за основу новой сборки. Если кому-то интересна эта сборка, то по ссылке можете посмотреть.

Сразу ставлю статоскоп, проверяю стату, не радуюсь)

Статистика новой сборки
Статистика новой сборки

Я начал наворачивать плагины для сжатия и оптимизации сборки.

Вот, что я поставил помимо того, что у меня уже стояло:

1) Terser Plugin. Этот плагин минифицирует и сжимает код, что позволяет хорошо уменьшить размер сборки. (Я даже сразу замерил стату и получил результат, который меня порадовал)

Стата с терсером

2) Css Minimizer Webpack Plugin для оптимизации CSS в сборке.
Первые два плагина ставятся в раздел minimizer вебпака

module.exports = {
  ...
  optimization: {
    minimize: true,
    minimizer: [
      new TerserPlugin(),
      new CssMinimizerWebpackPlugin()
    ],
    ...
  }
  ...
}

3) React Refresh Webpack Plugin. Он нужен больше для разработки, чтобы при hot-reload страница не перезагружалась, если меняется только визуальная составляющая. Помогает сохранять стейт приложения.
4) Hashed Module Ids Plugin. Он составляет хэши модулей в сборке на основе их относительных путей. Больше нужен также для удобства.

После этого я:

  • немного доработал сборку для работы с Styled-Components;

  • добавил в работу с svg лоадер @svgr/webpack , чтобы можно было нормально импортить svg;

  • поставил нужные для проекта alias;

  • добавил historyApiFallback: true, чтобы работал React Router, иначе пути воспринимаются как гет запросы на сервер;

  • прописал client: { overlay: false } , чтобы ошибки линтеров не лезли поверх экрана, так как это очень бесит;

  • немного доработал tscoinfig.

В итоге я получил следующую статистику:

312,01 кб вес сжатой сборки, 28,1 секунд сборка и также 2 чанка.

Учитывая, что сам по себе проект небольшой, в принципе оптимизация выполнена успешно. Единственное, что я решил добавить - это разбиение всех пакетов в отдельные чанки. Для этого переработал блок optimization:

optimization: {
    minimize: true,
    minimizer: [
      new TerserPlugin(),
      new CssMinimizerWebpackPlugin()
    ],
    runtimeChunk: 'single',
    splitChunks: {
      chunks: 'all',
      maxInitialRequests: Infinity,
      minSize: 0,
      cacheGroups: {
        vendor: {
          test: /[\\/]node_modules[\\/]/,
          name(module) {
            const packageName = module.context.match(/[\\/]node_modules[\\/](.*?)([\\/]|$)/) ?? 'package';
            return `npm.${packageName[1].replace('@', '')}`;
          }
        }
      }
    },
  },

После этого получаем следующее:

Вес увеличился до 334,46 кб, сборка стала чуть быстрее и все пакеты лежат в отдельных чанках.

Что в итоге?

В итоге могу сказать, что сборка CRA действительно имеет лишние плагины и сложный код для поддержки вебпака, но при этом вроде как и не так плоха.

Оптимизация получилась, хоть и не супер пупер результативная, но все-таки она есть, проект стал меньше весить в сжатом виде (хотел бы обратить внимание, что вес в не сжатом виде все-таки выше, чем в CRA) и теперь все разбивается на кучу чанков. Сейчас результаты не сильно большие, но думаю, что когда проект будет разрастаться, данная настройка поможет проекту.

Также прикладываю полный код webpack.config.js и tsconfig.json, которые используются для этой сборки.

webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const ESLintPlugin = require('eslint-webpack-plugin');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const StatoscopeWebpackPlugin = require('@statoscope/webpack-plugin').default;
const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin');
const path = require('path');
const TerserPlugin = require('terser-webpack-plugin');
const CssMinimizerWebpackPlugin = require('css-minimizer-webpack-plugin');
const webpack = require('webpack')
const Dotenv = require('dotenv-webpack');
require('dotenv').config({path: path.resolve(__dirname, '.env')})

const mode = process.env.NODE_ENV || "development";
const port = process.env.PORT || 3000;
const devMode = mode === "development";
const target = devMode ? 'web' : 'browserslist';
const devtool = devMode && 'source-map';

module.exports = {
  mode,
  target,
  devtool,
  devServer: {
    port,
    open: true,
    hot: true,
    historyApiFallback: true,
    client: {
      overlay: false
    },
    headers: {
      'Access-Control-Allow-Origin': '*',
      'Access-Control-Allow-Methods': '*',
      'Access-Control-Allow-Headers': '*',
    },
    onBeforeSetupMiddleware(devServer) {
      devMode && require(path.resolve(__dirname, 'src/setupProxy.js'))(devServer.app);
    },
  },
  entry: path.resolve(__dirname, 'src', 'index.tsx'),
  output: {
    path: path.resolve(__dirname, 'build'),
    filename: 'js/[name].[contenthash].bundle.js',
    chunkFilename: 'js/[id].[contenthash].js',
    assetModuleFilename: 'assets/[hash][ext]',
    publicPath: '/'
  },
  optimization: {
    minimize: true,
    minimizer: [
      new TerserPlugin(),
      new CssMinimizerWebpackPlugin()
    ],
    runtimeChunk: 'single',
    splitChunks: {
      chunks: 'all',
      maxInitialRequests: Infinity,
      minSize: 0,
      cacheGroups: {
        vendor: {
          test: /[\\/]node_modules[\\/]/,
          name(module) {
            const packageName = module.context.match(/[\\/]node_modules[\\/](.*?)([\\/]|$)/) ?? 'package';
            return `npm.${packageName[1].replace('@', '')}`;
          }
        }
      }
    },
  },
  resolve: {
    extensions: ['.json', '.jsx', '.tsx', '.ts', '.js', '.mjs'],
    alias: {
      '~components': path.resolve(__dirname, 'src/components'),
      '~hooks': path.resolve(__dirname, 'src/hooks'),
      '~types': path.resolve(__dirname, 'src/types'),
      '~pages': path.resolve(__dirname, 'src/pages'),
      '~utils': path.resolve(__dirname, 'src/utils'),
      '~constants': path.resolve(__dirname, 'src/constants'),
      '~helpers': path.resolve(__dirname, 'src/utils/helpers'),
      '~layouts': path.resolve(__dirname, 'src/layouts'),
      '~api': path.resolve(__dirname, 'src/api'),
    },
  },
  plugins: [
    new Dotenv({ path: path.resolve(__dirname, '.env'), systemvars: true }),
    new HtmlWebpackPlugin({
      template: path.resolve(__dirname, 'public', 'index.html'),
    }),
    new MiniCssExtractPlugin({
      filename: 'css/[name].[contenthash].bundle.css',
    }),
    new CleanWebpackPlugin(),
    new ESLintPlugin({
      extensions: ['ts', 'tsx'],
      exclude: ['/node_modules/', '/.idea/', '/.vscode/'],
    }),
    new ReactRefreshWebpackPlugin({
      overlay: false,
    }),
    new webpack.ids.HashedModuleIdsPlugin(),
    new StatoscopeWebpackPlugin()
  ],
  module: {
    rules: [
      {
        test: /\.(c|sa|sc)ss$/i,
        use: [
          devMode ? 'style-loader' : MiniCssExtractPlugin.loader,
          {
            loader: 'css-loader',
            options: {
              esModule: true,
              importLoaders: 1,
              modules: {
                mode: 'icss'
              },
            },
          },
          {
            loader: 'postcss-loader',
            options: {
              postcssOptions: {
                plugins: [require('postcss-preset-env')],
              },
            },
          },
        ],
      },
      {
        test: /\.woff2?$/i,
        type: 'asset/resource',
        generator: {
          filename: 'fonts/[name].[ext]',
        },
      },
      {
        test: [/\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/],
        type: 'asset',
        parser: {
          dataUrlCondition: {
            maxSize: 10000,
          },
        },
      },
      {
        test: /\.svg$/,
        use: [
          {
            loader: require.resolve('@svgr/webpack'),
            options: {
              prettier: false,
              svgo: false,
              svgoConfig: {
                plugins: [{ removeViewBox: false }],
              },
              titleProp: true,
              ref: true,
            },
          },
          {
            loader: require.resolve('file-loader'),
            options: {
              name: 'static/media/[name].[hash].[ext]',
            },
          },
        ],
        issuer: {
          and: [/\.(ts|tsx|js|jsx|md|mdx)$/],
        },
      },
      {
        test: /\.tsx?$/,
        use: {
          loader: 'ts-loader',
          options: {
            transpileOnly: true
          }
        },
        exclude: /node_modules/,
      },
      {
        test: /\.m?jsx?$/i,
        exclude: /(node_modules|bower_components)/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['@babel/preset-env', '@babel/preset-react'],
          },
        },
      },
    ],
  },
};

tsconfig.json
{
  "compilerOptions": {
    "target": "esnext",
    "lib": [
      "dom",
      "dom.iterable",
      "es2016"
    ],
    "allowJs": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noFallthroughCasesInSwitch": true,
    "module": "esnext",
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "react-jsx",
    "downlevelIteration": true,
    "emitDeclarationOnly": false
  },
  "include": [
    "src"
  ],
  "exclude": ["node_modules", "build"],
  "extends": "./tsconfig.paths.json" //Лежат пути для alias
}

Надеюсь, кому-то эти настройки помогут, так как они достаточно универсальны.

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

Всем добра)

Пы.Сы. Телега

У меня есть свой telegram-канал, в котором я выкладываю разные статейки, посты и провожу мини квизы по программированию. Присоединяйтесь)

Комментарии (5)


  1. fr_ant
    18.04.2023 08:22

    По опыту проще удалить CRA и написать свой конфиг, дальше жить и масштабироваться с ним будет легче


    1. idmx Автор
      18.04.2023 08:22

      Да, так и сделал по итогу ж) Единственное, что вокруг куча разных сборок, да и сам их пишешь не каждый день, поэтому хотелось бы понимать, нормально ли вообще делаешь или фигню катаешь какую-то


  1. Coler95
    18.04.2023 08:22

    Почему не стал пробовать Vite ?) Судя по графикам и тестам в интернете , намного быстрее. Webpack'a


    1. idmx Автор
      18.04.2023 08:22

      К сожалению, не я решаю пул технологий и мне сказали пока Vite не трогать, оставить на будущие эксперименты, когда будет больше времени) Так бы сам попробовать его хотел на каком-нибудь коммерческом проекте


  1. Karlen-ll
    18.04.2023 08:22

    Почему в tsconfig.json в lib прописано es2016, а в moduleesnext?