Перейти к содержанию

Создание альтернативы i18n

Кризис производительности в современной i18n

Если вы используете i18next с TypeScript, вы, вероятно, чувствовали эту боль. Несмотря на улучшения производительности, реальность отрезвляет:

протестировано на Apple M1

  • Компиляция TypeScript: Каждые 1,000 ключей переводов добавляют ~1 секунду к времени сборки tsc
  • Отзывчивость IDE: Подсказки типов замедляются на 0.3+ секунды с большими словарями
  • Размер бандла: i18next весит 41.6 КБ (13.2 КБ gzip) еще до добавления переводов
  • Производительность во время выполнения: Парсинг пользовательского DSL становится узким местом в масштабе

Реальные разработчики чувствуют эту боль:

"Нам пришлось полностью убрать типизацию i18n из-за переполнения памяти CI с ~3к переводами" - Продакшн разработчик

"Удаление i18next улучшило производительность нашего SSR в 3 раза без потери функциональности" - Инженер по производительности

Но вот в чем дело: современный JavaScript имеет все необходимое встроенное.

Почему использовать нативные решения?

API интернационализации значительно созрел. У нас есть:

Эти API имеют нулевую стоимость, поддерживают tree-shaking и молниеносно быстры.

Решение: Система i18n из 5 файлов

Вот полная система интернационализации, которая проще, быстрее и более удобна в сопровождении, чем традиционные библиотеки:

1. Определение и управление языками

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// translations/lang.ts
import { cookie } from '../cookie';

const LANGS = {
    en: 'en',
    ru: 'ru',
} as const;
export type LANGS = keyof typeof LANGS;

const userLang =
    cookie.get('lang') ?? window.navigator.language;
export const LANG =
    userLang in LANGS ? (userLang as LANGS) : 'en';

export const changeLang = (lang: string) => {
    cookie.set('lang', lang);
    window.location.reload();
};

// Нативные форматеры - нулевые накладные расходы
export const degree = new Intl.NumberFormat(LANG, {
    style: 'unit',
    unit: 'degree',
    unitDisplay: 'long',
});

2. Динамическая загрузка переводов

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// translations/index.ts
import { LANG } from './lang';

export * from './lang';

const vocabModule = {
    en: () => import('./en'),
    ru: () => import('./ru'),
} as const;

// Загружаем только нужный нам язык
export const { vocab: t } = await vocabModule[LANG]();

3. Типобезопасные файлы переводов

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// translations/en.ts
import { degree } from './lang';

export const vocab = {
    hi: 'Hello',
    temperature: (n: number) =>
        `Temperature is ${degree.format(n)}`,
};

export type Vocab = typeof vocab;
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// cookie.ts
const getCookieRec = () =>
    Object.fromEntries(
        document.cookie
            .split('; ')
            .map((rec) => rec.split('='))
    );

export const cookie = {
    get(name: string): string | undefined {
        return getCookieRec()[name];
    },
    set(name: string, value: string) {
        document.cookie = `${name}=${value}`;
    },
};

5. Использование в компонентах

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// App.tsx
import { useState } from 'react';
import { LANG, changeLang, t } from './translations';

export function App() {
    const [count, setCount] = useState(0);

    return (
        <main>
            <p>{t.hi}</p>
            <p>
                <button
                    onClick={() => setCount((s) => s + 1)}
                >
                    {count}
                </button>
            </p>
            <p>{t.temperature(count)}</p>
            <p>
                <select
                    value={LANG}
                    onChange={(e) =>
                        changeLang(e.target.value)
                    }
                >
                    {['ru', 'en'].map((lang) => (
                        <option key={lang} value={lang}>
                            {lang}
                        </option>
                    ))}
                </select>
            </p>
        </main>
    );
}

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

Молниеносно быстрые типы: Прямой доступ к объекту, без сложного сопоставления.

Нулевые накладные расходы времени выполнения: Без парсинга DSL, без дополнительного размера библиотеки.

Автоматическое разделение кода: Загружайте только те переводы, которые вам нужны

Полная типобезопасность: TypeScript автоматически выводит всё

Нативное форматирование: Используйте браузерные API для чисел, дат, множественного числа

Простой API: t.key вместо t('key')

SSR из коробки: никаких дополнительных настроек для SSR

Независимость от фреймворка: используйте со Svelte, React, Vue или jQuery 😁

Продвинутые паттерны

Поддержка пространств имён

Создавайте подкаталоги для различных функциональных областей:

1
2
3
4
5
6
7
8
9
const vocabModule = {
    en: () => import('./en'),
    ru: () => import('./ru'),
} as const;

const authModule = {
    en: () => import('./auth/en'),
    ru: () => import('./auth/ru'),
} as const;

Множественные формы с Intl.PluralRules

Для сложных форм множественного числа интегрируйте нативный API Intl.PluralRules непосредственно в ваш словарь:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// translations/en.ts
import { degree } from './lang';

const pluralRules = new Intl.PluralRules('en-US');

const createPlural = (
    forms: Record<Intl.LDMLPluralRule, string>
) => (count: number) => {
    const rule = pluralRules.select(count);
    return forms[rule].replace('{count}', count.toString());
};

export const vocab = {
    hi: 'Hello',
    temperature: (n: number) =>
        `Temperature is ${degree.format(n)}`,
    items: createPlural({
        zero: 'No items',
        one: '1 item',
        two: '{count} элементов', // для языков, которые различают "два"
        few: '{count} элементов', // для языков с категорией "несколько"
        many: '{count} элементов', // для языков с категорией "много"
        other: '{count} items',
    }),
    // Более сложный пример с вариациями по роду/падежу
    notifications: createPlural({
        zero: 'No new notifications',
        one: 'You have 1 new notification',
        other: 'You have {count} new notifications',
    }),
};

export type Vocab = typeof vocab;

Использование остается красиво простым:

1
2
3
<p>{t.items(0)}</p>  // "No items"
<p>{t.items(1)}</p>  // "1 item"
<p>{t.items(5)}</p>  // "5 items"

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

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

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

Для серверных сред без состояния (Lambda, Vercel Functions и т.д.) это решение идеально подходит как есть. Каждый запрос получает свой собственный контекст выполнения, поэтому статические импорты работают прекрасно.

Для серверов с состоянием (Express, Fastify и т.д.) у вас есть простой путь миграции. Преобразуйте точечную нотацию t.key в вызовы функций t().key, затем реализуйте функцию t используя AsyncLocalStorage из Node.js:

1
2
3
4
// translations/variable.ts
import { AsyncLocalStorage } from 'async_hooks';

export const tVariable = new AsyncLocalStorage<string>();

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// translations/index.ts

// ...

const { vocab } = await vocabModule[LANG]();

export let t = () => vocab;

if (SSR) {
    const vocabs = {
        en: import('./en'),
        ru: import('./ru'),
    } as const;

    const { tVariable } = await import('./variable');

    t = () => {
        const locale = tVariable.getStore() ?? 'en';
        return vocabs[locale];
    };
}

1
2
3
4
5
6
import { tVariable } from 'translations/variable';

app.use((req, res, next) => {
    const locale = detectLocale(req);
    tVariable.run(locale, next);
});

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

Компромисс

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

Для команд, которым нужно нетехническое редактирование, рассмотрите:

  • Генерацию во время сборки из внешних источников
  • Git-воркфлоу с инструментами управления переводами
  • Гибридные подходы для разных типов контента

Попробуйте

Демо на StackBlitz

Этот подход полностью изменил то, как я думаю об интернационализации. Иногда лучшее решение - не самое популярное, а то, которое использует то, что уже встроено в платформу.

Каков ваш опыт работы с производительностью i18n? Нашли ли вы другие легковесные альтернативы? Поделитесь своими мыслями в комментариях!

Источник — https://dev.to/artalar/building-a-lightning-fast-i18n-alternative-why-i-ditched-i18next-for-native-javascript-2o06

Комментарии