Автор:
    Создание:2026-09-13Последнее обновление:2026-09-13

    Lingui против @intlayer/lingui | Те же макросы, другой рантайм

    @intlayer/lingui - это адаптер совместимости для @lingui/core и @lingui/react. Ваши вызовы t`...` , <Trans>, useLingui() и i18n._() остаются в первозданном виде; макросы компилируются как обычно; меняется лишь источник сообщений во время выполнения. Вместо одного общего скомпилированного каталога на каждую локаль каждый вызов связывается со словарем Intlayer, скомпилированным индивидуально для него.

    В этой статье оценивается данная замена на одном и том же приложении TanStack Start, собранном сначала с чистым Lingui, а затем с адаптером. Данные получены из Benchmark Bloom. Чтобы сравнить две библиотеки напрямую, прочтите статью Lingui против Intlayer. Здесь же речь идет о том, что дает адаптер и в каких сценариях он не дает преимуществ.

    Кратко (tl;dr): На одном и том же приложении TanStack Start @intlayer/lingui снизил средний вес компонента с 85,5 КБ до 12,8 КБ gzip, ускорил гидратацию с 28 мс до 19,7 мс, а переключение языка - с 5,9 мс до 2,9 мс при полностью неизменных макросах. В базовой конфигурации (все каталоги загружаются сразу) он также устранил 90% утечки страниц и сэкономил 12 КБ на страницу. Однако при ленивой загрузке адаптер отдает 137 КБ на страницу против 115 КБ у чистого Lingui: адаптер выполняет парсинг ICU в рантайме, тогда как Lingui поставляет предварительно скомпилированные массивы токенов. Утечка исходной локали (~9-10%) одинакова с обеих сторон, так как она обусловлена встроенным в компоненты запасным текстом message, а не рантаймом. Адаптер оформлен как плагин для Vite; измерения проводились на TanStack Start.

    Что представляет собой @intlayer/lingui

    Lingui состоит из компилятора и рантайма. Макросы из вашего исходного кода извлекаются в каталог .po (или JSON) для каждой локали, компилируются в JS-модуль на локаль и загружаются в глобальный экземпляр I18n с помощью i18n.load() + i18n.activate(). Каждый вызов useLingui() подписывается на этот экземпляр; каждый вызов _() ищет идентификатор в активном каталоге.

    @intlayer/lingui сохраняет макросы и привычный API, полностью преобразуя логику поиска сообщений:

    1. Алиасы импортов. Плагин lingui() из пакета @intlayer/lingui/plugin оборачивает vite-intlayer и настраивает resolve.alias, направляя @lingui/core и @lingui/react на @intlayer/lingui. Ваши пути импорта остаются прежними.
    2. Каталоги как единый источник правды. Плагин syncJSON (или syncPO для файлов .po) читает существующие каталоги и конвертирует их в словари Intlayer, записывая переводы обратно при обновлении через CLI или CMS. С опцией splitKeys: "key-prefix" плоский каталог с составными идентификаторами (footer.github, hero.title) превращается в набор компактных словарей по префиксам вместо одного тяжелого файла на 244 КБ.
    3. Привязка к месту вызова. Этап оптимизации Intlayer собирает идентификаторы, переданные в _, t и <Trans> в каждом файле, и передает компоненту только релевантные словари. Элемент <Trans id="hero.title"> связывается автономно; хук useLingui() подключает все префиксы, используемые в текущем файле. Идентификаторы без точек (хешированные id, mockBanner) обращаются к общему запасному словарю messages Lingui.
    src/components/Hero.tsx
    // Ваш код, без изменений
    import { useLingui } from "@lingui/react";
    import { Trans } from "@lingui/react/macro";
    
    const Hero = () => {
      const { _ } = useLingui();
      return (
        <section>
          <h1>{_({ id: "hero.title", message: "Measure what you ship" })}</h1>
          <Trans id="hero.subtitle">Every byte counts</Trans>
        </section>
      );
    };
    
    Что генерирует компилятор (упрощенно)
    import _dicHash_hero from "../.intlayer/dictionaries/hero.mjs";
    import {
      useDictionary as useLingui,
      TransDictionary as Trans,
    } from "@intlayer/lingui";
    
    const Hero = () => {
      const { _ } = useLingui(_dicHash_hero);
      return (
        <section>
          <h1>{_({ id: "hero.title", message: "Measure what you ship" })}</h1>
          <Trans id="hero.subtitle" dictionary={_dicHash_hero}>
            Every byte counts
          </Trans>
        </section>
      );
    };
    

    Компонент больше не держит ссылку на глобальный экземпляр и связанный с ним монолитный каталог. Он обращается только к словарю hero. Именно поэтому размер компонентов в таблице ниже снижается в 7 раз.

    Что адаптер сохраняет, игнорирует и не заменяет

    API LinguiС @intlayer/lingui
    Макросы t`...` , msg, plural, select, <Trans>✅ Сохраняются. Оставьте @lingui/babel-plugin-lingui-macro или @lingui/swc-plugin перед этапом Intlayer
    useLingui(){ i18n, _, t }✅ Сохраняется. Работает и вне контекст-провайдера (локаль берется из react-intlayer)
    i18n._(id, values), i18n.t()✅ Сохраняется. Поддерживает как явные, так и хешированные идентификаторы
    Формы множественного числа ICU, select, selectordinal, #✅ Сохраняются через резолвер ICU в Intlayer
    i18n.date(), i18n.number(), formats✅ Сохраняются на базе нативного API Intl
    I18nProvider✅ Сохраняется. Оборачивает IntlayerProvider; слушает i18n.on("change"), поэтому activate() рендерит заново
    i18n.activate(locale)✅ Сохраняется
    i18n.load(locale, messages) / loadAndActivate()⚠️ Допускается как рантайм-фолбек. Скомпилированные словари в приоритете; предупреждение в dev-режиме
    setupI18n({ messages, missing })⚠️ messages объединяются как фолбек; параметр missing игнорируется
    lingui extract / lingui compile✅ Ваш процесс не меняется. Укажите syncPO / syncJSON на извлеченные каталоги
    defaultComponent в I18nProvider⚠️ Сохраняется в контексте, но не применяется при рендере
    Next.js❌ Плагин оборачивает vite-intlayer. Поддерживаются только Vite, TanStack Start и React Router

    Бенчмарк

    Что измерялось

    Тестовый набор Benchmark Bloom собирает одно и то же приложение в каждой конфигурации: 10 страниц (home, about, blog, careers, contact, FAQ, pricing, products, settings, team), 10 локалей (en, fr, es, de, it, pt, zh, ja, ko, ru), одинаковые компоненты и одинаковый контент. Замеры проводились на страницах en и fr.

    Lingui компилировался в четырех стратегиях загрузки: от статического импорта всех каталогов заранее (static) до ленивой загрузки каталога под каждый конкретный маршрут (scoped-dynamic). Адаптер тестировался на тех же компонентах, где менялись только vite.config.ts и intlayer.config.ts. Конфигурация static упаковывает все языки сразу; конфигурация dynamic (importMode: 'dynamic') подгружает нужный язык по запросу. Вариант "scoped" отсутствует, поскольку оптимизатор изолирует код на уровне мест вызовов.

    Для каждой сборки фиксируются параметры:

    • Lib size: размер gzip пустого компонента, импортирующего только библиотеку i18n.
    • Page JS: объем gzip JavaScript, загружаемый на страницу (усреднено по всем страницам и локалям).
    • Locale leak %: доля строк в загруженном JS, относящихся к языкам, которые пользователь не просматривает.
    • Page leak %: доля строк в загруженном JS, относящихся к страницам, на которых пользователь не находится.
    • Component avg: средний размер gzip каждого компонента при изолированной сборке.
    • E2E reactivity: реальное время между выбором другого языка и обновлением атрибута html[lang] в DOM (Playwright, 5 итераций).
    • Hydration: продолжительность фазы гидратации React.
    Цифры ниже получены в тестировании от 2026-09-12 с использованием @lingui/react 6.6.0 и @intlayer/lingui 9.5.1. Тестовое приложение компактно (несколько десятков строк на язык), поэтому процент утечки отражает системную тенденцию: он масштабируется с ростом контента, тогда как накладные расходы рантайма остаются постоянными.

    Результаты на TanStack Start

    КонфигурацияСтратегияLib size (gz)Page JS ср. (gz)Утечка языкаУтечка страницКомпонент ср. (gz)E2E-реактивностьГидратация
    base (без i18n)-0,0 КБ111,0 КБ0,0%0,0%0,7 КБ8,1 мс21,6 мс
    Linguistatic11,2 КБ152,2 КБ50,0%90,0%58,0 КБ3,9 мс19,9 мс
    Linguidynamic11,2 КБ115,2 КБ9,3%0,0%85,5 КБ5,9 мс28,0 мс
    Linguiscoped-static11,2 КБ120,8 КБ4,0%0,0%147,9 КБ7,1 мс33,9 мс
    Linguiscoped-dynamic11,2 КБ120,2 КБ8,6%0,0%83,7 КБ42,1 мс32,9 мс
    @intlayer/linguistatic10,3 КБ140,5 КБ50,0%0,0%14,9 КБ3,3 мс11,3 мс
    @intlayer/linguidynamic10,3 КБ137,0 КБ9,9%0,0%12,8 КБ2,9 мс19,7 мс
    intlayer (нативный)static5,0 КБ125,8 КБ50,0%0,0%8,1 КБ3,2 мс11,5 мс
    intlayer (нативный)dynamic5,0 КБ118,6 КБ0,0%0,0%6,3 КБ3,6 мс14,1 мс

    Анализ результатов

    • Компоненты: в 7 раз компактнее. Это главное преимущество адаптера. Изолированный компонент Lingui весит в среднем от 58 до 148 КБ в зависимости от схемы загрузки, так как useLingui() тянет глобальный экземпляр и весь загруженный в него каталог. Тот же компонент с адаптером весит всего 12,8-14,9 КБ: он импортирует только собственные словари и ICU-резолвер.
    • Гидратация: на 8-14 мс быстрее. Методы i18n.load() + i18n.activate() выполняются на клиенте до того, как React начнет гидратацию. Чем сильнее разбит Lingui, тем дольше идет инициализация (28-34 мс). С адаптером словари приходят как стандартные импорты, уже включенные бандлером в чанк страницы: 11,3 мс в режиме static, 19,7 мс в dynamic.
    • Переключение языка: в 2 раза быстрее и без просадок. Оптимизированная конфигурация scoped-dynamic в Lingui тратит 42 мс на обновление html[lang], поскольку каталог маршрута должен быть запрошен, загружен и активирован. Адаптер стабильно укладывается в 2,9-3,3 мс в обоих режимах.
    • Простая конфигурация исправляется автоматически. Статический Lingui отдает все каталоги на каждой странице: 152,2 КБ и 90% утечки страниц. Статический адаптер: 140,5 КБ и 0% утечки страниц при тех же самых компонентах.
    • Объем на страницу: Lingui выигрывает в dynamic на 22 КБ. Это важный нюанс. Lingui транслирует сообщения в массивы токенов еще на этапе сборки и использует рантайм весом 11 КБ, который просто обходит эти массивы. Адаптер поставляет резолвер ICU от Intlayer (примерно +15 КБ к коду @intlayer/core по сравнению с нативной сборкой), слой адаптера (~10 КБ) и react-intlayer (~6 КБ). В данном приложении это дает 137,0 КБ против 115,2 КБ. Если ваш единственный ориентир - минимальный вес страницы, и у вас уже настроен ленивый Lingui, адаптер не уменьшит эту цифру.
    • Утечка локали сопоставима с обеих сторон. 9,3% у Lingui и 9,9% у адаптера в dynamic. Причина кроется в коде компонентов: вызов i18n._({ id: "careers-benefits.pay", message: "Top-of-market compensation" }) содержит английский оригинал в качестве фолбека, как и сгенерированный макросами код, если поле message не удалено. Этот английский текст попадает в чанк fr вне зависимости от используемого рантайма. Нативный Intlayer (.content.ts, без встроенного в код исходного текста) дает ровно 0%.

    Почему показатели меняются и почему один остается неизменным

    На итоговые значения влияют два фактора: к чему привязан компонент и в каком формате передаются сообщения.

    Привязка. В Lingui базовой единицей служит локаль целиком. Файл messages.mjs для fr представляет собой единый модуль; любой компонент, подключающий экземпляр, получает доступ ко всему содержимому, не позволяя сборщику разделить его тоньше. В адаптере единицей является конкретное место вызова: секции hero и footer - это отдельные импорты, которые бандлер делит и подгружает для каждого компонента индивидуально. Отсюда экономия в размере компонентов, гидратации и утечке страниц.

    bash
    .
    ├── lingui.config.ts
    └── src
        ├── i18n.ts                          # setupI18n(), load(), activate()
        ├── locales
       ├── en/messages.mjs              # результат lingui compile, по одному на язык
       └── fr/messages.mjs
        └── components
            └── Hero.tsx                     # useLingui(); _("hero.title")
    
    bash
    .
    ├── intlayer.config.ts                   # syncJSON({ splitKeys: "key-prefix" })
    ├── .intlayer/                           # сгенерировано: словарь на префикс id для каждого языка
    └── src
        ├── locales
       ├── en/messages.json             # без изменений, остается источником правды
       └── fr/messages.json
        └── components
            └── Hero.tsx                     # useLingui(); _("hero.title")  ← без изменений
    

    Формат. Компилятор Lingui преобразует конструкцию {count, plural, one {# item} other {# items}} в массив токенов, поэтому рантайму не требуется парсить ICU. Адаптер хранит сообщение в виде строки и разбирает его через ICU-резолвер Intlayer. Это создает фиксированные накладные расходы около 15 КБ один раз на страницу. По этой причине строка dynamic уступает по объему байтов, выигрывая по всем остальным метрикам. Нативный Intlayer избегает этого, поскольку в файлах .content.ts узлы enu() / insert() рассчитываются компилятором заранее.

    Миграция в три шага

    1. Установка

      bash
      npx intlayer init --interactive
      

      Команда обнаружит Lingui, изучит lingui.config.ts, чтобы подключить syncPO (для каталогов .po) или syncJSON (для каталогов JSON), установит пакеты intlayer, react-intlayer, @intlayer/lingui и требуемый плагин синхронизации, а также заменит @lingui/vite-plugin на плагин адаптера в vite.config.ts. Пакеты @lingui/core, @lingui/react и плагин макросов удалять не нужно: макросы компилируются штатно, а адаптер опирается на типы Lingui.

    2. Настройка связи Intlayer с каталогами

      Для каталогов JSON (format: "minimal" в lingui.config.ts):

      intlayer.config.ts
      import { Locales, type IntlayerConfig } from "intlayer";
      import { syncJSON } from "@intlayer/sync-json-plugin";
      
      const config: IntlayerConfig = {
        internationalization: {
          locales: [Locales.ENGLISH, Locales.FRENCH, Locales.SPANISH],
          defaultLocale: Locales.ENGLISH,
        },
        dictionary: {
          importMode: "dynamic",
          format: "icu",
        },
        plugins: [
          syncJSON({
            format: "icu",
            source: ({ locale, key }) => `./src/locales/${locale}/${key}.json`,
            // Группировка идентификаторов по первому сегменту: `footer.github` → словарь `footer`
            splitKeys: "key-prefix",
          }),
        ],
      };
      
      export default config;
      

      Для каталогов .po замените syncJSON на syncPO из пакета @intlayer/sync-po-plugin с аналогичным шаблоном в свойстве source и расширением .po. Подробнее в документации плагина Sync PO.

      Параметр splitKeys: "key-prefix" как раз обеспечивает уменьшение размера компонентов. Сам файл каталога сохраняет линейную структуру, разбивка применяется только к сгенерированным словарям, а обратная синхронизация автоматически объединяет ключи.

    3. Подключение плагина

      vite.config.ts
      import { defineConfig } from "vite";
      import { tanstackStart } from "@tanstack/react-start/plugin/vite";
      import viteReact from "@vitejs/plugin-react";
      import { lingui } from "@intlayer/lingui/plugin";
      
      export default defineConfig({
        plugins: [
          tanstackStart(),
          viteReact({
            // Сохраните плагин макросов; он должен выполниться до этапа Intlayer
            babel: { plugins: ["@lingui/babel-plugin-lingui-macro"] },
          }),
          lingui(),
        ],
      });
      

      Плагин lingui() оборачивает vite-intlayer (отслеживание изменений контента, сборка словарей, фаза оптимизации) и настраивает алиасы для перенаправления @lingui/core и @lingui/react на адаптер. Запустите сборку, и зафиксированные выше преимущества станут доступны вашему приложению.

    Что можно удалить после перехода

    Файл / паттернПричина
    await import(`./locales/${locale}/messages.mjs`) | Словари импортируются компонентами напрямую. Метод i18n.load() становится фолбеком
    i18n.load() / i18n.loadAndActivate()Оставьте i18n.activate(locale); удалите ручную загрузку каталогов
    lingui compile в сценариях сборкиТолько если вы перешли на JSON / .po как источник и больше не импортируете скомпилированные модули

    Что вы получаете помимо экономии байтов

    • Поиск пропущенных переводов. Команда npx intlayer test прерывает пайплайн CI, если в каком-либо языке отсутствует ключ; lingui extract отображает лишь сводную статистику.
    • npx intlayer fill переводит недостающие строки с помощью выбранного вами ИИ-провайдера (OpenAI, Anthropic, Mistral, Gemini...) и сохраняет их прямо в исходные каталоги.
    • Визуальный редактор и CMS работают с теми же словарями, позволяя контент-менеджерам редактировать файлы .po и JSON через удобный интерфейс.
    • Плавная миграция на .content.ts. В любой момент отдельный компонент можно перевести с useLingui() на useIntlayer("hero") с выделенным файлом декларации контента. Оба формата словарей прекрасно сосуществуют.

    Ограничения, о которых важно знать заранее

    • Накладные расходы на страницу в режиме dynamic. Как отмечалось выше: рассчитывайте примерно на +20 КБ на страницу относительно лениво загружаемого Lingui на небольшом приложении. Эта разница не растет вместе с текстами (она задается резолвером, а не каталогами), но и не сокращается.
    • Утечка исходной локали сохраняется. Дескрипторы сообщений и сгенерированный макросами код содержат английский оригинал для подстраховки. Для ее устранения потребуется очистить поле message или переписать компонент на .content.ts.
    • i18n.load() - это фолбек, а не целевой путь. Если продолжать импортировать скомпилированные каталоги и вызывать load(), в итоговый бандл попадут и старая, и новая схемы. Удалите эти импорты.
    • Поддерживается только Vite. Плагин для Next.js в рамках @intlayer/lingui отсутствует. Проектам на Next.js с Lingui имеет смысл сразу рассмотреть next-intlayer.
    • Свойство defaultComponent не задействуется. Если вы использовали его для оборачивания каждого <Trans>, добавляйте обертку явно в разметку компонентов.

    Что выбрать в вашей ситуации?

    • Оставайтесь на Lingui, если в проекте уже реализована схема scoped-dynamic, вашей приоритетной метрикой является минимальный вес страницы в КБ, а переключение языка за 42 мс и гидратация за 30 мс полностью приемлемы для задач проекта.
    • Выбирайте @intlayer/lingui, если проект использует Lingui и вам требуются легковесные компоненты, быстрая гидратация и отзывчивая смена локали, отсутствие утечек страниц в простых схемах, типизированные идентификаторы, валидация в CI и автоперевод через ИИ без изменения макросов. Это оптимальный мост для существующей кодовой базы.
    • Переходите на нативный Intlayer (react-intlayer), когда начнется плановый рефакторинг компонентов. Это единственное решение в тесте с 0% утечки локали, рантаймом 5 КБ и приростом всего +7,6 КБ на страницу по сравнению с базовым приложением.

    Похожие сравнения

    Заключение

    @intlayer/lingui меняет то, к чему привязываются места вызова Lingui: вместо глобального экземпляра и монолитного каталога локали каждый компонент получает персонально скомпилированный словарь. На приложении TanStack Start это дает уменьшение компонентов в 7 раз, ускорение гидратации на 8-14 мс, в 2 раза более быстрое переключение языка без пауз в 42 мс без необходимости переписывать макросы. Решение не удаляет встроенные запасные строки (утечка исходного языка сохраняется) и парсит ICU на лету (динамическая схема загружает примерно на 20 КБ больше на страницу, чем чистый Lingui). Оцените ваши цели по производительности перед выбором подходящего пути.

    Все исходные данные, тестовые стенды и скрипты опубликованы в репозитории Benchmark Bloom. Вы можете повторить эти тесты самостоятельно.

    Ознакомьтесь с материалом 'Почему Intlayer?' для получения дополнительной информации.

    Комментарии

    Пока нет комментариев. Будьте первым, кто поделится своими мыслями.

    Похожие сообщения

    Последние сообщения