Angularを使用した翻訳の遅延読み込み

画像



ローカリゼーションをサポートする大規模なAngularプロジェクトの開発に参加したことがある場合は、この記事が役に立ちます。そうでない場合は、アプリケーションの起動時に翻訳付きの大きなファイルをダウンロードする問題をどのように解決したのか疑問に思われるかもしれません。この場合、各言語で約2300行と約200KBです。



ちょっとした文脈



こんにちは!私はVMmanagerチームのISPsystemのフロントエンド開発者です



, 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