Развлекаемся с миксинами в Angular

Развлекаемся с миксинами в Angular

От автора: в своем последнем посте я писал об Управлении состоянием в Angular. В конце я написал о Connect Mixin, который я использовал в то время. Ладно, если честно, я имел в виду компонент более высокого уровня, но в Angular это не так просто сделать. Итак, давайте по порядку. Сегодня говорим о том, как использовать в Angular.js миксины.

Цель Connect Mixin — подключиться к хранилищу NGRX и предоставить нам из него данные. Если вы не хотите читать весь пост, я бы посоветовал вам перейти к части Connect-Mixin. Или, по крайней мере, прочитайте последнюю часть: Плохие миксины.

1. Что такое миксины?

В программировании MixIn — это стиль разработки программного обеспечения, в котором функциональные единицы создаются в классе, а затем смешиваются с другими классами.

Класс mixin — это родительский класс, унаследованный от другого — но не как средство специализации. Как правило, миксин экспортирует сервисы в дочерний класс, но семантика не подразумевает, что дочерний класс «является своего рода» родителем.

По моему собственному определению: Миксин — это особый тип наследования классов. Они дают вам возможность множественного наследования и позволяют использовать код из разных классов. Но для чего это нужно?

2. Для чего используются миксины

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

class Connect { connect(selectors, actions) { //.. }
} class View { viewMode: 'create' | 'show' | 'edit';
} class CommitView extends View, Connect { // [ts] Classes can only extend a single class. [1174] }

Множественное наследование через расширение не допускается

Но компилятор не совсем доволен этим. Выдается ошибка: Classes can only extend a single class!

Как мы можем решить эту проблему? Миксин нам в помощь. Давайте сразу рассмотрим код. Чтобы достичь желаемого поведения, мы должны создать некоторые функции, которые смешивают (MixIn ^^) поведение. Такая функция Mixin ожидает по крайней мере один аргумент, а именно класс, который мы хотим расширить. И это именно то, что происходит внутри функции. Она возвращает выражение класса, которое выходит из данного класса и добавляет к нему дополнительное поведение:

class CommitView {} // Mixin Functions
function mixinView<B extends Constructor>(Base: B) { return class extends Base { // every view has a viewMode viewMode: 'create' | 'show' | 'edit'; }
} function mixinConnect<B extends Constructor>(Base: B) { return class extends Base { constructor(...args: any []) { super(...args); // Connect to a specific store implementation this.connect({}, {}); } connect(selectors, actions) { // TBD // 1. Read values with selectors // 2. Bind Actions to the Redux Dispatch function and the result to the Base class } }
} // Usage:
// Enhance the base class CommitView with multiple behaviours
const ConnectedView = mixinConnect(mixinView(CommitView)); const view = new ConnectedView();
// access to the connect method provided by the Connect-Mixin and
// access to the viewMode provided by the View-Mixin
view.connect({}, {});
view.viewMode;

Образец миксинов

Теперь мы можем улучшить базовый класс CommitView с помощью поведения Connect-Mixin и поведения View-Mixin. По сути, функция Mixin берет определение класса и дополняет его. В моем примере выше View-Mixin добавляет поле viewMode, а Connect-Mixin подключается к конкретной реализации хранилища. Чтобы увидеть полную реализацию Connect-Mixin, пожалуйста, прокрутите вниз до пункта 4.

3. Миксины в Angular Material

Просматривая определенный источник Angular Material, я заметил, что там тоже используются миксины Это было своего рода сюрпризом, но в этом есть смысл. В Material есть много общих функций в разных директивах, таких как Color Mixin, Disabled Mixin, Tabindex Mixin, Error Sate Mixin или Initialized Mixin. Создание нескольких базовых классов со всеми различными вариантами поведения было бы просто слишком затратно и с трудом обслуживаемо. Я думаю, что это прекрасный пример правильного использования миксинов. Посмотрите на Color Mixin:

export function mixinColor<T extends Constructor<HasElementRef>>( base: T, defaultColor?: ThemePalette): CanColorCtor & T { return class extends base { private _color: ThemePalette; get color(): ThemePalette { return this._color; } set color(value: ThemePalette) { const colorPalette = value || defaultColor; if (colorPalette !== this._color) { if (this._color) { this._elementRef.nativeElement.classList.remove(`mat-${this._color}`); } if (colorPalette) { this._elementRef.nativeElement.classList.add(`mat-${colorPalette}`); } this._color = colorPalette; } } constructor(...args: any[]) { super(...args); // Set the default color that can be specified from the mixin. this.color = defaultColor; } };
}

До введения миксинов в Angular Material один и тот же код копировался снова и снова, вставлялся в несколько компонентов.

4. Connect Mixin

Как и было обещано выше, вот Connect Mixin. Его задача — работать аналогично компоненту высшего порядка Connect из React Redux. Это позволяет вам указать селекторы и действия, которые затем смешиваются с данным компонентом.
В этом мини-примере я создал и подключил Counter Counter к хранилищу NGRX.

Примитивный счетчик — я знаю

Давайте посмотрим на использование Connect Mixin. Обратите внимание, что это очень тонкая реализация компонента контейнера. Контейнер выполняет только одну задачу: подключение счетчика. Это другой, но чистый подход к компонентам контейнера. Чем он отличается? В Angular Applications я часто вижу очень большие компоненты контейнеров, которые соединяют все виды данных из хранилища и выполняют дополнительную бизнес-логику. Они таким образом не соответствуют принципу единичной ответственности.

export class CounterViewBase { constructor(public injector: Injector) {}
} export const CounterViewMixins = mixinConnect(CounterViewBase, select => ({ counter: select(counterSelector) }), dispatch => ({ increment: (payload: number) => dispatch(incrementActionCreator(payload)), decrement: (payload: number) => dispatch(decrementActionCreator(payload)), reset: (payload: number) => dispatch(resetActionCreator(payload)) })); @Component({ selector: 'counter-view', templateUrl: 'counter.container.html', changeDetection: ChangeDetectionStrategy.OnPush
})
export class CounterContainer extends CounterViewMixins { constructor(public injector: Injector) { super(injector); }
}

Чтобы использовать включение зависимостей Angular, мы должны добавить между ними класс CounterViewBase и расширить контейнер из созданного класса Mixins. Классы Mixin должны иметь конструктор с единственным параметром типа any[], и поэтому мы не можем внедрить Injector непосредственно в Mixin.

В приведенном ниже шаблоне мы напрямую вызываем методы, привязанные к контейнеру через Connect-Mixin. Эти методы доступны через объект vm. Это ограничение, которое мы должны принять, чтобы оставаться совместимыми с AOT. Вы можете назвать это как угодно. Важно только то, чтобы переменная использовалась в шаблоне.

<!-- counter.container.html -->
<ng-container *let="let count=counter from { counter: vm.counter | async }"> <counter [count]="count" (onIncrement)="vm.increment(count+1)" (onDecrement)="vm.decrement(count-1)" (onReset)="vm.reset(0)"> </counter>
</ng-container>

Шаблон контейнера счетчика

Сам Connect-Mixin ожидает компонент, который содержит инжектор, поэтому мы создали интерфейс HasInjector. Нам нужен инжектор, чтобы получить во время выполнения сервис NGRX Store. Мы могли бы включить NGRX Store непосредственно в контейнер, но я хотел отделить контейнер от реализации хранилища, которую мы используем.

import {Injector} from '@angular/core';
import {Action, Store} from '@ngrx/store'; export function mixinConnect<T extends Constructor<HasInjector>, I, O>(base: T, inputs: Inputs<I>, outputs: Outputs<O>): T & Constructor<HasViewModel<I & O>> { return class extends base { vm: I & O; store: Store<any>; constructor(...args: any[]) { super(...args); this.store = this.injector.get(Store); // Bind inputs const selectedInputs: I = inputs(this.store.select.bind(this.store)); // Bind outputs const boundOutputs: O = outputs(this.store.dispatch.bind(this.store)); this.vm = Object.assign({}, boundOutputs, selectedInputs); } };
} /* ––––––––––––––––––––––––––––––– */
/* –– Types & Interfaces ––– */
/* ––––––––––––––––––––––––––––––– */
export type Constructor<T> = new(...args: any[]) => T; export interface HasInjector { injector: Injector;
} export interface HasViewModel<T> { vm: T;
} const store: Store<any> = null;
type DispathFn = typeof store.dispatch;
type SelectFn = typeof store.select; type Outputs<O> = (dispatch: DispathFn) => O; type Inputs<I> = (select: SelectFn) => I; /* ––––––––––––––––––––––––––––––– */
/* –– NGRX Typings ––– */
/* ––––––––––––––––––––––––––––––– */
export interface Payload<T> { payload: T; }; export type ActionCreator<T> = (payload: T) => Action & Payload<T>;

Хотя в этом примере я не использую объект vm в компоненте контейнера, типы Inputs <I> и Outputs <O> дают нам безопасную обработку типов.

5. Миксины плохие?

Я должен признать, что до написания этого исследования я был скорее критически настроен к наследованию. Я пытался избегать наследования, особенно множественного, любой ценой. Но мир не состоит только из черного и белого. И для библиотек компонентов, таких как Angular Material, использование множественного наследования через миксины имеет смысл.

Я бы не стал злоупотреблять этим. Если вам нужно гораздо более динамичное поведение в компонентах, я бы стремился к «Составлению вместо наследования». Составление допускает взаимозаменяемое поведение во время выполнения и придерживается принципа Open closed.

Оставайтесь на связи

Следующий пост будет на тему Миксины vs компонентов высшего порядка. Если я выдержу графики, он выйдет в июне 2019 года. Шучу :) Ознакомьтесь с полным исходным кодом примера на Github.

Автор: Christian Janker

Источник: https://codeburst.io/

Редакция: Команда webformyself.