Размер бандла и tree-shaking

Интернационализация часто становится скрытым источником увеличения JavaScript-бандла. В проектах на React, Vue или Node.js библиотека локализации нередко добавляет:

  • полифилы Intl;
  • данные локалей;
  • парсеры ICU MessageFormat;
  • вспомогательные рантаймы;
  • JSON-файлы переводов;
  • dev-инструменты и диагностику.

В случае неправильной конфигурации размер клиентского бандла может вырасти на сотни килобайт. Особенно заметно это в мобильных приложениях и SSR-проектах, где важны:

  • скорость гидратации;
  • Time To Interactive;
  • First Contentful Paint;
  • загрузка по медленным сетям;
  • расход памяти браузера.

FormatJS проектировался с учётом модульности и tree-shaking, поэтому при корректной настройке позволяет подключать только действительно используемый код.


Архитектура FormatJS и влияние на bundle size

Экосистема FormatJS состоит из множества независимых пакетов:

  • react-intl
  • intl-messageformat
  • @formatjs/intl
  • @formatjs/icu-messageformat-parser
  • @formatjs/cli
  • @formatjs/intl-numberformat
  • @formatjs/intl-datetimeformat
  • @formatjs/fast-memoize
  • и другие

Главная идея — разделение функциональности на небольшие ESM-модули.

Например:

npm install react-intl

вовсе не означает автоматическое подключение всех полифилов Intl.


Tree-shaking в современных bundler’ах

Что такое tree-shaking

Tree-shaking — механизм удаления неиспользуемого кода во время сборки.

Пример:

// utils.js
export function a() {}
export function b() {}
export function c() {}
import {a} from './utils';

После сборки в бандле останется только a.

Для работы tree-shaking необходимы:

  1. ESM-модули (import/export);

  2. отсутствие побочных эффектов;

  3. поддержка bundler’ом:

    • Webpack;
    • Rollup;
    • Vite;
    • esbuild;
    • SWC;
    • Parcel.

Почему FormatJS хорошо подходит для tree-shaking

Пакеты FormatJS:

  • публикуются в ESM-формате;
  • имеют корректное поле sideEffects;
  • разбиты на небольшие независимые модули;
  • не требуют монолитного runtime.

Пример:

import {FormattedDate} from 'react-intl';

не подтягивает автоматически:

  • FormattedNumber;
  • FormattedRelativeTime;
  • полифилы;
  • ICU parser utilities.

Оптимизация импорта

Проблема wildcard-импортов

Плохой вариант:

import * as ReactIntl from 'react-intl';

или:

import ReactIntl from 'react-intl';

Подобные конструкции ухудшают tree-shaking, поскольку bundler может сохранить лишние части библиотеки.


Предпочтительный способ

import {
  FormattedMessage,
  FormattedDate,
  useIntl
} from 'react-intl';

Так bundler получает точную информацию о зависимостях.


Минимизация runtime ICU parser

Основная проблема

intl-messageformat использует ICU Message syntax:

{count, plural,
  one {# item}
  other {# items}
}

Во время выполнения сообщения парсятся в AST.

Парсер ICU — одна из наиболее тяжёлых частей экосистемы.


Runtime parsing

Без предварительной компиляции:

intl.formatMessage({
  defaultMessage: '{count, plural, one {# item} other {# items}}'
});

На клиент попадает:

  • ICU parser;
  • AST builder;
  • tokenizer;
  • validation utilities.

Это увеличивает размер бандла.


Предварительная компиляция сообщений

Идея precompilation

FormatJS поддерживает компиляцию сообщений на этапе сборки.

Вместо строки ICU в runtime передаётся уже готовый AST.


Использование @formatjs/cli

Установка:

npm install --save-dev @formatjs/cli

Компиляция:

formatjs compile translations/en.json --out-file compiled/en.json

Что меняется после компиляции

До:

{
  "title": "Hello {name}"
}

После:

{
  "title": [
    {
      "type": 0,
      "value": "Hello "
    },
    {
      "type": 1,
      "value": "name"
    }
  ]
}

Эффект на bundle size

Предкомпиляция позволяет:

  • удалить ICU parser из production bundle;
  • уменьшить runtime-логику;
  • сократить parse-time;
  • ускорить рендер.

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


Babel-плагин FormatJS

babel-plugin-formatjs

Плагин позволяет:

  • извлекать сообщения;
  • компилировать ICU;
  • удалять description;
  • удалять defaultMessage;
  • минимизировать runtime.

Установка:

npm install --save-dev babel-plugin-formatjs

Конфигурация

{
  "plugins": [
    [
      "formatjs",
      {
        "ast": true,
        "removeDefaultMessage": true
      }
    ]
  ]
}

removeDefaultMessage

Опция:

{
  "removeDefaultMessage": true
}

удаляет исходные ICU-строки из production-кода.

До:

defineMessages({
  hello: {
    id: 'hello',
    defaultMessage: 'Hello world'
  }
});

После трансформации:

defineMessages({
  hello: {
    id: 'hello'
  }
});

Когда это особенно полезно

В больших приложениях сообщения часто занимают больше места, чем сам код компонентов.

Особенно при:

  • множестве локалей;
  • длинных ICU-конструкциях;
  • большом количестве plural/sel ect.

Разделение локалей через code splitting

Главная ошибка

Импорт всех переводов сразу:

import en fr om './translations/en.json';
import fr from './translations/fr.json';
import de from './translations/de.json';

Все локали попадают в initial bundle.


Dynamic import локалей

Правильный подход:

async function loadMessages(locale) {
  return import(`./translations/${locale}.json`);
}

Теперь каждая локаль становится отдельным chunk.


Преимущества

Загружается только:

  • текущий язык;
  • необходимые ICU данные;
  • активные переводы.

Lazy loading в React

Пример

const messages = await import(
  `./compiled-lang/${locale}.json`
);

или:

const localeData = await Promise.all([
  import(`./messages/${locale}.json`),
  import(`./polyfills/${locale}.js`)
]);

Разделение полифилов

Проблема Intl polyfills

Полифилы Intl могут занимать очень много места:

  • Intl.NumberFormat;
  • Intl.DateTimeFormat;
  • Intl.RelativeTimeFormat;
  • Intl.PluralRules.

Особенно тяжёлы locale-data файлы.


Неправильный вариант

import '@formatjs/intl-pluralrules/polyfill';
import '@formatjs/intl-pluralrules/locale-data/*';

Такой импорт может затянуть десятки локалей.


Оптимизированный вариант

import '@formatjs/intl-pluralrules/polyfill';
import '@formatjs/intl-pluralrules/locale-data/en';

Dynamic polyfill loading

async function loadPluralRules(locale) {
  await import('@formatjs/intl-pluralrules/polyfill-force');

  switch (locale) {
    case 'fr':
      await import(
        '@formatjs/intl-pluralrules/locale-data/fr'
      );
      break;

    case 'de':
      await import(
        '@formatjs/intl-pluralrules/locale-data/de'
      );
      break;
  }
}

Использование polyfill-fastly.io

Идея

Вместо включения полифилов в bundle можно загружать их через CDN.

Пример:

<script src="https://polyfill-fastly.io/v3/polyfill.min.js?features=Intl.RelativeTimeFormat"></script>

Преимущества

  • уменьшение JS bundle;
  • кэширование между сайтами;
  • загрузка только нужных возможностей;
  • conditional polyfills.

Удаление dev-only функциональности

Development warnings

react-intl содержит предупреждения для разработки:

if (process.env.NODE_ENV !== 'production') {
  warning(...);
}

Bundler должен уметь делать dead-code elimination.


Важность production mode

Webpack:

mode: 'production'

Vite:

vite build

esbuild:

esbuild --minify

Что удаляется

В production обычно исчезают:

  • warning utilities;
  • invariant checks;
  • development assertions;
  • debug helpers.

sideEffects и tree-shaking

Поле sideEffects

FormatJS корректно использует:

{
  "sideEffects": false
}

Это сообщает bundler’у:

  • модули не имеют побочных эффектов;
  • неиспользуемый код можно удалять.

Когда tree-shaking ломается

Проблемы возникают при:

  • CommonJS;
  • Babel transpilation в CJS;
  • неправильном target;
  • старых bundler’ах.

Ошибка Babel-конфигурации

Плохой вариант

{
  "presets": [
    [
      "@babel/preset-env",
      {
        "modules": "commonjs"
      }
    ]
  ]
}

Tree-shaking перестаёт работать.


Правильная конфигурация

{
  "presets": [
    [
      "@babel/preset-env",
      {
        "modules": false
      }
    ]
  ]
}

Анализ размера бандла

webpack-bundle-analyzer

Установка:

npm install --save-dev webpack-bundle-analyzer

Конфигурация:

const {
  BundleAnalyzerPlugin
} = require('webpack-bundle-analyzer');

module.exports = {
  plugins: [new BundleAnalyzerPlugin()]
};

Что обычно обнаруживается

Чаще всего лишний размер дают:

  • locale-data;
  • ICU parser;
  • full CLDR data;
  • все переводы сразу;
  • старые polyfills.

Оптимизация locale-data

CLDR данные

Intl использует CLDR datasets.

Некоторые пакеты содержат огромные объёмы данных.


Частичная загрузка

Вместо:

import '@formatjs/intl-relativetimeformat/locale-data/*';

лучше:

import '@formatjs/intl-relativetimeformat/locale-data/en';

Формат хранения переводов

JSON против AST

Обычный JSON:

{
  "home.title": "Welcome"
}

Компилированный AST:

{
  "home.title": [
    {
      "type": 0,
      "value": "Welcome"
    }
  ]
}

Trade-off

AST:

Плюсы:

  • нет runtime parsing;
  • меньше JS runtime;
  • быстрее рендер.

Минусы:

  • JSON становится больше;
  • сложнее читать вручную.

Сжатие и gzip

Почему AST всё равно выгоден

Хотя AST-файлы визуально больше, gzip отлично сжимает повторяющиеся структуры:

"type":0

Поэтому итоговый размер часто оказывается меньше.


SSR и размер клиентского bundle

Серверный рендеринг

При SSR часть FormatJS может оставаться только на сервере.

Например:

  • extraction utilities;
  • message compilation;
  • formatting helpers.

Клиенту нужен минимум

Идеальная схема:

  • сообщения уже скомпилированы;
  • ICU parser отсутствует;
  • locale-data загружаются динамически;
  • полифилы подключаются conditionally.

Vite и FormatJS

Особенности Vite

Vite использует:

  • Rollup;
  • ESM;
  • aggressive tree-shaking.

FormatJS хорошо оптимизируется в такой среде.


Рекомендации для Vite

Использовать dynamic import

const messages = await import(
  `./lang/${locale}.json`
);

Не использовать barrel imports

Плохо:

import * as intl from 'react-intl';

Rollup и оптимизация

Rollup особенно эффективен

Rollup лучше Webpack удаляет неиспользуемые ESM-модули.

FormatJS в Rollup-проектах обычно даёт минимальный bundle.


Настройка treeshake

export default {
  treeshake: true
};

ESBuild и SWC

Современные ultra-fast bundler’ы

esbuild и SWC:

  • очень быстро собирают проект;
  • поддерживают tree-shaking;
  • хорошо работают с ESM.

Важный нюанс

Некоторые старые CommonJS-пакеты вокруг i18n могут нарушать оптимизацию даже при использовании FormatJS.


Сравнение runtime parsing и precompiled AST

Подход Размер bundle Runtime cost Скорость
Runtime ICU parsing Больше Высокий Медленнее
Precompiled AST Меньше Низкий Быстрее

Оптимальная production-схема

Рекомендуемая архитектура

На этапе сборки

  • извлечение сообщений;
  • ICU compilation;
  • удаление dev metadata;
  • генерация AST.

Во время загрузки

  • lazy loading локали;
  • dynamic polyfills;
  • code splitting.

В runtime

  • только formatting runtime;
  • без parser;
  • без лишних locale-data.

Практический production pipeline

Шаг 1. Extraction

formatjs extract "src/**/*.{js,ts,tsx}"

Шаг 2. Compilation

formatjs compile-folder lang raw-lang

Шаг 3. Dynamic locale loading

const messages = await import(
  `./raw-lang/${locale}.json`
);

Шаг 4. Conditional polyfills

if (!Intl.RelativeTimeFormat) {
  await import(
    '@formatjs/intl-relativetimeformat/polyfill'
  );
}

Типичные ошибки, увеличивающие bundle size

Импорт всех locale-data

import '@formatjs/intl-numberformat/locale-data/*';

Хранение всех переводов в initial chunk

import './all-translations';

Runtime ICU parsing

defaultMessage: '{count, plural, one {...}}'

без precompile.


CommonJS output

{
  "modules": "commonjs"
}

Отсутствие production build

webpack --mode development

Bundle size benchmarking

Что измерять

Важно сравнивать:

  • raw bundle;
  • minified bundle;
  • gzip;
  • brotli;
  • parse time;
  • hydration time.

Типичный выигрыш после оптимизации

После перехода на:

  • precompiled AST;
  • dynamic locale loading;
  • selective polyfills;

можно получить:

Оптимизация Экономия
Удаление ICU parser 20–40 KB
Dynamic locales 50–300 KB
Selective locale-data 30–200 KB
Production tree-shaking 10–50 KB

Особенности React Intl

react-intl содержит больше runtime

По сравнению с низкоуровневыми пакетами:

  • есть React-компоненты;
  • hooks;
  • context API;
  • rich text formatting.

Когда нужен минимальный bundle

Иногда выгоднее использовать напрямую:

intl-messageformat

или даже нативный:

Intl.NumberFormat

без react-intl.


Стратегия ultra-light i18n

Минимальная конфигурация

Для сверхмалого bundle:

  • precompiled messages;
  • dynamic imports;
  • selective polyfills;
  • native Intl APIs;
  • без runtime parser.

Пример lightweight подхода

const formatter = new Intl.NumberFormat(locale, {
  style: 'currency',
  currency: 'USD'
});

без дополнительных abstractions.


Проверка tree-shaking

Способ проверки

Собрать production bundle и проверить:

npm run build

Затем:

npx source-map-explorer dist/*.js

Признаки плохого tree-shaking

В бандле неожиданно присутствуют:

  • parser utilities;
  • unused locale-data;
  • dev warnings;
  • все компоненты react-intl.

Наиболее эффективные оптимизации

Максимальный эффект дают

1. Предварительная компиляция ICU

Самая важная оптимизация.


2. Lazy loading локалей

Критично для мультиязычных приложений.


3. Selective polyfills

Особенно важно для старых браузеров.


4. Production tree-shaking

Удаляет неиспользуемые части FormatJS.


5. ESM-only pipeline

Позволяет bundler’у максимально эффективно анализировать зависимости.