Angular: ngx-translate. Improving infrastructure with Webpack

Good day.


It's time for ngx-translate lifehacks. Initially, I planned 3 parts, but because the second part is actually not very informative - in this I will try to summarize the 2nd parts as briefly as possible.


Part 1


Consider the AppTranslateLoader in place of the TranslateHttpLoader . Our AppTranslateLoader will first of all pay attention to the browser language and contain fallback logic, import MomentJs localizations, and load via APP_INITIALIZER. Also, as a result of merging 2 parts of life hacking, we will delve further into creating a convenient and flexible localization infrastructure in the project.


The main goal is not AppTranslateLoader (as it is rather simple and not difficult to make it), but infrastructure creation.


I tried to write as much as possible, but because the article contains a lot of things that can be described in more detail - it will take a lot of time and will not be interesting to those who already know how). Because the article came out not very friendly to beginners. On the other hand at the end there is a link to expample prodzh.


Before starting, I want to note that in addition to downloading languages ​​via http, it is possible to write a loader in such a way that it loads the necessary languages ​​into our bundle at the assembly stage. Thus, there is no need to build any loaders on http, but on the other hand, with this approach, you will need to rebuild the application every time we change our files with localizations, as well as, it can greatly increase the size of the .js bundle.


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

If the IDE swears at System you need to add it to typings.d.ts:


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

Now we can use the WebpackTranslateLoader in app.module:


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

AppTranslateLoader


So, let's start writing our AppTranslateLoader . First I want to identify a few problems that I’ll have to deal with using the standard TranslateHttpLoader :



AppTranslateLoader draft


Problem solution:

1. translate flickering problem - use AppTranslateLoader within APP_INITIALIZER

APP_INITIALIZER was also actively closed in the article about the refresh token , if not vkurse about the initializer - I advise you to read the article despite the fact that there is a refresh token. In fact, the decision to use initializer is very obvious (for those who are the initializer zakom), but I hope there are people who come in handy:


 //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. The problem of dates. Just switch the language at the momentJs together with ngx-tranlate.

Everything is simple here - after json with localization is loaded, we will simply switch localization to momentJs (or i18n).


It is also worth noting that momentJs, like i18n, can import localizations separately, momentJs can also import a bundle, but the entire bundle of localizations takes ~ 260KB, and you only need 2e of them.


In this case, you can import only 2e of them directly in the file where AppTranslateLoader declared.


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

Now the localization of en-gb and ru will be in the js bundle of the application. AppTranslateLoader can add a freshly loaded language handler to the AppTranslateLoader :


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

!!! This handler has a flaw: If we only have en localization in the project for ngx-translate, and for example for momentJs you need to use either en or en-gb, the handler logic will have to be expanded, or en-gb localization must be provided ngx-translate.


!!! for the moment with // TODO: you can write a webpack plugin, we will look at a couple of plug-ins further, but this is not what I have yet.


You may ask why it is impossible to load the localization of dates and times as well as the localization of text in the interface (tobish dynamically via HTTP)? That's because date localizations contain their logic, and therefore are presented in the form of javascript code.


But despite this, there is a way to download such localizations by writing some 'dirty' code. I do not use this code in production, but I am not bothered by the 2nd localization inside my bundle. But if you have a lot of localizations, you want to download them dynamically and not very safely, keep in mind:


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

The last time I tested this method in Angular 4. Most likely it is now working.


Unfortunately, such a 'dirty' life hack will not work in the case of c momentJs (Angular localization only). At least I could not find a way to do this, but if you are a very bearded hacker programmer, I will be glad to see the solution in the comments.


3. Caching. Like the .js bundle build, you can add a hash to the .json bundle name.

It all depends on how you collect all the json's in one file, maybe you just have everything in one file. In Internet spaces, you can find a certain number of npm modules that can assemble small json'ki in one file. I did not find those that can attach to the hash and collect everything in one file. The webpack itself also cannot handle json as required by the specific ngx-translate. Therefore, we will write your webpack plugin.


In short: we need to collect all the json in the project according to a specific pattern, and we need to group them by name (en, ru, de, etc.) because in different folders it may be for example en.json. Then to each collected file you need to attach a hash.


There is a problem here. How does AppTranslateLoader recognize file names if each localization has its own name? For example, including the bundle in index.html we can connect the HtmlWebpackPlugin and ask it to add a script tag with the name of the bundle.


To solve this problem for .json localizations, our webpack plugin will create config.json, which will contain the association of the language code to the file name with the hash:


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

config.json will also be cached by the browser but it takes a little and we can simply specify a random queryString parameter when GET overgrown this file (thus constantly loading it again). Or assign a random ID to config.json (I will describe this method, the first can be found in Google).


I also want to simplify the infrastructure and the atomicity of localizations. json with localization will be in the folder with its component. And in order to avoid duplicate keys, the json bundle struct will be built based on the path to a specific json file. For example, we have two en.json, one is on the path src/app/article-component , and the other is src/app/comment-component . At the exit, I want to get this json:


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

We can discard a part of the path that we do not need, so that the keys are as short as possible in the views.


!!! There is a drawback: when the component is moved to another folder, we will change the localization key.


Later we will look at another life hack that will allow us to indicate in the component only the last key field, regardless of where and how deeply our component is in the project, and accordingly we will be able to transfer it as you please and rename as you like.


Basically, I want to achieve encapsulation and even some hint of polymorphism of ngx-translate localizations. I like the concept of encapsulating views in Angular - Angular View Encapsulation , or rather, Shadow DOM . Yes, it increases the size of the application as a whole, but I will say in advance, after the ngx-translate has become more encapsulated, it has become much more pleasant to work with localization files. Components of steel cares only about their localizations, in addition, it will be possible to override localizations in the child component depending on the localizations in the parent component. Also, now you can transfer components from a project to a project, and they will already be localized. But like everywhere there are nuances, more on that later.


So go to our plugin. What is it and how . merge localizations plugin .
The sources of the loader and plug-in can be found at the link to the example at the very bottom of the article (folder ./build-utils).


The plugin does everything described above and accepts the following options:



In my project, the plugin is connected in this way:


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

Components that need localization have a folder @translations , it contains en.json, ru, etc.


As a result, everything will be collected in a single file with a @translations given the path to the @translations folder. The localization bundle will be in dist / langs /, and the config will be named as config. $ {By some random} .json.


Further we will make so that the necessary localization bundle is loaded into the application. There is a fragile moment - only webpack knows about the path to localizations and about the name of the config file, let's learn this so that the AppTranslateLoader gets actual data and there is no need to change the names in two places.


 // 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 just will not work this way, we need to declare another plugin in the webpack (DefinePlugin or 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), }), 

Now we can change the path to localizations and the name of the config only in one place.
By default, from the default Angular prodja generated in the webpack assembly ( ng eject ), you cannot specify process.env.someValue from the code (even if you use DefinePlugin), the compiler can swear. In order for this to work, you must fulfill 2a conditions:



Let's go directly to the download process.
If you are going to use APP_INITIALIZER, be sure to return the Promise, not the Observable. Our task is to write a query chain:



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

Is done.


Now back to the problem of moving components to other folders, encapsulations, and the like of polymorphism.


In fact, we already have some kind of encapsulation. Localizations are pushed to the folders next to the components, all the paths to the keys are unique, but we can still localize the keys of the component some-component1 inside some-component2 and it will be difficult for everyone to follow this, later we'll figure it out.


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

Concerning the movement of components:
Now the key that we will use in the view is rigidly tied to the relative path to the localization file and depends on the specific infrastructure of the project.


I will give a rather sad case of this situation:


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

What if I change the name of the component folder to post-component?
It will be quite hard to enter this key in all necessary places. Of course, nobody canceled copy-paste and find-replace, but writing it without prompts from the IDE is also stressful.


To solve these problems, pay attention to what the webpack is doing about this? Webpack has such a thing as a loader , there are many loaders that operate with paths to files: for example, the paths to resources in css - thanks to the webpack, we can specify relative paths background-image: url (../ relative.png), and so the rest of the paths to the files in the project - they are everywhere!


Whoever did his webpack builds, knows that the loader receives a file at the input that matches a pattern. The task of the loader itself, in some way to transform this input file and return it, for further changes by other loaders.


Because we need to write your loader. The question is which files will we change: views or components? On the one hand, views can be directly in the component and separately. Views can be quite large and difficult to parse, imagine if we have a view where 100 translate directives (not in a loop):


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

we can, through the loader, substitute the path-key to the localizations of the components near each pipe or directive.


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

we can add a field to the component that provides localization:


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

It’s just as bad - you’ll have to make a localization key everywhere.


Because the most obvious options look bad, try using a decorator and save some metadata in the component's prototype (as does Angular).


image


annotations - Angular decorator metadata
__app_annotations__ - metadata that we will keep for ourselves


The path to the localization folder relative to the component can be written to the decorator, the same decorator can be expanded with other options besides the path.


 //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 { } 

As a result, after the assembly through the webpack, our loader will process the components and the decorator will know about the path relative to the root of the prodja, which means that it will know the path-key to the desired localization of the component. , ( 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/413787/


All Articles