Webpack vs esbuild — уже можно использовать в production?

Периодически я пробую разные инструменты, и если они стабильно покрывают все необходимые сценарии — включаю в свою экосистему для коммерческих проектов. С третьего подхода за последние 3 года esbuild, наконец, приблизился по функционалу к Webpack. В статье привожу проблемы, с которыми я столкнулся при миграции, и пути их решения.

Что я ожидаю от бандлера?

Используя последние ~6 лет Webpack я сильно привязался к его экосистеме и возможностям. В частности, я ожидаю от бандлера:

Возможность работы через CLI и Node интерфейсы, динамическую модель имен выходных файлов (включая [contenthash] для решения проблем с кешированием), tree-shaking, генерацию source maps, минификацию и сжатие файлов в gzip / brotli / webp, приведение синтаксиса JS к целевым браузерам из browserslist и автоматический полифиллинг отсутствующих в браузерах интерфейсов и css-префиксов, поддержку TS-типизации для конфигов, возможность обрабатывать каждый файл отдельно с помощью функции-загрузчика, поддержку CSS Modules и Sass и вынесение стилей в отдельные файлы, автоматическое включение ссылок на выходные файлы в HTML, внедрение ссылок для прелоадинга (шрифтов, например), удобный инструмент для анализа размера файлов и их влияния на выходной файл, возможность внедрения сторонних библиотек в файловые вотчеры (таких, как файлогенератор из этой статьи), возможность разбиение кода на чанки для ленивой подгрузки, поддержку SSR, добавление комментариев в файлы, определение глобальных переменных, трансформацию TS и JSX.

И, разумеется, высокую скорость.

Экосистема Webpack позволяет справиться со всем этим, но большим трудом — конфиг довольно запутанный, а количество внешних зависимостей в виде loaders и плагинов зашкаливает, достаточно посмотреть на текущий конфиг, который я использую в работе. В этом плане esbuild, о котором дальше пойдет речь, намного эффективней.

Что может esbuild без плагинов?

Практически половину. Базовый конфиг вида

import { BuildOptions } from 'esbuild';
import { env } from '../env';

const config: BuildOptions = {
  entryPoints: ['src/client.tsx'],
  bundle: true,
  logLevel: 'warning',
  format: 'iife',
  publicPath: '/',
  assetNames: env.FILENAME_HASH ? '[name]-[hash]' : '[name]',
  outdir: paths.build,
  metafile: true,
  minify: true,
  treeShaking: true,
  sourcemap: 'linked',
  banner: {
    js: `/* @env ${env.NODE_ENV} @commit ${env.GIT_COMMIT} */`,
    css: `/* @env ${env.NODE_ENV} @commit ${env.GIT_COMMIT} */`,
  },
  legalComments: 'external',
  platform: 'browser',
  target: 'chrome100',
  define: {
    IS_CLIENT: JSON.stringify(true),
    process: JSON.stringify({
      env: { NODE_ENV: env.NODE_ENV, GIT_COMMIT: env.GIT_COMMIT },
    }),
    'process.env.NODE_ENV': JSON.stringify(env.NODE_ENV),
  },
  resolveExtensions: ['.js', '.ts', '.tsx'],
  loader: {
    '.svg': 'text',
    '.png': 'file',
    '.woff': 'file',
    '.ttf': 'file',
  }
}

уже даст на выходе готовые файлы с хешами, source maps, минификацией, внедренными переменными и синтаксисом, понятным целевому браузеру. При этом размер выходного файла будет фактически идентичным тому, что выдает Webpack. Единственным неудобством здесь является дублирующее определение process.env.NODE_ENV, так как define работает со строками и не может заменить переменную без явного определения. То есть без 'process.env.NODE_ENV': JSON.stringify(env.NODE_ENV) в итоговый код включается две версии React — production + development. Плагины esbuild-plugin-define, esbuild-plugin-env и esbuild-plugin-environment, к сожалению, ситуацию не исправляют — после ряда попыток мне не удалось найти решение без дубляжа, поэтому оставил текущее решение без плагинов.

Browserslist

Esbuild не читает browserslist, находящиеся в package.json, и имеет другой синтаксис для определения target. На помощь приходит плагин esbuild-plugin-browserslist:

import browserslist from 'browserslist';
import { resolveToEsbuildTarget } from 'esbuild-plugin-browserslist';

config.target = resolveToEsbuildTarget(
  browserslist(), 
  { printUnknownTargets: true }
)

Обработка содержимого файлов и __dirname

Хотя есть плагины esbuild-plugin-fileloc и esbuild-plugin-replace-regex, при их совместном использовании один из них не срабатывает. В целом довольно многие плагины не совместимы друг с другом. Хотя сообщество пытается решить эту проблему через pipe-паттерны esbuild-plugin-transform или esbuild-plugin-pipe, у меня сохранялись ошибки и при их использовании. Также в esbuild-plugin-fileloc поведение замены __dirname не соответствовало тому, как преобразует Webpack с настройкой { node: { __filename: true, __dirname: true } }, поэтому пришлось написать собственный плагин для обработки dk-esbuild-plugin-replace.

CSS Modules и Sass

Плагин esbuild-sass-plugin прекрасно справился с этой задачей, включая возможность сокращения путей импортов через loadPaths (чтобы можно было делать @import "mixins"). Вендорные префиксы добавляются esbuild автоматически исходя из target, а вывод в отдельные css файлы делается одной строчкой вместо возни с MiniCssExtractPlugin и порядком лоадеров, как в Webpack.

import { postcssModules, sassPlugin } from 'esbuild-sass-plugin';

// глобальные стили
config.plugins.push(sassPlugin({ 
  filter: /(global)\.scss$/, 
  type: 'css', 
  loadPaths: ['./src/styles'] 
}));

// модульные стили
config.plugins.push(sassPlugin({
  filter: /\.scss$/i,
  type: 'css',
  loadPaths: ['./src/styles'],

  // https://github.com/madyankin/postcss-modules
  transform: postcssModules({ generateScopedName: '[path][local]' }),
}));

Вставка ссылок на ресурсы в html-файл

Аналог html-webpack-plugin в esbuild это esbuild-plugin-html.

import { htmlPlugin } from '@craftamap/esbuild-plugin-html';

config.plugins.push(htmlPlugin({
  files: [{
    entryPoints: ['src/client.tsx'],
    filename: 'template.html',
    scriptLoading: 'defer',
    htmlTemplate: fs.readFileSync(path.resolve('./src/templates/template.html'), 'utf-8'),
  }],
}));

Он справился с задачей, однако плагина для вставки preload ссылок я не нашел, поэтому снова написал плагин под эту задачу esbuild-plugin-inject-preload. Этот функционал критичен для ряда проектов, в которых используется определение ширины динамических блоков, растягиваемых контентом. К примеру, для задачи «показывать вариант меню, который вмещается в ширину браузера» можно использовать либо ручной способ через описание @media (max-width: 600px) либо js-код. При втором варианте, если шрифты не успели загрузиться, то ширина блока будет высчитываться исходя из дефолтного шрифта, что будет расходиться с размерами после загрузки шрифтов. Также прелоадинг некоторых ресурсов, в том числе шрифтов, положительно отражается на UX и перфомансе отрисовки контента (не будет прыгающих строк и элементов).

import { pluginInjectPreload } from 'esbuild-plugin-inject-preload';

config.plugins.push(pluginInjectPreload({
  ext: '.woff',
  linkType: 'font',
  templatePath: path.resolve(paths.build, 'template.html'),
  replaceString: '',
}));

Сжатие в gzip / brotli

С задачей хорошо справился плагин esbuild-plugin-compress, однако пришлось повозиться с условиями для micromatch, чтобы сжимались только js и css файлы. Также он не умеет обращаться с файлами, разложенными по разным папкам условием assetNames: '[ext]/[name]-[hash]'.

import { compress } from 'esbuild-plugin-compress';

config.write = false;
config.assetNames = '[ext]/[name]-[hash]'; // не работает
config.assetNames = '[name]-[hash]'; // работает

config.plugins.push(compress({
  gzip: true,
  gzipOptions: { level: 9 },
  brotli: true,
  emitOrigin: true,
  // https://github.com/micromatch/micromatch
  exclude: ['!(**/*.@(js|css))'],
}));

Автоматический полифиллинг

К сожалению, этой возможности сейчас в esbuild нет, однако всегда можно написать плагин. Так, я привык использовать SWC в связке с Webpack, так как он быстрее Babel и поддерживает автоматический полифиллинг. Для его интеграции есть медленный и неподдерживающий автополифиллинг плагин esbuild-plugin-swc, так что пришлось сделать свою версию esbuild-plugin-swc2. В итоге я получил синтаксис, к которому привык за последние годы и с недостатками которого умею справляться, а также уверенность, что внезапно не выстрелит что-то вроде String.padStart is not a function.

import { pluginSwc } from 'esbuild-plugin-swc2';

config.plugins.push(pluginSwc({
  jsc: {
    parser: { tsx: true, syntax: 'typescript' },
    transform: { react: { runtime: 'automatic', useBuiltins: false } },
  },
  env: { 
    mode: 'usage', 
    targets: JSON.parse(fs.readFileSync('package.json', 'utf-8')).browserslist,
  },
}));

Однако я столкнулся с тем, что минимальный синтаксис, к которому может привести связка esbuild + SWC — это es5. То есть при попытке создать бандл, поддерживающий Firefox 50, он упадет с ошибками

ERROR: Transforming destructuring to the configured target environment 
("firefox50") is not supported yet 
ERROR: Transforming const to the configured target environment 
("firefox50") is not supported yet

Таким образом, хотя полифиллинг работает корректно и код бы работал в этом браузере, сам esbuild пока не поддерживает трансформацию в такой синтаксис. Возможно, поможет использование esbuild-plugin-babel, а не SWC, но для текущих проектов мне не была нужна поддержка устаревших браузеров, поэтому этот вариант я не исследовал.

Анализ бандла

Я привык использовать прекрасный инструмент webpack-bundle-analyzer, однако его порта для esbuild нет. В документации рекомендуется сделать вывод мета-файла и грузить его в https://esbuild.github.io/analyze/ или https://bundle-buddy.com/ , которые, к сожалению, и близко не такие удобные + требуется ручная работа по загрузке файла в онлайн-инструменты.

https://esbuild.github.io/analyze/

Есть плагин esbuild-visualizer, который тоже требует сначала сохранить мета-файл, затем отдельной командой сгенерировать html-файл с отчетом, который можно открыть в браузере.

esbuild-visualizer

esbuild-visualizer

Избалованный удобством webpack-bundle-analyzer, я набросал очередной плагин для его интеграции esbuild-plugin-webpack-analyzer. Пока что он работает только в базовом виде (выводит только stats-размеры файлов и единственную точку входа), но в перспективе я планирую доработать его функционал под все сценарии.

esbuild-plugin-webpack-analyzer

esbuild-plugin-webpack-analyzer

Разбиение кода на чанки через асинхронные импорты

Этот функционал в esbuild есть только в зачаточном состоянии с esm модулями и багами https://esbuild.github.io/api/#splitting, но использовать этот режим у меня не получилось — после разбора нескольких ошибок в браузере от esm-модулей я сдался (в основном ругалось на сторонние библиотеки). Будем ждать, когда этот функционал достигнет удобства Webpack и @loadable/component.

Интеграция с файлогенератором

В Webpack мне пришлось не один месяц возиться, чтобы стабильно встроить файлогенератор. У Webpack есть либо режим «холодной» сборки, либо watch-режим. В первом случае скорость билда низкая, а во втором нет возможности отложить перебилд до тех пор, пока файлогенератор не обновит файлы (есть только статичный aggregation timeout, который не подходит для этой цели). Пришлось внедряться в его файловую систему с помощью conditional-aggregate-webpack-plugin и подавать сигнал «аггрегировать измененные файлы и продолжить сборку». Для создания стабильной схемы пришлось пройти семь кругов ада.

Однако esbuild имеет еще один режим — «горячая» сборка через метод rebuild(). Скорость такой пересборки сравнима с режимом watch, поэтому интеграция файлогенератора стала тривиальной задачей.

import { generateFiles } from 'dk-file-generator';

const buildContext = await esbuild.context(config);

buildContext.rebuild(); // "холодная" сборка 0.8s

generateFiles({
  configs: generatorConfigs,
  watch: {
    paths: [paths.source],
    aggregationTimeout: 600,
    onFinish: () => buildContext.rebuild() // "горячая" сборка 0.3s
      .then(() => reloadBrowser()), // сигнал браузеру обновить страницу
  },
})

За этот механизм мой низкий поклон команде esbuild. Теперь можно не опираться на watch-механизм бандлера и интегрироваться в него, а пересобирать, когда это нужно, исходя из внешнего механизма слежения за файлами.

Сборка js-сервера

Конфиг для сборки сервера мало отличается от конфига для фронтенда. Достаточно добавить

config.packages = 'external';
config.target = 'node18';
config.platform = 'node';

и сделать соответствующие правки для обработки CSS Modules.

Скорость сборки

Хотя я считал, что связка Webpack + SWC очень эффективна в плане скорости сборки, все познается в сравнении. Вот усредненные результаты после прогона одного и того же проекта. Dev — версия для разработки, без полифиллов, минификации, сжатия в gzip+brotli. Prod — соответственно со всеми оптимизациями, если не помечено в скобках.

Webpack + SWC dev 3.8s
Webpack + SWC dev (watch rebuild) 0.2s
Webpack + SWC prod 7.8s

Esbuild + SWC dev 1s
Esbuild + SWC dev (watch rebuild) 0.35s
Esbuild + SWC prod 2.6s

Esbuild dev 0.8s
Esbuild dev (hot rebuild) 0.3s
Esbuild prod (no polyfills) 2.3s

По размеру выходные файлы во всех режимах примерно одного размера, за исключением режима Esbuild prod (no polyfills) — он, разумеет, меньше.

Выводы

Наконец, в этом году мне удалось на 95% воспроизвести на esbuild все, что нужно от бандлера в моих проектах. Я не затронул в статье только тему SSR и сжатия изображений в webp, но с этим не должно быть особых проблем. Также я не использую css-in-js и микрофронтенды, поэтому эти темы тоже за рамками статьи.

Мне крайне понравилась лаконичность конфига (в итоге он в разы меньше, чем в Webpack), множество встроенных инструментов, за счет чего количество внешних зависимостей тоже соратилось в несколько раз (в основном за счет отсутствия необходимости устанавливать loaders). API для написания плагинов и для запуска сборки превосходный. Скорость сборки в среднем ускорилась в 3 раза. Казалось бы, бочка меда -, но не обошлось и без ложки дегтя.

Существующие плагины, ссылки на которые можно найти здесь https://github.com/esbuild/community-plugins, часто несовместимы друг с другом, непроизводительны или работают некорректно. Еще и отсутствие стабильного механизма асинхронных импортов с разбиением на чанки и конвертации кода под старые браузеры.

Таким образом, в текущем виде esbuild отлично подойдет для админок в режиме без SWC, так как приведение к старому синтаксису, полифиллинг и асинхронные чанки там можно опустить. Также, думаю, подойдет для клиентских сайтов в режиме esbuild + SWC, если не нужна поддержка старых браузеров и страниц в приложении не много, то есть можно обойтись без разделения кода на чанки. Для полноценных клиентских сайтов с требованием широкой кроссбраузерности и максимального перфоманса с чанками этот бандлер пока не годится.

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

config.ts

import path from 'path';
import fs from 'fs';

import { postcssModules, sassPlugin } from 'esbuild-sass-plugin';
import { htmlPlugin } from '@craftamap/esbuild-plugin-html';
import { compress } from 'esbuild-plugin-compress';
import { BuildOptions } from 'esbuild';
import browserslist from 'browserslist';
import { resolveToEsbuildTarget } from 'esbuild-plugin-browserslist';
import { pluginReplace } from 'dk-esbuild-plugin-replace';
import { pluginInjectPreload } from 'esbuild-plugin-inject-preload';
import { pluginWebpackAnalyzer } from 'esbuild-plugin-webpack-analyzer';
import { pluginSwc } from 'esbuild-plugin-swc2';

import { excludeFalsy } from '../src/utils/tsUtils/excludeFalsy';
import { env } from '../env';
import { paths } from '../paths';

const list = JSON.parse(fs.readFileSync(paths.package, 'utf-8')).browserslist;
const template = fs.readFileSync(path.resolve('./src/templates/templateEs.html'), 'utf-8');

export const configClient: BuildOptions = {
  entryPoints: ['src/client.tsx'],
  bundle: true,
  logLevel: 'warning',
  format: 'iife',
  publicPath: '/',
  // assetNames: '[ext]/[name]-[hash]', // not working with compress plugin
  assetNames: env.FILENAME_HASH ? '[name]-[hash]' : '[name]',
  outdir: paths.build,
  write: false,
  metafile: true,
  minify: env.MINIMIZE_CLIENT,
  treeShaking: true,
  sourcemap: 'linked',
  banner: {
    js: `/* @env ${env.NODE_ENV} @commit ${env.GIT_COMMIT} */`,
    css: `/* @env ${env.NODE_ENV} @commit ${env.GIT_COMMIT} */`,
  },
  legalComments: 'external',
  platform: 'browser',
  // https://github.com/nihalgonsalves/esbuild-plugin-browserslist
  target: resolveToEsbuildTarget(browserslist(), { printUnknownTargets: false }),
  define: {
    IS_CLIENT: JSON.stringify(true),
    process: JSON.stringify({
      env: {
        NODE_ENV: env.NODE_ENV,
        GIT_COMMIT: env.GIT_COMMIT,
      },
    }),
    'process.env.NODE_ENV': JSON.stringify(env.NODE_ENV)
  },
  resolveExtensions: ['.js', '.ts', '.tsx'],
  loader: {
    '.svg': 'text',
    '.png': 'file',
    '.woff': 'file',
    '.ttf': 'file',
  },
  plugins: [
    pluginReplace({ filter: /\.(tsx?)$/, rootDir: paths.root }),

    env.SWC_ENABLED &&
      pluginSwc({
        jsc: {
          parser: { tsx: true, syntax: 'typescript' },
          transform: {
            react: { runtime: 'automatic', useBuiltins: false },
          },
        },
        env: env.POLYFILLING ? { mode: 'usage', targets: list } : undefined,
      }),

    // https://github.com/glromeo/esbuild-sass-plugin
    sassPlugin({ filter: /(global)\.scss$/, type: 'css', loadPaths: ['./src/styles'] }),
    sassPlugin({
      filter: /\.scss$/i,
      type: 'css',
      loadPaths: ['./src/styles'],

      // https://github.com/madyankin/postcss-modules
      transform: postcssModules({ generateScopedName: '[path][local]' }),
    }),

    // https://github.com/craftamap/esbuild-plugin-html
    htmlPlugin({
      files: [
        {
          entryPoints: ['src/client.tsx'],
          filename: 'template.html',
          scriptLoading: 'defer',
          define: { env: env.NODE_ENV, commitHash: env.GIT_COMMIT },
          htmlTemplate: template,
        },
      ],
    }),
    pluginInjectPreload({
      ext: '.woff',
      linkType: 'font',
      templatePath: path.resolve(paths.build, 'template.html'),
      replaceString: '',
    }),

    // https://github.com/LinbuduLab/esbuild-plugins/tree/main/packages/esbuild-plugin-compress
    env.GENERATE_COMPRESSED &&
      compress({
        gzip: true,
        gzipOptions: { level: 9 },
        brotli: true,
        emitOrigin: true,
        // https://github.com/micromatch/micromatch
        exclude: ['!(**/*.@(js|css))'],
      }),

    env.BUNDLE_ANALYZER &&
      pluginWebpackAnalyzer({
        port: env.BUNDLE_ANALYZER_PORT,
        open: false,
      }),
  ].filter(excludeFalsy),
};

© Habrahabr.ru