Angular: ngx-translate. Улучшаем инфраструктуру c помощью Webpack

    Доброго времени суток.


    Пришло время ngx-translate лайфхаков. Изначально я планировал 3 части, но т.к вторая часть на деле мало информативна — в этой постараюсь максимально кратко изложить 2е части.


    Часть 1


    Рассмотрим AppTranslateLoader в замену TranslateHttpLoader. Наш AppTranslateLoader будет в первую очередь обращать внимание на язык браузера и содержать fallback логику, импортировать локализации MomentJs, и производить загрузку через APP_INITIALIZER. Так же в результате объединения 2ух частей лайфхаков, по ходу мы углубимся в создание удобной и гибкой инфраструктуры локализаций в проекте.


    Основной целью является не AppTranslateLoader (т.к он достаточно простой и не сделать его сложно), а создание инфраструктуры.


    Я пытался писать максимально доступно, но т.к в статье достаточно много чего можно расписать подробнее — это займет много вермени и будет не интересно тем, кто уже умеет). Потому статья вышла сильно не дружелюбной к новичкам. С другой стороны в конце есть ссылка на expample продж.


    Перед тем как начать хочу обратить внимание, что кроме загрузки языков по http, есть возможность написать лоадер таким образом, что бы он загружал нужные языки в наш бандл еще на этапе сборки. Таким образом не нужно дописывать никаких лоадеров по http, но с другой стороны, с таким подходом нужно будет делать ребилд приложения каждый раз, когда мы меняем наши файлы с локализациями, а так же, это может сильно увеличить размер .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`));
      }
    }

    Если IDE ругается на System нужно добавить его в typings.d.ts:


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

    Теперь мы можем использвовать WebpackTranslateLoader в app.module:


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

    AppTranslateLoader


    Итак, приступим к написания нашего AppTranslateLoader. Для начала хочу обозначить несколько проблем с которыми придется столкнутся используя стандартный TranslateHttpLoader:


    • Translate flickering. TranslateHttpLoader не умеет выполняется в рамках процесса инициализации приложения и мы можем попасть в ситуацию когда после инициализации видим, что у нас в приложении место корректных лейблов — ключи (MY_BUTTON_KEY место My button), которые спустя мгновение меняются на корректный текст.


    • Даты. Неплохо было бы иметь сервис переключающий локализацию дат. Когда речь идет о локализации текста, скорее всего вам придется позаботится и о локализации дат, времени и т.д. Вы можете использовать momentJs или же встроенное в Angular решение i18n. Оба решения хороши, и имеют Angular 2+ пайпы для форматирования во вьюшках.


    • Кеширование. используя TranslateHttpLoader обязательно нужно настроить ваш FE сервер корректно кешировать ваши json бандлы. Иначе пользователи будут видеть старые версии локализации, хуже того они будут видеть ключи локализации (если были добавлены новые после кеширования юзером). Я не хочу каждый раз при деплое на новом сервере заморачиваться с моментом настройки кештрования. Значит сделаем так, что бы Webpack делал все за нас так, как он делает это для .js бандлов.

    AppTranslateLoader draft


    Решения проблем:

    1. проблема translate flickering — использовать AppTranslateLoaderв рамках APP_INITIALIZER

    APP_INITIALIZER так же активно был заюзан, в статье про refresh token, если не вкурсе про initializer — советую почитать статью несмотря на то, что там про refresh token. На самом деле решение юзать initializer очень очевидное (для тех кто заком initializer), но все же надеюсь есть люди кому пригодится:


    //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. Проблема дат. Просто будем переключать язык в momentJs вместе c ngx-tranlate.

    Здесь все просто — после того, как json с локализацией загружен, мы просто переключим локализацию в momentJs (или i18n).


    Cтоит так же обратить внимание, что momentJs как и i18n может импортировать локализации отдельно, momentJs так же может импортировать и пачкой, но вся пачка локализаций занимает ~260KB, а вам допустим нужно только 2е из них.


    В таком случае можно импортировать только 2е из них прямо в файле где объявлен AppTranslateLoader.


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

    Теперь локализации en-gb и ru будут в js бандле приложения. В 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(); // оповещаем подписчиков - все что  нужно загружено и инициализировано.
    }

    !!! этот обработчик имеен недостаток: В случае если у нас в проекте для ngx-translate предусмотрена только локализация en, а к примеру для momentJs нужно использовать или en или en-gb, логику обработчика придется расширить, или же предусмотреть локализацию en-gb и в рамках ngx-translate.


    !!! для момента с // TODO: можно написать webpack плагин, парочку плагинов мы рассмотрим далее, но конретно этого у меня пока нет.


    Вы спросите, а почему нельзя загружать локализации дат и времени так же как и локализации текста в интерфейсе (тобишь динамически, по HTTP)? Все потому, что локализации дат сожержат свою логику, и потому представлены в виде javascript кода.


    Но несмотря на это есть способ загрузить подобные локализации, написав немного 'грязного' кода. Я не использую этот код в продакшне, но меня не напрягает 2е локализации внутри моего бандла. Но если у вас много локализаций, хочется загрузить их динамически и не очень безпасно, имейте ввиду:


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

    Последний раз я тестировал этот способ в Angular 4. Скорее всего и сейчас он рабочий.


    К сожалению подобный 'грязный' лайфхак не сработает в случае c momentJs (только Angular локализации). По крайней мере у меня не получилось найти способа это сделать, но если вы очень бородатый хакер программист — буду рад увидеть решение в комментариях.


    3. Кеширование. Подобно сборке .js бандла можно добавить к имени .json бандла хеш.

    Здесь все зависит от того, как именно вы собираете все json'ки в один файл, возможно у вас просто все лежит в одном файле. В просторах интерета можно найти некоторое количество npm модулей которые умеют собирать мелкие json'ки в один фалй. Я не нашёл тех, которые смогут и приделать к имеши хеш и собрать все в один файл. Сам webpack тоже не может обработать json как этого требует специфика ngx-translate. Потому мы напишем свой webpack плагин.


    Коротко говоря: нам нужно собрать все json в прокте по определенному паттерну, при этом нужно сгрупировать их по имени (en,ru,de и т.д) т.к в разных папках может лежать к примеру en.json. Затем к каждому собранному файлу нужно приделать хеш.


    Здесь есть проблема. Как AppTranslateLoader узнает имена файлов если у каждой локализации будет собственное имя? Например включая бандл в index.html мы можем подключить HtmlWebpackPlugin и попросить его самостоятельно добавить script тег с указанием имени бандла.


    Что бы решить эту проблему для .json локализаций наш webpack плагин будет создавать config.json, в котором будет содержаться ассоциация кода языка к имени файла с хешем:


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

    config.json так же будет закеширован браузером но занимает он мало и мы можем просто при GET заросе этого файла указать рандомный queryString параметр (таким образом постоянно загружая его заново). Или же приписать к config.json рандомный ID (я опишу этот способ, первый можно найти в гугле).


    Так же я хочу немного упростить инфраструктуру и атомарность локализаций. json с локализацией будет лежать в папке со своим компонентом. И во избежание дубликатов ключей, структрура json бандла будет строится на основе пути к конкретному json файлу. Например у нас есть два en.json, один лежит по пути src/app/article-component, а другой src/app/comment-component. На выходе хочу получить вот такой json:


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

    Мы можем отбросить часть пути которая нам не нужна, что бы во вьюшках ключи были максимально короткими.


    !!! Здесь есть недостаток: при меремещении компонента в другую папку у нас поменяется ключ локализации.


    Позже мы рассмотрим еще один лайфхак который позволит нам указывать в компоненте только последнее поле ключа независимо от того, где и как глубоко в проекте наш компонент, и соответственно мы сможет переносить его как угодно и переименовывать как угодно.


    Принципиально, я хочу добится инкапсуляции и даже некого намека на полиморфизм ngx-translate локализаций. Мне нравится концепция инкапсуляции вьюшек в Angular — Angular View Encapsulation, а если быть точнее Shadow DOM. Да, это увеличивает размер приложения в целом, но скажу наперед, после того, как ngx-translate стал более инкапсулированным, работать с файлами локализаций стало намного приятнее. Компоненты стали заботится только о своих локализациях, кроме того можно будет переопределять локализации в дочернем компоненте в зависимости от локализаций в родительском компоненте. Так же, теперь можно переносить компоненты из проекта в проект, и они уже будут локализованы. Но как и везде есть нюансы, об этом позже.


    Итак перейдем к нашему плагину. Что это и как. merge localizations plugin.
    Исходники лоадера и плагина можно найти по ссылке на example в самом низу статьи (папка ./build-utils).


    Плагин делает все о чем написано выше, и принимает следующие опции:


    • omit. имена в пути к локализации которые нужно игнорировать (это именно тот самый момент, где я хочу убарть лишние части пути к файлу)
    • fileInput. регулярка для выборки файлов локализаций в продже (как test в webpack)
    • rootDir. откуда начинать искать фалы по паттерну fileInput
    • outputDir. где в папке dist будут созданы config файл и локализации
    • configName. под каким именем будет создан config файл.

    В моем проекте плагин подключен таким образом:


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

    внурти компонентов, которым нужна локализация имеется папка @translations, в ней лежат en.json, ru и т.д.


    В итоге при сбоке все будет собрано в один файл с учетом пути к папке @translations. Бандл локализаций будет в dist/langs/, а конфиг будет назван как config.${некий-рандом}.json.


    Далее сделаем так, что бы нужный бандл локализации загружался в приложение. Тут есть хрупкий момент — про путь к локализациям и про имя config файла знает только webpack, давайте учем это, дабы в AppTranslateLoader попадали актуальные данные, и не было надобности менять имена в двух местах.


    // 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 просто так работать не будет, нам нужно в webpack объявить еще один плагин (DefinePlugin или 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),
    }),

    Теперь мы можем менять путь к локализациям и имя конфига только в одном месте.
    По умолчанию из дефолтного Angular проджа сгенерированного в webpack сборку (ng eject), нельзя из кода указывать process.env.someValue (даже если использовать DefinePlugin), компилятор может ругатся. Дабы это сработало нужно выполнить 2а условия:


    • в main.ts добавть 1-ую строку /// <reference types="node"/>
    • в package.json должен присутствовать @types/nodenpm install --save-dev @types/node.

    Перейдем непосредственно к процессу загрузки.
    Если вы собираетесь использовать APP_INITIALIZER, обязательно возвращайте Promise, а не Observable. Наша задача написать цепочку запросов:


    • Для начала необходимо загрузить config.json (только если не загружен).
    • попытаться загрузить язык, который является языком браузера юзера
    • предусмотреть fallback логику с заргузкой языка по умолчанию.

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

    Готово.


    Теперь вернемся к проблеме перемещения компонентов в другие папки, инкапсуляции и подобию полиморфизма.


    По сути некую инкапсуляцию мы уже имеем. Локализации распиханы по папкам рядом с компонентами, все пути-ключи уникальны, но мы все же можем локализовать ключи компонента some-component1 внутри some-component2 и за этим всем сложно будет уследить, позже мы с этим разберемся.


    <some-component1 [someLabel]="'components.some-component2.some_key' | tanslate"></some-component1>
    // components.some-component2 - глобальный и доступен отовсюду

    По поводу перемещения компонентов:
    Сечас ключ который мы будем использовать во вьюшке жестко завязан на относительный путь к файлу локализации и зависит от конкретной инфраструктуры проекта.


    Приведу достаточно печальный случай этой ситуации:


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

    А что если я поменяю имя папки компонента на post-component?
    Довольно тяжело будет вписывать этот ключ во всех необходимых местах. Конечно копипаст и find-replace никто не отменял, но и писать это без подсказок IDE тоже напряжно.


    Для решения этих пороблем, обратим внимание на то, что по этому поводу предпринимает webpack? Webpack имеет такую вещь, как loader, в наличии есть много loaders, которые оперируют путями к файлам: например пути к ресурсам в css — благодаря webpack мы можем указывть относительные пути background-image: url(../relative.png), а так же остальные пути к файлам в проекте — они всюду!


    Кто делал свои webpack сборки, знает, что loader получает на входе файл который соответсвует некому паттерну. Задача самого loader, неким образом трансформировать этот входной файл и вернуть его, для дальнейших изменений другими loaders.


    Потому нам небходимо написать свой loader. Вопрос в том какие именно файлы мы будем менять: вьюшки или компоненты? С одной стороны вьюшки могут быть прямо в компоненте и отдельно. Вьюшки могут быть достаточно большими и их сложно парсить, представим если у нас есть вьюшка где 100 translate директив (не в цикле):


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

    можем через loader подставить путь-ключ к локализациям компонентов возле каждого пайпа или директивы.


    <div id="1">{{'app.some-component.some_key_1' | translate}}</div>
    // app.some-component. - будет подставлен loader'ом

    можем добавить поле в компонент, который предоставляет локализацию:


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

    Так же плохо — придется везде составлять ключ локализации.


    Т.к самые очевидные варианты выглядят плохо, попробуем использовать декоратор и сохранять некие метаданные в прототипе компонента (так как это делает Angular).


    image


    annotations — метаданные декораторов Angular
    __app_annotations__ — метаданные которые мы будем хранить для себя


    Путь к папке локализаций относительно компонента, можно записать в декоратор, этот же декоратор можно будет расширить и другими опциями кроме пути.


    //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 не может.

    Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.

    Что читаем дальше?
    • 42.8%логгер ошибок для FE с красивыми трейсами на node.js с использованием stacktrace.js3
    • 28.5%подключаем Jest к Angular проекту2
    • 28.5%web worker костыли) Когда очень нужно запустить в воркере, то, что Angular не может2
    • +10
    • 2,1k
    • 2
    Поделиться публикацией
    Ой, у вас баннер убежал!

    Ну. И что?
    Реклама
    Комментарии 2
      0

      В приципе, если языки зашиваются во время компиляции, то можно написать лоадер который будет делать подстановку ключей и в шаблонах и коде (если выбрать какой-нибудь префикс например locale:my.key) и бонусом получить проверку ключей на компиляции. Еще можно написать кастомное правило для tslint и добавить немного строгости в использование ключей локализации.

        0
        Да, вполне можно попробовать сделать так, но есть нюанс насчет бонуса (проверки ключей), возможны проблемы при написании этой логики, т.к на этапе выполнения loader`a у нас еще нет удобного 'бандла' со всеми ключами.
        Мысль по поводу tslint отличная, но с большим количеством файлов не очень хочется парсить все регуляркой через loader в поисках префикса, и везде дописывать locale.
        Было бы идеально оставить декоратор, и убрать forwardRef(), но это нужно, что бы инжектор был более доброжелательный, таким образом ничего дописывать не придется.

        К замечанию о проверке ключей: есть такой плагин на vs code, но в такой реализации указания ключей этот плагин работать не будет.

      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

      Самое читаемое