Angulaire: ngx-translate. Amélioration de l'infrastructure avec Webpack

Bonjour.


Il est temps pour les hacks de vie ngx-translate. Au départ, j'avais prévu 3 parties, mais parce que la deuxième partie n'est en fait pas très informative - j'essaierai de résumer la 2ème partie aussi brièvement que possible.


Partie 1


Envisagez AppTranslateLoader pour remplacer TranslateHttpLoader . Notre AppTranslateLoader fera tout d'abord attention à la langue du navigateur et contiendra la logique de secours, importera les localisations MomentJs et chargera via APP_INITIALIZER. En outre, à la suite de la combinaison de 2 parties de hacks de vie, nous nous plongerons en cours de route dans la création d'une infrastructure de localisation pratique et flexible dans le projet.


L'objectif principal n'est pas AppTranslateLoader (car il est assez simple et pas difficile à réaliser), mais la création d'infrastructures.


J'ai essayé d'écrire aussi accessible que possible, mais parce que l'article contient beaucoup de choses qui peuvent être décrites plus en détail - cela prendra beaucoup de temps et ne sera pas intéressant pour ceux qui savent déjà comment) Par conséquent, l'article n'était pas très convivial pour les débutants. D'un autre côté, à la fin, il y a un lien pour donner un exemple de prodg.


Avant de commencer, je veux attirer l'attention sur le fait qu'en plus de télécharger des langues via http, il est possible d'écrire un chargeur de telle manière qu'il charge les langues nécessaires dans notre bundle au stade de l'assemblage. Ainsi, vous n'avez pas besoin d'ajouter de chargeurs via http, mais d'un autre côté, avec cette approche, vous devrez reconstruire l'application chaque fois que nous modifions nos fichiers avec des localisations, et cela peut également augmenter considérablement la taille du bundle .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 l'IDE ne jure que sur le System vous devez l'ajouter à typings.d.ts:


 declare var System: System; interface System { import(request: string): Promise<any>; } 

Maintenant, nous pouvons utiliser WebpackTranslateLoader dans app.module:


 @NgModule({ bootstrap: [AppComponent], imports: [ TranslateModule.forRoot({ loader: { provide: TranslateLoader, useClass: WebpackTranslateLoader } }) ] }) export class AppModule { } 

AppTranslateLoader


Commençons donc à écrire notre AppTranslateLoader . Pour commencer, je veux identifier plusieurs problèmes qui devront être rencontrés en utilisant le TranslateHttpLoader standard:


  • Traduisez le scintillement. TranslateHttpLoader ne sait pas comment s'exécuter dans le cadre du processus d'initialisation de l'application et nous pouvons nous retrouver dans une situation où, après l'initialisation, nous voyons que nous avons la place pour les étiquettes correctes dans l'application - les clés (MY_BUTTON_KEY est l'emplacement de mon bouton), qui après un moment changer pour le texte correct.


  • Les dates Ce serait bien d'avoir un service qui permute la localisation des dates. En ce qui concerne la localisation du texte, vous devrez probablement vous occuper de la localisation des dates, des heures, etc. Vous pouvez utiliser momentJs ou la solution i18n intégrée à Angular. Les deux solutions sont bonnes et disposent de canaux Angular 2+ pour le formatage dans les vues.


  • Mise en cache. à l'aide de TranslateHttpLoader , vous devez configurer votre serveur FE pour mettre correctement en cache vos bundles json. Sinon, les utilisateurs verront les anciennes versions de localisation, pire ils verront les clés de localisation (si de nouvelles ont été ajoutées après la mise en cache par l'utilisateur). Je ne veux pas déranger chaque fois que je déploie sur un nouveau serveur au moment de la mise en cache. Nous allons donc faire en sorte que Webpack fasse tout pour nous comme il le fait pour les bundles .js.

Brouillon AppTranslateLoader


Solutions aux problèmes:

1. traduire le problème de scintillement - utilisez AppTranslateLoader dans le cadre de APP_INITIALIZER

APP_INITIALIZER a également été activement impliqué dans un article sur le jeton d'actualisation , sinon de bon goût sur l'initialiseur - je vous conseille de lire l'article malgré le fait qu'il s'agit d'un jeton d'actualisation. En fait, la décision d'utiliser l'initialiseur est très évidente (pour ceux qui ont un initialiseur), mais j'espère quand même qu'il y a des gens qui sont utiles:


 //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. Le problème des dates. Nous allons simplement changer la langue de momentJs avec ngx-tranlate.

Tout est simple ici - après le chargement de json avec localisation, nous basculons simplement la localisation sur momentJs (ou i18n).


Il convient également de noter que momentJs, comme i18n, peut importer des localisations séparément, momentJs peut également importer un ensemble, mais l'ensemble complet de localisations prend ~ 260 Ko, et vous n'avez besoin que de 2 d'entre elles.


Dans ce cas, vous ne pouvez en importer que 2 directement dans le fichier où AppTranslateLoader déclaré.


 import 'moment/locale/en-gb'; import 'moment/locale/ru'; 

Les localisations en-gb et ru seront désormais dans le bundle d'application js. Dans AppTranslateLoader vous pouvez ajouter un gestionnaire de langue fraîchement chargé:


 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(); //   -      . } 

!!! ce gestionnaire a un inconvénient: si dans notre projet seule la localisation en est fournie pour ngx-translate, mais par exemple moment ou besoin d'utiliser en ou en-gb, la logique du gestionnaire devra être développée, ou la localisation de en-gb devra également être fournie dans ngx-translate.


!!! pour le moment avec // TODO: nous pouvons écrire un plugin webpack, nous considérerons quelques plugins plus tard, mais je ne l'ai pas encore.


Vous vous demandez pourquoi il est impossible de charger des localisations de dates et d'heures ainsi que des localisations de texte dans l'interface (dynamiquement, via HTTP)? En effet, les localisations de date contiennent leur propre logique et sont donc présentées sous forme de code javascript .


Mais malgré cela, il existe un moyen de charger de telles localisations en écrivant un peu de code «sale». Je n'utilise pas ce code en production, mais la 2ème localisation à l'intérieur de mon bundle ne me dérange pas. Mais si vous avez de nombreuses localisations, vous voulez les charger dynamiquement et pas de manière très sécurisée, gardez à l'esprit:


 private async loadAngularCulture(locale) { let angularLocaleText = await this.httpClient.get(`assets/angular-locales/${locale}.js`).toPromise(); // extracting the part of the js code before the data, // and i didn't need the plural so i just replace plural by null. const startPos = angularLocaleText.indexOf('export default '); angularLocaleText = 'return ' + angularLocaleText.substring(startPos + 15).replace('plural', null); // The trick is here : to read cldr data, i use a function const f = new Function(angularLocaleText); const angularLocale = f(); // console.log(angularLocale); // And now, just registrer the object you just created with the function registerLocaleData(angularLocale); } 

La dernière fois que j'ai testé cette méthode dans Angular 4. Il est fort probable qu'elle fonctionne maintenant.


Malheureusement, un tel hack de vie sale ne fonctionnera pas dans le cas de c momentJs (uniquement la localisation angulaire). Au moins, je n'ai pas trouvé de moyen de le faire, mais si vous êtes un programmeur de hackers très barbu, je serai heureux de voir la solution dans les commentaires.


3. Mise en cache. Comme pour la création d'un bundle .js, vous pouvez ajouter un hachage au nom du bundle .json.

Tout dépend de la façon dont vous collectez exactement tous les fichiers json dans un seul fichier, peut-être que vous avez tout dans un seul fichier. Sur Internet, vous pouvez trouver un certain nombre de modules npm qui peuvent collecter de petits fichiers json dans un seul fichier. Je n'ai pas trouvé ceux qui peuvent s'attacher à un hachage et rassembler tout dans un seul fichier. Webpack lui-même ne peut pas non plus gérer json comme requis par les spécificités de ngx-translate. Par conséquent, nous allons écrire notre plugin webpack.


En bref: nous devons collecter tous les fichiers json du projet selon un modèle spécifique, tandis que nous devons les regrouper par nom (en, ru, de, etc.) car, par exemple, en.json peut se trouver dans différents dossiers. Ensuite, pour chaque fichier collecté, vous devez joindre un hachage.


Il y a un problème ici. Comment AppTranslateLoader reconnaît-il les noms de fichiers si chaque localisation a son propre nom? Par exemple, en incluant le bundle dans index.html, nous pouvons inclure le HtmlWebpackPlugin et lui demander d'ajouter lui-même une balise de script avec le nom du bundle.


Pour résoudre ce problème pour les localisations .json, notre plugin webpack créera config.json, qui contiendra l'association du code de langue avec le nom du fichier de hachage:


 { "en": "en.some_hash.json", "ru": "ru.some_hash.json" } 

config.json sera également mis en cache par le navigateur, mais cela prendra un peu de temps et nous pouvons simplement spécifier un paramètre queryString aléatoire lorsque GET envahit ce fichier (donc le charge constamment à nouveau). Ou attribuez un ID aléatoire à config.json (je décrirai cette méthode, la première peut être trouvée dans Google).


Je veux également simplifier un peu l'infrastructure et l'atomicité des localisations. json avec localisation se trouvera dans le dossier avec son composant. Et afin d'éviter les clés en double, la structure du bundle json sera construite en fonction du chemin d'accès à un fichier json spécifique. Par exemple, nous avons deux en.json, l'un se trouve sur le chemin src/app/article-component , et l'autre src/app/comment-component . Je veux obtenir le json suivant dans la sortie:


 { "article-component": { "TITLE": "Article title" }, "comment-component": { "TITLE": "Comment title" } } 

Nous pouvons supprimer la partie du chemin dont nous n'avons pas besoin, afin que les clés soient aussi courtes que possible dans les vues.


!!! Il y a un inconvénient: lorsque vous placez le composant dans un autre dossier, la clé de localisation change.


Plus tard, nous envisagerons un autre hack de vie qui nous permettra d'indiquer dans le composant uniquement le dernier champ clé, indépendamment de l'endroit et de la profondeur de notre projet dans le projet, et en conséquence, nous pouvons le transférer comme vous le souhaitez et le renommer comme vous le souhaitez.


Fondamentalement, je veux réaliser l'encapsulation et même un soupçon de polymorphisme des localisations ngx-translate. J'aime le concept d'encapsuler des vues dans Angular - Angular View Encapsulation , ou plutôt le Shadow DOM . Oui, cela augmente la taille de l'application dans son ensemble, mais je dirai à l'avance qu'après que ngx-translate est devenu plus encapsulé, travailler avec des fichiers de localisation est devenu beaucoup plus agréable. Les composants ont commencé à se soucier uniquement de leurs localisations, en outre, il sera possible de redéfinir les localisations dans le composant enfant en fonction des localisations dans le composant parent. De plus, vous pouvez désormais transférer des composants d'un projet à l'autre et ils seront déjà localisés. Mais comme ailleurs, il y a des nuances, plus à ce sujet plus tard.


Passons donc à notre plugin. Qu'est-ce que c'est et comment . fusionner le plugin de localisation .
Le code source du chargeur et du plugin se trouve sur le lien exemple au bas de l'article (dossier ./build-utils).


Le plugin fait tout ce qui est écrit ci-dessus et accepte les options suivantes:


  • omettre. noms dans le chemin de localisation qui doivent être ignorés (c'est exactement le moment où je veux supprimer les parties supplémentaires du chemin d'accès au fichier)
  • fileInput. régulier pour récupérer les fichiers de localisation dans prodge (comme test dans le webpack)
  • rootDir. où commencer la recherche de fichiers par le modèle fileInput
  • outputDir. où le fichier de configuration et les localisations seront créés dans le dossier dist
  • configName. sous quel nom le fichier de configuration sera-t-il créé.

Dans mon projet, le plugin est connecté de cette façon:


 // build-utils.js // part of METADATA { // ... translationsOutputDir: 'langs/', translationsFolder: '@translations', translationsConfig: `config.${Math.random().toString(36).substr(2, 9)}.json`, } //webpack.common.js new MergeLocalizationPlugin({ fileInput: [`**/${METADATA.translationsFolder}/*.json`, 'app-translations/**/*.json'], rootDir: 'src', omit: new RegExp(`app-translations|${METADATA.translationsFolder}|^app`, 'g'), outputDir: METADATA.translationsOutputDir, configName: METADATA.translationsConfig }), 

À l'intérieur des composants qui ont besoin de localisation, il y a un dossier @translations , il contient en.json, ru, etc.


Par conséquent, lors du retournement, tout sera collecté dans un fichier, en tenant compte du chemin d'accès au dossier @translations . Le bundle de localisation sera dans dist / langs /, et la configuration sera nommée config. $ {Some-random} .json.


Ensuite, nous nous assurerons que le bundle de localisation souhaité est chargé dans l'application. Il y a un point fragile - seul le webpack connaît le chemin d'accès aux localisations et le nom du fichier de configuration, prenons cela en compte pour que les dernières données arrivent dans AppTranslateLoader et il n'est pas nécessaire de changer les noms à deux endroits.


 // 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 ne fonctionnera tout simplement pas, nous devons déclarer un autre plugin dans webpack (DefinePlugin ou EnvironmentPlugin):


 // METADATA declaration const METADATA = { translationsOutputDir: 'langs/', translationsFolder: '@translations', translationsConfig: `config. ${Math.random().toString(36).substr(2, 9)}.json`, }; // complex webpack config... // webpack plugins... new DefinePlugin({ 'process.env.TRANSLATE_OUTPUT': JSON.stringify(METADATA.translationsOutputDir), 'process.env.TRANSLATE_CONFIG': JSON.stringify(METADATA.translationsConfig), }), 

Maintenant, nous pouvons changer le chemin d'accès aux localisations et le nom de la configuration en un seul endroit.
Par défaut, à partir de la vente angulaire par défaut générée dans l'assembly du webpack ( ng eject ), vous ne pouvez pas spécifier process.env.someValue partir du code (même si vous utilisez DefinePlugin), le compilateur peut jurer. Pour que cela fonctionne, vous devez remplir les conditions 2a:


  • dans main.ts ajoutez la 1ère ligne /// <reference types="node"/>
  • package.json doit avoir @types/node - npm install --save-dev @types/node .

Nous passons directement au processus de démarrage.
Si vous avez l'intention d'utiliser APP_INITIALIZER, assurez-vous de renvoyer Promise, not Observable. Notre tâche consiste à écrire une chaîne de requête:


  • Vous devez d'abord télécharger config.json (uniquement s'il n'est pas chargé).
  • essayez de charger la langue, qui est la langue du navigateur de l'utilisateur
  • Fournissez une logique de secours avec la langue de téléchargement par défaut.

 // 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); } } 

C'est fait.


Revenons maintenant au problème du déplacement des composants vers d'autres dossiers, de l'encapsulation et de la ressemblance du polymorphisme.


En fait, nous avons déjà une sorte d'encapsulation. Les localisations sont poussées dans des dossiers à côté des composants, tous les chemins de clé sont uniques, mais nous pouvons toujours localiser les clés du composant some-component1 à l'intérieur de some-component2 et il sera difficile de garder une trace de tout, nous le découvrirons plus tard.


 <some-component1 [someLabel]="'components.some-component2.some_key' | tanslate"></some-component1> // components.some-component2 -     

Concernant le mouvement des composants:
Maintenant, la clé que nous utiliserons dans la vue est liée de manière rigide au chemin relatif vers le fichier de localisation et dépend de l'infrastructure spécifique du projet.


Je vais donner un cas assez triste de cette situation:


 <div translate="+lazy-module.components.article-component.article_title"></div> 

Mais que se passe-t-il si je change le nom du dossier de composants en post-composant?
Il sera assez difficile de saisir cette clé à tous les endroits nécessaires. Bien sûr, personne n'a annulé le copier-coller et la recherche-remplacement, mais écrire cela sans les invites de l'IDE est également stressant.


Pour résoudre ces problèmes, prêtons attention à ce que le webpack fait à ce sujet? Webpack a une telle chose comme chargeur , il existe de nombreux chargeurs disponibles qui fonctionnent sur les chemins de fichiers: par exemple, les chemins de ressources dans css - grâce à webpack, nous pouvons spécifier les chemins relatifs de l'image d'arrière-plan: url (../ relative.png), etc. le reste des chemins de fichiers dans le projet sont partout!


Celui qui a construit son webpack sait que le chargeur reçoit à l'entrée un fichier qui correspond à un certain modèle. La tâche du chargeur lui-même est de transformer en quelque sorte ce fichier d'entrée et de le renvoyer, pour d'autres modifications par d'autres chargeurs.


Par conséquent, nous devons écrire notre chargeur. La question est de savoir quel type de fichiers allons-nous changer: vues ou composants? D'un côté, les vues peuvent être directement dans le composant et séparément. Les vues peuvent être suffisamment grandes et difficiles à analyser, imaginez si nous avons une vue où 100 directives de traduction (pas dans une boucle):


 <div id="1">{{'./some_key_1' | translate}}</div> ... <div id="100">{{'../another_key_!' | translate}}</div> 

grâce au chargeur, nous pouvons remplacer le chemin d'accès clé aux localisations de composants à proximité de chaque tuyau ou directive.


 <div id="1">{{'app.some-component.some_key_1' | translate}}</div> // app.some-component. -   loader' 

nous pouvons ajouter un champ à un composant qui fournit la localisation:


 @Component({ selector: 'app-some', template: '<div>{{(localization + 'key') | tanslate}}</div>' }) export class SomeComponent { localization = './' } 

C'est aussi mauvais - vous devez composer une clé de localisation partout.


Étant donné que les options les plus évidentes semblent mauvaises, essayez d'utiliser un décorateur et enregistrez des métadonnées dans le prototype du composant (comme le fait Angular).


image


annotations - métadonnées pour les décorateurs angulaires
__app_annotations__ - métadonnées que nous allons stocker pour nous-mêmes


Le chemin d'accès au dossier de localisation relatif au composant peut être écrit dans le décorateur, le même décorateur peut être développé avec d'autres options à l'exception du chemin d'accès.


 //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'.


image


, 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


:


  1. FE node.js stacktrace.js.
  2. Jest Angular .
  3. Web worker ) , , Angular .

Source: https://habr.com/ru/post/fr413787/


All Articles