Типизированные переводы в Angular
Поддержка интернационализации — одна из сложных тем во фронтенд-разработке, все всегда добавляют только ключи и значения для основной локали, обычно английского (en
или en-gb
), оставляя переводы для других языков на потом.
Мы столкнулись с этой проблемой в одном из наших проектов при реализации локализации.
В исходной версии мы не настраивали параметры локализации, завершили сборку и получили результат.
Переводы по токену, в случае неудачи, заменялись на имя токена.
Решением проблемы является использование резервного языка (fallbackLang
) при разрешении переводов по токену.
Решает fallbackLang
вопрос с отображением текста, чтобы не путать пользователя, но следить за наличием ключей всё равно остаётся, а также привлекать QA для выявления проблем.
Многие, кто использует переводы во время выполнения, полностью понимают проблему: «как можно гарантировать, что ключевой токен правильный и можно найти соответствующий перевод для текущей локали во время выполнения браузера?»
Я тоже задавался этим вопросом, но все, что мне удалось найти, это статью от 21 октября 2018 года. Набранные переводы в Angular.
Решение для того времени было отличным, но автор использует объект напрямую, а нативная множественность через js с наличием ICU кажется излишней.
Несомненно, это решение можно было бы улучшить, например, сделав токен TRANSLATION
потоковым, добавив сервис-агрегатор с переводами при смене языка и так далее. Но опять же, это совсем другая история.
ESLINT — i18n-json
Ситуацию частично спасает eslint-плагин eslint-plugin-i18n-json, который проверяет идентичность ключей в json-файлах, но для надежности этого тоже недостаточно.
Потому что это односторонняя проверка, и она не гарантирует, что разработчик, используя текущую базу перевода, укажет правильный ключ, и QA это заметит, если что-то пойдет не так.
Нам нужно будет установить eslint
, commitlint
проверяет изменения в json, а также интегрирует его в пайплайны, если у нас есть встраивание переводов из внешних источников.
Мое предложение
А что, если я скажу вам, что все эти проверки не нужны?
Чтобы обеспечить согласованность переводов под локаль, забудьте про fallbackLang
и missingKeyHandler
и больше не беспокойтесь о правильности ключа, можно ли этого добиться исключительно TypeScript?
Данное решение подходит как для текущих реализаций на ngx-translate или transloco, так и для новых.
Итак, каким требованиям должно соответствовать решение?
- Сборка приложения завершается неудачей, если в одном из переводов отсутствует ключ.
- Разработчики уведомляются об ошибках с неправильным ключом, а также есть подсказки о доступном списке ключей.
- Обеспечьте масштабируемость, добавляя новые переводы по локали, без прямого взаимодействия с кодом, но обеспечивайте все проверки.
- Текущие ключевые переводы
ngx-translate
иtransloco
работа по-прежнему. Их можно параметризовать, и они поддерживают отделение интенсивной терапии. - Переводы можно использовать в библиотеках и публиковать как самодостаточные пакеты.
Вторично:
- QA через консоль браузера может понять, какой элемент с какой клавишей взаимодействует.
- Исключить переключение на локаль, если ее нет в списке доступных.
Решение
Демонстрационный репозиторий состоит из:
assets
— каталог с подкаталогом i18n, в котором хранятся файлы перевода.libs
— набор библиотек для:ui-actions
иui-layouts
— набор демо-библиотек.i18n-wrapper
— содержит сущности для ввода сервиса transloco.typed-transloco
— типизированная версия transloco со своим файлом перевода и основанная на i18n-обертке.ui-actions
иui-layouts
— набор демо-библиотек.i18n-wrapper
— содержит сущности для ввода сервиса transloco.typed-transloco
— типизированная версия transloco со своим файлом перевода и основанная на i18n-обертке.src
— каталог хост-приложения.scripts
— каталог для сервисных скриптов.
Для простоты и удобства я решил взять за основу библиотеку @ngneat-transloco
, общую для двух библиотек репозитория.
Она предоставляет единый файл перевода и сервис, с которым они взаимодействуют.
В случае публикации библиотек пользовательского интерфейса эта библиотека также будет опубликована и будет служить одноранговой зависимостью.
Это решение больше подходит для крупных монорепозиториев, где переводы управляются через основную библиотеку с assets/i18n
. Ничто не мешает применить мое решение к ngx-translate
, где есть механизм наследования или разделения сервисов.
1. Библиотека i18n-wrapper
Эта библиотека будет содержать два каталога:
transloco
— каталог для оберток transloco.types
— распространенные типы набора файла перевода.
Ввод токенов для перевода
Мы привыкли указывать токен перевода в виде строки, разделенной точками. Пример: lib.scope.module.component.somekey
.
Я хотел сохранить этот подход и создать тип, который вычисляет все возможные ключи из нашего единого файла перевода.
В отличие от универсальной версии в статье, я обрежу этот рекурсивный тип для нашего решения, чтобы он предоставлял только конечные пути через точку к примитиву.
type TAddPrefix<Prefix extends string, Key extends string> = [Prefix] extends [never]
? `${Key}`
: `${Prefix}.${Key}`;
export type TObjectKeyPaths<T extends Record<string, any>, Prefix extends string = never> = {
[P in string & keyof T]: T[P] extends object
? TObjectKeyPaths<T[P], TAddPrefix<Prefix, P>>
: T[P] extends string
? TAddPrefix<Prefix, P>
: never;
}[string & keyof T];
Имея рекурсивный тип для получения путей перевода, все, что нам остается сделать, — это создать тип, который преобразует тип только для чтения в i18n.db.ts
карту ключей для каждой локали, обеспечивая синхронизацию между языками.
export type TTranslatePath<
DB extends object,
Langs extends keyof DB = keyof DB
> = TObjectKeyPaths<DB[Langs]>
С этими двумя типами мы получили основную часть нашей типизации, которая решает 80% вопросов из поставленной задачи, которую уже можно применять к разным частям проекта, а не только к локализации.
Осталось всего 20% — набранная обертка для transloco.
Оберток будет всего три, и все они абстрактные:
AbstractTranslocoConfigService
— сервис, регулирующий список доступных языков при настройкеTRANSOLO_CONFIG
. Он также реализуетTranslocoLoader
интерфейс для загрузки нашего файла перевода.AbstractTranslocoService
— сервис-обертка над TranslocoService, предлагающая аналогичный список методов из TranslocoService, но при этом сужающая типы доступных языков и ключей до доступных изi18n.db.ts
.AbstractTranslocoComponent
— одна из сущностей, которая будет вставлять переводы в шаблон на основе предоставленного токена (аналогично каналу и директиве).
AbstractTranslocoConfigService
Как я упоминал ранее, необходимо предоставить функции для токенов TRANSLOCO_CONFIG
и TRANSLOCO_LOADER
, когда значения доставляются через фабрику.
Этот сервис прост и принимает один общий тип нашего файла перевода для ввода доступного списка языков.
Поскольку мы знаем доступный список языков и получаем язык по умолчанию в конструкторе, для метода, предоставляющего конфиг, мы можем исключить, defaultLang
и availableLangs
, fallbackLang
оставив настройки для transloco.
export type TTypedTranslocoConfig = Partial<Omit<TranslocoConfig, 'defaultLang' | 'availableLangs' | 'fallbackLang'>>;
Результат:
export abstract class AbstractTranslocoConfigService<
i18nDb extends Record<string, Translation>,
Lang extends keyof i18nDb = keyof i18nDb,
> implements TranslocoLoader {
public readonly AVAILABLE_LANGS: Lang[]
protected constructor(
public readonly DB: i18nDb,
public readonly DEFAULT_LANG: Lang,
) {
this.AVAILABLE_LANGS = Array.from(
new Set<Lang>([
DEFAULT_LANG,
...Object.keys(DB) as Lang[],
]),
);
}
public getProvidedConfig(config: TTypedTranslocoConfig): TranslocoConfig {
const patchedConfig: Partial<TranslocoConfig> = {
...config,
defaultLang: this.initializeDefaultLanguage() as string,
availableLangs: this.AVAILABLE_LANGS as string[],
fallbackLang: this.DEFAULT_LANG as string
}
return translocoConfig(patchedConfig);
}
private initializeDefaultLanguage(): Lang {
const browserLanguage = getBrowserLang() as Lang | undefined;
if (!browserLanguage) {
return this.DEFAULT_LANG
}
return this.isAvailableLang(browserLanguage) ? browserLanguage : this.DEFAULT_LANG;
}
public isAvailableLang(lang: Lang | string): lang is Lang {
return this.AVAILABLE_LANGS.includes(lang as Lang)
}
public getTranslation(lang: string): Observable<Translation> {
return of(this.DB[lang])
}
}
AbstractTranslocoService
Этот сервис не содержит какой-либо конкретной логики и служит типизированным посредником между объектами, желающими использовать TranslocoService
.
Для простоты и минимизации кода я добавил только методы управления языком.
Единственное отличие этого сервиса от нативного TranslocoService
— это фильтрация языков по признаку во AbstractTranslocoConfigService
избежание установки недоступного языка.
export abstract class AbstractTranslocoService<
i18nDb extends Record<string, Translation>,
Lang extends keyof i18nDb = keyof i18nDb,
> {
protected constructor(
protected readonly translocoService: TranslocoService,
protected readonly translocoConfigService: AbstractTranslocoConfigService<i18nDb, Lang>
) {
}
public getActiveLang(): Lang {
return this.translocoService.getActiveLang() as Lang;
}
public setActiveLang(candidate: Lang): void {
this.translocoService.setActiveLang(this.checkLanguage(candidate) as string);
}
public getDefaultLang(): Lang {
return this.translocoService.getDefaultLang() as Lang;
}
public setDefaultLang(candidate: Lang): void {
this.translocoService.setDefaultLang(this.checkLanguage(candidate) as string);
}
public getAvailableLangs(): Lang[] {
return this.translocoService.getAvailableLangs() as Lang[];
}
private checkLanguage(language: Lang): Lang {
if (this.translocoConfigService.isAvailableLang(language)) {
return language as Lang
}
const browserLanguage: string | undefined = getBrowserLang();
if (browserLanguage && this.translocoConfigService.isAvailableLang(browserLanguage)) {
return browserLanguage;
}
return this.getActiveLang() || this.translocoConfigService.DEFAULT_LANG;
}
}
AbstractTranslocoComponent
Я решил реализовать это через компонент, и этот выбор осознан по нескольким причинам:
- Это позволяет нам не объявлять в шаблоне функции, которые могут быть ограничены правилами eslint.
- Есть возможность отсоединить компонент от дерева компонентов для оптимизации при переключении языков.
- В целях контроля качества существует возможность привязать наш токен к элементу с помощью HostBinding, что позволяет определить, какой токен используется в определенном месте.
- Предусмотрены автоматические предложения доступных токенов.
Минусы:
- Необходимо указать полный ключ из файла перевода.
- Невозможно передать значение от одного компонента к другим, которые принимают текст только в строковом формате и не допускают настройки с помощью
<ng-content>
илиTemplateRef
.
К счастью, эти недостатки устраняются путем создания оболочек для translocoPipe и translocoDirective.
Чтобы уменьшить связанность, я представлю интерфейс с именем ISelectTranslateService
, который будет регулировать реализацию сервиса для переводов на основе параметров.
export interface ISelectTranslateService<
i18nDB extends Record<string, Translation>,
Lang extends keyof i18nDB = keyof i18nDB> {
selectTranslate$: (
path: TTranslatePath<i18nDB>,
params?: HashMap,
lang?: Lang
) => Observable<string>
}
Результат:
@Directive()
export abstract class AbstractTranslocoComponent<
i18nDb extends Record<string, Translation>, Lang extends keyof i18nDb = keyof i18nDb,
> implements OnChanges {
private readonly update$: ReplaySubject<void> = new ReplaySubject<void>(1);
public translation$: Observable<string> = this.update$.asObservable().pipe(
switchMap(() => this.getCurrentTranslation$())
);
@Input()
public key!: TTranslatePath<i18nDb>
@Input()
public params?: HashMap;
@Input()
public lang?: Lang;
protected constructor(
protected service: ISelectTranslateService<i18nDb, Lang>
) {
}
public ngOnChanges(): void {
this.update();
}
public update(): void {
this.update$.next();
}
private getCurrentTranslation$(): Observable<string> {
return this.service.selectTranslate$(
// @ts-expect-error: not be infinite
this.key,
this.params,
this.lang
)
}
}
2. Библиотека typed-transloco
Единая база данных переводов
Во-первых, хотелось бы отойти от привычной практики помещения переводов под каждую локаль в json в один ts-файл.
assets/i18n/en.json
{
"actions": {
"back": "Back",
"confirm": "Are you sure?",
"new": "New"
},
"layouts": {
"about": "About",
"account": "Account",
"app_store": "App Store"
}
}
assets/i18n/de.json
{
"actions": {
"back": "Zurück",
"confirm": "Bist du sicher?",
"new": "Neu"
},
"layouts": {
"about": "Um",
"account": "Konto",
"app_store": "Appstore"
}
}
Он экспортирует один объект со всеми переводами для каждой локали.
i18n.db.ts
export const TRANSLATE_DB = {
"de": {
"actions": {
"back": "Zurück",
"confirm": "Bist du sicher?",
"new": "Neu"
},
"layouts": {
"about": "Um",
"account": "Konto",
"app_store": "Appstore"
}
},
"en": {
"actions": {
"back": "Back",
"confirm": "Are you sure?",
"new": "New"
},
"layouts": {
"about": "About",
"account": "Account",
"app_store": "App Store"
}
}
};
По сути да, я ухожу от ленивой загрузки переводов в сторону синхронной загрузки и предсказуемого набора текста.
Конечно, вы можете импортировать файлы JSON в TS, но тогда вам придется настроить tsconfig.json
формат resolveJsonModule
. Кроме того, по личным причинам я считаю этот подход неоптимальным, если существует процесс экспорта одного файла перевода из проекта.
Типизированные сущности
Благодаря наличию единого файла перевода и абстрактных оберток мы можем реализовать типизированные версии для наших переводов:
- TypedTranslocoConfigService
@Injectable()
export class TypedTranslocoConfigService extends AbstractTranslocoConfigService<typeof TRANSLATE_DB> {
constructor() {
super(TRANSLATE_DB, 'en');
}
}
- TypedTranslocoService
@Injectable()
export class TypedTranslocoService extends AbstractTranslocoService<typeof TRANSLATE_DB>{
constructor(
translocoService: TranslocoService,
typedTranslocoConfigService: TypedTranslocoConfigService
) {
super(translocoService, typedTranslocoConfigService);
}
}
- TypedTranslocoComponent
@Component({
selector: 'typed-transloco-component',
standalone: true,
imports: [CommonModule],
template: '{{ translation$ | async }}',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TypedTranslocoComponent extends AbstractTranslocoComponent<typeof TRANSLATE_DB>{
constructor(
public typedTranslocoService: TypedTranslocoService
) {
super(typedTranslocoService);
}
}
TypedTranslocoModule
Также потребуется объявить все созданные сервисы и предоставить необходимые значения, когда мы захотим использовать переводы.
@NgModule({
imports: [TranslocoModule],
providers: [TranslocoService]
})
export class TypedTranslocoModule {
static forRoot(config: TTypedTranslocoConfig = {}): ModuleWithProviders<TypedTranslocoModule> {
return {
ngModule: TypedTranslocoModule,
providers: [
TypedTranslocoService,
TypedTranslocoConfigService,
{
provide: TRANSLOCO_LOADER,
useExisting: TypedTranslocoConfigService,
},
{
provide: TRANSLOCO_CONFIG,
deps: [TypedTranslocoConfigService],
useFactory: (configService: TypedTranslocoConfigService) => {
return configService.getProvidedConfig(config)
}
}
]
}
}
}
3. Интеграция в библиотеки пользовательского интерфейса.
Основная цель этой статьи — продемонстрировать решение для типизации переводов, поэтому примеры для этих библиотек будут простыми и содержат минимум логики.
Для этого в каждом из них был создан витринный компонент.
Оба компонента, условно, отображают для пользователя список доступных компонентов из библиотеки, аналогичный сборнику рассказов.
Я взаимодействую только с шаблоном компонента и пока создам один элемент, поддерживающий типизированную локализацию.
Поскольку мы передали вычисленный тип при TRANSLATE_DB
создании нашего компонента, редактор кода уже может предложить нам, какие доступные токены у нас есть для перевода.
Это также работает для списка доступных языков.
При попытке указать неправильный токен редактор высветит предупреждение о том, что мы указали ключ неправильно. Мы намеренно оставим эту проблему без исправления.
Результат
- Библиотека UI-действий
<button>
<typed-transloco-component key="actions.back"></typed-transloco-component>
</button>
<button>
<typed-transloco-component key="actions.confirm"></typed-transloco-component>
</button>
<button>
<typed-transloco-component key="actions.new-wrong"></typed-transloco-component>
</button>
- Библиотека UI-макетов
<button>
<typed-transloco-component key="layouts.app_store"></typed-transloco-component>
</button>
<button>
<typed-transloco-component key="layouts.account"></typed-transloco-component>
</button>
<button>
<typed-transloco-component key="layouts.about"></typed-transloco-component>
</button>
Использование библиотек в приложении
Вы не сможете продемонстрировать локализацию в библиотеках без использования демо-приложения. Он будет небольшим и будет содержать минимум кода.
Отображает два наших компонента из библиотек, а также переключает язык через файл TypedTranslocoService
.
@Component({
selector: 'app-root',
template: `
<lib-actions-showcase></lib-actions-showcase>
<lib-layouts-showcase></lib-layouts-showcase>
<div>
<button (click)="onChangeLang('en')">EN</button>
<button (click)="onChangeLang('de')">DE</button>
</div>
`,
styles: [`:host {display: flex; flex-direction: column; gap: 8px}`]
})
export class AppComponent {
constructor(
private typedTranslocoService: TypedTranslocoService
) {}
public onChangeLang($event: any): void {
console.log('lang: ', $event)
this.typedTranslocoService.setActiveLang($event)
}
}
Помимо импорта компонентов библиотеки, с помощью forRoot
импортируется модуль локализации для регистрации всех необходимых сервисов.
При попытке запустить наше приложение мы сталкиваемся с ошибкой.
Это тот результат, к которому мы стремились на протяжении всей статьи — ловим ошибки от неправильных токенов и обеспечиваем типизацию.
Давайте исправим ошибку.
И давайте пересоберем приложение. Оно успешно скомпилировалось, и теперь мы можем переключиться в браузер, чтобы увидеть конечный результат.
И наша локализация работает.