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

Как будем сравнивать?

Для начала попробуем написать на Angular простое приложение. Перед этим предлагаю прочитать базовые моменты из документации и пройти «Тур Героев». Получив основные навыки и взяв «Тур героев» за основу разработаем своё первое приложение на Angular и React, сравнив субьективные преимущества и недостатки.

Описание проекта

В качестве первого приложения отлично подходят проекты вроде «список задач», «блог» или «учёт расходов». Чтобы не повторяться, попробуем создать приложение для выявления автобусного фактора в команде. Bus-фактор — то количество людей в проекте, внезапное исчезновение которых (например, из-за ДТП с автобусом) приведёт к остановке или значительному замедлению различных процессов.

Приложение по аналогии с «Туром героев» будет состоять из главной dashboard-страницы, страницы с управлением навыками и сотрудниками, и с детализированными страницами по каждому навыку и сотруднику. Текущий дизайн приложения далёк от совершенства, основная цель — изучить логику работы с Angular и React при создании приложения.

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

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

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

Попробовать проект вживую можно вот тут: https://bus-factor.web.app, ссылка на кодовую базу — https://github.com/domclick/bus_factor

Описание кодовой базы и принятых решений

Чтобы при проектировании приложения не отвлекаться на написание любимого бекенда, был выбран Google Firebase как представитель Backend-as-a-service. Структура данных для проекта выглядит так:

Всего две сущности — «Сотрудники» и их «Навыки», и таблица для прикрепления навыка к сотруднику. Для взаимодействия с этими сущностями были написаны Angular-сервисы, которые реализуют необходимый CRUD для общения с Firebase-бекендом:

Код
import { Injectable } from '@angular/core';
import { FbCreateResponse, Employee } from '../interfaces';
import { Observable, of } from 'rxjs';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { catchError, map, tap } from 'rxjs/operators';
import { environment } from '../../../environments/environment';

@Injectable({
  providedIn: 'root'
})
export class EmployeesService {
  constructor(private http: HttpClient) { }

  private employeesUrl = 'api/employees';
  private entityName = 'employees';

  httpOptions = {
    headers: new HttpHeaders({ 'Content-Type': 'application/json' })
  };

  getEmployees(): Observable<Employee[]> {
    return this.http.get<Employee[]>(`${environment.fbDbUrl}/${this.entityName}.json`)
      .pipe(
        tap(_ => this.log('fetched employees')),
        map((response: {[key: string]: any}) => {
          return Object.
          keys(response)
            .map(key => ({
              ...response[key],
              id: key,
            }));
        }),
        catchError(this.handleError<Employee[]>('getEmployees', []))
      );
  }

  getEmployee(id: string): Observable<Employee> {
    const url = `${environment.fbDbUrl}/${this.entityName}/${id}.json`;
    return this.http.get<Employee>(url).pipe(
      tap(_ => this.log(`fetched employee id=${id}`)),
      map((employee: Employee) => {
        return {
          ...employee,
          id
        };
      }),
      catchError(this.handleError<Employee>(`getEmployee id=${id}`))
    );
  }

  updateEmployee(employee: Employee): Observable<any> {
    return this.http.patch(`${environment.fbDbUrl}/employees/${employee.id}.json`, employee, this.httpOptions).pipe(
      tap(_ => this.log(`updated employee id=${employee.id}`)),
      catchError(this.handleError<any>('updateEmployee'))
    );
  }

  addEmployee(employee: Employee): Observable<Employee> {
    return this.http.post<Employee>(`${environment.fbDbUrl}/employees.json`, employee, this.httpOptions).pipe(
      tap((newEmployee: Employee) => this.log(`added employee w/ id=${newEmployee.id}`)),
      map((response: FbCreateResponse) => {
        return {
          ...employee,
          id: response.name,
        };
      }),
      catchError(this.handleError<Employee>('addEmployee'))
    );
  }

  deleteEmployee(employee: Employee | string): Observable<Employee> {
    const id = typeof employee === 'string' ? employee : employee.id;
    const url = `${environment.fbDbUrl}/${this.entityName}/${id}.json`;

    return this.http.delete<Employee>(url, this.httpOptions).pipe(
      tap(_ => this.log(`deleted employee id=${id}`)),
      catchError(this.handleError<Employee>('deleteEmployee'))
    );
  }

  searchEmployees(term: string): Observable<Employee[]> {
    if (!term.trim()) {
      // if not search term, return empty employee array.
      return of([]);
    }
    return this.http.get<Employee[]>(`${this.employeesUrl}/?name=${term}`).pipe(
      tap(x => x.length ?
        this.log(`found employees matching "${term}"`) :
        this.log(`no employees matching "${term}"`)),
      catchError(this.handleError<Employee[]>('searchEmployees', []))
    );
  }


Описанный выше дизайн было решено реализовать на следующем наборе компонентов:

dashboard

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

Код
import { Component, OnInit } from '@angular/core';
import { Employee, EmployeeSkill } from '../shared/interfaces';
import { SkillsService } from '../shared/services/skills.service';
import { forkJoin } from 'rxjs';
import { EmployeeSkillsService } from '../shared/services/employee-skills.service';
import { EmployeesService } from '../shared/services/employee.service';

@Component({
  selector: 'app-dashboard',
  templateUrl: './dashboard.component.html',
  styleUrls: [ './dashboard.component.scss' ]
})
export class DashboardComponent implements OnInit {
  employees: Employee[] = [];
  employeeSkills: EmployeeSkill[];
  employeesHasSkills = {};
  employeesDictionary = {};
  bestEmployees = [];
  employeesHasSkill: Employee[] = [];

  constructor(
    private skillsService: SkillsService,
    private employeesService: EmployeesService,
    private employeeSkillsService: EmployeeSkillsService,
  ) { }

  ngOnInit(): void {
    forkJoin([
      this.employeesService.getEmployees(),
      this.employeeSkillsService.getEmployeeSkills()])
      .subscribe(([employees, employeeSkills]) => {
        this.employeeSkills = employeeSkills;
        this.employees = employees;
        if (this.employeeSkills) {
          for (const es of this.employeeSkills) {
            if (this.employeesHasSkills.hasOwnProperty(es.employeeId)) {
              this.employeesHasSkills[es.employeeId] += 1;
            } else {
              this.employeesHasSkills[es.employeeId] = 1;
            }
          }
          this.bestEmployees = Object.entries(this.employeesHasSkills).sort((a, b) => {
            const aCount = a[1];
            const bCount = b[1];
            if (aCount < bCount) {
              return 1;
            } else if (aCount > bCount) {
              return -1;
            } else {
              return 0;
            }
          });
          for (const e of employees) {
            this.employeesDictionary[e.id] = e;
          }
          this.bestEmployees = this.bestEmployees.slice(0, 4);
        }
      });
  }
}

bus-factor-list

Страница для управления навыками и сотрудниками использует компоненты skill-item и employee-item. Для получения событий из дочерних компонентов и реакции на них используем директиву Output, обработав полученные данные в handleDeleteSkill и handleDeleteEmployee.

Код
<div class="skills">
  <div class="skills-header">
    <input #skillName placeholder="Название Навыка" />
    <button (click)="addSkill(skillName.value); skillName.value=''">
      Добавить
    </button>
  </div>
  <div class="bus_factors">
    <app-skill-item *ngFor="let skill of skills"
                       [skill]="skill"
                       [skillCount]="skillTeachedByEmployee[skill.id] ||  0"
                       (deleteButtonClick)="handleDeleteSkill($event)"
    >
    </app-skill-item>
  </div>
</div>
<!--Код для сотрудников аналогичен, в листинге не приведён-->

В целом код для сущностей «Сотрудник» и «Навык» в MVP очень похож, но я не стал убирать дублирование кода, потому что в дальнейшем каждый компонент будет кастомизироваться. Typescript-логика для bus-factor-list выглядит так:

Код
import { SkillsService } from '../shared/services/skills.service';
import { Component, OnInit } from '@angular/core';
import { Employee, EmployeeSkill, Skill } from '../shared/interfaces';
import { EmployeesService } from '../shared/services/employee.service';
import { EmployeeSkillsService } from '../shared/services/employee-skills.service';
import { forkJoin } from 'rxjs';

@Component({
  selector: 'app-bus-factor-list',
  templateUrl: './bus-factor-list.component.html',
  styleUrls: ['./bus-factor-list.component.scss']
})
export class BusFactorListComponent implements OnInit {
  skills: Skill[];
  employees: Employee[];
  employeeSkills: EmployeeSkill[];
  employeesHasSkills = {};
  skillTeachedByEmployee = {};

  constructor(
    private skillsService: SkillsService,
    private employeesService: EmployeesService,
    private employeeSkillsService: EmployeeSkillsService,
  ) { }

  ngOnInit() {
    forkJoin([
      this.skillsService.getSkills(),
      this.employeesService.getEmployees(),
      this.employeeSkillsService.getEmployeeSkills()])
      .subscribe(([skills, empoyees, employeeSkills]) => {
        this.skills = skills;
        this.employees = empoyees;
        this.employeeSkills = employeeSkills;
        this.calculateSkillsData();
      });
  }

  calculateSkillsData(): void {
    this.employeesHasSkills = {};
    this.skillTeachedByEmployee = {};

    if (this.employeeSkills) {
      for (const es of this.employeeSkills) {
        if (this.employeesHasSkills.hasOwnProperty(es.employeeId)) {
          this.employeesHasSkills[es.employeeId] += 1;
        } else {
          this.employeesHasSkills[es.employeeId] = 1;
        }

        if (this.skillTeachedByEmployee.hasOwnProperty(es.skillId)) {
          this.skillTeachedByEmployee[es.skillId] += 1;
        } else {
          this.skillTeachedByEmployee[es.skillId] = 1;
        }
      }
    }
  }

  getSkills(): void {
    this.skillsService.getSkills()
      .subscribe(skills => this.skills = skills);
  }

  addSkill(name: string): void {
    name = name.trim();
    if (!name) { return; }
    this.skillsService.addSkill({ name } as Skill)
      .subscribe(skill => {
        this.skills.push(skill);
      });
  }

  deleteSkill(skill: Skill): void {
    const skillId = typeof skill === 'string' ? skill : skill.id;

    this.skills = this.skills.filter(h => h !== skill);
    this.skillsService.deleteSkill(skill).subscribe();

    for (const es of this.employeeSkills) {
      if (es.skillId === skillId) {
        this.employeeSkillsService.deleteEmployeeSkill(es.id).subscribe();
      }
    }
    this.skills = this.skills.filter(s => s !== skill);
    this.skillsService.deleteSkill(skill).subscribe();
    this.employeeSkills = this.employeeSkills.filter(es => es.skillId !== skillId);
    this.calculateSkillsData();
  }

  handleDeleteSkill(skill: Skill): void {
    const skillId = typeof skill === 'string' ? skill : skill.id;
    for (const es of this.employeeSkills) {
      if (es.skillId === skillId) {
        this.employeeSkillsService.deleteEmployeeSkill(es.id).subscribe();
      }
    }
    this.skills = this.skills.filter(h => h !== skill);
    this.employeeSkills = this.employeeSkills.filter(es => es.skillId !== skillId);
    this.calculateSkillsData();
  }
  // код для Сотрудников аналогичен, в листинге не приведён
}

skill-item

Для того, чтобы просклонять в зависимости от численности слово «сотрудник», был использован pipe pluralize:

Код
import { Pipe, PipeTransform } from '@angular/core';

@Pipe({
    name: 'pluralize'
})
export class PluralizePipe implements PipeTransform {

 decline(num: number, titles: string[]) {
    const cases = [2, 0, 1, 1, 1, 2];
    return titles[(num % 100 > 4 && num % 100 < 20) ? 2 : cases[(num % 10 < 5) ? num % 10 : 5]];
  }

  transform(value: any, titles: string[]): any {
        return this.decline(+value, titles);
    }
}
import { Pipe, PipeTransform } from '@angular/core';

@Pipe({
    name: 'pluralize'
})
export class PluralizePipe implements PipeTransform {

 decline(num: number, titles: string[]) {
    const cases = [2, 0, 1, 1, 1, 2];
    return titles[(num % 100 > 4 && num % 100 < 20) ? 2 : cases[(num % 10 < 5) ? num % 10 : 5]];
  }

  transform(value: any, titles: string[]): any {
        return this.decline(+value, titles);
    }
}

В компонент skill-item он подключается следующим образом:

<div class="skill-container" (click)="onSkillClick()">
  <div class="skill-info">
    <div class="skill-name">{{ skill.name }}</div>
    <div class="skill-skills">
      {{ skillCount }} {{ skillCount | pluralize: ['сотрудник', 'сотрудника', 'сотрудников'] }}
    </div>
  </div>
  <button class="delete" title="delete skill"
          (click)="deleteSkill(skill)">
    Удалить
  </button>
</div>

Внутри Typescript-логики можно увидеть использование события Output:

Код
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { Skill } from '../shared/interfaces';
import { SkillsService } from '../shared/services/skills.service';
import { ActivatedRoute, Router } from '@angular/router';

@Component({
  selector: 'app-skill-item',
  templateUrl: './skill-item.component.html',
  styleUrls: ['./skill-item.component.scss']
})
export class SkillItemComponent implements OnInit {
  @Input() skill: Skill;
  @Input() skillCount: number;
  @Output() deleteButtonClick = new EventEmitter<Skill>();

  constructor(
    private skillsService: SkillsService,
    private router: Router,
    private activatedRoute: ActivatedRoute,
  ) { }

  ngOnInit(): void {
  }

  deleteSkill(skill: Skill): void {
    this.skillsService.deleteSkill(skill).subscribe();
    this.deleteButtonClick.emit(skill);
  }

  onSkillClick(): void {
    this.router.navigate(['skills', this.skill.id],
      {relativeTo: this.activatedRoute.parent});
  }

}

skill-detail и employee-detail

На страницах с управлением навыками каждого сотрудника (employee-detail), а также на странице с редактированием навыка и списком сотрудников, обладающим этим навыком (skill-detail) можно увидеть использование директивы ng-template в else-блоке отображения наличия/отсутствия навыка:

<div class="card-title">Управление навыком</div>
<div class="skill-info">
  <div class="skill-id"></div>
  <input [(ngModel)]="skill.name" placeholder="Название навыка"/>
  <div class="skill-controls">
    <button (click)="goBack()">Назад</button>
    <button (click)="save()">Сохранить</button>
  </div>
</div>
<div class="skill-box">
  <div class="skills-title">Сотрудники с этим навыком</div>
  <div class="skill" *ngFor="let employee of employees" (click)="changeEmployeeSkill(employee.id)">
    <img src="assets/icons/done.svg" *ngIf="employeeIdsHasSkill.includes(employee.id); else noSkill">
    <ng-template #noSkill><img class="square-img" src="assets/icons/icon_warning_red.svg"></ng-template>
    <div class="skill-name"> {{employee.name}}</div>
  </div>
</div>

header и sidebar

Довольно типовые для большинства проектов блоки, код можно посмотреть в исходниках проекта.

Что дальше?

Прототип приложения на Angular написан, базовые навыки получены. Теперь можно приступить к React-проекту либо углубить текущий Angular-прототип, добавив в него авторизацию, админку, профили пользователей, красивые графики и прочую функциональность. Либо поработать с бекендом, заменив Firebase на полноценный микросервис на чём-нибудь современном, например, на fastAPI. Как лучше поступить — пишите в комментариях :)

Комментарии (5)


  1. Imbecile
    28.12.2021 14:47
    +1

    Чтобы приблизиться к реальности, даже без учёта использования централизованного state management (redux, mobx, ngrx...), стоит:

    • Прикрутить авторизацию с jwt

    • Прикрутить что-то для UI, тот же Bootstrap или Material

    • Перейти на использование async pipe и Change Detection Strategy OnPush


    1. SeekerOfTruth Автор
      28.12.2021 15:03

      Согласен
      возможно выбранный пример слишком простоват, и надо в первой итерации усложнить пунктами выше, а после этого уже переписать на react и оценить полученный опыт


    1. Launcelot
      30.12.2021 11:10

      Хорошая идея!)


  1. kkupl
    29.12.2021 09:50
    +4

    Название статьи не отражает реальность. В статье нету ни слова про отличия реакта от ангуляр. Добавьте что-ли "часть 1. Пишем ангуляр код"


    1. SeekerOfTruth Автор
      29.12.2021 09:51

      в черновиках так статья и называлась, видимо я неправильно перенёс на хабр название
      поправил, спасибо :)