Когда дело доходит до шаблона интеллектуальных и глупых компонентов, вы можете легко найти множество статей, посвященных этой теме. Есть великий от Дана Абрамова от марта 2015 года! Так что концепция не нова. Первоначально он был представлен как архитектурный дизайн для приложений React, но позже он был применен ко всем современным фреймворкам, построенным на основе шаблона компонентов. Поэтому неудивительно, что у Angular есть своя версия. Давайте подробнее рассмотрим этот шаблон в более конкретной среде, например, в большом приложении корпоративного уровня.

Корпоративное приложение

Приложения корпоративного масштаба - странное животное, они требуют строгого подхода к программированию. Я имею в виду, что когда вы работаете над проектом, который состоит из пары сотен тысяч строк кода и над одной базой кода одновременно работает множество команд разработчиков, для этого требуется определенный набор правил по вопросам, которые не существуют в приложения меньшего размера. Прежде всего, очень важно писать чистый, читаемый код. Я настоятельно рекомендую прочитать книгу «Чистый код» великого Роберта К. Мартина.

Программистам нужно прилагать больше усилий, чем обычно, чтобы работать вместе и не наступать друг другу на ноги. Вещи, которые могут сделать ваше приложение более «чистым», - это соглашения и шаблоны. При чтении этой статьи имейте в виду, что представленные идеи применимы в основном к большим приложениям, требующим особого уровня чистоты и порядка. Это не значит, что вы не можете использовать их в меньшем. Это хорошая практика - подготовить архитектуру вашего проекта на будущее.

Основы

Шаблон умных и глупых компонентов - очень распространенная практика. Мне нравится называть это чистым и нечистым, я считаю, что это имеет больше смысла, если принять во внимание функциональное программирование. Есть и другие названия этого паттерна: «Контейнерный и презентационный», «Расширенный и простой». Мне лично не нравится термин «умный и тупой», и я стараюсь его избегать, насколько это возможно. С другой стороны, это наиболее часто используемый термин, поэтому я буду использовать его в этой статье, чтобы избежать путаницы.

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

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

Чем крупнее приложение, тем больше пар маленьких и глупых компонентов вы найдете. Его можно использовать много раз. Многие разработчики имеют обыкновение просто создавать умный и тупой компонент всякий раз, когда данные поступают из внешней службы. Трудно сказать, хорошая это практика или плохая, это определенно зависит от ситуации. Преимущества заключаются в разделении приложения на уровни, где менее опытные разработчики работают над компонентами представления, а более опытные разработчики - над компонентами, отвечающими за обработку состояния приложения. Очевидный недостаток - чрезмерное усложнение кода и добавление ненужных шаблонов в репозиторий. Я не хочу вдаваться в сравнение этих двух типов компонентов, вы можете прочитать об этом в статье Дэна Абрамова.

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

Также нет причин, по которым умный компонент не должен предоставлять данные нескольким глупым компонентам. В большинстве случаев эти компоненты находятся во взаимно однозначном отношении. Один родительский компонент предоставляет данные одному дочернему компоненту. Также важно указать, что нет причин не использовать отношение «один ко многим», когда один интеллектуальный компонент передает данные нескольким «глупым» компонентам. Все зависит от ситуации.

Тупой компонент

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

@Component({
  selector: 'user',
  template: `
    Name: {{user.name}}
    Email: {{user.email}}
    <button click="emitActive()">Activate</button>
  `
})
class UserComponent {
  @Input()
  user: User;
  @Output()
  activeChanged = new EventEmitter();
  emitActive(): void {
    this.activeChanged.emit();
  }
}

Тупой компонент должен быть простым, как в примере выше. Основное внимание следует уделять шаблонной части компонента. Часть класса должна состоять только из входов, выходов и вспомогательных методов. Имейте это в виду, потому что мы постараемся сосредоточиться на этом предположении в следующей части статьи.

Глупый компонент предприятия

Тупой компонент должен принимать значения только из входных данных. Им не следует пользоваться услугами или любыми другими средствами получения данных. В принципе, если для этого типа компонента требуются какие-либо данные, он должен получать их из ввода. Итак, как мы можем быть уверены, что наш компонент не получает данные от службы? Мы можем запретить использование любых инъекций зависимостей!

export abstract class DumbComponent {

   private readonly subClassConstructor: Function;

   protected constructor() {
      this.subClassConstructor = this.constructor;

      if (this.isEmptyConstructor() || arguments.length !== 0) {
         this.throwError('it should not inject services');
      }
   }

   private isEmptyConstructor(): boolean {
      return this.subClassConstructor.toString().split('(')[1][0] !== ')';
   }

   private throwError(reason: string): void {
      throw new Error(`Component "${this.subClassConstructor.name}" is a DumbComponent, ${reason}.`);
   }
}

isEmptyConstructor проверяет количество параметров в конструкторе подкласса. По сути, этот метод преобразует функцию-конструктор в строку, а затем с помощью split проверяет, принимает ли функция какие-либо параметры. Если число больше нуля, это означает, что что-то было введено в компонент, и мы хотим избежать этого.

@Component({...})
export class UserComponent extends DumbComponent {
  constructor(private readonly userService: UserService) {
    super();
  }
}

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

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

export abstract class DumbComponent {

   private readonly subClassNgOnInit: Function;

   protected constructor() {
      this.subClassNgOnInit = (this as any).ngOnInit;

      if (this.subClassNgOnInit) {
         this.throwError('it should not use ngOnInit');
      }
   }

   private throwError(reason: string): void {
      throw new Error(`Component "${this.subClassConstructor.name}" is a DumbComponent, ${reason}.`);
   }
}

Реализация действительно проста, мы в основном проверяем, есть ли у подкласса метод ngOnInit, и если он есть, выдается ошибка. Эта реализация DumbComponent позволяет нам избежать такой ситуации, как следующая:

@Component({...})
export class UserComponent extends DumbComponent {
  users: Array<User>;
  constructor(private readonly userService: UserService) {
    super();
  }
  ngOnInit() {
    this.userService
        .selectAll()
        .subscribe((users) => {
          this.users = users;
        })
  }
}

Ясно, что мы хотим запретить выбор данных внутри немого компонента. Вся обработка данных и манипуляции с ними должны выполняться в умном.

Другие полезные ловушки жизненного цикла, такие как ngAfterViewInit, относятся к части представления компонента. Так что нет никаких причин мешать разработчикам их использовать.

Декоратор DumbComponent

Паттерн немого компонента прочно связан с механизмом обнаружения изменений. Все немые компоненты должны использовать ChangeDetectionStrategy.OnPush, к сожалению, свойство changeDetection является частью метаданных @Component decorator и не может быть унаследовано от базового класса. К счастью, есть еще одно решение: мы можем создать собственный @Component декоратор, который всегда будет устанавливать желаемую changeDetection стратегию:

const dumbComponentArgs: Component = {
  changeDetection: ChangeDetectionStrategy.OnPush
};
export function DumbComponent(args: Component = {}): (cls: any) => void {
const compArgs = Object.assign(dumbComponentArgs as Component,args),
ngCompDecorator = Component(componentArgs);
return function(compType: any) {
  ngCompDecorator(compType);
};
}

Мы можем использовать собственный декоратор, чтобы гарантировать, что каждый тупой компонент использует стратегию OnPush:

@DumbComponent({
  selector: ‘user’,
  styles: [`...`],
  template: `...`
})
class UserComponent {
}

К сожалению, этот метод не является дружественным к AOT и не может использоваться в производственной сборке.

Умный компонент

Основная ответственность интеллектуального компонента - получение данных из внешнего источника. В большинстве случаев это делается с помощью службы, внедренной в компонент. Либо это простая государственная служба, либо более сложная служба, построенная на шаблоне Redux. Он возвращает данные как наблюдаемые (этот подход тесно связан с популярной архитектурой NgRx). Мы всегда внедряем службу в конструктор, а затем подписываемся на реактивный поток данных в ловушке жизненного цикла ngOnInit.

Типичный пример использования умного компонента может выглядеть следующим образом:

@Component({...})
export class UserListComponent implements ngOnInit, OnDestroy {
  users: Array<User>;
  private readonly unsubscribe$ = new Subject();
  constructor(private readonly userService: UserService) {
  }
  ngOnInit() {
    this.userService
        .selectAll()
        .pipe(takeUntil(this.unsubscribe$))
        .subscribe((users) => {
          this.users = users;
        })
  }
  ngOnDestroy() {
    this.unsubscribe$.next();
    this.unsubscribe$.complete();
  }
}

Обычной практикой является создание сервисов управления состоянием с помощью наблюдаемых Rxjs. Такой подход позволяет нам реагировать на изменения в нашем приложении. Неудобство, связанное с наблюдаемыми Rxjs, заключается в том, что вам всегда нужно помнить об отказе от подписки. Если вы этого не сделаете, вы можете быть уверены, что рано или поздно столкнетесь с утечкой памяти. Это действительно проблематичное требование. Если вы думаете, что не всегда нужно отказываться от подписки, вы ошибаетесь, мой друг. Приведите мне пример наблюдаемого использования без отказа от подписки, и я покажу вам утечку памяти.

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

export abstract class SmartComponent implements OnDestroy {

   private readonly unsubscribe$ = new Subject<void>();

   private readonly subClassNgOnDestroy: Function;

   constructor() {
      this.subClassNgOnDestroy = this.ngOnDestroy;
      this.ngOnDestroy = () => {
         this.subClassNgOnDestroy();
         this.unsunscribe();
      };
   }

   ngOnDestroy() { }

   protected untilComponentDestroy() {
     return takeUntil(this.unsubscribe$);
   }

   private unsunscribe() {
     if (this.unsubscribe$.isStopped) {
       return;
     }
     this.unsubscribe$.next();
     this.unsubscribe$.complete();
   }
}

Абстрактный класс SmartComponent дает нам удобный метод takeUntil, который помогает нам справиться с отказом от подписки. Не нужно забывать и о реализации метода ngOnDestroy, все происходит автоматически. Теперь наш UserListComponent выглядит так:

@Component({...})
export class UserListComponent extends SmartComponent implements ngOnInit {
users: Array<User>;
constructor(private readonly userService: UserService) {
    super();
  }
ngOnInit() {
    this.userService
        .selectAll()
        .pipe(this.untilComponentDestroy())
        .subscribe((users) => {
          this.users = users;
        })
  }
}

Компонент чище и проще для понимания, потому что у вас нет кода, отвечающего за обработку отказа от подписки. Читатель может сосредоточиться на том, что является важным в этом компоненте, и может игнорировать то, что не является важным.

Эта реализация SmartComponent даже не позволяет программисту переопределить базовый ngOnDestroy метод:

private readonly subClassNgOnDestroy: Function;

   constructor() {
      this.subClassNgOnDestroy = this.ngOnDestroy;
      this.ngOnDestroy = () => {
         this.subClassNgOnDestroy();
         this.unsunscribe();
      };
   }

Этот механизм защищает программиста от переопределения базового ngOnDestroy метода в подклассе. В конструкторе он назначает метод ngOnDestroy из подкласса переменной subClassNgOnDestroy, а затем назначает новую функцию, которая вызывает оба метода ngOnDestroy один за другим.

Резюме

Компоненты Smart & Dumb - очень популярный шаблон для использования в угловых приложениях. Он предлагает множество преимуществ, но, как и любой шаблон, его может быть трудно поддерживать, если вы не согласны с общим методом реализации. Я надеюсь, что соглашения, представленные в этой статье, помогут вам справиться с типичными проблемами, которые могут возникнуть при использовании этого шаблона. Помните, что чем больше приложение, тем строже должны быть используемые соглашения и шаблоны, чтобы скорость роста кода оставалась на стабильном уровне.