Использование возможностей Mixins в Angular

2329
Использование возможностей Mixins в Angular
Использование возможностей Mixins в Angular

Что такое миксин? Версия 4.4.2 TypeScript поддерживает концепцию mixin — функцию, которая может взять класс, расширить его некоторой функциональностью, а затем вернуть новый класс, позволяя другим классам расширяться от него, что позволяет классам смешивать и обмениваться функциональностью!

Как это работает?

Концепция довольно проста — если мы знакомы с наследованием, классами/функциями высшего порядка и их синтаксисом, мы можем сразу перейти к ним. Вот пример из документации по TypeScript:

class Point {
    constructor(public x: number, public y: number) {}
}

class Person {
    constructor(public name: string) {}
}

type Constructor<T> = new(...args: any[]) => T;

function Tagged<T extends Constructor<{}>>(Base: T) {
    return class extends Base {
        _tag: string;
        constructor(...args: any[]) {
            super(...args);
            this._tag = "";
        }
    }
}

const TaggedPoint = Tagged(Point);

let point = new TaggedPoint(10, 20);
point._tag = "hello";

class Customer extends Tagged(Person) {
    accountBalance: number;
}

let customer = new Customer("Joe");
customer._tag = "test";
customer.accountBalance = 0;

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

Как это можно использовать?

Представьте себе обычное приложение Angular с различными страницами, некоторые из которых содержат формы. Все хорошо, но однажды мы решаем, что отныне, если пользователь коснется формы, но попытается покинуть страницу, не сохранив ее, должно появиться окно с просьбой подтвердить, действительно ли он хочет покинуть страницу (очень стандартная функция).
Конечно, первое, что приходит на ум, — это  Guard. Поскольку наше приложение разработано в хорошей манере, большинство (но не все!) компонентов с формами имеют общее поле form, которое является экземпляром AbstractFormControl. Конечно, с этого момента мы можем проверить поле touched свойства form  и выяснить, нужно ли показывать подсказку. Защита может выглядеть следующим образом:

export class FormTouchedGuard implements CanDeactivate<{form: AbstractFormControl}> {
  canDeactivate(component) {
    return component.form.touched ? confirm('Are you sure you want to leave?') : true;
  }
}

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

interface FormCheck {
  isFormTouched(): boolean;
}

@Component({...})
export class SomeComponent implements FormCheck {
  @ViewChild('nested_component') nested: SomeOtherComponentWithForm;
  
  isFormTouched() {
    return this.nested.form.touched;
  }
}

Конечно, это здорово, но для большинства компонентов (например, 90%) метод isFormTouched  сводится только к этому:

@Component({...})
export class SomeComponent implements FormCheck {
  form: FormGroup;
  
  isFormTouched() {
    return this.form.touched;
  }
}

Конечно, мы можем скопировать и вставить этот метод в каждый компонент, в котором он нам нужен, но это действие само по себе попахивает чем-то подозрительным, верно? Причина, по которой мы не хотим такого решения, заключается в том, что однажды может возникнуть необходимость проверить, была ли форма уже отправлена (используя isSubmitted того же класса). Это потребует от нас переписать много кода. И если мы пропустим один случай — может пройти целая вечность, прежде чем кто-то обнаружит такую незначительную ошибку. Поэтому, естественно, мы хотим найти решение, которое позволит нам написать этот конкретный метод всего один раз, но при этом разделить его между всеми компонентами, которым он нужен. Естественно, на ум приходит наследование — но вот три его фундаментальных недостатка:

  1. Идиоматично: наследование предназначено для представления отношений «есть-есть» (как в «лошадь — это животное»), а не для разделения функциональности. Для этого используются инъекции зависимостей или композиция объектов.
  2. Парадигматический: что вообще должен представлять этот класс? В некоторых языках ООП есть концепция под названием trait, которая позволяет делать такие вещи, но в JS у нас нет такой возможности, поэтому наши классы должны что-то представлять, а не просто содержать один простой метод.
  3. Практический момент: что если в будущем нам понадобится расширить наш компонент за счет другого базового класса? В этом случае у нас будут связаны руки.

Mixins (Миксины) на помощь

Взгляните на эту функцию:

function WithFormCheck<T extends Constructor<{}>>(Base: T = (class {} as any)) {
  return class extends Base implements FormCheck {
    form: FormGroup;
    
    isFormTouched() {
      return this.form.touched;
    }
  }
}

Как вы видите, эта функция принимает простой класс (который также может быть компонентом Angular) и возвращает другой компонент, который расширяется от него и на котором также реализован метод isFormTouched . Затем мы можем сделать вот такую простую вещь:

@Component({...})
export class SomeComponent extends WithFormCheck() {
  // other code
}

Вот как эта функция решает все три вышеупомянутые проблемы:

  1. Поскольку она названа  WithFormTouchedCheck, она теперь представляет класс (который она может получить в качестве аргумента — или без него, обратите внимание на строку  Base: Constructor<T> = (class {} as any) , что в основном означает, что если класс не указан, будет расширен пустой), который был усилен некоторыми дополнительными функциями.
  2. Эта функция сама по себе не является классом, а лишь представляет функциональность, которую она добавляет к уже существующему классу — при условии, что она реализует определенный интерфейс — вот почему она объявлена как абстрактная).
  3. Поскольку она принимает в качестве аргумента другой класс, это позволяет нам расширять наш компонент и из других классов. На самом деле, пока мы сохраняем этот формат, мы можем иметь столько классов, сколько захотим!

Смешивая вещи
Я уверен, что многие из нас слышали страшные истории о зомби-подписках и о том, как их можно избежать, используя оператор takeUntil . Конечно, для этого нам нужно будет создать специальную тему  Subject и отправить очередное уведомление внутри нашего метода ngOnDestroy. Собственно, вот код:

export class HasSubscriptionComponent implements OnDestroy {

  destroy$  = new Subject<void>()

  ngOnDestroy() {
    this.destroy$.next();
  }

}

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

function WithDestroy<T extends Constructor<{}>>(Base: T = (class {} as any)) {
  return class extends Base implements OnDestroy {
    destroy$  = new Subject<void>()

    ngOnDestroy() {
      this.destroy$.next();
    }
  }
}

Конечно, компоненту, использующему наш предыдущий миксин, может понадобиться и этот новый, но объединить их очень просто:

export class HasSubscriptionComponent extends WithDestroy(WithFormCheck()) implements OnInit {
  ngOnInit() {
    someObservable.pipe(takeUntil(this.destroy$)).subscribe(/* do something */); // we can now use the destroy$ Ssubject
  }
}

Будьте внимательны: мы реализовали метод ngOnDestroy  в нашем миксине: если вы собираетесь использовать его в классе, который сам будет реализовывать метод  ngOnDestroy , обязательно вызывайте  super.ngOnDestroy()  внутри него!
Cons
Конечно, у каждого подхода в программировании могут быть свои минусы. Большинство из них в нашем случае связаны либо с какими-то специфическими проблемами компилятора Typescript, либо с проблемами сборки Angular. Здесь я привожу два из них, которые могут быть самыми неприятными.

1.  Decorators are not valid here
Если мы попытаемся использовать декоратор внутри нашего миксина, как это сделано здесь:

function WithInputs<T extends Constructor<{}>>(Base: T = (class {} as any)) {
  return class extends Base {
    @Input() type;
  }
}

Мы получим ошибку, в которой будет сказано, Decorators are not valid here. На самом деле это проблема компилятора Typescript. Вот обходной путь:

function WithInputs<T extends Constructor<{}>>(Base: T = (class {} as any)) {
  class Temporary extends Base {
    @Input() type;
  }

  return Temporary;
}

2. Проблема с Angular Inputs:
Если мы включим декоратор в один из наших mixin-классов, чтобы, например, определить свойство Input в классе, он будет работать как положено, когда мы  ng serve  наше приложение, но будет выдавать ошибку во время производственной сборки. Это связано с данной проблемой. Обходной путь для этого немного сложнее:

@Component({
  // other metadata
  inputs: ['type'],
})
export class SomeOtherComponent extends WithInputs() {
  // other code
}

Конечно, это больше работы для дочернего компонента, и собственное руководство по стилю Angular не одобряет использование массива  inputs в декораторах, но это все равно обходной путь. Лично я не возражаю против этого, пока команда Angular не предоставит нам решение.

Заключение
Typescript mixins — это отличный способ расширить функциональность нашего приложения Angular, не нарушая при этом никакого потока. Хотя у этой технологии все еще есть некоторые небольшие недостатки, я лично считаю, что ее преимущества значительно перевешивают их.