Buen dia
Es hora de hackear la vida ngx-translate. Inicialmente, planeé 3 partes, pero debido a que la segunda parte en realidad no es muy informativa, trataré de resumir la segunda parte lo más brevemente posible.
Parte 1
Considere AppTranslateLoader
para reemplazar TranslateHttpLoader
. Nuestro AppTranslateLoader
prestará atención en primer lugar al idioma del navegador y contendrá la lógica de respaldo, importará localizaciones de MomentJs y cargará a través de APP_INITIALIZER. Además, como resultado de combinar 2 partes de hacks de vida, en el camino profundizaremos en la creación de una infraestructura de localización conveniente y flexible en el proyecto.
El objetivo principal no es AppTranslateLoader
(porque es bastante simple y no difícil de hacer), sino la creación de infraestructura.
Traté de escribir lo más accesible posible, pero debido a que el artículo tiene muchas cosas que se pueden describir con más detalle, tomará mucho tiempo y no será interesante para aquellos que ya saben cómo hacerlo. Por lo tanto, el artículo no fue muy amigable para los principiantes. Por otro lado, al final hay un enlace para expandir prodg.
Antes de comenzar, quiero señalar que además de descargar idiomas a través de http, es posible escribir un cargador de tal manera que cargue los idiomas necesarios en nuestro paquete en la etapa de ensamblaje. Por lo tanto, no necesita agregar ningún cargador a través de http, pero, por otro lado, con este enfoque, deberá reconstruir la aplicación cada vez que cambiemos nuestros archivos con localizaciones y, además, esto puede aumentar considerablemente el tamaño del paquete .js.
// webpack-translate-loader.ts import { TranslateLoader } from '@ngx-translate/core'; import { Observable } from 'rxjs/Observable'; export class WebpackTranslateLoader implements TranslateLoader { getTranslation(lang: string): Observable<any> { return Observable.fromPromise(System.import(`../assets/i18n/${lang}.json`)); } }
Si el IDE jura en el System
debe agregarlo a typings.d.ts:
declare var System: System; interface System { import(request: string): Promise<any>; }
Ahora podemos usar WebpackTranslateLoader
en app.module:
@NgModule({ bootstrap: [AppComponent], imports: [ TranslateModule.forRoot({ loader: { provide: TranslateLoader, useClass: WebpackTranslateLoader } }) ] }) export class AppModule { }
AppTranslateLoader
Entonces, comencemos a escribir nuestro AppTranslateLoader
. Para empezar, quiero identificar varios problemas que deberán encontrarse utilizando el estándar TranslateHttpLoader
:
Traducir parpadeo. TranslateHttpLoader
no sabe cómo ejecutar como parte del proceso de inicialización de la aplicación y podemos entrar en una situación en la que después de la inicialización vemos que tenemos el lugar para las etiquetas correctas en la aplicación: las teclas (MY_BUTTON_KEY es el lugar de Mi botón), que después de un momento cambia al texto correcto.
Fechas Sería bueno tener un servicio que cambie la localización de fechas. Cuando se trata de localizar texto, lo más probable es que tenga que ocuparse de localizar fechas, horas, etc. Puede usar momentJs o la solución i18n integrada en Angular. Ambas soluciones son buenas y tienen tuberías Angular 2+ para formatear en vistas.
- Almacenamiento en caché. usando
TranslateHttpLoader
, debe configurar su servidor FE para almacenar correctamente sus paquetes json. De lo contrario, los usuarios verán versiones antiguas de localización, peor aún verán claves de localización (si se agregaron nuevas después del almacenamiento en caché del usuario). No quiero molestarme cada vez que despliegue en un nuevo servidor con el momento de configurar el almacenamiento en caché. Así que haremos que Webpack haga todo por nosotros de la misma manera que lo hace con los paquetes .js.
Borrador de AppTranslateLoader
Soluciones a problemas:
1. problema de parpadeo de traducción: use AppTranslateLoader
como parte de APP_INITIALIZER
APP_INITIALIZER también participó activamente en un artículo sobre token de actualización , si no tiene buen gusto sobre el inicializador. Le aconsejo que lea el artículo a pesar de que se trata de token de actualización. De hecho, la decisión de usar initializer es muy obvia (para aquellos que tienen un initializer), pero aun así espero que haya personas que sean útiles:
//app.module.ts export function translationLoader(loader: AppTranslateLoader) { return () => loader.loadTranslation(); } @NgModule({ bootstrap: [AppComponent], providers: [ { provide: APP_INITIALIZER, useFactory: translationLoader, deps: [AppTranslateLoader], multi: true } ] }) export class AppModule { }
2. El problema de las fechas. Simplemente cambiaremos el idioma en el momento Js junto con ngx-tranlate.
Aquí todo es simple: después de cargar json con localización, simplemente cambiamos la localización a momentJs (o i18n).
También vale la pena señalar que momentJs, como i18n, puede importar localizaciones por separado, momentJs también puede importar un paquete, pero el paquete completo de localizaciones toma ~ 260 KB, y solo necesita 2 de ellas.
En este caso, puede importar solo 2 de ellos directamente en el archivo donde AppTranslateLoader
declara AppTranslateLoader
.
import 'moment/locale/en-gb'; import 'moment/locale/ru';
Ahora las localizaciones en-gb y ru estarán en el paquete de aplicaciones js. En AppTranslateLoader
puede agregar un controlador de idioma recién cargado:
export Class AppTranslateLoader { // .... private onLangLoaded(newLang: string) { // if (this.loadedLang && this.loadedLang !== newLang) { this.translate.resetLang(this.loadedLang); } this.loadedLang = newLang; this.selectedLang = newLang; // TODO: // , // en ru, momentJs en. moment().locale(newLang); // . momentJs localStorage.setItem(this.storageKey, newLang); // ls this.loadSubj.complete(); // - . }
!!! este controlador tiene un inconveniente: si en nuestro proyecto solo se proporciona la localización en para ngx-translate, pero, por ejemplo, en el momento o la necesidad de usar en o en-gb, la lógica del controlador tendrá que expandirse, o la localización de en-gb también debe proporcionarse en ngx-translate.
!!! por el momento con // TODO: podemos escribir un complemento de paquete web, consideraremos un par de complementos más adelante, pero todavía no lo tengo.
Usted pregunta, ¿por qué es imposible cargar localizaciones de fechas y horas, así como localizaciones de texto en la interfaz (dinámicamente, a través de HTTP)? Esto se debe a que las localizaciones de fechas contienen su propia lógica y, por lo tanto, se presentan como código JavaScript .
Pero a pesar de esto, hay una manera de cargar tales localizaciones escribiendo un pequeño código 'sucio'. No uso este código en producción, pero la segunda localización dentro de mi paquete no me molesta. Pero si tiene muchas localizaciones, desea cargarlas de forma dinámica y no muy segura, tenga en cuenta:
private async loadAngularCulture(locale) { let angularLocaleText = await this.httpClient.get(`assets/angular-locales/${locale}.js`).toPromise();
La última vez que probé este método en Angular 4. Lo más probable es que esté funcionando ahora.
Desafortunadamente, un truco de vida tan sucio no funcionará en el caso de c momentJs (solo localización angular). Al menos no pude encontrar una manera de hacer esto, pero si eres un programador de hackers muy barbudo, me alegrará ver la solución en los comentarios.
3. Almacenamiento en caché. Similar a la construcción de un paquete .js, puede agregar un hash al nombre del paquete .json.
Todo depende de cómo recopile exactamente todos los json'es en un archivo, tal vez solo tenga todo en un archivo. En Internet, puede encontrar varios módulos npm que pueden recopilar pequeños json en un archivo. No encontré a aquellos que pueden adjuntar a un hash y recopilar todo en un archivo. Webpack en sí tampoco puede manejar json como lo requieren los detalles de ngx-translate. Por lo tanto, escribiremos nuestro complemento webpack.
En resumen: necesitamos recopilar todos los json en el proyecto de acuerdo con un patrón específico, mientras que necesitamos agruparlos por nombre (en, ru, de, etc.) porque, por ejemplo, en.json puede estar en diferentes carpetas. Luego, para cada archivo recopilado, debe adjuntar un hash.
Hay un problema aquí. ¿Cómo reconoce AppTranslateLoader
los nombres de los archivos si cada localización tiene su propio nombre? Por ejemplo, incluyendo el paquete en index.html, podemos incluir HtmlWebpackPlugin y pedirle que agregue una etiqueta de script con el nombre del paquete por sí mismo.
Para resolver este problema para las localizaciones .json, nuestro complemento webpack creará config.json, que contendrá la asociación del código de idioma con el nombre del archivo hash:
{ "en": "en.some_hash.json", "ru": "ru.some_hash.json" }
config.json también será almacenado en caché por el navegador, pero tomará un poco de tiempo y simplemente podemos especificar un parámetro de queryString aleatorio cuando GET está cubierto con este archivo (cargando constantemente de nuevo). O asigne una ID aleatoria a config.json (describiré este método, el primero se puede encontrar en Google).
También quiero simplificar un poco la infraestructura y la atomicidad de las localizaciones. json con localización se ubicará en la carpeta con su componente. Y para evitar claves duplicadas, la estructura del paquete json se construirá en función de la ruta a un archivo json específico. Por ejemplo, tenemos dos en.json, uno se encuentra en la ruta src/app/article-component
y el otro src/app/comment-component
. En la salida, quiero obtener este json:
{ "article-component": { "TITLE": "Article title" }, "comment-component": { "TITLE": "Comment title" } }
Podemos descartar la parte de la ruta que no necesitamos, para que las claves sean lo más cortas posible en las vistas.
!!! Hay un inconveniente: cuando coloca el componente en otra carpeta, la clave de localización cambiará.
Más adelante consideraremos otro truco de vida que nos permitirá indicar en el componente solo el último campo clave, independientemente de dónde y qué tan profundo esté nuestro proyecto en el proyecto, y en consecuencia podemos transferirlo a su gusto y cambiarle el nombre a su gusto.
Básicamente, quiero lograr la encapsulación e incluso una pizca de polimorfismo de localizaciones ngx-translate. Me gusta el concepto de encapsular vistas en Angular - Angular View Encapsulation , o más bien el Shadow DOM . Sí, esto aumenta el tamaño de la aplicación en su conjunto, pero diré de antemano que después de que ngx-translate se haya encapsulado más, trabajar con archivos de localización se ha vuelto mucho más agradable. Los componentes comenzaron a preocuparse solo por sus localizaciones, además, será posible redefinir las localizaciones en el componente secundario dependiendo de las localizaciones en el componente primario. Además, ahora puede transferir componentes de un proyecto a otro, y ya estarán localizados. Pero como en otros lugares, hay matices, más sobre eso más adelante.
Así que pasemos a nuestro complemento. Qué es y cómo . Fusionar el complemento de localizaciones .
El código fuente para el cargador y el complemento se puede encontrar en el enlace de ejemplo en la parte inferior del artículo (carpeta ./build-utils).
El complemento hace todo lo que está escrito arriba y acepta las siguientes opciones:
- omitir nombres en la ruta de localización que deben ignorarse (este es exactamente el momento en el que quiero eliminar las partes adicionales de la ruta al archivo)
- fileInput. regular para recuperar archivos de localización en prodge (como prueba en paquete web)
- rootDir. dónde comenzar a buscar archivos por el patrón fileInput
- outputDir. donde se crearán el archivo de configuración y las localizaciones en la carpeta dist
- configName. bajo qué nombre se creará el archivo de configuración.
En mi proyecto, el complemento está conectado de esta manera:
Dentro de los componentes que necesitan localización hay una carpeta @translations
, que contiene en.json, ru, etc.
Como resultado, al voltear, todo se recopilará en un archivo, teniendo en cuenta la ruta a la carpeta @translations
. El paquete de localización estará en dist / langs /, y la configuración se denominará como config. $ {Some-random} .json.
A continuación, nos aseguraremos de que el paquete de localización deseado esté cargado en la aplicación. Hay un punto frágil: solo el paquete web conoce la ruta a las localizaciones y el nombre del archivo de configuración, tengamos esto en cuenta para que los datos más recientes ingresen a AppTranslateLoader y no haya necesidad de cambiar los nombres en dos lugares.
// some inmports // ... // momentJs import * as moment from 'moment'; import 'moment/locale/en-gb'; import 'moment/locale/ru'; @Injectable() export class AppTranslateLoader { // public additionalStorageKey: string = ''; private translationsDir: string; private translationsConfig: string; private selectedLang: string; private fallbackLang: string; private loadedLang: string; private config: { [key: string]: string; } = null; private loadSubs = new Subscription(); private configSubs = new Subscription(); private loadSubj = new Subject(); private get storageKey(): string { return this.additionalStorageKey ? `APP_LANG_${this.additionalStorageKey}` : 'APP_LANG'; } constructor(private http: HttpClient, private translate: TranslateService) { // webpack // . this.translationsDir = `${process.env.TRANSLATE_OUTPUT}`; this.translationsConfig = `${process.env.TRANSLATE_CONFIG}`; this.fallbackLang = 'en'; const storedLang = this.getUsedLanguage(); if (storedLang) { this.selectedLang = storedLang; } else { this.selectedLang = translate.getBrowserLang() || this.fallbackLang; } } }
process.env.TRANSLATE_OUTPUT
simplemente no funcionará, necesitamos declarar otro complemento en webpack (DefinePlugin o EnvironmentPlugin):
Ahora podemos cambiar la ruta a las localizaciones y el nombre de la configuración en un solo lugar.
De manera predeterminada, desde la venta angular generada en el ensamblaje de paquete web ( ng eject
eject), no puede especificar process.env.someValue
desde el código (incluso si usa DefinePlugin), el compilador puede jurar. Para que esto funcione, debe cumplir las condiciones 2a:
- en main.ts agregue la primera línea
/// <reference types="node"/>
- package.json debe tener
@types/node
- npm install --save-dev @types/node
.
Procedemos directamente al proceso de arranque.
Si tiene la intención de usar APP_INITIALIZER, asegúrese de devolver Promise, not Observable. Nuestra tarea es escribir una cadena de consulta:
- Primero debe descargar config.json (solo si no está cargado).
- intenta cargar el idioma, que es el idioma del navegador del usuario
- Proporcione lógica de respaldo con el idioma de descarga predeterminado.
// imports @Injectable() AppTranslateLoader { // fields ... // , // , // Subscription unsubscribe // private loadSubs = new Subscription(); private configSubs = new Subscription(); // - // Subject private loadSubj = new Subject(); // constructor ... // Promise! public loadTranslation(lang: string = ''): Promise<any> { if (!lang) { lang = this.selectedLang; } // if (lang === this.loadedLang) { return; } if (!this.config) { this.configSubs.unsubscribe(); this.configSubs = this.http.get<Response>(`${this.translationsDir}${this.translationsConfig}`) .subscribe((config: any) => { this.config = config; this.loadAndUseLang(lang); }); } else { this.loadAndUseLang(lang); } return this.loadSubj.asObservable().toPromise(); } private loadAndUseLang(lang: string) { this.loadSubs.unsubscribe(); this.loadSubs = this.http.get<Response>(`${this.translationsDir}${this.config[lang] || this.config[this.fallbackLang]}`) .subscribe(res => { this.translate.setTranslation(lang, res); this.translate.use(lang).subscribe(() => { this.onLangLoaded(lang); }, // fallback ngx-translate (err) => this.onLoadLangError(lang, err)); }, // fallback http (err) => this.onLoadLangError(lang, err)); } private onLangLoaded(newLang: string) { // if (this.loadedLang && this.loadedLang !== newLang) { this.translate.resetLang(this.loadedLang); } this.loadedLang = newLang; this.selectedLang = newLang; // TODO: // , // en ru, momentJs en. moment().locale(newLang); // . momentJs localStorage.setItem(this.storageKey, newLang); // ls this.loadSubj.complete(); // - . } private onLoadLangError(langKey: string, error: any) { // , if (this.loadedLang) { this.translate.use(this.loadedLang) .subscribe( () => this.onLangLoaded(this.loadedLang), (err) => this.loadSubj.error(err)); // } else if (langKey !== this.fallbackLang) { // fallback this.loadAndUseLang(this.fallbackLang); } else { // this.loadSubj.error(error); } }
Listo
Ahora volvamos al problema de mover componentes a otras carpetas, encapsulación y semejanza del polimorfismo.
De hecho, ya tenemos algún tipo de encapsulación. Las localizaciones se insertan en carpetas al lado de los componentes, todas las rutas de teclas son únicas, pero aún podemos localizar las teclas del componente some-component1 dentro de some-component2 y será difícil hacer un seguimiento de todo, lo resolveremos más adelante.
<some-component1 [someLabel]="'components.some-component2.some_key' | tanslate"></some-component1> // components.some-component2 -
En cuanto al movimiento de componentes:
Ahora, la clave que usaremos en la vista está rígidamente vinculada a la ruta relativa al archivo de localización y depende de la infraestructura específica del proyecto.
Daré un caso bastante triste de esta situación:
<div translate="+lazy-module.components.article-component.article_title"></div>
Pero, ¿qué sucede si cambio el nombre de la carpeta del componente a postcomponente?
Será bastante difícil ingresar esta clave en todos los lugares necesarios. Por supuesto, nadie canceló copiar-pegar y buscar-reemplazar, pero escribir esto sin las indicaciones del IDE también es estresante.
Para resolver estos problemas, prestemos atención a lo que está haciendo webpack al respecto. Webpack tiene un cargador , hay muchos cargadores disponibles que operan en rutas de archivos: por ejemplo, rutas de recursos en css - gracias a webpack podemos especificar rutas relativas a la imagen de fondo: url (../ relative.png), y así ¡El resto de las rutas de archivo en el proyecto están en todas partes!
Quien haya realizado sus compilaciones de paquetes web sabe que el cargador recibe un archivo en la entrada que coincide con un cierto patrón. La tarea del cargador en sí es transformar de alguna manera este archivo de entrada y devolverlo, para otros cambios por parte de otros cargadores.
Por lo tanto, necesitamos escribir nuestro cargador. La pregunta es qué tipo de archivos cambiaremos: ¿vistas o componentes? Por un lado, las vistas pueden estar directamente en el componente y por separado. Las vistas pueden ser lo suficientemente grandes y difíciles de analizar, imagine si tenemos una vista donde 100 directivas de traducción (no en un bucle):
<div id="1">{{'./some_key_1' | translate}}</div> ... <div id="100">{{'../another_key_!' | translate}}</div>
a través del cargador podemos sustituir la ruta clave a las localizaciones de componentes cerca de cada tubería o directiva.
<div id="1">{{'app.some-component.some_key_1' | translate}}</div>
podemos agregar un campo a un componente que proporciona localización:
@Component({ selector: 'app-some', template: '<div>{{(localization + 'key') | tanslate}}</div>' }) export class SomeComponent { localization = './' }
También es malo: debe componer una clave de localización en todas partes.
Dado que las opciones más obvias se ven mal, intente usar un decorador y guarde algunos metadatos en el prototipo del componente (como lo hace Angular).

anotaciones - metadatos para decoradores angulares
__app_annotations__ - metadatos que almacenaremos para nosotros mismos
La ruta a la carpeta de localización relativa al componente se puede escribir en el decorador, el mismo decorador se puede ampliar con otras opciones excepto la ruta.
//translate.service.ts const app_annotations_key = '__app_annotations__'; export function Localization(path: string) { // tslint:disable-next-line:only-arrow-functions return function (target: Function) { const metaKey = app_annotations_key; Object.defineProperty(target, metaKey, { value: { // path. path, name: 'Translate' } } as PropertyDescriptor); }; } //some.component.ts @Component({...}) @Localization({ path: './', otherOptions: {...} }); export class SomeComponent { }
webpack, loader , - . , ( styleUrls) . loader, npm . .
, -. , -.
<div>{{'just_key' | translate}}</div>
. , , , . — Injector, . , Injector, '' , translate . Injector, ( ), 'get'.

, parent , , Injector'a , , , , , .
, API, forwarRef() ( Angular reactive forms, control ). , . .
// translate.service.ts export const TRANSLATE_TOKEN = new InjectionToken('MyTranslateToken'); // app.component.ts @Component({ selector: 'app-root', templateUrl: './app.component.html', styleUrls: ['./app.component.css'], providers: [{provide: TRANSLATE_TOKEN, useExisting: forwardRef(() => AppComponent)}] }) @Localization('./') export class AppComponent { title = 'app'; }
, , , forwardRef().
, Injector forwardRef() , . , '' . , , .
// my-translate.directive.ts @Directive({ // tslint:disable-next-line:directive-selector selector: '[myTranslate]' }) export class MyTranslateDirective extends TranslateDirective { @Input() public set myTranslate(e: string) { this.translate = e; } private keyPath: string; constructor(private _translateService: TranslateService, private _element: ElementRef, _chRef: ChangeDetectorRef, // forwardRef() @Inject(TRANSLATE_TOKEN) @Optional() protected cmp: Object) { super(_translateService, _element, _chRef); // const prototype = Object.getPrototypeOf(cmp || {}).constructor; if (prototype[app_annotations_key]) { // this.keyPath = prototype[app_annotations_key].path; } } public updateValue(key: string, node: any, translations: any) { if (this.keyPath) { // , // key = `${this.keyPath.replace(/\//, '.')}.${key}`; } super.updateValue(key, node, translations); } }
.
- :
<div>{{'just_this_component_key' | myTranslate}}</div> // <div myTranslate="just_this_component_key"></div>
translate , . , , - :
//en.bundle.json { "global_key": "Global key" "app-component": { "just_key": "Just key" } } //some-view.html <div translate="global_key"></div>
Research and improve!
full example
:
- FE node.js stacktrace.js.
- Jest Angular .
- Web worker ) , , Angular .