Internationalization в Angular с помощью ngx-translate
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
После запуска сервера, должны увидеть следующее:
Исходники
Все исходники находятся на github, в репозитории https://github.com/Fafnur/medium-stories
Хранилище реализовано в отдельной Nx библиотеке, которая располагается в libs/translation.
Для того, чтобы посмотреть состояние проекта на момент написания статьи, нужно выбрать соответствующий tag — translation
.
git checkout translation
Предыдущие статьи:
- Статья о настройки Angular Universal & Nx
- Статья о настройке Prettier, tslint и eslint в Angular
- Статья про LocalStorage, SessionStorage в Angular Universal
Следующие статьи: