Динамические переводы в Angular стали возможными

Практическое руководство по реализации переводов с ленивой загрузкой

Если вы когда-либо имели дело с интернационализацией (или для краткости i18n) в Angular или собираетесь ее реализовать, вы можете придерживаться официального руководства, которое является замечательным, использовать сторонние пакеты, которые могут быть сложными для отладки, или выбрать альтернативный путь, который я опишу ниже.

Одной из распространенных ошибок при использовании i18n является большой размер файлов перевода и невозможность разделить их, чтобы скрыть части вашего приложения от посторонних глаз. Некоторые решения, такие как встроенная реализация Angular, действительно мощные и SEO-совместимые, но требуют большой подготовки и не поддерживают переключение языков на лету в режиме разработки (что доставляло проблемы по крайней мере в версии 9); другие решения, такие как ngx-translate, требуют установки нескольких пакетов и по-прежнему не поддерживают разделение одного языка (обновление: на самом деле ngx-translate поддерживает это).

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

Подготовка основы

Первый шаг — создать тип для языков, которые будут использоваться в приложении:

export type LanguageCode = 'en' | 'de';

Одной из любимых функций Angular является внедрение зависимостей, которое очень много делает для нас — давайте использовать его для наших нужд. Я также хотел бы немного оживить ситуацию, используя NgRx для этого руководства, но если вы не используете его в своем проекте, не стесняйтесь заменить его простым BehaviorSubject.

В качестве дополнительного шага, который упростит дальнейшую разработку с NgRx, создайте тип для фабрик DI:

export type Ti18nFactory<Part> = (store: Store) => Observable<Part>;

Создание файлов перевода

Общие строки

Предположим, у нас есть несколько основных строк, которые мы хотели бы использовать в приложении. Некоторые простые, но общие вещи, которые никогда не связаны с конкретным модулем, функцией или библиотекой, такие как кнопки «ОК» или «Назад».
Мы поместим эти строки в «основной» модуль и начнем делать это с помощью простого интерфейса. это поможет нам не забыть ни одной строки в наших переводах:

export interface I18nCore {
  errorDefault: string;
  language: string;
}

Просто для ясности: этот интерфейс не гарантирует, что все строки будут действительно переведены, но компилятор TypeScript (и ваша IDE) выдаст ошибку «TS2741», если вы забудете включить какую-либо строку в свои «языковые» файлы.

Переходя к реализации интерфейса и для этого фрагмента жизненно важно предоставить пример пути к файлу, который в данном случае будет libs/core/src/lib/i18n/lang-en.lang.ts:

export const lang: I18nCore = {
  errorDefault: 'An error has occurred',
  language: 'Language',
};

Чтобы уменьшить дублирование кода и получить максимальную отдачу от процесса разработки, мы также создадим фабрику внедрения зависимостей. Вот рабочий пример с использованием NgRx (опять же, это совершенно необязательно, для этого вы можете использовать BehaviorSubject):

export const I18N_CORE =
  new InjectionToken<Observable<I18nCore>>('I18N_CORE');

export const i18nCoreFactory: Ti18nFactory<I18nCore> =
  (store: Store): Observable<I18nCore> => 
    (store as Store<LocalePartialState>).pipe(
      select(getLocaleLanguageCode),
      distinctUntilChanged(),
      switchMap((code: LanguageCode) =>
        import(`./lang-${code}.lang`)
          .then((l: { lang: I18nCore }) => l.lang)
      ),
    );

export const i18nCoreProvider: FactoryProvider = {
  provide: I18N_CORE,
  useFactory: i18nCoreFactory,
  deps: [Store],
};

Очевидно, что селектор getLocaleLanguageCode выберет код языка из Store.

Не забудьте включить файлы перевода в вашу компиляцию, так как на них нет прямых ссылок, поэтому они не будут включены автоматически. Для этого найдите соответствующий «tsconfig» (тот, в котором указан «main.ts») и добавьте следующее в массив «include»:

"../../libs/core/src/lib/i18n/*.lang.ts"

Обратите внимание, что путь к файлу здесь включает подстановочный знак, так что все ваши переводы будут включены сразу. Кроме того, исходя из вкуса, мне нравится ставить префиксы для похожих файлов, что в значительной степени объясняет, почему имя примера ([prefix]-[langCode].lang.ts) выглядит так странно.

Строки, специфичные для модуля

Давайте сделаем то же самое для любого модуля, чтобы мы могли видеть, как отдельно будут загружаться переводы в браузере. Для простоты этот модуль будет называться «tab1».

Опять же, начнем с интерфейса:

export interface I18nTab1 {
  country: string;
}

Реализуйте этот интерфейс:

export const lang: I18nTab1 = {
  country: 'Country',
};

Включите ваши переводы в компиляцию:

"../../libs/tab1/src/lib/i18n/*.lang.ts"

И, при желании, создать фабрику внедрения зависимостей, которая будет выглядеть буквально так же, как предыдущая, но с другим интерфейсом.

Предоставление переводов

Я предпочитаю уменьшить количество провайдеров, поэтому «основные» переводы будут перечислены только в AppModule:

providers: [i18nCoreProvider],

Любой другой перевод должен предоставляться только в соответствующих модулях — либо в функциональных модулях с ленивой загрузкой, либо, если вы следуете шаблону SCAM, в модулях-компонентах:

@NgModule({
  declarations: [TabComponent],
  imports: [CommonModule, ReactiveFormsModule],
  providers: [i18nTab1Provider],
})
export class TabModule {}

Также обратите внимание на элегантность использования готовых FactoryProviders вместо добавления здесь объектов.

Вставьте токены в component.ts:

constructor(
  @Inject(I18N_CORE)
  public readonly i18nCore$: Observable<I18nCore>,
  @Inject(I18N_TAB1)
  public readonly i18nTab1$: Observable<I18nTab1>,
) {}

И, наконец, оберните component.html ng-container и простым оператором ngIf:

<ng-container *ngIf="{
    core: i18nCore$ | async,
    tab1: i18nTab1$ | async
  } as i18n">
    <p>{{ i18n.core?.language }}</p>
    <p>{{ i18n.tab1?.country }}: n/a</p>
</ng-container>

Проверка результата

Давайте запустим это и посмотрим, действительно ли это работает и, что более важно, как именно будут загружаться эти переводы. Я создал простое демонстрационное приложение, состоящее из двух лениво загружаемых модулей Angular, поэтому вы можете клонировать его и экспериментировать с ним. А пока вот собственно скриншоты DevTools:

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

  • С помощью этого решения вы сможете, но не обязаны, разделить ваши переводы на несколько файлов любым удобным для вас способом;
  • Он реактивный, что означает, что при правильной реализации он обеспечивает вашим пользователям беспроблемный опыт;
  • Вам не нужно устанавливать что-либо, что не поставляется с Angular из коробки;
  • Его легко отладить и полностью настроить, так как он будет реализован непосредственно в вашем проекте;
  • Он поддерживает сложные разрешения локали, такие как связь с языком браузера, выбор региональных настроек из учетной записи пользователя при авторизации и переопределение с помощью определяемого пользователем языка — и все это без единой перезагрузки страницы;
  • Он также поддерживает завершение кода в современных IDE.

Недостатки

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

Гитхаб

Не стесняйтесь экспериментировать с полностью рабочим примером, который доступен в этом репозитории.
Оставайтесь позитивными и создавайте отличные приложения!