Angular

Загрязнение ngOnInit

ngOnInit — один из самых важных хуков жизненного цикла в компонентах Angular. Он используется для инициализации данных, настройки слушателей, создания соединений и т.д. Однако в некоторых случаях он перегружает хук жизненного цикла:

@Component({
  selector: 'some',
  template: 'template',
})
export class SomeComponent implements OnInit, OnDestroy {
  @ViewChild('btn') buttonRef: ElementRef<HTMLButtonElement>;
  form = this.formBuilder.group({
    firstName: [''],
    lastName: [''],
    age: [''],
    occupation: [''],
  })
  destroy$ = new Subject<void>();

  constructor(
    private readonly service: Service,
    private formBuilder: FormBuilder,
  ) {}

  ngOnInit() {
    this.service.getSomeData().subscribe(res => {
      // обработка ответа
    });
    this.service.getSomeOtherData().subscribe(res => {
      // здесь может поместиться МНОГО логики
    });
    this.form.get('age').valueChanges.pipe(
      map(age => +age),
      takeUntil(this.destroy$),
    ).subscribe(age => {
      if (age >= 18) {
        // выполняем какие-либо действия
      } else {
        // выполняем другие действия
      }
    });

    this.form.get('occupation').valueChanges.pipe(
      filter(occupation => ['engineer', 'doctor', 'actor'].indexOf(occupation) > -1),
      takeUntil(this.destroy$),
    ).subscribe(occupation => {
      // здесь выполняем грязную работу
    });

    combineLatest(
      this.form.get('firstName').valueChanges,
      this.form.get('lastName').valueChanges,
    ).pipe(
      debounceTime(300),
      map(([firstName, lastName]) => `${firstName} ${lastName}`),
      switchMap(fullName => this.service.getUser(fullName)),
      takeUntil(this.destroy$),
    ).subscribe(user => {
      // делаем что-либо
    });

    fromEvent(this.buttonRef.nativeElement, 'click').pipe(
      takeUntil(this.destroy$),
    ).subscribe(event => {
      // обработка события
    })
  }

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

Рассмотрим этот компонент. У него есть всего два жизненных цикла. Однако метод ngOnInit выглядит ужасно. Он подписывается на различные события изменения формы, потоки fromEvent, а также загружает много данных. Он состоит из 40 строк кода, а если включить опущенное здесь содержимое обратных вызовов subscribe, то мы получим более 100 строк, что противоречит даже самым мягким рекомендациям. Чтобы добраться до других методов, помимо ngOnInit придется прокручивать весь этот беспорядочный кусок кода (или закрывать/открывать его при необходимости). Более того, из-за большого количества смешанных концепций и задач усложняется поиск элементов внутри самого метода ngOnInit.

Теперь рассмотрим исправленную версию того же компонента:

@Component({
  selector: 'some',
  template: 'template',
})
export class SomeComponent implements OnInit, OnDestroy {
  @ViewChild('btn') buttonRef: ElementRef<HTMLButtonElement>;
  form = this.formBuilder.group({
    firstName: [''],
    lastName: [''],
    age: [''],
    occupation: [''],
  })
  destroy$ = new Subject<void>();

  constructor(
    private readonly service: Service,
    private formBuilder: FormBuilder,
  ) {}

  ngOnInit() {
    this.loadInitialData();
    this.setupFormListeners();
    this.setupEventListeners();
  }

  private setupFormListeners() {
    this.form.get('age').valueChanges.pipe(
      map(age => +age),
      takeUntil(this.destroy$),
    ).subscribe(age => {
      if (age >= 18) {
        // выполняем какие-либо действия 
      } else {
        // выполняем другие действия
      }
    });

    this.form.get('occupation').valueChanges.pipe(
      filter(occupation => ['engineer', 'doctor', 'actor'].indexOf(occupation) > -1),
      takeUntil(this.destroy$),
    ).subscribe(occupation => {
      // здесь выполняем грязную работу
    });

    combineLatest(
      this.form.get('firstName').valueChanges,
      this.form.get('lastName').valueChanges,
    ).pipe(
      debounceTime(300),
      map(([firstName, lastName]) => `${firstName} ${lastName}`),
      switchMap(fullName => this.service.getUser(fullName)),
      takeUntil(this.destroy$),
    ).subscribe(user => {
      // выполняем какие-либо действия
    });
  }

  private loadInitialData() {
    this.service.getSomeData().subscribe(res => {
      // обработка ответа
    });
    this.service.getSomeOtherData().subscribe(res => {
      // здесь может поместиться МНОГО логики
    });
  }
  
  private setupEventListeners() {
    fromEvent(this.buttonRef.nativeElement, 'click').pipe(
      takeUntil(this.destroy$),
    ).subscribe(event => {
      // обработка события
    })
  }

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

Логика компонента сохраняется, меняется лишь способ организации кода. Теперь метод ngOnInit вызывает три различных метода для загрузки начальных данных из сервисов, установки прослушивателей изменения формы и прослушивателей событий DOM (при необходимости). Эти изменения упрощают чтение компонента с первого раза (прочтите ngOnInit — разберитесь, с чего он начинается, и если вам нужны подробности реализации, просмотрите соответствующие методы). Поиск источника ошибок также упрощается: если прослушиватели форм работают неправильно, перейдите в setupFormListeners и т.д.

Не загрязняйте метод ngOnInit — разделите его на несколько частей!

Написание бесполезных селекторов директив

Директивы Angular — мощный инструмент, позволяющий применять пользовательскую логику к различным элементам HTML. При этом также используются селекторы CSS, которые предоставляют еще больше возможностей. Для примера возьмем директиву ErrorHighlightDirective, которая проверяет наличие ошибок в formControl соответствующего элемента и применяет к нему определенный стиль. Допустим, мы добавляем в нее селектор атрибута [errorHighlight]. Она работает хорошо, однако теперь необходимо найти все элементы формы с атрибутом formControl и добавить в них [errorHighlight]. Утомительная задача. Мы можем использовать селектор атрибута директивы [formControl]. Директива выглядит следующим образом:

@Directive({
  selector: '[formControl],[formControlName]'
})
export class ErrorHighlightDirective {
 // реализация
}

Теперь она будет автоматически связываться со всеми элементами управления формы в модуле.

Но на этом использование не заканчивается. Допустим, мы хотим применить анимацию тряски ко всем formControls, в которых есть класс has-error. Мы можем с легкостью написать директиву и привязать ее с помощью селектора класса: .has-error.

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

Логика внутри конструктора сервиса

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

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

@Injectable()
class SocketService {
  private connection: SocketConnection;

  constructor() {
    this.connection = openWebSocket(); // детали реализации опущены
  }

  subscribe(eventName: string, cb: (event: SocketEvent) => any) {
    this.connection.on(eventName, cb);
  }

  send<T extends any>(event: string, payload: T) {
    this.connection.send(event, payload);
  }
}

Этот базовый сервис создает соединение с сокетом и обрабатывает взаимодействия с ним. Однако проблема здесь в том, что при каждом создании нового экземпляра этого сервиса, открывается новое соединение.

Приложение будет использовать соединение с одним сокетом множество раз, поэтому, применяя этот сервис внутри отложено загруженных модулей, мы получим новое открытое соединение. Чтобы избежать этого, нужно удалить логику инициализации из конструктора и найти другой способ разделить соединение между отложено загруженными модулями. Также нам может потребоваться метод для перезагрузки соединения (например, чтобы открыть его повторно, если оно неожиданно закроется):

@Injectable()
class SocketService {
  

  constructor(
    private connection: SocketConnection 
    // SocketConnection находится в корне приложения и везде одинаков
  ) {  }

  // обработка перезагрузки сокета, наивная реализация
  openConnection() {
    this.connection = openWebSocket();
  }

  subscribe(eventName: string, cb: (event: SocketEvent) => any) {
    this.connection.on(eventName, cb);
  }

  send<T extends any>(event: string, payload: T) {
    this.connection.send(event, payload);
  }
}

Добавление нового состояния

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

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

@Component({
  selector: 'some',
  template: '<button>{{ text }}</button>',
})
export class SomeComponent {
  isAuth = false;
  text = 'Sign Out';

  constructor(
    private authService: AuthService,
  ) {}

  ngOnInit() {
    this.authService.authChange.subscribe(auth => {
      this.isAuth = auth;
      this.text = this.isAuth ? 'Sign Out' : 'Sign In';
    });
  }
}

… можно преобразовать в этот:

@Component({
  selector: 'some',
  template: `<button>{{ isAuth ? 'Sign Out' : 'Sign In' }}</button>`,
})
export class SomeComponent {
  isAuth = false;

  constructor(
    private authService: AuthService,
  ) {}

  ngOnInit() {
    this.authService.authChange.subscribe(auth => this.isAuth = auth);
  }
}

Свойство text было производным состоянием, которое не нужно хранить.

Не создавайте отдельные переменные и свойства для хранения производного состояния, лучше рассчитывайте их при необходимости.

Может показаться, что этой ошибки легко избежать, однако при работе со сложными данными ее могут допустить даже самые опытные разработчики, особенно с потоками RxJS.

Заключение

При написании приложений Angular можно совершить множество ошибок. Некоторые из них распространяются и становятся шаблонами, изучив которые, можно улучшить качество своих приложений.

Читайте также:


Перевод статьи Armen Vardanyan: Angular Bad Practices: Revisited