Lazy loading of translations with Angular

image



If you have ever participated in the development of a large Angular project with localization support, then this article is for you. If not, then you might be wondering how we solved the problem of downloading large files with translations at application startup: in our case, ~ 2300 lines and ~ 200 KB for each language.



A bit of context



Hello! I am a Frontend developer at ISPsystem in the VMmanager team .



, frontend-. angular 9- . ngx-translate. json-. POEditor.



?



-, json- .

, , 2 .



, , ( , , ), .



-, json- .



, . namespace . , TITLE, HOME(HOME.....TITLE), TITLE, HOME .



?



: , .



angular. angular-, .



() , . , , , , ? .



, , ยซยป ( ).



:



<projectRoot>/i18n/
  ru.json
  en.json
  HOME/
    ru.json
    en.json
  HOME.COMMON/
    ru.json
    en.json
  ADMIN/
    ru.json
    en.json


json โ€” , (, ). HOME โ€” . ADMIN โ€” .

HOME.COMMON โ€” , .



json- , namespace:



  • {...};
  • ADMIN { "ADMIN": {...} };
  • HOME.COMMON { "HOME": { "COMMON": {...} } } ;
  • ..


, .



. , .



ngx-translate , , :



  • โ€” , ;
  • โ€” , .




: TranslateLoader



, abstract getTranslation(lang: string): Observable<any>. TranslateLoader ( ngx-translate), .



, - , , :



export class MyTranslationLoader extends TranslateLoader implements OnDestroy {
  /**        (    ,   ) */
  private static TRANSLATES_LOADED: { [lang: string]: { [scope: string]: boolean } } = {};

  /**      (     ) */
  private sortedScopes = typeof this.scopes === 'string' ? [this.scopes] : this.scopes.slice().sort((a, b) => a.length - b.length);

  private getURL(lang: string scope: string): string {
    //      ,       
    //           i18n
    return `i18n/${scope ? scope + '/' : ''}${lang}.json`;
  }

  /**    ,     */
  private loadScope(lang: string, scope: string): Observable<object> {
    return this.httpClient.get(this.getURL(lang, scope)).pipe(
      tap(() => {
        if (!MyTranslationLoader.TRANSLATES_LOADED[lang]) {
          MyTranslationLoader.TRANSLATES_LOADED[lang] = {};
        }
        MyTranslationLoader.TRANSLATES_LOADED[lang][scope] = true;
      })
    );
  }

  /** 
   *         
   * ..  ,        , 
   *            ,
   *       ,        scope  ,
   *   HOME.COMMON  HOME,   
   */
  private merge(scope: string, source: object, target: object): object {
    //     root 
    if (!scope) {
      return { ...target };
    }

    const parts = scope.split('.');
    const scopeKey = parts.pop();
    const result = { ...source };
    //     ,      
    const sourceObj = parts.reduce(
      (acc, key) => (acc[key] = typeof acc[key] === 'object' ? { ...acc[key] } : {}),
      result
    );
        //        
    sourceObj[scopeKey] = parts.reduce((res, key) => res[key] || {}, target)?.[scopeKey] || {};

    return result;
  }

  constructor(private httpClient: HttpClient, private scopes: string | string[]) {
    super();
  }

  ngOnDestroy(): void {
    //  ,   hot reaload  
    MyTranslationLoader.TRANSLATES_LOADED = {};
  }

  getTranslation(lang: string): Observable<object> {
    //      scope
    const loadScopes = this.sortedScopes.filter(s => !MyTranslationLoader.TRANSLATES_LOADED?.[lang]?.[s]);

    if (!loadScopes.length) {
      return of({});
    }

    //       
    return zip(...loadScopes.map(s => this.loadScope(lang, s))).pipe(
      map(translates => translates.reduce((acc, t, i) => this.merge(loadScopes[i], acc, t), {}))
    );
  }
}


, scope url , json, .



, .



: MissingTranslationHandler



, , handle. MissingTranslationHandler, ngx-translate.

ngx-translate :



export declare abstract class MissingTranslationHandler {
  /**
   * A function that handles missing translations.
   *
   * @param params context for resolving a missing translation
   * @returns a value or an observable
   * If it returns a value, then this value is used.
   * If it return an observable, the value returned by this observable will be used (except if the method was "instant").
   * If it doesn't return then the key will be used as a value
   */
  abstract handle(params: MissingTranslationHandlerParams): any;
}


: Observable .



export class MyMissingTranslationHandler extends MissingTranslationHandler {
  //  Observable  , ..    ,     ,
  //  translate pipe   handle
  private translatesLoading: { [lang: string]: Observable<object> } = {};

  handle(params: MissingTranslationHandlerParams) {
    const service = params.translateService;
    const lang = service.currentLang || service.defaultLang;

    if (!this.translatesLoading[lang]) {
      //     loader ( ,   )
      this.translatesLoading[lang] = service.currentLoader.getTranslation(lang).pipe(
        //      ngx-translate
        //  true   ,    
        tap(t => service.setTranslation(lang, t, true)),
        map(() => service.translations[lang]),
        shareReplay(1),
        take(1)
      );
    }

    return this.translatesLoading[lang].pipe(
      //          
      map(t => service.parser.interpolate(service.parser.getValue(t, params.key), params.interpolateParams)),
      //     ,    โ€”  
      catchError(() => of(params.key))
    );
  }
}


(HOME.TITLE), ngx-translate (['HOME', 'TITLE']). , catchError of(typeof params.key === 'string' ? params.key : params.key.join('.')).





, TranslateModule:



export function loaderFactory(scopes: string | string[]): (http: HttpClient) => TranslateLoader {
  return (http: HttpClient) => new MyTranslationLoader(http, scopes);
}

// ...

// app.module.ts
TranslateModule.forRoot({
  useDefaultLang: false,
  loader: {
    provide: TranslateLoader,
    useFactory: loaderFactory(''),
    deps: [HttpClient],
  },
})

// home.module.ts
TranslateModule.forChild({
  useDefaultLang: false,
  extend: true,
  loader: {
    provide: TranslateLoader,
    useFactory: loaderFactory(['HOME', 'HOME.COMMON']),
    deps: [HttpClient],
  },
  missingTranslationHandler: {
    provide: MissingTranslationHandler,
    useClass: MyMissingTranslationHandler,
  },
})

// admin.module.ts
TranslateModule.forChild({
  useDefaultLang: false,
  extend: true,
  loader: {
    provide: TranslateLoader,
    useFactory: loaderFactory(['ADMIN', 'HOME.COMMON']),
    deps: [HttpClient],
  },
  missingTranslationHandler: {/*...*/},
})


useDefaultLang: false missingTranslationHandler.

extend: true ( ngx-translate@12.0.0) , .



, , :



export function translateConfig(scopes: string | string[]): TranslateModuleConfig {
  return {
    useDefaultLang: false,
    loader: {
      provide: TranslateLoader,
      useFactory: httpLoaderFactory(scopes),
      deps: [HttpClient],
    },
  };
}

@NgModule()
export class MyTranslateModule {
  static forRoot(scopes: string | string[] = [], config?: TranslateModuleConfig): ModuleWithProviders<TranslateModule> {
    return TranslateModule.forRoot({
      ...translateConfig([''].concat(scopes)),
      ...config,
    });
  }

  static forChild(scopes: string | string[], config?: TranslateModuleConfig): ModuleWithProviders<TranslateModule> {
    return TranslateModule.forChild({
      ...translateConfig(scopes),
      extend: true,
      missingTranslationHandler: {
        provide: MissingTranslationHandler,
        useClass: MyMissingTranslationHandler,
      },
      ...config,
    });
  }
}


, ( translate ) TranslateModule.



( ngx-translate@12.1.2) , , , translate [object Object]. .



POEditor



, POEditor, . API:





, . , , .



python3 .

, MyTranslateLoader. , , .



:



  • split โ€” , , ( โ€” i18n);
  • join โ€” : json stdout, ;
  • download โ€” POEditor, , , ;
  • upload โ€” POEditor , ;
  • hash โ€” md5 . , , .


argparse, --help .



, , .

, , . stackblitz, .



GitHub

Stackblitz





VMmanager 6. , , . , .



, , .



? ?




All Articles