В 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)
kkupl
29.12.2021 09:50+4Название статьи не отражает реальность. В статье нету ни слова про отличия реакта от ангуляр. Добавьте что-ли "часть 1. Пишем ангуляр код"
SeekerOfTruth Автор
29.12.2021 09:51в черновиках так статья и называлась, видимо я неправильно перенёс на хабр название
поправил, спасибо :)
Imbecile
Чтобы приблизиться к реальности, даже без учёта использования централизованного state management (redux, mobx, ngrx...), стоит:
Прикрутить авторизацию с jwt
Прикрутить что-то для UI, тот же Bootstrap или Material
Перейти на использование async pipe и Change Detection Strategy OnPush
SeekerOfTruth Автор
Согласен
возможно выбранный пример слишком простоват, и надо в первой итерации усложнить пунктами выше, а после этого уже переписать на react и оценить полученный опыт
Launcelot
Хорошая идея!)