Internationalization в Angular с помощью ngx-translate

Aleksandr Serenko
F.A.F.N.U.R
Published in
8 min readAug 5, 2019
Angular Universal ngx-translate и Nx

Update 18 feb 2020: Реализация идей мультиязычности, описанных в статье, немного устарела , а выход Angular 9 привел к изменению некоторых подходов связанных с SSR. О новой реализации можно прочитать в статье — Мультиязычность ngx-translate в Angular 9 c монорепозиторием Nx.

В Angular есть два подхода для реализации мультиязычности:
Internationalization;
Ngx-translate.

Первый подход, рекомендуемый Angular, основан на LDML.

Ngx-translate перелагает же просто перевод, в формате ключ значение.

Данная статья посвящена второму подходу, так как он решает два основных недостатка:
- динамическое изменение языка (в LDML происходит статическая компиляция локали, и где приложение компилируется под конкретный язык);
- динамическое формирование переводов (есть возможность менять переводы в зависимости от логики, например переводы для select’ов и др.)

Установка

Установим ngx-translate:

yarn add @ngx-translate/core @ngx-translate/http-loader @nguniversal/common

Модуль — ngx-translate/core содержит основные services, pipes для переводов.

Модуль — ngx-translate/http-loader предоставляет возможность загружать переводы по http.

Модуль — nguniversal/common позволяет сохранять локаль при SSR рендеренге и не загружать ее повторно, на клиенте.

Получение и сохранение языка

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

/**
* Translation storage interface
*/
export abstract class TranslationStorage<T = any> {
/**
* Return language from storage
*/
abstract getLanguage(): T | null;

/**
* Remove language from storage
*/
abstract removeLanguage(): void;

/**
* Set language to storage
*/
abstract setLanguage(language: T): void;
}

В данной реализации локаль является обычной строкой. Реализация, будет следующей:

import { Injectable } from '@angular/core';

import { CookieStorage } from '@medium-stories/storage';

import { TranslationStorage } from '../interfaces/translation-storage.interface';

/**
* Keys for storage
*/
export const TRANSLATION_STORAGE_KEYS = {
language: 'language'
};

@Injectable()
export class BaseTranslationStorage implements TranslationStorage {
constructor(private storage: CookieStorage) {}

getLanguage(): string | null {
return this.storage.getItem(TRANSLATION_STORAGE_KEYS.language) || null;
}

removeLanguage(): void {
this.storage.removeItem(TRANSLATION_STORAGE_KEYS.language);
}

setLanguage(language: string): void {
this.storage.setItem(TRANSLATION_STORAGE_KEYS.language, language);
}
}

Так как нам важно знать локаль при SSR, используется CookieStorage.

Создадим service над страндартым сервисом локили в ngx-translate:

import { Observable } from 'rxjs';

/**
* Translation service interface
*/
export abstract class TranslationService<T = string> {
/**
* Return current lang
*/
abstract getLanguage(): T;

/**
* Get languages
*/
abstract getLanguages(): T[];

/**
* Set selected language by code
*
@param language Language code
*/
abstract setLanguage(language: string): Observable<string>;
}

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

import { isPlatformBrowser } from '@angular/common';
import { Inject, Injectable, PLATFORM_ID } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { Observable } from 'rxjs';

import { TranslationService } from '../interfaces/translation-service.interface';
import { TranslationConfig } from '../interfaces/translation-config.interface';
import { TranslationStorage } from '../interfaces/translation-storage.interface';
import { TRANSLATION_LANG_DEFAULT } from '../translation.common';
import { TRANSLATION_CONFIG } from '../translation.tokens';

@Injectable()
export class BaseTranslationService implements TranslationService {
constructor(
private translateService: TranslateService,
private translationStorage: TranslationStorage,
@Inject(PLATFORM_ID) private platformId: any,
@Inject(TRANSLATION_CONFIG) private translationConfig: TranslationConfig
) {
this.init();
}

getLanguage(): string {
return this.translateService.currentLang;
}

getLanguages(): string[] {
return this.translateService.getLangs();
}

setLanguage(language: string): Observable<string> {
this.translationStorage.setLanguage(language);

return this.translateService.use(language);
}

/**
* Init
*/
private init(): void {
const languages = this.translationConfig.languages || [TRANSLATION_LANG_DEFAULT];
let language = this.translationConfig.language || null;
if (!language) {
// If browser, select locale from browser
if (isPlatformBrowser(this.platformId)) {
language = window.navigator.language.split('-').shift();
}
if (!language) {
language = languages[0];
}
}
const currentLanguage = this.translationStorage.getLanguage() || language;

this.translateService.addLangs(languages);
this.translateService.setDefaultLang(language);
this.translateService.use(currentLanguage);
this.translationStorage.setLanguage(currentLanguage);
}
}

Метод init() берет конфиги локали и инициализирует локаль в приложении и задает их для приложения.

Было создано несколько InjectionToken’ов:

TRANSLATION_CONFIG — токен, который содержит список доступных языков, и язык, который используется по-умолчанию.

TRANSLATION_PREFIX — токен, который содержит путь до папки, в которой храняться переводы.

TRANSLATION_SUFFIX — токен, который определяет расширения файлов локали.

import { InjectionToken } from '@angular/core';

import { TranslationConfig } from './interfaces/translation-config.interface';

export const TRANSLATION_CONFIG = new InjectionToken<TranslationConfig>('TranslationConfig');

export const TRANSLATION_PREFIX = new InjectionToken<string>('TranslationPrefix');
export const TRANSLATION_SUFFIX = new InjectionToken<string>('TranslationSuffix');

Интерфейс TranslationConfig, содержит в себе список доступных языков и язык по-умолчанию:

/**
* Translation config
*/
export interface TranslationConfig<T = string> {
/**
* Default language
*/
language: T;

/**
* Available language
*/
languages: T[];
}

Модули локализации

Теперь реализуем сами модули для angular и universal.

Сначала реализуем для angular, где наши переводы будут браться по http*, если они не были загружены ранее (например с помощью TransferState, в нашем случае с universal).

import { HttpClient } from '@angular/common/http';
import { ModuleWithProviders, NgModule } from '@angular/core';
import { TransferState } from '@angular/platform-browser';
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';

import { TranslationOptions } from './interface/translation-options.interface';
import { TranslationService } from './interface/translation-service.interface';
import { TranslationStorage } from './interface/translation-storage.interface';
import { BrowserTranslateLoader } from './loaders/browser-translate.loader';
import { BaseTranslationService } from './services/base-translation.service';
import { BaseTranslationStorage } from './storages/base-translation.storage';
import { TRANSLATION_CONFIG_DEFAULT, TRANSLATION_PREFIX_DEFAULT, TRANSLATION_SUFFIX_DEFAULT } from './translation.common';
import { TRANSLATION_CONFIG, TRANSLATION_PREFIX, TRANSLATION_SUFFIX } from './translation.tokens';

export function browserTranslateFactory(transferState: TransferState, httpClient: HttpClient, prefix: string, suffix: string) {
return new BrowserTranslateLoader(transferState, httpClient, prefix, suffix);
}

@NgModule({
imports: [
TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
useFactory: browserTranslateFactory,
deps: [TransferState, HttpClient, TRANSLATION_PREFIX, TRANSLATION_SUFFIX]
}
})
]
})
export class BrowserTranslationModule {
static forRoot(options: Partial<TranslationOptions> = {}): ModuleWithProviders {
return {
ngModule: BrowserTranslationModule,
providers: [
{
provide: TRANSLATION_CONFIG,
useValue: options.config || TRANSLATION_CONFIG_DEFAULT
},
{
provide: TRANSLATION_PREFIX,
useValue: options.prefix || TRANSLATION_PREFIX_DEFAULT
},
{
provide: TRANSLATION_SUFFIX,
useValue: options.suffix || TRANSLATION_SUFFIX_DEFAULT
},
{
provide: TranslationService,
useClass: options.service || BaseTranslationService
},
{
provide: TranslationStorage,
useClass: options.storage || BaseTranslationStorage
}
]
};
}
}

Factory — BrowserTranslateLoader, является надстройкой над TranslateHttpLoader, которая проверяет, если локаль была загружена на сервере (в SSR), то тогда в браузере загружать локаль не нужно

import { HttpClient } from '@angular/common/http';
import { makeStateKey, StateKey, TransferState } from '@angular/platform-browser';
import { TranslateLoader } from '@ngx-translate/core';
import { TranslateHttpLoader } from '@ngx-translate/http-loader';
import { Observable } from 'rxjs';

/**
* Browser TranslateLoader
*/
export class BrowserTranslateLoader implements TranslateLoader {
constructor(private transferState: TransferState, private httpClient: HttpClient, private prefix: string, private suffix: string) {}

getTranslation(lang: string): Observable<any> {
const key = makeStateKey<number>(`transfer-translate-${lang}`);
const data = this.transferState.get(key, null);
if (data) {
return new Observable(observer => {
observer.next(data);
observer.complete();
});
} else {
return new TranslateHttpLoader(this.httpClient, `/${this.prefix}/`, this.suffix).getTranslation(lang);
}
}
}

Токены TRANSLATION_PREFIX, TRANSLATION_SUFFIX — соответствуют папке в которой находятся переводы и соответствующее расширение для файлов локали.

export const TRANSLATION_PREFIX = new InjectionToken<string>('TranslationPrefix');export const TRANSLATION_SUFFIX = new InjectionToken<string>('TranslationSuffix');

По-умолчанию значения токенов равны:

export const TRANSLATION_PREFIX_DEFAULT = 'assets/i18n';
export const TRANSLATION_SUFFIX_DEFAULT = '.json';

Реализация для universal аналогична, за исключением TranslateLoader.

import { ModuleWithProviders, NgModule } from '@angular/core';
import { TransferState } from '@angular/platform-browser';
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';

import { APP_DIST } from '@luxcar/common';

import { TranslationOptions } from './interface/translation-options.interface';
import { TranslationService } from './interface/translation-service.interface';
import { TranslationStorage } from './interface/translation-storage.interface';
import { ServerTranslateLoader } from './loaders/server-translate.loader';
import { BaseTranslationService } from './services/base-translation.service';
import { BaseTranslationStorage } from './storages/base-translation.storage';
import { TRANSLATION_CONFIG_DEFAULT, TRANSLATION_PREFIX_DEFAULT, TRANSLATION_SUFFIX_DEFAULT } from './translation.common';
import { TRANSLATION_CONFIG, TRANSLATION_PREFIX, TRANSLATION_SUFFIX } from './translation.tokens';

export function serverTranslateFactory(transferState: TransferState, appDist: string, prefix: string, suffix: string) {
return new ServerTranslateLoader(transferState, appDist, prefix, suffix);
}

@NgModule({
imports: [
TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
useFactory: serverTranslateFactory,
deps: [TransferState, APP_DIST, TRANSLATION_PREFIX, TRANSLATION_SUFFIX]
}
})
]
})
export class ServerTranslationModule {
static forRoot(options: Partial<TranslationOptions> = {}): ModuleWithProviders {
return {
ngModule: ServerTranslationModule,
providers: [
{
provide: TRANSLATION_CONFIG,
useValue: options.config || TRANSLATION_CONFIG_DEFAULT
},
{
provide: TRANSLATION_PREFIX,
useValue: options.prefix || TRANSLATION_PREFIX_DEFAULT
},
{
provide: TRANSLATION_SUFFIX,
useValue: options.suffix || TRANSLATION_SUFFIX_DEFAULT
},
{
provide: TranslationService,
useClass: options.service || BaseTranslationService
},
{
provide: TranslationStorage,
useClass: options.storage || BaseTranslationStorage
}
]
};
}
}

Где в качестве TranslateLoader используется ServerTranslateLoader, который по заданному пути, читает файлы локали и сохраняет их в TransferState, из которой позднее браузерная платформа angular сможет ее прочитать.

import { makeStateKey, TransferState } from '@angular/platform-browser';
import { TranslateLoader } from '@ngx-translate/core';
import { Observable } from 'rxjs';

import * as fs from 'fs';
import { join } from 'path';

export class ServerTranslateLoader implements TranslateLoader {
constructor(private transferState: TransferState, private appDist: string = '', private prefix: string, private suffix: string) {}

getTranslation(lang: string): Observable<any> {
return new Observable(observer => {
const assets_folder = join(process.cwd(), 'dist/apps', this.appDist, 'browser', this.prefix);
const jsonData = JSON.parse(fs.readFileSync(`${assets_folder}/${lang}${this.suffix}`, 'utf8'));
const key = makeStateKey<number>('transfer-translate-' + lang);

this.transferState.set(key, jsonData);

observer.next(jsonData);
observer.complete();
});
}
}

Для того, чтобы angular (webpack) не ругался на недоступность fs, path, необходимо в package.json добавить настройки

"browser": {
"fs": false,
"path": false,
"os": false
}

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

import { Type } from '@angular/core';

import { TranslationConfig } from './translation-config.interface';
import { TranslationService } from './translation-service.interface';
import { TranslationStorage } from './translation-storage.interface';

export interface TranslationOptions {
/**
* Translation config (languages)
*/
config: TranslationConfig;

/**
* Translation prefix (i18n folder)
*/
prefix: string;

/**
* Translation service
*/
service: Type<TranslationService>;

/**
* Translation storage
*/
storage: Type<TranslationStorage>;

/**
* Translation prefix (file extension)
*/
suffix: string;
}

Проверка работоспособности

Для демонстрации, сделаем клон frontend/storage приложения, создание которого было описано здесь, и назовем его frontend/translation.

Добавим файлы локали в папку apps/frontend/translation/src/assets/i18n

Для русского языка: ru.json

{
"languages": {
"ru": "Русский",
"en": "English"
},
"home": {
"title": "Главная страница с ngx-translate",
"count": "Количество",
"actions": {
"add": "Добавить"
}
}
}

Для английского в en.json:

{
"languages": {
"ru": "Русский",
"en": "English"
},
"home": {
"title": "Home page with ngx-translate",
"count": "Count",
"actions": {
"add": "Add"
}
}
}

Выведем переключатель языка на главную страницу:

<h2>{{ 'home.title' | translate }}</h2>

<div class="home-page">
<div class="home-langs">
<button type="button" (click)="setLang('ru')">RU</button> /
<button type="button" (click)="setLang('en')">En</button>
</div>

<div class="home-title">{{ 'home.count' | translate }}: {{ counter }}</div>

<div class="home-actions">
<button type="btn btn-default home-action" (click)="add()">
{{ 'home.actions.add' | translate }}
</button>
</div>
</div>

В компонент добавим логику выбора языка

import { ChangeDetectionStrategy, Component } from '@angular/core';

import { CookieStorage } from '@medium-stories/storage';
import { TranslationService } from '@medium-stories/translation';

@Component({
selector: 'medium-stories-home',
templateUrl: './home.component.html',
styleUrls: ['./home.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class HomeComponent {
/**
* Counter storage key
*/
static readonly counterKey = 'myCounter';

/**
* Counter
*/
counter: number;

constructor(private cookieStorage: CookieStorage, private translationService: TranslationService) {
const savedCounter = this.cookieStorage.getItem(HomeComponent.counterKey);
this.counter = savedCounter ? +savedCounter : 0;
}

/**
* To increment the counter
*/
add(): void {
this.counter++;
this.cookieStorage.setItem(HomeComponent.counterKey, this.counter.toString());
}

/**
* Set language
*
@param language Language
*/
setLang(language: string): void {
this.translationService.setLanguage(language);
}
}

В package.json пропишем scripts для сборки и запуска приложения:

"build:ssr:translation": "ng build frontend-translation --prod && ng run frontend-translation:server:production && webpack --config apps/frontend/translation/webpack.ssr.config.js --progress --colors",
"serve:ssr:translation": "node dist/apps/frontend/translation/server.js"

Теперь можно проверить, как это работает, запустив команды:

yarn run build:ssr:translationyarn run serve:ssr:translation

После запуска сервера, должны увидеть следующее:

Демонстрация работы Angular Universal, ngx-translate и Nx

Исходники

Все исходники находятся на github, в репозитории https://github.com/Fafnur/medium-stories

Хранилище реализовано в отдельной Nx библиотеке, которая располагается в libs/translation.

Для того, чтобы посмотреть состояние проекта на момент написания статьи, нужно выбрать соответствующий tag — translation.

git checkout translation

Предыдущие статьи:

  1. Статья о настройки Angular Universal & Nx
  2. Статья о настройке Prettier, tslint и eslint в Angular
  3. Статья про LocalStorage, SessionStorage в Angular Universal

Следующие статьи:

  1. Статья про создание Store в Angular с помощью Ngrx
  2. Статья про Организацию Stat’ов в Angular c Ngrx и Nx

--

--

Aleksandr Serenko
F.A.F.N.U.R

Senior Front-end Developer, Angular evangelist, Nx apologist, NodeJS warlock