Проблемы ООП в Angular

2525
Проблемы ООП в Angular
Проблемы ООП в Angular

Объектно-ориентированное программирование — это словосочетание мгновенно узнает практически каждый разработчик программного обеспечения в мире; даже те, кто его не использует (или даже ненавидит). ООП — самая популярная и широко используемая парадигма программирования в мире; и веб-фронтенд не является исключением: React (все еще) имеет компоненты, основанные на классах; компоненты Vue сами являются объектами; но фреймворк, который больше всего использует ООП, — это, без сомнения, Angular. Angular использует ООП для многих своих вещей; большинство его функций основано на классах (компоненты, директивы, трубы…), он использует такие подходы, как внедрение зависимостей и т.д., и т.п. Поэтому, естественно, Angular — это место, где с ООП обращаются больше всего. В этой статье мы рассмотрим, как иногда люди неправильно используют практику ООП в проектах Angular, и как мы можем сделать лучше.

Пожалуйста, подумайте о миксинах
Миксины — это особенность JavaScript (и, как следствие, TypeScript), которая позволяет нам писать функции, которые получают класс (не экземпляр класса, а сам класс), расширяют его и возвращают полученный класс. Этот результирующий класс можно использовать для расширения другого класса, например, компонента. Вот пример миксина, который создает destroy$ Subject  который затем может быть использован для утилизации потоков Observable при ngOnDestroy, так что нам не нужно каждый раз писать его вручную:

function WithDestroy(Base) {
  return class extends Base implements OnDestroy {
    destroy$ = new Subject();
    
    ngOnDestroy() {
      super.ngOnDestroy();
      this.destroy();
    }
  };
}

Если мы затем расширим некоторые наши компоненты на основе этого миксина, мы сможем использовать destroy$ Subject с takeUntil для автоматической отписки, и нам даже не придется реализовывать метод ngOnDestroy.

Если мы затем расширим некоторые наши компоненты на основе этого миксина, мы сможем использовать destroy$ Subject с оператором takeUntil для автоматического отказа от подписки, и нам даже не придется реализовывать метод ngOnDestroy.

Это работает так, как если бы у нас был класс Base, реализующий метод ngOnDestroy, и мы расширяем наш класс, которому нужна логика отписки, от него. Но поскольку это функция, которая принимает класс в качестве аргумента и расширяется от него, мы все равно можем расширять наш класс от другого класса, используя WithDestroy, создавая своего рода множественное наследование.

export class MyComponent extends WithDestroy(SomeOtherComponent) {
  constructor(private service: DataService) {
    super();
  }
  ngOnInit() {
    this.subscription = this.service.selectSomeData().pipe(
      takeUntil(this.destroy$), // we can use this from the mixin class
    ).subscribe(
      // handle data
    );
  }
}

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

Наследование — это не игрушка

Наследование, или практика расширения одного класса за счет другого, является наиболее известной (и, вероятно, самой простой) практикой в ООП. Но она получила некоторую известность из-за того, что ее используют там, где она не нужна или неприменима.

Наследование — это отношение «есть — есть», то есть если мы расширяем класс A из класса B, мы должны быть в состоянии сказать, что A — это B таким образом, чтобы это имело смысл. Например, если мы расширяем класс Car из класса Vehicle, мы можем с уверенностью сказать, что Car — это Vehicle.

Простое расширение класса: автомобиль - это транспортное средство
Простое расширение класса: автомобиль — это транспортное средство

Но иногда между классами существуют отношения, которые лучше всего описать как отношения «имеет — не имеет». Например, у автомобиля есть двигатель, но автомобиль не является двигателем, поэтому не имеет смысла расширять класс Car из класса Engine, несмотря на то, что Car может получить доступ к некоторым методам и свойствам двигателя.

Теперь это не имеет смысла, потому что у автомобиля просто есть двигатель, но нет свойств двигателя. Вместо этого мы должны использовать композицию объектов и включить Engine в качестве вложенного свойства Car.

Часто возникает путаница из-за того, что некоторые разработчики могут подумать: «Ну, мне нужен метод M из класса B в классе A, поэтому я должен расширить A из B»; в 99 процентах случаев это ловушка.

В таких случаях полезно пересмотреть отношения и выяснить, являются ли эти отношения отношения отношениями типа is-like-a. В нашем предыдущем примере отношения явно не были такими (автомобиль не похож на двигатель). Но в случае с сервисами Angular могут быть случаи, когда это может быть применимо. Например, представьте себе обертку вокруг HttpClient и сервис получения данных, например ProductService. Очевидно, что служба обработки данных не является общей оберткой вокруг HttpClient, она ведет себя подобно ему, поэтому наследование может быть рассмотрено в этом сценарии. Важно быть внимательным и помнить об DI и о том, где он более применим, чем наследование.

Основная цель наследования — не разделение функциональности, а представление отношений между структурами данных. Однако наследование может быть использовано в сценарии отношений типа «есть-есть».

Если мы хотим разделить функциональность класса A в классе B без наследования, в Angular есть средство для этого: инъекция зависимостей. Мы должны внедрить класс B в класс A и использовать его через предоставленную ссылку.

Использование классов повсюду

В TypeScript есть и классы, и интерфейсы; обычно мы не можем жить без классов, и если приходится выбирать, многие разработчики часто предпочитают использовать только классы, а интерфейсы не использовать вообще. Но это путь к некоторым неприятным подводным камням. Представьте себе сценарий: у нас есть сервис, который делает вызов данных, чтобы получить некоторые данные о Product. Методы HttpClient являются общими, поэтому мы можем указать, что будет являться данными ответа. Естественно, мы напишем что-то вроде этого:

@Injectable()
  export class ProductService {
    constructor(
      private readonly http: HttpClient,
    ) { }
    
    getProducts(): Observable<Product[]> {
      return this.http.get<Product[]>('/api/products');
    }
  }

Что же такое Product? Предположим, что это класс:

export class Product {
  name: string;
  price: number;
  amountSold: number;
}

Вроде бы неплохо. Так где же проблема? Бэкенд наверняка возвращает именно этот объект, так что все должно работать, верно? Так и будет, пока кто-то не решит вмешаться:

export class Product {
  name: string;
  price: number;
  amountSold: number;

  get total() {
    return this.price * this.amountSold;
  }
}

Как вы видите, кто-то только что добавил метод getter к нашему классу, и это окажется проблемой. Давайте посмотрим на компонент, который использует наш сервис и класс:

@Component({
  selector: 'app-product-list',
  template: `
    <div *ngFor="let product of products">
      <span>{{ product.name }} - {{ product.price }}</span>
      <span>{{ product.total }}</span>
    </div>
    
  `,
})
export class ProductListComponent {
  products: Product[];
  
  constructor(
    private productService: ProductService,
  ) {}
  
  ngOnInit() {
    this.productService.getProducts().subscribe(products => {
      this.products = products;
    });
  }
}

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

В интерфейсах, хотя мы можем добавить объявления методов, у разработчиков нет соблазна делать это, если только интерфейсы не будут затем реализованы классами. Другой подход заключается в использовании определений типов вместо интерфейсов:

export type Product = {
  name: string;
  price: number;
  amountSold: number;
}

Неиспользование классов там, где это необходимо

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

export function isObject(obj: any): obj is object {
  return obj !== null && typeof obj === 'object';
}

export function isArray(obj: any): obj is any[] {
  return Array.isArray(obj);
}

export function copy<T>(obj: T): T {
  if (isObject(obj)) {
    return JSON.parse(JSON.stringify(obj));
  } else {
    return obj;
  }
}

// and so on...

Конечно, мы можем не знать, куда именно их поместить, и просто поместить их в какой-нибудь файл с именем functions.ts, например, и покончить с этим.

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

Безумно большие классы

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

Не оптимизируйте преждевременно

Наличие сервисов для определенных компонентов

В проектах Angular мы часто сталкиваемся с тем, что определенные компоненты имеют специальные сервисы для себя, например, компонент  HomePageComponent  может иметь  HomePageService, который специально загружает все данные, необходимые для главной страницы. Хотя это звучит как приятное разделение проблем, это все еще тесно связывает компонент HomePageComponent  со службой. В будущем, возможно, нам понадобятся некоторые методы из этого сервиса, скажем, в SideBarComponent, что будет по меньшей мере странно — домашняя страница может даже не быть загружена, но ее специальные сервисы уже будут работать из-за боковой панели. Кроме того, это несколько затуманивает цель службы: не очень понятно, с какими данными работает HomePageService. Поэтому лучшим подходом было бы создание различных сервисов, работающих с определенными типами данных и бизнес-логики; например, у нас может быть UserService, PermissionsService, ProductService и так далее, и использовать их в соответствующих компонентах, когда это необходимо.

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