habrahabr

Angular: ngx-translate лайфхаки

  • пятница, 18 мая 2018 г. в 00:20:24
https://habr.com/post/358742/
  • TypeScript
  • JavaScript
  • Angular


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


В ближайшее время планирую опубликовать немного ngx-translate лайфхаков.


  • В первой части заоверрайдим TranslateСompiler дабы научить его компилить пайпы внутри наших json файлов.
  • Во второй части будем писать свой TranslateLoader в замену TranslateHttpLoader (который не идет из коробки ngx-translate но все же это дефолтный лоадер, который можно поставить отдельно).
    Наш TranslateLoader будет в первую очередь обращать внимание на язык браузера и содержать fallback логику, так же ипмортить локализации MomentJs, и производить загрузку через APP_INITIALIZER.
    Так же рассмотрим иниые способы загрузки ngx-translate json`oв в приложение.
  • Третья часть будет эксперементальной — лайфхак для юзеров Webpack. Будем улучшать инфраструктуру ngx-translate в проекте и рассмотрим способы сборки translate json`ов в бандл(ы).


    Приступим.


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



<p>This entity created at 15:47 01-02-2003 by Some Guy</p>

Добавим новое поле в en.json для этой строки:


"ENTITY_CREATED_AT": "This entity created at {{createdAtDate}} by {{guyName}}"

Во вьюшке нашего компонента естественно мы добавляем translate директиву или пайп


<div translate="ENTITY_CREATED_AT" [translateParams]="{createdAtDate: entity.createdAt, guyName: entity.name}"></div>

Но при таком подходе, возникает проблема — поле createdAtDate уже должно быть локализованным иначе мы увидим просто результат new Date().toString().


У нас есть 2а варианта решения этой проблемы:


Некий best practice!

Предпочитаю передавать в translateParams самостоятельный объект место того, что бы подвязывать поля в json`е к полям реальной модели.
тобишь:


//our Entity model
class Entity {
    createdAt: Date;
    name: string;
    someComplexField: ComplexType;
}

//inside some HTML
[translateParams]="{createdAtDate: entity.createdAt, guyName: entity.name}" // separate object

конечно можно было в json`е сослатся на поля реальной модели, явно указав имена полей в модели Entity:


"ENTITY_CREATED_AT": "This entity created at {{createdAt}} by {{name}}" // ющаем entity.createdAt, entity.name

а в translateParams просто передать entity:


<div translate="ENTITY_CREATED_AT" [translateParams]="entity"></div>

А, что если у нас уже есть поддержка 10ти языков и внезапно у модели меняется название одного из полей, которое подвязано к ngx-translate?


Help функция внутри компонента вьюшки.

Мы можем создать функцию внутри компонента вьюшки, которая вернет нам уже готовый объект для передачи в translateParams


public getEntityTranslateParam(dateFormat: string) {
    return {
        createdAtDate: moment.format(dateFormat, entity.createdAt), // приводим дату в нужный вид
        guyName: entity.name
    };
}

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


Разбить ENTITY_CREATED_AT на две части.

"ENTITY_CREATED_AT_1": "This entity created at",
"ENTITY_CREATED_AT_2": "by {{guyName}}"

Здесь у нас растет и становится тяжелым для прочтения HTML, но зато теперь мы может юзать какой угодно пайп! (Конечно можно использовать директиву место пайпа, но придется создавать еще html внутри div)


<div>
{{'ENTITY_CREATED_AT_1' | translate}}
{{entity.createdAt | dateFormatPipe:LL}}
{{'ENTITY_CREATED_AT_2' | translate:{guyName:entity.guyName} }}
</div>

Хвала разработчику ngx-translate, за отличную инфраструктуру!

При импорте модуля ngx-translate в наше приложение мы можем предоставить кастомный TranslateСompiler.
Кстати вот ссылка на отличный plural\gender etc. compiler который работает с ngx-translate.


Наша задача написать свой TranslateСompiler, который будет в состоянии выполнять пайпы внутри ngx-translate локализаций.
Начнем с подготовки DI (т.к пайпы мы будем брать из Injector`a), и инициализации ngx-translate.


Добавим нужный пайп в providers модуля, в моем случа это SharedModule т.к приложение содержит не один модуль.


@NgModule({
  // прочие модули необходимые для нашего приложения
  imports: [
    ...exportedModules
  ],
  declarations: [
    ...exportedDeclarations
  ],
  exports: [
    ...exportedModules,
    ...exportedDeclarations
  ],

  providers: [
      // declare pipes available with injector
      [
        DateFormatPipe,
        {provide: 'dateFormat', useExisting: DateFormatPipe}
      ]
    ]
})
export class SharedModule {
}

Теперь инициализируем ngx-translate и укажем ему наш CustomTranslateCompiler.
В моем случае я использую CoreModule.


import { CustomTranslateCompiler } from 'app/_core/services/translate/translate.compiler';

@NgModule({
  // some needed providers 
  imports: [
    CommonModule,
    TranslateModule.forRoot({
      compiler: {
        provide: TranslateCompiler,
        useClass: CustomTranslateCompiler,
        deps: [Injector]
      },
     })
    ],
  exports: [CommonModule, TranslateModule]
})
export class CoreModule {
}

Мы можем использовать только те пайпы, которые объявили в providers модуля (в примере пайп — dateFormst)


"ENTITY_CREATED_AT": "This entity created at {{createdAtDate | dateFormat:LL}} by {{guyName}}"

Определим, пока по дефолту, PipeTranslateCompiler;


export class PipeTranslateCompiler implements TranslateCompiler {

  constructor(private injector: Injector, private errorHandler: ErrorHandler) {
  }

  public compile(value: string, lang: string): string | Function {
    return value;
  }

  public compileTranslations(translations: any, lang: string): any   {
    return translations;
  }
}

Мы имеем 2е функции:
compileTranslations — на входе получает наш загруженный json (который вы могли загрузить через TranslateHttpLoader)
compile — получает только одно значение, и будет вызвана, когда мы явно добавляем какие-то поле в ngx-translate через TranslateService.set('some translate val', 'key', 'en').


Так как в compileTranslations может попасть достаточно большой json, нам нужно будет рекурсивно пройтись по полям всего объекта и спарсить каждое значение, мне хотелось бы заранее знать какие поля парсить, а какие нет. Поэтому я решил все поля которые нужно спасрить через PipeTranslateCompiler помечать символом @


"@ENTITY_CREATED_AT": "This entity created at {{createdAtDate | dateFormat:LL}} by {{guyName}}", // это поле будем парсить
"OTHER_KEY": "This is regular entity" // а это не будем

А теперь давайте обратим внимание на тип возвращаемого значения ф-ции compile - string | Function, это значит, что мы можем превратить любое строковое значение в функцию, и когда мы в нашей HTML вьюшке воспользуемся translate пайпом или директивой, ngx-translate, зная, что если по некому ключу находится ф-ция, он вызовет ее с параметрами, которые мы передадим в translateParams.


<div>{{'ENTITY_CREATED_AT' | translate:paramsObject}}</div> // если ENTITY_CREATED_AT ф-ция то transform пайп вызовет ее передав туда paramsObject.

Значит нам нужно спарсить строку "This entity created at {{createdAtDate | dateFormat:LL}} by {{guyName}}" и заменить ее на ф-цию.


Сначала давайте представим как мы будем хранить результат парсинга строки, что бы позже на осонове параметра можно было быстро получить строку с уже примененными пайпами.
Я хочу иметь возможность парсить сразу 2+ пайпов с 2+ параметрами


"SOME_KEY": "value1: {{dateField | dateFormat:LL}} and value2: {{anotherField | customPipe:param1:param2}}

Значит нам нужно спарсить строку регулярным выражением, выделив части внутри скобок {{.*}}.
Для каждой выделенной скобки нужны данные о том, что внутри, тобишь название пайпа, массив параметров пайпа и имя поля объекта.


{{имя_поля_объекта | имя_пайпа:параметр1:параметр2}}

Определим парочку интерфейсов для удобства:


interface PipedObject {
  property: string; // имя_поля_объекта
  pipe: PipeDefinition; // пайп
}

interface PipeDefinition {
  name: string; // имя_пайпа
  params: string[]; // параметры пайпа
}

Ф-ция парсинга:


private parseTranslation(res: string): {pipedObjects: PipedObject[], matches: string[]} {

  // выделяем выражения "{{dateField | dateFormat:LL | additionalPipe:param1}}", "{{anotherField | customPipe:param1:param2}}"
  let matches = res.match(/{{.[^{{]*}}/g); 
  let pipedObjects: PipedObject[] = [];
    (matches || []).forEach((v) => {
      // убираем скобки и пробелы {{dateField | dateFormat:LL}} -> dateField|dateFormat:LL|additionalPipe:param1
      v = v.replace(/[{}\s]+/g, '');

      // выделим пайпы: dateField|dateFormat:LL|additionalPipe:param1
      let pipes = v.split('|');
      let objectPropertyName = pipes[0]; // dateField
      pipes = pipes.slice(1); // [dateFormat:LL,          additionalPipe:param1]

      for(let pipe of pipes) {
        // customPipe:param1:param2 -> ['customPipe', 'param1', 'param2']
        let pipeTokens = pipe.split(':');
        pipedObjects.push({
          property: objectPropertyName,
          pipe: {
              name: pipeTokens[0], // customPipe
              params: pipeTokens.slice(1) // ['param1', 'param2']
            }
          });
        }
    });
    return {pipedObjects, matches};
}

Теперь, для начала, свяжем parseTranslation и ф-цию компилирующую только одно значение — compile


public compile(value: string, lang: string): string | Function {
    return this.compileValue(value);
}

private compileValue(val): Function {
  let parsedTranslation = this.parseTranslation(val); // парсинг, который описан выше
  return (argsObj: object) => { // вернем ф-цию которая будет вызвана изнутри ngx-translate когда нужено будет сделать перевод по ключу

  // по умолчанию результат будет равен значению:  value1: {{dateField | dateFormat:LL}} and value2: {{anotherField | customPipe:param1:param2}}
    let res = val; 
    parsedTranslation.pipedObjects.forEach((o, i) => {
      // достаем из DI контейнера нужный пайп, мой инжектор вернет ошибку если пайпа не окажется.
      const pipe = this.injector.get(o.pipe.name);
      const property = argsObj[o.property]; // argsObj - это объект-параметр переданный в [translateParams]

      // этот шаг не обязателен можно просто использовать параметры как есть pipe.transform(prop, o.pipe.params);
      const pipeParams = this.assignPipeParams(argsObj,         o.pipe.params || []);
      if(!property) {
        return res;
      }

      let pipedValue = pipe.transform(
        property,
        pipeParams.length === 1 ? pipeParams[0] : pipeParams
      );

      // заменим {{*.}} на результат выполнения пайпа. {{dateField | dateFormat:LL}} -> 15:00
      res = res.replace(parsedTranslation.matches[i], pipedValue);
    });
   return res;
  };
}

private assignPipeParams(obj: object, params: string[]) {
  let assignedParams = [];
  params.forEach(p => {
    if(obj.hasOwnProperty(p)) {
      assignedParams.push(obj[p]);
    } else {
      assignedParams.push(p);
    }
   });
  return assignedParams;
}

Для ясности — assignPipeParams позволяет нам передать параметры в customPipe из компонента.


"@SOME": "value1: {{dateField | dateFormat:LL}} and value2: {{anotherField | customPipe:param1}} // у нас здесь два параметра :LL и :param1 - по умолчанию они будут использованы как стоки

<div>{{'@SOME' | myTranslate:{dateField: entity.value1, anotherField: entity.value2, param1: 'DYNAMIC_VALUE'} }}</div> // здесь мы передаем :param1 из компонента

Метод парсинга всего json'a из compileTranslations описывать не буду, на stack overflow можно найти много методов обхода всей структуры json'a.


public compileTranslations(translations: any, lang: string): any {
  this.iterateTranslations(translations);
  return translations;
}

private iterateTranslations(obj) {
  for (let key in obj) {
    if (obj.hasOwnProperty(key)) {
      const val = obj[key];
      const isString = typeof val === 'string';
      if(key[0] === '@' && isString) {
        obj[key] = this.compileValue(val); // вышеописаеный метод compileValue
      } else if(!isString) {
        this.iterateTranslations(val);
      }
    }
  }
}

Готово! теперь мы можем использовать пайпы внутри ngx-translate json`ов!


Новая полезная Angular 6 фича

Тут можно посмотреть коротко и по сути, что такое Angular Elements
Тут есть исходник из видео


В общем т.к Angular Elements пока эксперементальный, мы все еще не можем компилить web компоненты из Angular и использовать в любом JavaScript приложении.


Но! все же польза уже есть. Теперь мы можем создавать self bootstrap компоненты, Angular 5, делал bootstrap всего прложения только на этапе инициализации, но теперь мы можем "скомпилить" компонент в любое время работы приложения из HTML строки.


Это может быть полезно, если нам нужно превратить в компонент некую HTML строку, пришедшую от нашего API.


Но так же это будет полезно и для того, что бы написать ComponentTranslateCompiler! Который сможет собирать компоненты из наших ngx-translate json`ов.


На этом все. Research and improve!


весь исходник