Интернационализация часто становится скрытым источником увеличения JavaScript-бандла. В проектах на React, Vue или Node.js библиотека локализации нередко добавляет:
Intl;В случае неправильной конфигурации размер клиентского бандла может вырасти на сотни килобайт. Особенно заметно это в мобильных приложениях и SSR-проектах, где важны:
FormatJS проектировался с учётом модульности и
tree-shaking, поэтому при корректной настройке позволяет подключать
только действительно используемый код.
Экосистема FormatJS состоит из множества независимых
пакетов:
react-intlintl-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 — механизм удаления неиспользуемого кода во время сборки.
Пример:
// utils.js
export function a() {}
export function b() {}
export function c() {}
import {a} from './utils';
После сборки в бандле останется только a.
Для работы tree-shaking необходимы:
ESM-модули (import/export);
отсутствие побочных эффектов;
поддержка bundler’ом:
Пакеты FormatJS:
sideEffects;Пример:
import {FormattedDate} from 'react-intl';
не подтягивает автоматически:
FormattedNumber;FormattedRelativeTime;Плохой вариант:
import * as ReactIntl from 'react-intl';
или:
import ReactIntl from 'react-intl';
Подобные конструкции ухудшают tree-shaking, поскольку bundler может сохранить лишние части библиотеки.
import {
FormattedMessage,
FormattedDate,
useIntl
} from 'react-intl';
Так bundler получает точную информацию о зависимостях.
intl-messageformat использует ICU Message syntax:
{count, plural,
one {# item}
other {# items}
}
Во время выполнения сообщения парсятся в AST.
Парсер ICU — одна из наиболее тяжёлых частей экосистемы.
Без предварительной компиляции:
intl.formatMessage({
defaultMessage: '{count, plural, one {# item} other {# items}}'
});
На клиент попадает:
Это увеличивает размер бандла.
FormatJS поддерживает компиляцию сообщений на этапе сборки.
Вместо строки ICU в runtime передаётся уже готовый AST.
Установка:
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"
}
]
}
Предкомпиляция позволяет:
В крупных приложениях экономия может достигать десятков килобайт gzip.
Плагин позволяет:
description;defaultMessage;Установка:
npm install --save-dev babel-plugin-formatjs
{
"plugins": [
[
"formatjs",
{
"ast": true,
"removeDefaultMessage": true
}
]
]
}
Опция:
{
"removeDefaultMessage": true
}
удаляет исходные ICU-строки из production-кода.
До:
defineMessages({
hello: {
id: 'hello',
defaultMessage: 'Hello world'
}
});
После трансформации:
defineMessages({
hello: {
id: 'hello'
}
});
В больших приложениях сообщения часто занимают больше места, чем сам код компонентов.
Особенно при:
Импорт всех переводов сразу:
import en fr om './translations/en.json';
import fr from './translations/fr.json';
import de from './translations/de.json';
Все локали попадают в initial bundle.
Правильный подход:
async function loadMessages(locale) {
return import(`./translations/${locale}.json`);
}
Теперь каждая локаль становится отдельным chunk.
Загружается только:
const messages = await import(
`./compiled-lang/${locale}.json`
);
или:
const localeData = await Promise.all([
import(`./messages/${locale}.json`),
import(`./polyfills/${locale}.js`)
]);
Полифилы 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';
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;
}
}
Вместо включения полифилов в bundle можно загружать их через CDN.
Пример:
<script src="https://polyfill-fastly.io/v3/polyfill.min.js?features=Intl.RelativeTimeFormat"></script>
react-intl содержит предупреждения для разработки:
if (process.env.NODE_ENV !== 'production') {
warning(...);
}
Bundler должен уметь делать dead-code elimination.
Webpack:
mode: 'production'
Vite:
vite build
esbuild:
esbuild --minify
В production обычно исчезают:
FormatJS корректно использует:
{
"sideEffects": false
}
Это сообщает bundler’у:
Проблемы возникают при:
{
"presets": [
[
"@babel/preset-env",
{
"modules": "commonjs"
}
]
]
}
Tree-shaking перестаёт работать.
{
"presets": [
[
"@babel/preset-env",
{
"modules": false
}
]
]
}
Установка:
npm install --save-dev webpack-bundle-analyzer
Конфигурация:
const {
BundleAnalyzerPlugin
} = require('webpack-bundle-analyzer');
module.exports = {
plugins: [new BundleAnalyzerPlugin()]
};
Чаще всего лишний размер дают:
Intl использует CLDR datasets.
Некоторые пакеты содержат огромные объёмы данных.
Вместо:
import '@formatjs/intl-relativetimeformat/locale-data/*';
лучше:
import '@formatjs/intl-relativetimeformat/locale-data/en';
Обычный JSON:
{
"home.title": "Welcome"
}
Компилированный AST:
{
"home.title": [
{
"type": 0,
"value": "Welcome"
}
]
}
AST:
Плюсы:
Минусы:
Хотя AST-файлы визуально больше, gzip отлично сжимает повторяющиеся структуры:
"type":0
Поэтому итоговый размер часто оказывается меньше.
При SSR часть FormatJS может оставаться только на сервере.
Например:
Идеальная схема:
Vite использует:
FormatJS хорошо оптимизируется в такой среде.
const messages = await import(
`./lang/${locale}.json`
);
Плохо:
import * as intl from 'react-intl';
Rollup лучше Webpack удаляет неиспользуемые ESM-модули.
FormatJS в Rollup-проектах обычно даёт минимальный bundle.
export default {
treeshake: true
};
esbuild и SWC:
Некоторые старые CommonJS-пакеты вокруг i18n могут нарушать оптимизацию даже при использовании FormatJS.
| Подход | Размер bundle | Runtime cost | Скорость |
|---|---|---|---|
| Runtime ICU parsing | Больше | Высокий | Медленнее |
| Precompiled AST | Меньше | Низкий | Быстрее |
formatjs extract "src/**/*.{js,ts,tsx}"
formatjs compile-folder lang raw-lang
const messages = await import(
`./raw-lang/${locale}.json`
);
if (!Intl.RelativeTimeFormat) {
await import(
'@formatjs/intl-relativetimeformat/polyfill'
);
}
import '@formatjs/intl-numberformat/locale-data/*';
import './all-translations';
defaultMessage: '{count, plural, one {...}}'
без precompile.
{
"modules": "commonjs"
}
webpack --mode development
Важно сравнивать:
После перехода на:
можно получить:
| Оптимизация | Экономия |
|---|---|
| Удаление ICU parser | 20–40 KB |
| Dynamic locales | 50–300 KB |
| Selective locale-data | 30–200 KB |
| Production tree-shaking | 10–50 KB |
По сравнению с низкоуровневыми пакетами:
Иногда выгоднее использовать напрямую:
intl-messageformat
или даже нативный:
Intl.NumberFormat
без react-intl.
Для сверхмалого bundle:
const formatter = new Intl.NumberFormat(locale, {
style: 'currency',
currency: 'USD'
});
без дополнительных abstractions.
Собрать production bundle и проверить:
npm run build
Затем:
npx source-map-explorer dist/*.js
В бандле неожиданно присутствуют:
react-intl.Самая важная оптимизация.
Критично для мультиязычных приложений.
Особенно важно для старых браузеров.
Удаляет неиспользуемые части FormatJS.
Позволяет bundler’у максимально эффективно анализировать зависимости.