
В 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. Как лучше поступить — пишите в комментариях :)