Angular vs React глазами новичка. Часть 1: Angular

от автора

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


ссылка на оригинал статьи https://habr.com/ru/company/domclick/blog/596573/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *