Chargement paresseux des traductions avec Angular

image



Si vous avez dĂ©jĂ  participĂ© au dĂ©veloppement d'un grand projet Angular avec prise en charge de la localisation, cet article est pour vous. Sinon, vous vous demandez peut-ĂȘtre comment nous avons rĂ©solu le problĂšme du tĂ©lĂ©chargement de gros fichiers avec des traductions au dĂ©marrage de l'application: dans notre cas ~ 2300 lignes et ~ 200 Ko pour chaque langue.



Un peu de contexte



salut! Je suis développeur Frontend chez ISPsystem dans l'équipe VMmanager .



, 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