/angular-guide > _

ანგულარის ქართული გზამკვლევი

ეს წიგნი არის Angular ფრეიმვორქის არაოფიციალური სახელმძღვანელო, სადაც შესაძლებელია გაეცნოთ ფრეიმვორქის ძირითად კონცეფციებს, ტექნიკებსა და პრაქტიკის საუკეთესო პატერნებს. ეს წიგნი არ არის ტექნიკური დოკუმენტაცია ან ცნობარი, ანუ რეკომენდირებული არ არის ამ წიგნის გამოყენება აბსოლუტურად ზუსტი ფაქტობრივი ტექნიკური ინფორმაციის მოსაძიებლად. ამ წიგნის დანიშნულებაა, რომ ეტაპობრივად გაგაცნოთ Angular-ს.

თითოეული თავი გარკვეულწილად გამომდინარეობს წინა თავებიდან, შესაბამისად რეკომენდირებულია (თუმცა აუცილებელი არ არის) რომ თემების შესწავლას მიჰყვეთ თანმიმდევრულად. ყოველი თავი ეფუძნება წინა თავში განხილულ თემას, თუმცა კოდის მაგალითები ხშირ შემთხვევაში უნიკალურია თითოეული თავისთვის, ამიტომ თუ გსურთ რომ მხოლოდ კონკრეტული თავის თემა შეისწავლოთ, წინა თავებში არსებული კოდი არ დაგჭირდებათ.

ეს წიგნი გათვლილია დამწყებ ვებ დეველოპერებზე, რომლებმაც უკვე იციან:

გაითვალისწინეთ, რომ მხოლოდ ამ წიგნიდან მიღებული ცოდნა არ არის საკმარისი იმისთვის, რომ ანგულარს სრულფასოვნად დაეუფლოთ – ამას სჭირდება დამატებითი რესურსების მოძიება, დამოუკიდებლად პროექტების აწყობა და მთლიანობაში კონსისტენტური და ხანგრძლივი შრომა. ამ წიგნიდან შეძლებთ აითვისოთ ის საბაზისო ცოდნა, რომლის საფუძველზეც უკვე თავდაჯერებულად შეძლებთ პატარა ვებ აპლიკაციების აწყობასა და შედარებით უფრო სერიოზული ტექნიკების ათვისებას.

წიგნი შექმნილია ფრიდონ თეთრაძის მიერ.

ამ საიტის მასალა ეყრდნობა ანგულარის ოფიციალურ დოკუმენტაციებს:

შესავალი Angular-ში

ანგულარი არის პოპულარული ჯავასკრიპტის ფრეიმვორქი, რომლითაც მარტივად, სტრუქტურირებულად და მოქნილად შეგვიძლია ერთგვერდიანი ვებ აპლიკაციების (SPA - Single Page Application) შექმნა. ფრეიმვორქი გულისხმობს ხელსაწყოების კომპლექტსა და პრაქტიკის პატერნების ერთობლიობას, რაც ერთგვარ ეკოსისტემას ქმნის. ანგულარში გაცილებით უფრო მარტივია რეაქტიული აპლიკაციების წერა სანდო და მკაცრი კოდით.

გაკვეთილების უმეტესობა ეფუძნება standalone სისტემას, რომელიც ანგულარის მე-17 ვერსიიდან გახდა სტანდარტი. ანგულარის (და ამ წიგნის) ძველ ვერსიებში ვიყენებდით მოდულებს.

ამ თავში ისწავლით:

ინსტალაცია

ანგულარს აქვს თავისი CLI, რომლის დასაინსტალირებლად და გამოსაყენებლად საჭიროა Node-ის პაკეტების მენეჯერი - npm, რომელიც Nodejs-ს მოყვება:

npm install -g @angular/cli

ინსტალაციის შემდეგ შეგვიძლია შევამოწმოთ CLI-ის ვერსია, რათა დავრწმუნდეთ რომ ის დაინსტალირდა.

ng v

CLI ანგულარის ერთ-ერთი უმძლავრესი ხელსაწყოა, რომლითაც ყველა სხვა ფრეიმვორქს აღემატება. CLI-ს საშუალებით შეგვიძლია საწყისი აპლიკაციების შექმნა ან აპლიკაციების კონკრეტული ნაწილების შექმნა, მოდიფიკაცია, კონფიგურაცია და ა.შ. ეს არ ამოწურავს ანგულარის CLI-ს შესაძლებლობებს, მაგრამ ყველაზე ხშირად ის ასეთი საქმეებისთვის დაგვჭირდება.

პირველი აპლიკაცია

საწყისი აპლიკაციის შესაქმნელად ჩვენ ტერმინალში უნდა გადავინაცვლოთ ჩვენთვის სასურველ დირექტორიაში და გავუშვათ ბრძანება:

ng new my-app

my-app შეგიძლიათ ნებისმიერი სახელით ჩაანაცვლოთ - ეს ჩვენი პროექტის სახელი იქნება. ანგულარი რამდენიმე შეკითხვას დაგვისვამს, ამჯერად რას ვუპასუხებთ არსებითი მნიშვნელობა არ აქვს, თუმცა შეგვიძლია SSR-სა და რაუტინგზე უარი ვთქვათ, ხოლო სტილებისთვის ავირჩიოთ CSS.

ანგულარი შეგვიქმნის ახალი პროექტის ფოლდერს, შიგნით ყველა საჭირო ფაილით. გავხსნათ პროექტი ჩვენს ედიტორში და თვალი შევავლოთ მის სტრუქტურას:

  • angular.json - ანგულარის კონფიგურაცია
  • node_modules - პაკეტები (კოდი), რომელიც ჩვენ აპლიკაციას და ანგუალრს სჭირდება
  • package.json - ინფორმაცია საჭირო პაკეტების და მათი ვერსიების შესახებ
  • package-lock.json - პაკეტების ვერსიებს შორის ურთიერთდამოკიდებულების შესახებ ინფორმაცია
  • README.md - ფაილი, სადაც შეგვიძლია ჩვენი პროექტი აღვწეროთ სხვების დასანახად
  • src - ფოლდერი, სადაც აპლიკაციაზე სამუშაო კოდია
    • app - მთავარი აპლიკაციის საშენი ბლოკების ფოლდერი
      • app.component.css - მთავარი კომპონენტის სტილების ფაილი
      • app.component.html - მთავარი კომპონენტის მარკაპის ფაილი
      • app.component.spec.ts - მთავარი კომპონენტის ავტომატიზირებული ტესტების ფაილი
      • app.component.ts - მთავარი აპლიკაციის კომპონენტის ფაილი
      • app.config.ts - აპლიკაციის კონფიგურაციის ფაილი
      • app.routes.ts - აპლიკაციის რაუთინგის ფაილი
    • assets - ფოლდერი ისეთი რესურსებისთვის, როგორიცაა სურათები, აიქონები, JSON ფაილები და ა.შ
    • favicon.ico - აპლიკაციის აიქონი რაც ტაბებზე ჩნდება
    • index.html - აპლიკაციის მთავარი ამოსავალი წერტილი. მთლიანი აპლიკაცია ამ ერთ ფაილში იმუშავებს
    • main.ts - ქმნის აპლიკაციას app.config.ts-ში არსებული კონფიგურაციის მიხედვით და მას სვამს index.html-ში
    • styles.css - გლობალური სტილების ფაილი
  • tsconfig.app.json - ტაიპსკრიპტის კონფიგურაცია, რომელსაც ანგულარის ქომფაილერი გამოიყენებს
  • tsconfig.json - ტაიპსკრიპტის კონფიგურაცია, რომლითაც ჩვენ ვიხელმძღვანელებთ კოდის წერისას
  • tsconfig.spec.json - ტაიპსკრიპტის კონფიგურაცია, რომელსაც ტესტის ფაილები გამოიყენებენ

სადეველოპმენტო სერვერის გასაშვებად გავცეთ ბრძანება

npm run start

ან

ng serve

ბრაუზერი გავხსნათ localhost:4200-ზე. აქ ვხედავთ ზუსტად იმ მარკაპს, რომელიც app.component.html-შია დაწერილი. შეგვიძლია იქ ყველაფერი წავშალოთ და უბრალოდ დავწეროთ <h1>Hello world!<h1> რათა დავრწმუნდეთ, რომ ყველაფერი მუშაობს.

რეკომენდირებული ექსთენშენები

visual studio code-ში რეკომენდირებულია რომ დააყენოთ შემდეგი ექსთენშენები:

  • Angular Language Service
  • angular2-inline
  • ESLint
  • Material Icon Theme
  • Prettier - Code formatter

ახლა მზად ვართ, რომ ანგულარზე ვიმუშაოთ.

კომპონენტის შექმნა და კონფიგურაცია

ფრეიმვორქის ერთ-ერთი უპირატესობა ის არის, რომ შეგვიძლია აპლიკაცია დავყოთ კომპონენტებად. ეს საშუალებას გვაძლევს, რომ აპლიკაციის განმეორებადი ელემენტები ერთ ადგილას განვსაზღვროთ და ბევრგან გამოვიყენოთ.

შენიშვნა: ამ თავში განვიხილავთ მე-17 ვერსიაში სტანდარტიზირებულ standalone კომპონენტებს. ანგულარის ძველ ვერსიებში გამოიყენებოდა მოდულებზე დაფუძნებული მიდგომა.

ანგულარის კომპონენტი ჩვეულებრივ სამ ფაილად დაყოფილი ფოლდერია. გვაქვს ts ფაილი, სადაც კომპონენტის კონფიგურაცია და ჯავასკრიპტის ლოგიკა ერთიანდება, html თემფლეითი, სადაც კომპონენტის მარკაპია და scss/css ფაილი, სადაც კომპონენტის სტილებია.

# example კომპონენტი

example.component.ts
example.component.html
example.component.css

ახალი კომპონენტის შემოტანა ასეთი ფაილების ხელით შექმნით შეიძლება, ან უბრალოდ CLI-ში ბრძანების გაშვებით:

ng generate component child

child იქნება ის სახელი, რომელსაც კომპონენტს დავარქმევთ. ანგულარი შექმნის კომპონენტს, რომელიც განთავსდება child სახელის მქონე ფოლდერში. ფაილების დასახელება მათი დანიშნულების მიხედვით ხდება. კონვენციურად წერტილებით გამოიყოფა component ფაილების დასახელებაში. html ფაილში ჩვენ ვწერთ მარკაპს. ანგულარის CLI-იმ წინასწარ თემფლეითში ჩაგვისვა ტექსტი, რომელიც კომპონენტის სადმე განთავსების შემთხვევაში უნდა გამოჩნდეს. .css ფაილში ვწერთ ამ კომპონენტის სტილებს, ხოლო .ts ფაილში კომპონენტის ლოგიკას. როგორ არის ეს ყველაფერი ერთმანეთთან დაკავშირებული?

ტაიპსკრიპტის ფაილში ვხედავთ, რომ დაესქპორტებულია კომპონენტის კლასი, რომელსაც კონვენციურად სახელთან ერთად ეწერება სიტყვა ‘Component’, მაგრამ კლასის დაექსპორტებამდე მას თან ახლავს @angular/core-დან დაიმპორტებული @Component დეკორატორი. დეკორატორი ტაიპსკრიპტის ხელსაწყოა, რომელიც მეტაპროგრამირებაში გამოიყენება. იგი ერთგვარი ფუნქციაა, რომელიც მოდიფიკაციას უკეთებს მონაცემის სტრუქტურას - ამ შემთხვევაში კომპონენტის კლასს. ამ დეკორატორში კონფიგურაციის ობიექტის მიწოდებით ანგულარი მუშა კომპონენტის ინსტანციას ქმნის.

import { Component } from "@angular/core";
import { CommonModule } from "@angular/common";

@Component({
  selector: "app-child",
  standalone: true,
  imports: [CommonModule],
  templateUrl: "./child.component.html",
  styleUrl: "./child.component.css",
})
export class ChildComponent {}

selector არის ის თეგი, რომლითაც ამ კომპონენტს თემფლეითში განვათავსებთ. კონვენციურად კომპონენტის თეგებს აქვთ app პრეფიქსი, მაგრამ ეს შეიძლება იყოს პროექტის დასახელება, კომპანიის სახელი, რომელიმე მათგანის აბრივიაცია და ა.შ. სცადეთ app.component.html-ში ამ თეგის განთავსება:

<!-- დამხურველი თეგით -->
<app-child></app-child>
<!-- შესაძლებელია void ტიპის თეგიც -->
<app-child />

თუ სადეველოპმენტო სერვერი გაშვებული გაქვთ, მაშინ ის მარკაპი უნდა დავინახოთ, რომელიც child.component.html-ში დავწერეთ.

კონფიგურაციაში standalone თვისება მიუთითებს იმაზე, დამოუკიდებელია თუ არა კომპონენტი. standalone კომპონენტი დამოუკიდებლად ფუნქციონირებს იმ თვალსაზრისით, რომ ყველა dependency (ანუ საჭირო კლასები) არის უშუალოდ ამ კომპონენტში დასაიმპორტებელი, თუკი მათი გამოყენება გვსურს. ეს განსხვავდება მოდულზე დამოკიდებული კომპონენტებისგან, სადაც ცალკეულ ფაილში მოდულის კლასის დეკლარაცია შეგვიძლია და იქ არსებული კომპონენტების გამოცხადება, ისევე როგორც მათთვის dependency-ების მიწოდება. თუ ეს ყველაფერი ბუნდოვნად ჟღერს, არ ინერვიულოთ! დროთა განმავლობაში ყველაფერი გასაგები გახდება. უბრალოდ გაითვალისწინეთ, რომ ანგულარის მე-17 ვერსიიდან სტანდარტული პრაქტიკაა standalone კომპონენტების გამოყენება.

imports არის თვისება, სადაც ჩვენ შეგვიძლია შემოვიტანოთ ანგულარის სხვადასხვა საშენი ბლოკები (კომპონენტები, დირექტივები, ფაიფები და ა.შ), რათა ისინი ამ კომპონენტში გამოვიყენოთ. CommonModule არის მოდული, სადაც ასეთი ყველაზე გამოყენებადი ბლოკებია, ამიტომ CLI-მ ის წინასწარ დაგვიმატა.

კონფიგურაციაში templateUrl მიუთითებს იმ მისამართზე, სადაც თემფლეითის ფაილი უნდა იყოს. styleUrl, შესაბამისად, მიუთითებს სტილების ფაილის მისამართზე.

აღსანიშნავია, რომ @Component დეკორატორში templateUrl-ის მაგივრად შეგვიძლია გამოვიყენოთ template და მარკაპი პირტაპირ ამ თვისების მნიშვნელობაში ჩავწეროთ, როგორც სტრინგი:

@Component({
  selector: 'app-child',
  standalone: true,
  template: `<h1>Hello from ChildComponent!</h1>`,
  styleUrl: "./child.component.css",
})

ასევე შეგვიძლია styles თვისების გამოყენება styleUrl-ის მაგივრად და სტრინგის მიწოდება, სადაც ჩვენ მიერ არჩეული სტილების ფაილის სინტაქსი იმუშავებს:

@Component({
  selector: 'app-child',
  template: `<h1>Hello from ChildComponent!</h1>`,
  styles:`
    h1 {
      color: red;
    }
  `,
})

@Component-ში არსებულ კონფიგურაციას ანგულარის ქომფაილერი კითხულობს, და საჭირო ადგილას (სადაც კომპონენტის თეგებს განვათავსებთ) DOM-ში ამ კომპონენტს ჩასვამს სათანადო ფუნქციონალით. @Component დეკორატორის დანიშნულება ამით არ ამოიწურება. მის შესახებ დეტალური ინფორმაციისთვის გაეცანით ოფიციალურ დოკუმენტაციას.

სიცოცხლის ციკლი

აპლიკაციაში კომპონენტებს გააჩნიათ სიცოცხლის ციკლი. ისინი თავიანთი არსებობის განმავლობაში კონკრეტულ ეტაპებს გადიან და ჩვენ შგევიძლია ამ ეტაპებში კონკრეტული ოპერაციების განხორციელება, ეგრედ წოდებული lifecycle hook-ების დახმარებით. ანგულარი ამის საშუალებას გვაძლევს კომპონენტის კლასში ინტერფეისების იმპლემენტაციით და ამ ინტერფეისების მიხედვით კომპონენტში მეთოდების შექმნით. გავეცნოთ ორ ყველაზე გამოყენებად ჰუკებს: ngOnInit და ngOnDestroy.

ngOnInit

ngOnInit ეშვება, როცა კომპონენტი ინიციალიზირდება. AppComponent-ის შემთხვევაში ეს შეიძლება იყოს როცა მომხმარებელი აპლიკაციას გახსნის. სხვა კომპონენტების შემთხვევაში - მაშინ, როცა აპლიკაცია მათ გამოაჩენს NgIf დირექტივით ან რაუტერით (ამ კონცეფციებს მოგვიანებით ვისწავლით).

ჩვენ შემოგვაქვს OnInit ინტერფეისი angular/core-დან და მას იმპლემენტაციას ვუკეთებთ.

import { Component, OnInit } from "@angular/core";
import { CommonModule } from "@angular/common";

@Component({
  selector: "app-root",
  standalone: true,
  imports: [CommonModule],
  templateUrl: "./app.component.html",
  styleUrls: ["./app.component.css"],
})
export class AppComponent implements OnInit {
  ngOnInit(): void {
    console.log("კომპონენტი ინიციალიზირებულია!");
  }
}

ngOnDestroy

ngOnDestroy მაშინ აქტიურდება, როცა კომპონენტი ნადგურდება. ეს შეიძლება მოხდეს მაშინ, როცა აპლიკაცია გააქრობს კომპონენტს NgIf დირექტივით ან რაუტერით, ან როცა აპლიკაცია დაიხურება. ხშირად ამ ჰუკში ე.წ “მოსუფთავების ლოგიკას” ვწერთ, რათა ბრაუზერის მეხსიერება გავწმინდოთ ზედმეტი მონაცემებისგან.

import { Component, OnDestroy, OnInit } from "@angular/core";
import { CommonModule } from "@angular/common";

@Component({
  selector: "app-root",
  standalone: true,
  imports: [CommonModule],
  templateUrl: "./app.component.html",
  styleUrls: ["./app.component.css"],
})
export class AppComponent implements OnInit, OnDestroy {
  ngOnInit(): void {
    console.log("კომპონენტი ინიციალიზირებულია!");
  }

  ngOnDestroy(): void {
    console.log("აპლიკაცია განადგურდა!");
  }
}

როგორც ხედავთ, ერთ კომპონენტში შეგვიძლია ერთზე მეტი ჰუკის იმპლემენტაცია. დროთა განმავლობაში ამ ჰუკებს პრაქტიკულ მაგალითებში ვნახავთ. ამ ეტაპზე უბრალოდ უნდა ვიცოდეთ ზოგადად რა არის მათი დანიშნულება.

ახლა ვნახოთ, როგორ მუშაობს მარტივი რეაქტიულობა ანგულარში!

ინტერპოლაცია და მოვლენების მოხელთება

აპლიკაციის რეაქტიულობის უმთავრესი ნაწილია ერთი მხრივ გარკვეულ მოვლენებზე რეაგირება, და, მეორე მხრივ, მნიშვნელობების დინამიკურად გამოსახვა. სწორედ ამის საშუალებას გვაძლევს ანგულარში “event handling”-ი და “interpolation”.

ინტერპოლაცია

ინტერპოლაცია გულისხმობს მნიშვნელობების დოკუმენტში გამოსახვას. ეს მნიშვნელობები შეიძლება იყოს კომპონენტის კლასში არსებული თვისებები, მეთოდების მიერ დაბრუნებული შედეგები, ლოგიკური ოპერაციის შედეგები და ა.შ.

საწყისი ანგულარის აპლიკაცია ავიღოთ სანიმუშოდ, სადაც კლასში შევქმნით თვისებებს title და count:

export class AppComponent {
  title = "Hello there";
  count = 0;
}

მათი გამოსახვა ამავე კომპონენტის თემფლეითში შეგვიძლია ორმაგი კლაკნილი ფრჩხილებით:

<h1>{{ title }}</h1>
<h2>The count is {{ count }}</h2>

ანგულარის ქომფაილერი ავტომატურად ეძებს ცვლადის სახელებს იმავე კომპონენტის კლასის თვისებებში. ამ პრინციპს “data binding-საც” უწოდებენ. ჩვენი კლასის თვისება მიბმულია თემფლეითის ელემენტზე - პირველის ცვლილება იწვევს მეორეს განახლებას. რა თქმა უნდა, ახლა როგორმე ამ მონაცემის შეცვლა უნდა შევძლოთ მომხმარებლის ქმედებებიდან გამომდინარე.

მოვლენების მოხელთება

ვებ-გვერდზე მრავალი მოვლენა არსებობს, ზოგი ავტომატურად ხდება, ზოგს მომხმარებელი იწვევს: დასქროლვა, ელემენტზე დაკლიკება, მაუსის მოძრაობა, ერთი გვერდიდან მეორეზე გადასვლა და ა.შ. ანგულარი საშუალებას გვაძლევს, რომ მომხმარებლის ქმედებებზე ვირეაგიროთ. ქმედებათა შორის ყველაზე გავრცელებულია მაუსის კლიკი (ან ეკრანზე თითის დაჭერა). ვთქვათ გვსურს დაკლიკებაზე რეაგირება: ღილაკზე დაკლიკებამ გვერდზე გამოსახული count უნდა ყოველ ჯერზე ერთით გაიზარდოს.

ჯერ დაგვჭირდება ღილაკის შექმნა და მასზე დაკლიკების მოვლენის მოსმენა:

<h1>{{ title }}</h1>
<h2>The count is {{ count }}</h2>
<button (click)="increment()">Increment</button>

ეს არის ელემენტზე “event binding-ის” სინტაქსი. ღილაკზე არსებობს მრავალი მოვლენა, როგორც ერთგვარი ელემენტის თვისებები, რომელსაც ჩვენ ფრჩხილებში მოვაქცევთ და შემდეგ ტოლობაში, ბრჭყალებს შორის ვუწერთ მეთოდის ძახილს. ანგულარი აპლიკაციას არ დააქომფაილებს, რადგან increment მეთოდი კომპონენტის კლასში არ არსებობს. შევქმნათ ეს მეთოდი:

export class AppComponent {
  title = "Hello there";
  count = 0;

  increment() {
    this.count += 1;
  }
}

ანგულარი აპლიკაციას ახლა წარმატებით დააქომფაილებს. შედეგად მივიღებთ ღილაკს, რომელზე დაკლიკებაც ეკრანზე რიცხვს ზრდის. დაკლიკება იწვევს increment() მეთოდზე დაძაახებას, რომელიც კლასში count თვისებას ერთით ზრდის. ამ ცვლილებას ანგულარი თავისით აფიქსირებს, და რადგან ის ინტერპოლაციით არის განთავსებული თემფლეითში, მას ხელახლა არენდერებს, ამჯერად ახალი მნიშვნელობით.

მოდით, შევისწავლოთ binding-ის პრინციპი ცოტა უფრო სიღრმისეულად!

Data Binding - მონაცემების მიბმა

ამ თავში გავეცნობით დატა ბაინდინგს. ეს არის საშუალება, რომლითაც ელემენტებსა თუ კომპონენტებს შეგვიძლია დინამიკურად მივანიჭოთ თვისებები ან ატრიბუტები და ასევე მოვუსმინოთ მათზე არსებულ მოვლენებს.

  • Poperty & attribute binding - ელემენტის თვისებებსა და ატრიბუტზე მონაცემების მიბმა;
  • Input & Output დეკორატორები - დატა ბაინდინგი კომპონენტის პერსპექტივიდან;
  • Two-way binding - დატა ბაინდინგისა და ივენთ ბაინდინგის კომბინაცია.

Property & Attribute Binding

დატა ბაინდინგის საშუალებით ჩვენ შეგვიძლია თემფლეითში მნიშვნელობები დინამიკურად მივაბათ HTML ელემენტის კონკრეტულ ატრიბუტებს, ან DOM ელემენტის თვისებებს.

ვთქვათ კლასში გვაქვს განსაზღვრული შემდეგი თვისებები და მეთოდები:

import { Component } from "@angular/core";
import { CommonModule } from "@angular/common";

@Component({
  selector: "app-root",
  standalone: true,
  imports: [CommonModule],
  templateUrl: "./app.component.html",
  styles: `
    div {
      transition: background 500ms ease;
    }
    .square-red {
      width: 100px;
      height: 100px;
      background: red;
    }
    .square-blue {
      width: 100px;
      height: 100px;
      background: blue;
    }
  `,
  ,
})
export class AppComponent {
  imgData = {
    src: "https://angular.io/assets/images/logos/angular/angular.svg",
    alt: "Angular Logo",
  };
  btnDisabled = true;
  squareClass = "square-red";

  changeSquare() {
    this.squareClass =
      this.squareClass === "square-red" ? "square-blue" : "square-red";
  }
}

თუ გვსურს რომ imgData ობიექტში არსებული src და alt თვისებათა მნიშვნელობები მივაბათ თემფლეითში არსებულ img ელემენტს, მაშინ ელემენტზე არსებულ ატრიბუტს ვაქცევთ ოთკუთხედ ფრჩხილებში და ტოლობის შემდეგ ვწერთ იმ ცვლადებს, რომლებიც კლასსში არსებობს. ახლა რაღაც თვალსაზრისით, ბრჭყალებში ტაიპსკრიპტის/ჯავასკრიპტის ექსფრეშენები (expressions) მუშაობს.

<!-- no binding -->
<img src="https://example.com" alt="some alt text" />
<!-- with binding -->
<img [src]="imgData.src" [alt]="imgData.alt" />
<button [disabled]="btnDisabled">Can't Click</button>
<br />
<div [class]="squareClass"></div>
<button (click)="changeSquare()">Change Color</button>

იგივე ეხება ღილაკზე disabled ატრიბუტს, რომელსაც boolean ცვლადს ვაბამთ. შესაძლებელია class ატრიბუტზე სტრინგის მნიშვნელობების დინამიკურად მიბმაც: ამ შემთხვევაში ჩვენ კლასში არსებულ ცვლადს ვაბამთ div-ზე class ატრიბუტს, ხოლო ღილაკზე დაჭერით (ე.წ event binding-ის საშუალებით) ვააქტიურებთ მეთოდს, რომელიც ამ squareClass ცვლადის მნიშვნელობას ცვლის. რადგან კომპონენტის styles-ში ჩვენ შესაძლო კლასის სახელებისთვის სტილები გვაქვს გაწერილი, div ელემენტი კლასის ცვლილებასთან ერთად სტილებსაც იცვლის.

შესაძლებელია თვითონ ელემენტზე სტილის ან სტილის კონკრეტული ფროფერთის მოდიფიკაციაც:

// Inside the class
myBgColor = "violet";
<button (click)="changeSquare()" [style.background]="myBgColor">
  Change Color
</button>

აუცილებელია ხაზი გავუსვათ იმას, რომ ჩვენ ახლა განვიხილავთ property binding-ს და არა attribute binding-ს. ამ ორს შორის მნიშვნელოვანი განსხვავებაა. როცა ჩვენ HTML-ში ერთი შეხედვით ელემენტის ატრიბუტებს ვუწერთ მნიშვნელობას - იქნება ეს binding-ით თუ მის გარეშე - ანგულარი პირდაპირ ამ HTML ელემენტს არ განათავსებს დოკუმენტში. იგი ინიციალიზაციას უკეთებს DOM-ის ნოუდებს, და მათ ანიჭებს ფროფერთიებს - ანუ HTML-ში ატრიბუტზე ტექსტსა და მისი DOM-ის ნოუდის ანალოგურ ფროფერთის შეიძლება ზოგჯერ ერთი და იგივე მნიშვნელობები არ ჰქონდეთ.

უშუალოდ HTML-ის ელემენტების ატრიბუტის მოდიფიკაციისთვის, ისევე როგორც ფროფერთი ბაინდინგის შემთხვევაშია, ატრიბუტის სახელს ოთხკუთხედ ფრჩხილებში ვწერთ, თუმცა წინ ვუწერთ attr პრეფიქსსა და წერტილს, ხოლო ტოლობის შემდეგ ბრჭყალებში უნდა დაიწეროს ექსფრეშენი, რომელის სტრინგს აბრუნებს:

<button
  (click)="changeSquare()"
  [style.background]="myBgColor"
  [attr.aria-label]="actionLabel"
>
  Change Color
</button>
// in the class
actionLabel = "Change square color";

ატრიბუტის ბაინდინგს ყველაზე ხშირად accsessibility-სთვის იყენებენ.

შეჯამება

რომ შევაჯამოთ, ამ თავში ჩვენ განვიხილეთ ფროფერთი და ატრიბუტ ბაინდინგი, რომლის საშუალებითაც კომპონენტის კლასში არსებული მნიშვნელობები მივაბით ელემენტის თვისებებსა და ატრიბუტებს. ერთმანეთისგან განვასხვავეთ ფროფერთისა და ატრიბუტის ბაინდინგი, სადაც ფროფერთი ბაინდინგი DOM-ის ნოუდს უცვლის თვისებებს, ხოლო ატრიბუტის ბაინდინგი ელემენტის HTML ატრიბუტების მნიშვნელობას, რომელიც აუცილებლად სტრინგი უნდა იყოს.

Input & Output

Input და Output არიან დეკორატორები, რომელთა საშუალებითაც შეგვიძლია მონაცემები (data) და მოვლენები (events) გადავცეთ ერთი კომპონენტიდან მეორეს. Input-ის საშუალებით შვილი კომპონენტი მშობელისგან იღებს მონაცემს, ხოლო Output-ის საშუალებით შვილი კომპონენტი მოვლენას გადასცემს მშობელ კომპონენტს.

input და output პრინციპების დიაგრამა

სანიმუშოდ შექმნილი გვაქვს ანგულარის ახალი აპლიკაცია, სადაც შევქმენით კომპონენტი სახელად child. ეს უკანასკნელი სელექტორით განვათავსეთ app.component.html-ში. ChildComponent გამოდის AppComponent-ის შვილი.

Input

გადავცეთ მშობელი კომპონენტიდან შვილ კომპონენტს ინფორმაცია. ამისთვის შვილ კომპონენტში ვქმნით თვისებას @Input დეკორატორით, რომელიც @angular/core-დან უნდა დავაიმპორტოთ.

import { Component, Input } from "@angular/core";
import { CommonModule } from "@angular/core";

@Component({
  selector: "app-child",
  standalone: true,
  imports: [CommonModule],
  templateUrl: "./child.component.html",
  styleUrl: "./child.component.scss",
})
export class ChildComponent {
  @Input() message: string = "";
}

message ცვლადს ჩვენ ინიციალიზაციას ვუკეთებთ, როგორც ცარიელ სტრინგს. ამის გარეშე, თუ ტაიპსკრიპტი მკაცრ რეჟიმზე გვაქვს, ქომფაილერში ერორი მოხდება. რადგან ამ ფროფერთის ინფუთ დეკორატორით ჩვენ ვაცხადებთ, რომ მშობელიდან ველით მონაცემს, რომელიც ამ ფროფერთიში შეინახება, ანგულარი დარწმუნებული უნდა იყოს, რომ რაღაც მნიშვნელობა ამ ფროფერთის იმ შემთხვევაშიც ექნება, თუ მან მშობელისგან მონაცემი არ მიიღო.

message-ს ჩვენ ამავე კომპონენტის თემფლეითში განვათავსებთ ინტერპოლაციით:

<p>{{ message }}</p>

მშობელ კომპონენტში შევქმნათ მასივი messages რომელშიც შევინახავთ ორ სტრინგს:

import { Component } from "@angular/core";
import { CommonModule } from "@angular/common";

@Component({
  selector: "app-root",
  standalone: true,
  imports: [CommonModule],
  templateUrl: "./app.component.html",
  styleUrl: "./app.component.scss",
})
export class AppComponent {
  messages = ["The first message", "The second message"];
}

მშობელი კომპონენტის თემფლეითში ფროფერთი ბაინდინგის საშუალებით შეგვიძლია შვილის კლასში არსებულ სახელზე რაიმე მნიშვნელობების მიბმა:

<app-child [message]="messages[0]"></app-child>
<app-child [message]="messages[1]"></app-child>

აქ ჩვენ ორი child კომპონენტი განვათავსეთ, სადაც პირველს message თვისებაზე ვაბამთ messages მასივში არსებულ პირველ სტრინგს, ხოლო მეორე child კომპონენტს ვაბამთ messages მასივში არსებულ მეორე სტრინგს. message ფროფერთი app-child-ზე მშობელი კომპონენტიდან ხელმისაწვდომია სწორედ @Input() დეკორატორის წყალობით. ასე child კომპონენტი თავის კლასში იმავე სახელის message თვისებაზე მიიღებს მშობელი ელემენტიდან მიწოდებულ მნიშვნელობას, და მას თემფლეითში განათავსებს.

ტაიპსკრიპტის წყალობით ეს ფროფერთი მხოლოდ კონკრეტული ტიპის მონაცემს მიიღებს, ანუ თუ ჩვენ message ფროფერთიზე სხვა ტიპის მნიშვნელობას მივაბამთ, ეს გამოიწვევს ერორს:

<!-- Error: cannot assign type number to type string -->
<app-child [message]="34"></app-child>

ასე ჩვენ მშობელი ელემენტიდან შვილ ელემენტს გადავცემთ ინფორმაციას.

Output

Output-ის საშუალებით ჩვენ შეგვიძლია შევქმნათ მოვლენები, რომელსაც შეგვიძლია მშობელი კომპონენტიდან მოვუსმინოთ (მაგალითად ‘click’ ივენთის მსგავსად).

ვთქვათ გვინდა, რომ შვილ კომპონენტში ღილაკზე დაჭერისას მშობელ კომპონენტს გადავცეთ მესიჯის ტექსტის სიგრძე. ჯერ შევქმნათ ღილაკი, რომელზე დაკლიკებასაც მოვუსმენთ და საპასუხოდ დავუძახებთ რამე მეთოდს.

<p>{{ message }}</p>
<button (click)="onCount()">Count Message Length</button>

შემდეგ გადავინაცვლოთ ts ფაილში. Input-ის მსგავსად ჩვენ შვილ კომპონენტში უნდა შევქმნათ თვისება, @Output დეკორატორით, რომელიც @angular/core-დან უნდა დავაიმპორტოთ. ამჯერად ჩვენ თვისების მნიშვნლობაში ახალი EventEmitter-ის ინსტანციას ვინახავთ, რომელიც ასევე @angular/core-დან შემოგვაქვს.

import { Component, EventEmitter, Input, Output } from "@angular/core";
import { CommonModule } from "@angular/common";

@Component({
  selector: "app-child",
  standalone: true,
  imports: [CommonModule],
  templateUrl: "./child.component.html",
  styleUrl: "./child.component.scss",
})
export class ChildComponent {
  @Input() message: string = "";
  @Output() lengthCount = new EventEmitter<number>();

  onCount() {
    this.lengthCount.emit(this.message.length);
  }
}

EventEmitter არის ე.წ Generic ტიპის კლასი, რაც იმას ნიშნავს, რომ მასში დამატებითი ტიპი უნდა შევფუთოთ. მარტივად რომ ვთქვათ, ტაიპსკრიპტმა იცის, რომ EventEmitter რაღაც მოვლენას გასცემს, თუმცა არ იცის ეს მოვლენა შედეგად რა ტიპის მნიშვნელობას გვაძლევს. რადგან ჩვენ რიცხვობრივი მნიშვნელობის გადაცემა გვინდა, ამიტომ ჩვენ მეტობა-ნაკლებობა ნიშნებს შორის ვწერთ number ტიპს, რათა დავაზუსტოთ, რომ EventEmiter number ტიპის მნიშვნელობას გადასცემს მშობელს.

onCount მეთოდში EventEmitter-ის ინსტანციაზე, ანუ lengthCount-ზე დავუძახებთ emit მეთოდს, რომელშიც მესიჯის სიგრძეს გადავცემთ არგუმენტად. როცა ღილაკზე დავაკლიკებთ, EventEmitter emit მეთოდის საშუალებით, ასე ვთქვათ, სიგნალს გასცემს მშობელ ელემენტს, რომ რაღაც მოვლენა მოხდა და ეს მოვლენა შეიცავს ინფორმაციას.

მშობელ ელემენტზე ჩვენ ამ მოვლენას შეგვიძლია მოვუსმინოთ, click მოვლენის მსგავსად, ოღონდ lengthCount-ზე, იმ თვისებაზე, რომელიც ჩვენ კლასში შევქმენით Output დეკორატორით:

<app-child
  [message]="messages[0]"
  (lengthCount)="logLength($event)"
></app-child>
<app-child
  [message]="messages[1]"
  (lengthCount)="logLength($event)"
></app-child>

$event არის განსაკუთრებული (key) სიტყვა, რომლითაც შეგვიძლია მოვიხელთოთ ის მნიშვნელობა, რომელსაც ივენთ ემითერი გასცემს და გადავცეთ ის ფუნქციას, რომლითაც ამ ივენთს მოვიხელთებთ. ეს ფუნქცია უბრალოდ კონსოლში დალოგავს რიცხვს:

// In AppComponent
logLength(length: number) {
  console.log(length);
}

ყურადღება მიაქციეთ, რომ ჩვენ length პარამეტრს ექსპლიციტურად ვუწერთ მოსალოდნელ ტიპს. შედეგად კონსოლში უნდა დავლოგოთ თითოეულ კომპონენტში არსებული მესიჯის სიგრძე.

შევაჯამოთ რა ხდება: ღილაკზე დაჭერისას აქტიურდება onCount მეთოდი, რომელიც EventEmitter-ის საშუალებით აემითებს მესიჯის სიგრძეს. ამ ივენთს მოვიხელთებთ მშობელი ელემენტიდან შვილ ელემენტზე ივენთ ბაინდინგით lengthCount თვისებაზე (რომელიც Output დეკორატორით შევქმენით). $event-ით ჩვენ დაემითებულ მნიშვნელობას ვიღებთ და ვაწვდით logLength მეთოდს, რომელიც ამ მნიშვნელობას კონსოლში ლოგავს.

ngOnChanges - ცვლილებებზე რეაგირება

ჩვენ საშუალება გვაქვს, რომ შვილ კომპონენტში ვირეაგიროთ @Input თვისებაში შემოსულ ცვლილებებზე. ამისთვის არსებობს ngOnChanges სიცოცხლის ციკლის ჰუკი.

import {
  Component,
  EventEmitter,
  Input,
  Output,
  OnChanges,
  SimpleChanges,
} from "@angular/core";
import { CommonModule } from "@angular/common";

@Component({
  selector: "app-child",
  standalone: true,
  imports: [CommonModule],
  templateUrl: "./child.component.html",
  styleUrl: "./child.component.scss",
})
export class ChildComponent implements OnChanges {
  @Input() message: string = "";
  @Output() lengthCount = new EventEmitter<number>();

  onCount() {
    this.lengthCount.emit(this.message.length);
  }

  ngOnChanges(changes: SimpleChanges): void {
    if (changes.message) {
      console.log("მესიჯი განახლდა: ", changes.message.currentValue);
    }
  }
}

ngOnChanges-ში გვაქვს SimpleChanges ტიპის პარამეტრი რომელსაც ანგულარი გვაწვდის. აქ თვისებების ფორმით ხელმისაწვდომია ყველა ჩვენი @Input დეკორატორით შექმნილი კლასის თვისება, მათ შორის message. ოღონდ ეს უშუალოდ ის სტრინგი არ არის, რომელიც ჩვენ შევქმენით, არამედ SimpleChange ტიპის ობიექტი, რომელსაც გააჩნია currentValue, previousValue და სხვა თვისებები. ngOnChanges ყოველ ჯერზე გაეშვება, როცა მშობელზე მიბმული @Input თვისება მნიშვნელობას შეიცვლის და ეს ახალი მნიშვნელობა ხელმისაწვდომია currentValue-ში. თუ რაიმე თვისება არ შეცვლილა, ის changes-ზე არ იარსებებს, ამიტომ ჯერ უნდა დავრწმუნდეთ, რომ ის არსებობს, სანამ მასზე ვირეაგირებთ.

ngOnChanges უნდა გამოვიყენოთ დიდი სიფრთხილით, რადგან აქ მძიმე ლოგიკის გამოყენება აპლიკაციას შეანელებს.

შეჯამება

ამ თავში ჩვენ განვიხილეთ Input და Output დეკორატორები. Input დეკორატორის საშუალებით შვილ კომპონენტს გადავცემთ მნიშვნელობას მშობელი კომპონენტიდან, ხოლო Output დეკორატორის საშუალებით შვილ კომპონენტზე ვქმნით ივენთის ემითერს, რომელიც კონკრეტულ მნიშვნელობებს აემითებს. შვილის ივენთ ემითერს ჩვენ შეგვიძლია მშობელი ელემენტიდან მოვუსმინოთ და მოვიხელთოთ დაემითებული მნიშვნელობები $event-ის საშუალებით. ჩვენ ასევე შეგვიძლია ვირეაგიროთ მშობლიდან შვილზე ჩამოწოდებული მონაცემების ცვლილებებზე ngOnChanges ჰუკით.

Two Way Binding

Two way binding გულისხმობს property binding-ისა და event binding-ის კომბინაციას კომპონენტებს შორის მონაცემების გასაზიარებლად ისე, რომ შესაძლებელი იყოს მშობელ და შვილ კომპონენტებს შორის ერთდროულად ივენთების მოსმენა და მნიშვნელობების განახლება.

შევქმნათ კომპონენტი, რომელიც დაგვეხმარება ფონტის ზომის შეცვლაში, დავარქვათ მას sizer.

ng g c sizer

sizer კომპონენტი მარტივი პრინციპით იმუშავებს. ის მშობელისგან მიიღებს საწყის ფონტის ზომას (@Input() size), ხოლო თვითონ, ღილაკზე დაჭერის საფუძველზე ამ ფონტის ზომას გაზრდის და ისე გადასცემს მშობელს (@Output() sizeChange).

import { Component, Input, Output, EventEmitter } from "@angular/core";
import { CommonModule } from "@angular/common";

@Component({
  selector: "app-sizer",
  standalone: true,
  imports: [CommonModule],
  templateUrl: "./sizer.component.html",
  styleUrl: "./sizer.component.css",
})
export class SizerComponent {
  @Input() size!: number | string;
  @Output() sizeChange = new EventEmitter<number>();

  dec() {
    this.resize(-1);
  }
  inc() {
    this.resize(+1);
  }

  resize(delta: number) {
    // Keep size only between 40px and 8px
    this.size = Math.min(40, Math.max(8, +this.size + delta));
    this.sizeChange.emit(this.size);
  }
}

გვაქვს შექმნილი resize მეთოდი, რომელიც არგუმენტად delta-ს იღებს, ანუ რიცხვობრივ მაჩვენებელს, რომლითაც ფონტის ზომა უნდა შეიცვალოს და sizeChange-ით დააემითოს ახალი ზომა. ამ მეთოდს დაუძახებენ dec და inc ფუნქციები, რომლებიც + და - ღილაკებზე იქნებიან მიბმულები ივენთ ბაინდინგით:

<div>
  <button type="button" (click)="dec()" title="smaller">-</button>
  <button type="button" (click)="inc()" title="bigger">+</button>
  <span [style.font-size.px]="size">FontSize: {{size}}px</span>
</div>

სანიმუშო ტექსტი, რომელიც ზომას შეიცვლის ამ sizer კომპონენტშიც გვექნება (span). ახლა app.component.html-ში შეგვიძლია განვათავსოთ sizer კომპონენტი და გამოვიყენოთ მასზე ჩვენთვის ცნობილი ბაინდინგები:

<app-sizer [size]="fontSizePx" (sizeChange)="fontSizePx = $event"></app-sizer>
<div [style.font-size.px]="fontSizePx">Resizable Text</div>

ამ მშობელი კომპონენტის კლასში ჩვენ გვაქვს ფროფერთი fontSizePx, რომელსაც ვიყენებთ ტექსტისთვის ზომის მისანიჭებლად ქვედა div-ზე, და რომელსაც ასევე გადავცემთ app-sizer კომპონენტს.

fontSizePx = 16;

ახლა ფონტის ზომა უნდა იცვლებოდეს. აპლიკაციაში შემდეგი რამ ხდება:

  • თავდაპირველად, როცა აპლიკაცია იტვირთება, AppComponent-ში არსებული fontSizePx გადაეცემა SizerComponent, რომელიც ინიციალიზაციისას სწორედ მის მნიშვნელობას შეინახავს size თვისებაში. სწორედ ამ ზომას გამოსახავს ეს კომპონენტი.
  • SizerComponent-ში ღილაკზე დაჭერით გააქტიურდება dec ან inc მეთოდი, რომელიც resize მეთოდს დაუძახებს და შეცვლის ფონტის ზომას, ამასთანავე დააემითებს ამ ახალ ზომას.
  • ახალ დაემითებულ ზომას AppComponent ივენთ ბაინდინგის საშუალებით დააფიქსირებს და შეცვლის თავის ფროფერთის fontSizePx რათა მან ეს ახალი მნიშვნელობა მიიღოს.
  • შედეგად იცვლება AppComponent-ის თემფლეითში არსებული ტექსტის ზომა.

ამ ბაინდინგის შესამოკლებლად ანგულარში შექმნეს შემდეგნაირი სინტაქსი:

<app-sizer [(size)]="fontSizePx"></app-sizer>
<div [style.font-size.px]="fontSizePx">Resizable Text</div>

რაც ზუსტად იგივეს აკეთებს. ჩვენ ერთდროულად ვუსმენთ ცვლილებას და ვაწვდით კონკრეტულ მნიშვნელობას. სწორედ ეს არის two way binding. ეს შესაძლებელია მხოლოდ იმ შემთხვევასი, თუ იმ კომპონენტში, რომელზეც ჩვენ ბაინდინგს ვაკეთებთ Input-ისა და Output თვისებების სახელები კონკრეტული კონვენციითაა შექმნილი: Output თვისება უნდა იწყებოდეს Input თვისების დასახელებით და ბოლოში უნდა ემატებოდეს Change.

@Input() size!: number | string;
@Output() sizeChange = new EventEmitter<number>();

two way binding-ით ჩვენ ანგულარს ერთდროულად ვეუბნებით, რომ:

  • size თვისებამ SizerComponent-ში ის მნიშვნელობა უნდა მიიღოს, რაც fontSizePx-ს აქვს.
  • fontSizePx-მა AppComponent-ში ის მნიშვნელობა უნდა მიიღოს, რასაც sizeChange ივენთი დააემითებს.

შეჯამება

ამ თავში განვიხილეთ two way binding რომელიც საშუალებას გვაძლევს ერთდროულად მონაცემები გადავცეთ შვილ კომპონენტს და ამასთანავე ეს მონაცემები შევცვალოთ შვილი კომპონენტისგან დაემითებული მნიშვნელობებით. two way binding იშვიათად გამოიყენება, თუმცა მისი საჭიროება შედარებით უფრო ხშირად ფორმების შემთხვევაში ჩნდება. ამისთვის ანგულარში არსებობს ngModel დირექტივი, რომელიც two way binding-ით გამოიყენება. მის შესახებ ინფორმაცია ხელმისაწვდიომია ოფიციალურ დოკუმენტაციაში.

Directives

დირექტივი არის ხელსაწყო რომლის საშუალებითაც შეგვიძლია ელემენტებსა და კომპონენტებს ქცევა შევუცვალოთ. ანგულარს მოყვება დირექტივების ფართო კომპლექტი, რომლებიც ფაქტობრივად დეველოპერის ყველა საჭიროებას აკმაყოფილებს, თუმცა ჩვენ გვაქვს შესაძლებლობა შევქმნათ ჩვენი დირექტივებიც. ამ თავში ვისწავლით:

დირექტივის შექმნა

შევქმნათ ატრიბუტის დირექტივი.

დირექტივი ისევე იქმნება როგორც კომპონენტი. ამისთვის გვჭირდება ფაილის შექმნა საიდანაც დავაექსპორტებთ კლასს, რომელიც რაღაც დეკორატორით იქნება დეკლარირებული. შესაძლებელია დირექტივის შექმნა CLI-ს საშუალებით. ჩვენ შევქმნით დირექტივს, რომელიც ელემენტს ტექსტის ფერს შეუცვლის მაუსის დაჰავერების დროს.

ng generate directive highlight

ჩვენ დირექტივს ერქმევა highlight. ანგულარი შექმნის ახალ ფაილს, რომელიც შეიცავს წერტილებით გამოყოფილ directive სიტყვას. დირექტივ ფაილებს კონვენციურად ასეთი დასახელებით გამოყოფენ. ფაილში დაექსპორტებულია კლასი რომელსაც კონვენციურად ჰქვია დირექტივის სახელი + Directive.

import { Directive } from "@angular/core";

@Directive({
  selector: "[appHighlight]",
  standalone: true,
})
export class HighlightDirective {
  constructor() {}
}

ეს კლასი შესაბამისი მოდულის დეკლარაციების სიაშიუნდა იყოს დამატებული, რათა ანგულარმა მისი არსებობის შესახებ იცოდეს. Directive დეკორატორში ჩვენ შეგვიძლია დირექტივის კონფიგურაცია. არსებობს სხვადასხვაგვარი სელექტორი და იმის მიხედვით თუ რა სელექტორს ავირჩევთ, ანგულარი განსხვავებული პრინციპით გაუკეთებს ამ დირექტივს ინიციალიზაციას. ოთკუთხედ ბრჭყალებში მოქცეული სახელი გულისხმობს, რომ ჩვენ დირექტივის გამოყენება ატრიბუტის სახელით შეგვიძლია რაიმე ელემენტზე:

<some-element appHighlight></some-element>

ყველაზე ხშირად ანგულარში ასეთი დირექტივის სელექტორს ვხვდებით. სხვა ტიპის სელექტორების შესახებ ინფორმაცია იხილეთ ანგულარის დოკუმენტაციაში. სელექტორი იმავე პრინციპით მუშაობს, როგორც CSS-ის სელექტორი ან ჯავასკრიპტში document.querySelector.

ჩვენი დირექტივის კლასში შემდეგი მოდიფიკაციები შეგვაქვს:

import { Directive, ElementRef, HostListener, Input } from "@angular/core";

@Directive({
  selector: "[appHighlight]",
  standalone: true,
})
export class HighlightDirective {
  @Input() highlightColor: "blue" | "green" | "yellow" = "yellow";

  constructor(private elementRef: ElementRef) {}

  @HostListener("mouseover")
  onMouseOver() {
    this.elementRef.nativeElement.style.color = this.highlightColor;
  }

  @HostListener("mouseout")
  onMouseOut() {
    this.elementRef.nativeElement.style.color = "initial";
  }
}

როცა ჩვენ ელემენტზე მოვათავსებთ appHighlight დირექტივს, მასზე იმუშავებს ამ დირექტივის კლასში არსებული ლოგიკა. ანუ Input დეკორატორით შექმნილი თვისება არსებულ ელემენტზე შესაძლებელია მიებას მშობელი ელემენტიდან data binding-ით. highlightColor-ში რამდენიმე შესაძლო მნიშვნელობის ტიპი განსვსაზღვრეთ და წინასწარი ფერიც დავუწერეთ.

კონსტრუქტორში შემოგვაქვს ElementRef, იგი იმ native ელემენტზე გვაძლევს წვდომას, რომელზეც ეს დირექტივი იჯდება. მას host ელემენტი ჰქვია. ElementRef-ის საშუალებით HostListener-ებში გარკვეული მოვლენების მიხედვით ვცვლით ამ ელემენტში CSS თვისებას - color (რაც დიდად ჩვეულებრივი ჯავასკრიპტისგან არ განსხვავდება).

HostListener არის დეკორატორი, რომელიც საშუალებას გვაძლევს, მოვუსმინოთ ივენთებს host ელემენტზე. HostListener გამოიყენება კომპონენტის კლასებშიც, თუმცა უფრო ხშირად ის დირექტივებში გვხვდება. mouseover მოვლენის შემთხვევაში ელემენტს ჩვენთვის სასურველი ფერი მიენიჭება, ხოლო როცა მაუსი მის არეალს დატოვებს, ფერი საწყის მდგომარეობას დაუბრუნდება.

HilightDirective არის standalone ტიპის დირექტივი, რაც იმას ნიშნავს, რომ მის გამოსაყენებლად საჭიროა ამ კლასის დამატება კომპონენტის იმპორტების სიაში. ჩვენ ამ დირექტივს AppComponent-ში ვიყენებთ.

import { Component } from "@angular/core";
import { CommonModule } from "@angular/common";
import { HighlightDirective } from "./highlight.directive";

@Component({
  // ...
  standalone: true,
  imports: [CommonModule, HighlightDirective],
  // ...
})
export class AppComponent {}

ყველა კომპონენტში, სადაც ამ დირექტივის გამოყენება დაგვჭირდება, მისი ასეთი პრინციპით დაიმპორტება იქნება საჭირო.

იმავე კომპონენტის თემფლეითში შევქმნათ რამდენიმე h1 ელემენტი დირექტივის შესამოწმენლად:

<h1 appHighlight>I love Angular 1</h1>
<h1 appHighlight highlightColor="blue">I love Angular 2</h1>
<h1>I love Angular 3</h1>

ახლა პირველ ორ სათაურს ფერი უნდა ეცვლებოდეს. ყურადღება მიაქციეთ, რომ highlightColor თვისებაზე ჩვენ ოთკუთხედი ფრჩხილები არ გამოვიყენეთ. ასე highlightColor თვისება დირექტივის კლასში იღებს პირდაპირ სტრინგის ტიპის მნიშვნელობას. ოთხკუთხედი ფრჩხილებით blue არა სტრინგი, არამედ ცვლადის სახელი იქნებოდა, როგორც ჯავასკრიპტის ექსპფრეშენი, ამისთვის სათანადო სახელის თვისება უნდა არსებობდეს AppComponent-ის კლასში.

შესაძლებელია დირექტივში არსებობდეს სელექტორის სახელის მქონე Input თვისება:

@Input() appHighlight: "blue" | "green" | "yellow" = "yellow";

ასე პირდაპირ დირექტივის სელექტორსვე შეგვიძლია გადავცეთ ფერი:

<h1 appHighlight="blue">I love Angular 2</h1>

პრობლემა ის არის, რომ ასე დირექტივის ელემენტზე დაწერა, მისთვის მნიშვნელობის მიცემის გარეშე, გამოიწვევს ერორს, რადგან ჩვეულებრივ ეს იგივეა, რაც ამ ფროფერთისთვის ცარიელი სტრინგის გადაცემა (appHighlight=""), რაც ჩვენი განსაზღვრული ტიპების მიხედვით მიუღებელია.

დირექტივებზე თავისუფლად არის შესაძლებელი ივენთების მოსმენა და two-way binding-იც. გირჩევთ რომ ამის გაკეთება თქვენით სცადოთ.

შეჯამება

ამ თავში ჩვენ განვიხილეთ დირექტივის დანიშნულება და შევქმენით ჩვენი დირექტივი, რომელიც ელემენტებს ტექსტის ფერს უცვლის. ჩვენ გამოვიყენეთ ატრიბუტის სელექტორი, რათა ელემენტზე დირექტივი ისე შეგვექმნა, როგორც ატრიბუტი. დირექტივის კლასში, ისევე როგორც კომპონენტში, შესაძლებელია დატა ბაინდინგი, ივენთ ბაინდინგი, სერვისების გამოყენება და host ელემენტის მანიპულაცია.

Attribute Directives

ატრიბუტის დირექტივებ HTML ელემენტსა და კომპონენტებს უცვლიან ქცევას, თვისებებსა და ატრიბუტებს. ანგულარის ბევრ მოდულში (მაგ. RouterModule, FormsModule) არსებობს წინასწარ შექმნილი დირექტივები. ამ თავში განვიხილავთ ატრიბუტის დირექტივებს: NgClass, და NgModel.

NgClass

NgClass-ის საშუალებით ელემენტზე ერთდროულად რამდენიმე კლასის დამატება ან მოშორება შეგვიძლია.

ეს დირექტივი, ისევე როგორც ბევრი სხვა, CommonModule-ის ნაწილს წარმოადგენენ, შესაბამისად ის უნდა დაიმპორტებული გვქონდეს სათანადო კომპონენტში.

import { CommonModule } from "@angular/core";

@Component({
  /* ... */
  standalone: true,
  imports: [CommonModule],
})
export class AppComponent {}

NgClass-ის პრიმიტიული მაგალითი:

<h1 [ngClass]="isSpecial ? 'special' : '' ">I love Angular</h1>

აქ ternary ლოგიკური ოპერაციით, იმის მიხედვით isSpecial არის თუ არა ჭეშმარიტი, დავაბრუნებთ special სტრინგს ან ცარიელ სტრინგს, რომელიც h1-ს მიენიჭება.

შეგვიძლია NgClass-ს მივაწოდოთ ჯავასკრიპტის ობიექტიც, სადაც ობიექტის key იქნება კლასის სახელი, ხოლო key-ს მნიშვნელობა იქნება ჭეშმარიტი ან მცდარი, რაც იმას განსაზღვრავს, key-ში მითითებული კლასი ელემენტს უნდა მიენიჭოს თუ არა:

<h1 [ngClass]="{special: isSpecial, interesting: isInteresting}">
  I love Angular
</h1>

აქ isSpecial და isInteresting ცვლადებია, რომლებიც კლასში უნდა არსებობდნენ.

აღსანიშნავია, რომ უშუალოდ ამ დირექტივის კლასის დაიმპორტებაც შეგვიძლია, მთლიანი მოდულების მაგივრად, თუკი CommonModule-დან მხოლოდ ეს კლასი გვჭირდება და მეტი არაფერი.

import { NgClass } from "@angular/common";

@Component({
  imports: [NgClass]
})
export class AppComponent

გაითვალისწინეთ, რომ ეს შესაძლებელია მხოლოდ standalone ტიპის დირექტივებზე (მაგ. NgModel არ არის standalone).

NgModel

NgModel დირექტივი არის FormsModule-ის ნაწილი, რომელიც ჩვენ საჭირო მოდულში უნდა დავაიმპორტოთ. ამ შემთხვევაში მათ ვაიმპორტებთ app.module.ts-ში:

import { CommonModule } from "@angular/core";
import { FormsModule } from "@angular/forms";

@Component({
  /* ... */
  standalone: true,
  imports: [CommonModule, FormsModule],
})
export class AppComponent {}

NgModel ფორმის ელემენტებზე გამოიყენება two way binding-ით. დავუშვათ, რომ კომპონენტის კლასში გვაქვს ინიციალიზებული თვისება title.

<label for="example-ngModel">[(ngModel)]:</label>
<input [(ngModel)]="title" id="example-ngModel" />

<h1>You are learning {{ title }}</h1>

NgModel დირექტივს აქვს წვდომა input ელემენტში ჩაწერილ ტექსტზე. ის ერთი მხრივ იმ ტექსტს განათავსებს ელემენტის ველში, რომელიც title თვისების მნიშვნელობაა, და, მეორე მხრივ, title-ს იმ მნიშვნელობას მისცემს, რაც input ველში ჩაიწერება.

შეჯამება

ამ თავში ჩვენ განვიხილეთ NgClass და NgModel ატრიბუტ დირექტივები. NgClass ელემენტს უცვლის კლასს გარკვეული პირობების მიხედვით, ხოლო NgModel input ელემენტზე თავსდება two way binding-ით, რაც საშუალებას გვაძლევს, რომ ფორმის ელემენტებს ინფორმაცია გადავცეთ და მათგან ავიღოთ მომხმარებლის მიერ შეყვანილი ინფორმაცია.

Structural Directives

სტრუქტურული დირექტივები HTML-ის განლაგებაზე არიან პასუხისმგებელნი. კერძოდ, ისინი ფორმას უცვლიან DOM-ის სტრუქტურას, ამატებენ ან შლიან ელემენტებს. მე-17 ვერსიიდან იგივე დანიშნულებისთვის შეგვიძლია გამოვიყენოთ Control Flow თემფლეითის სინტაქსი.

ამ თავში ვისაუბრებთ NgIf და NgFor დირექტივებზე.

სტრუქტურული დირექტივები CommonModule-ის ნაწილს წარმოადგენენ, ამიტომ შეგიძლიათ პირდაპირ ეს მოდული დაამატოთ კომპონენტის იმპორტების სიაში.

NgIf

NgIf დირექტივი host ელემენტს შექმნის ან წაშლის DOM-ში, იმის მიხედვით, თუ რა მნიშვნელობას დააბრუნებს დირექტივისთვის მიწოდებული ექსფრეშენი.

<button (click)="isActive = !isActive">toggle</button>
<app-item-detail *ngIf="isActive" [item]="item"></app-item-detail>

კომპონენტის კლასში გვაქვს isActive თვისება, რომლის მნიშვნელობაც არსებულის საპირისპირო ხდება ღილაკზე დაჭერისას. NgIf დირექტივში მიწოდებული ექსფრეშენი მაშასადამე იცვლება და ეს კომპონენტს აჩენს ან აქრობს. კომპონენტის შემთხვევაში NgIf დირექტივი მეხსიერებიდან მთლიანად შლის კოპმონენტს და მასში არსებულ შვილებს მთელი თავისი მონაცემებით, რათა მეხსიერება გათავისუფლდეს. აქედან გამომდინარე, გაჩენის შემთხვევაში host კომპონენტი ხელახლა იქმნება. რა თქმა უნდა, NgIf დირექტივი უბრალო HTML ელემენტებზეც მუშაობს.

NgFor

NgFor დირექტივი გამოიყენება მასივების ელემენტების გამოსახვისთვის. ჩვენ ჯერ ერთ HTML-ის ბლოკს განვსაზღვრავთ, რათა ანგულარმა იცოდეს თითო ელემენტი როგორ უნდა დარენდერდეს და ელემენტზე ვიყენებთ დირექტივს, სადაც for of loop-ის ექსფრეშენს ვაწვდით:

<app-item-detail *ngFor="let item of items" [item]="item"></app-item-detail>

აქ ყოველ იტერაციაზე item ცვლადში ინახება items მასივში არსებული ელემენტი, რომელიც გადაეცემა host კომპონენტს. NgFor ის host ბლოკში ეს ცვლადი ყველგან არის ხელმისაწვდომი.

<div *ngFor="let item of items">
  <h3>{{ item. name }}</h3>
  <p>{{ item.description }}</p>
</div>

ჩვენ შესაძლებლობა გვაქვს ოპტიმიზაცია გავუკეთოთ მასივის დარენდერებას, დირექტივში trackby-ს განსაზღვრით. ამისათვის კლასში გვჭირდება TrackByFunction ტიპის (@angular/core-დან) ფუნქციის შექმნა:

itemTrackBy(index, item) {
  return item.name
}

პირველ არგუმენტად ველოდებით მასივის ელემენტის ინდექსს, ხოლო მეორე არგუმენტად - უშუალოდ იმ ობიექტს, რომელიც ლუპის თითოეული იტერაციაში ბრუნდება. ამ ფუნქციამ უნდა დააბრუნოს ობიექტის რაიმე უნიკალური თვისება, რომლითაც ანგულარი ამ ობიექტებს ერთმანეთისგან გაარჩევს.

შემდეგ თემფლეითში, NgFor დირექტივში ;-ით გამოვწყოფთ ახალ ექსფრეშენს და ვამატებთ ამ ფუნქციას trackBy-ს არგუმენტად:

<div *ngFor="let item of items; trackBy: itemTrackBy">
  <h3>{{ item.name }}</h3>
  <p>{{ item.description }}</p>
</div>

ასე ანგულარი DOM-ში მხოლოდ იმ სპეციფიკურ ელემენტს განაახლებს, რომელიც მასივში შეიცვალა, რაც რესურსებს დაგვიზოგავს.

შეჯამება

ამ თავში ჩვენ განვიხილეთ სტრუქტურული დირექტივები, რომლებიც ცვლიან იმას, თუ როგორ რენდერდება ელემენტი. NgIf დირექტივი რაღაც პირობის მიხედვით ელემენტს შექმნის ან წაშლის. NgFor დირექტივით ჩვენ ერთგვარი ლუპის საშუალებით შეგვიძლია მასივებში არსებული ინფორმაციის გამოსახვა.

Control Flow

ანგულარის მე-17 ვერსიიდან საშუალება გვაქვს, რომ სტრუქტურული დირექტივების მაგივრად გამოვიყენოთ ახალი control flow სინტაქსი, რომლითაც ვმართავთ თემფლეითში რა უნდა გამოჩდეს და როგორ უნდა გამოჩნდეს. ამ სინტაქსის ქივორდები @ სიმბოლოთი იწყება და ძალიან ჰგავს ჯავასკრიპტის სტანდარტული if/else/switch/for სინტაქსს.

გაითვალისწინეთ, რომ ეს control-flow არის development preview-ში. ანუ ანგუალრის ახალ ვერსიებში მისი გამოყენების და ფუნქციონირების სპეციფიკა შეიძლება შეიცვალოს.

@if ბლოკი

@if ბლოკი მიწოდებული პირობის ჭეშმარიტების მიხედვით გამოაჩენს მის ბლოკში არსებულ კონტენტს.

დავუშვათ კლასში ვინახავთ ორ თვისებას:

a = 1121;
a = 1118;

თემფლეითში ამ ცვლადებისგან შეგვიძლია პირობითი ექსფრეშენის შედგენა:

@if (a > b) {
    <p>{{a}} is greater than {{b}}</p>
}

როგორც ჯავასკრიპტის if ბლოკს, ანგულარის თემფლეითში @if-ს შეგვიძლია მოვაყოლოთ @else ახალი პირობით ან პირობის გარეშე:

@if (a > b) {
    <p>{{a}} is greater than {{b}}</p>
} @else if (b > a) {
    <p>{{a}} is less than {{b}}</p>
} @else {
    <p>{{a}} is equal to {{b}}</p>
}

ექსფრეშენის შედეგის ცვლადად გამოტანა

ხშირად რაღაც ექსფრეშენის შედეგი (მაგალითად Observable-ის შედეგი) გვინდა რომ თემფლეითის ცვლადში შევინახოთ. @if-ში ეს შემდეგნაირად შეგვიძლია:

@if (users$ | async; as users) {
    {{ users.length }}
}

@for ბლოკი

მასივის თითოეული ელემენტის დასარენდერებლად ვიყენებთ @for ბლოკს, რომელიც ჰგავს ჯავასკრიპტის for of სინტაქსს.

დავუშვათ, რომ კომპონენტის კლასში ვინახავთ შემდეგ მასივს:

items = [
    { name: "ტარიელ", id: "0142"},
    { name: "ავთანდილ", id: "0153"},
    { name: "ფრიდონ", id: "0294"},
]

თუ თემფლეითში მათი სიაში გამოსახვა გვჭირდება:

<ul>
    @for (item of items; track item.id) {
      <li>{{ item.name }}</li>
    }
</ul>

ყურადღება მიაქციეთ, რომ აქ track ექსფრეშენი სავალდებულოა. ეს უკანასკნელი არის NgFor დირექტივის trackBy ფუნქციის ალტერნატივა. ჩვენ შეგვიძლია თემფლეითშივე მივუთითოთ რის მიხედვით განასხვავოს ანგულარმა მასივის ელემენტები ერთმანეთისგან. ამ მაგალითში თითოეულ item-ს პირობითად გააჩნია უნიკალური id ატრიბუტი.

თუ უნიკალური ატრიბუტი არ გვაქვს, შეგვიძლია trackBy-ს პირდაპირ იტერაციის ამჟამინდელი ერთეული მივაწოდოთ:

 @for (item of items; track item) {
   <li>{{ item.name }}</li>
 }

$index და სხვა იპმლიციტური ცვლადები

@for ბლოკის შიგნით საშუალება გვაქვს გამოვიყენოთ იმპლიციტური ცვლადები, რომელსაც ანგულარი ლუპში გვაწვდის, მაგალითად $index:

<ul>
    @for (item of items; track item.id) {
      <li>{{ item.name }} {{$index}}</li>
    }
</ul>

იმპლიციტური ცვლადების ჩამონათვალი:

  • $count - მასივში არსებული ელემენტების რაოდენობა
  • $index - მიმდინარე იტერაციის ინდექსი
  • $first - მიმდინარე იტერაცია არის თუ არა პირველი
  • $last - მიმდინარე იტერაცია არის თუ არა ბოლო
  • $even - მიმდინარე იტერაცია არის თუ არა ლუწი
  • $odd - მიმდინარე იტერაცია არის თუ არა კენტი

ეს ცვლადები ამ სახელებით ყოველთვის ხელმისაწვდომია, თუმცა მათგან ალიასების შექმნაც შეგვიძლია let სეგმენტით:

<ul>
    @for (item of items; track item.id; let idx = $index; let e = $even) {
      <li>{{ item.name }} {{idx}}</li>
    }
</ul>

@empty ბლოკი

ჩვენ შეგვიძლია @for ბლოკს მოვაყოლოთ @empty ბლოკი, რომლის შიგთავსიც მხოლოდ იმ შემთხვევაში გამოჩნდება, თუ მასივი ცარიელია:

<ul>
    @for (item of items; track item.id) {
        <li>{{ item.name }} {{$index}}</li>
    } @empty {
        <li>აქ არაფერია!</li>
    }
</ul>

@switch ბლოკი

@switch ბლოკი ჰგავს ჯავასკრიპტის switch-ს, სადაც შეგვიძლია კონკრეტული მნიშვნელობის სხვადასხვა შემთხვევების მიხედვით გამოვაჩინოთ ინფორმაცია:

@switch (condition) {
  @case (caseA) {
    Case A.
  }
  @case (caseB) {
    Case B.
  }
  @default {
    Default case.
  }
}

პირობის ექსფრეშენის მნიშვნელობის შედარება ქეისთან ხდება === (მკაცრი ტოლობის) ოპერატორით.

@switch-ში არ ხდება ჯავასკრიპტისთვის ჩვეული fall-through, ამიტომ break ან return-ის მსგავსი ქივორდები არ გვჭირდება.

@default ბლოკი არასავალდებულოა. თუ ის არ არსებობს და არც ერთი @case არ გამოდგა ჭეშმარიტი, მაშინ შედეგად არაფერი გამოჩნდება.

Pipes

ფაიფები თემფლეითში მონაცემების ტრანსფორმაციისთვის გამოიყენება. ფაიფები უბრალო ფუნქციები არიან, რომლებიც იღებენ მონაცემებს, და მათ შეცვლილი ფორმით აბრუნებენ. ამ თავში ვისწავლით:

ჩაშენებული ფაიფები

გავეცნოთ ანგულარში ჩაშენებულ ძირითად და ყველაზე გამოყენებად ფაიფებს. წინამდებარე ფაიფები CommonModule-ის ნაწილია, ამიტომ ის დააიმპორტეთ თქვენ კომპონენტში.

DatePipe

DatePipe არის ერთ-ერთი ყველაზე გამოყენებადი ფაიფი. იგი გარდაქმნის თარიღის ფორმატს.

ვთქვათ, კლასში გვაქვს შექმნილი Date ობიექტი:

holiday = new Date(1918, 5, 26);

ამ ობიექტის წაკითხვად ფორმატში გარდაქმნისთვის არ გვჭირდება ბევრი წვალება.

<p>Georgian independence day is {{ holiday | date }}</p>

ჩვენ თემფლეითის ექსფრეშენში ვიყენებთ ცვლადს, რომელსაც მოყვება |, ე.წ ფაიფ სიმბოლო და შემდგომ ფაიფის სახელი. შედეგად ბრაუზერში ჩვენ დაფორმატებული თარიღი უნდა დავინახოთ.

რადგან ფაიფებს ერთგვარ ფუნქციებად განვიხილავთ, მათთვის პარამეტრების მიწოდებაც არის შესაშლებელი ფაიფის სახელის შემდეგ : სიმბოლოს საშუალებით, რომელსაც უნდა მოყვეს პარამეტრად მისაღები ექსფრეშენი:

<p>Georgian independence day is {{ holiday | date:"dd/MM/yy" }}</p>

აქ ჩვენ სტრინგის ტიპის პარამეტრით მივუთითებთ როგორი ფორმატით გვინდა თარიღის გამოსახვა. წინასწარ განსაზღვრული პარამეტრების საშუალებით ჩვენ გვაქვს უფრო მეტი კონტროლი ტრანსფორმირებულ შედეგზე. თითოეული ფაიფისთვის მოძებნეთ შესაძლო პარამეტრების დოკუმენტაცია ანგულარის ვებსაიტზე.

აქვე გასათვალისწინებელია, რომ ფაიფები არა მხოლოდ ინტერპოლაციის დროს, არამედ ფროფერთი ბაინდინგის დროსაც გამოიყენება.

UpperCasePipe & LowerCasePipe

UpperCasePipe-ითა და LowerCasePipe-ის საშუალებით ჩვენ შეგვიძლია ტექსტის ქეისის შეცვლა.

title = "Hello there";
<p>{{ title | uppercase }}</p>
<p>{{ title | lowercase }}</p>

შესაძლებელია რამდენიმე ფაიფის ერთდროულად გამოყენება. მონაცემები ფაიფების თანმიმდევრობის მიხედვით ტრანსფორმირდება:

<p>{{ holiday | date | uppercase }}</p>

აქ ჩვენ holiday თვისებას ჯერ თარიღად ვაფორმატებთ და შემდეგ ტექსტს აფერქეისად გარდავქმნით.

CurrencyPipe

CurrencyPipe გარდაქმნის რიცხვს ვალუტის აღმნიშვნელ ტექსტად, რომელიც დაფორმატებული იქნება ლოკალის შესაბამისად:

budget = 50.335;
<p>Budget: {{ budget | currency }}</p>
<!--output '$50.34'-->
<p>Budget: {{ budget | currency:"EUR" }}</p>
<!--output '€50.34'-->
<p>Budget: {{ budget | currency:"EUR":"code" }}</p>
<!-- output 'EUR50.34' -->

მესამე მაგალითში ჩვენ მეორე პარამეტრს ვაწვდით ფაიფს, რომ ვალუტა კოდური სახელით გამოსახოს. როგორც ხედავთ, შესაძლებელია ერთზე მეტი პარამეტრის მიწოდება.

DecimalPipe

DecimalPipe გამოიყენება რიცხვების დასაფორმატებლად.

დიდ რიცხვებს ის წაკითხვადი ფორმით აფირმატებს:

<p>{{1000000 | number}}</p>
<!-- output '1,000,000' -->

მას ასევე შეუძლია არამთელი რიცხვების დამრგვალება. ამისთვის გარკვეული ფორმატით უნდა მივაწოდოთ პარამეტრები:

<p>{{3.6 | number: '1.0-0'}}</p>
<!--will output '4'-->

<p>{{-3.6 | number:'1.0-0'}}</p>
<!--will output '-4'-->

პარამეტრში 1 გულისხმობს მინიმალური ციფრების რაოდენობას წერტილამდე. პირველი ნული ნიშნავს წერტილის შემდეგ მინიმალური ციფრების ოდენობას. მეორე ნული ნიშნავს წერტილის შემდეგ მაქსიმალური ციფრების რაოდენობას. ამ პარამეტრის საფუძველზე ხდება რიცხვების დამრგვალება.

PercentPipe

PercentPipe რიცხვს გარდაქმნის პროცენტული მაჩვენებლის სტრინგად:

<p>{{ 0.259 | percent }}</p>
<!--output '26%'-->

აქ შესაძლებელია DecimlPipe-ის მსგავსი პარამეტრების მიწოდება, რათა უფრო კონკრეტულად დავაფორმატოთ შედეგი.

შეჯამება

ამ თავში ჩვენ განვიხილეთ ფაიფების დანიშნულება ანგულარში და შევისწავლეთ ანგულარში არსებული რამდენიმე ფაიფი.

ფაიფის შექმნა

ამ თავში ვისწავლით ფაიფების შექმნას.

წინასწარ გამზადებული გვაქვს სტრინგების მასივი და საფილტრო სიტყვა, რომლის მიხედვითაც გვინდა ამ მასივის გაფილტვრა:

import { Component } from "@angular/core";
import { CommonModule } from "@angular/common";

@Component({
  selector: "app-root",
  standalone: true,
  imports: [CommonModule],
  templateUrl: "./app.component.html",
  styleUrl: "./app.component.css",
})
export class AppComponent {
  filterKey = "";
  items = [
    "Some example text here",
    "Angular is an awesome framework",
    "ZA WARUDOO",
    "Learn Angular, it's worth it",
    "On step at a time",
  ];
}

თემფლეითში ეს მნიშვნელობები გამოსახული გვაქვს NgIf დირექტივით. ასევე NgModel დირექტივის საშუალებით filtlerKey ში ვნახავთ ყოველ ახალ ტექსტს, რომელსაც მომხმარებელი input-ში შეიყვანს:

<p>filter</p>
<input type="text" [(ngModel)]="filterKey" />
<hr />
<p *ngFor="let item of items">{{ item }}</p>

როგორღაც, შეცვლილი filterKey-ს მიხედვით უნდა შევცვალოთ NgFor დირექტივით დარენდერებული სია. სწორედ ამისთვის გამოგვადგება ფაიფი.

შევქმნათ ფაიფი, რომელიც ტექსტის მასივს გაფილტრავს ჩვენ მიერ მიწოდებული საძიებო სიტყვის მიხედვით. ფაიფის შექმნა შეიძლება ხელით, ან ანგულარის CLI-ს დახმარებით.

ng generate pipe my-filter

ანგულარი შექმნის ფაილს, რომელიც შეიცავს ჩვენი ფაიფის სახელს + .pipe.ts. ამ ფაილში დაექსpორტებულია ჩვენი ფაიფის კლასი, რომელიც Pipe დეკორატორით არის შექმნილი. ამ დეკორატორში შეგვიძლია ფაიფის კონფიგურაცია, მათ შორის იმ სახელის, რომლითაც ამ ფაიფს თემფლეითში გამოვიყენებთ. ჩვენი ფაიფის კლასი თავის მხრივ აუცილებლად იმპლემენტაციას უკეთებდეს PipeTransform ინტერფეისს. PipeTransform ინტერფეისი საჭიროებს რომ კლასში არსებობდეს transform მეთოდი. ეს მეთოდი ყველა ფაიფს გააჩნია, თუმცა ყველა მათგანი თავისებურად მუშაობს. ჩვენც, რა თქმა უნდა, ის ჩვენ ამოცანას უნდა მოვარგოთ.

import { Pipe, PipeTransform } from "@angular/core";

@Pipe({
  name: "myFilter",
  standalone: true,
})
export class MyFilterPipe implements PipeTransform {
  transform(value: string[], filterKey: string): string[] {
    return value.filter((item) =>
      item.toLowerCase().includes(filterKey.toLowerCase())
    );
  }
}

transform მეთოდი ორ პარამეტრს იღებს, value არის ის მნიშვნელობა, რომელიც უნდა გავფილტროთ, ხოლო მეორე პარამეტრიდან იწყება ის არგუმენტები, რომლელსაც ჩვენ ფაიფის გამოყენებისას მივაწვდით. ჩვენ გვინდა, რომ გავფილტროთ სტგრინგების მასივი რაღაც სიტყვის მიხედვით და ამიტომ ჩვენ დავაბრუნებთ გაფილტრული სტრინგების მასივს, სადაც თითოეული ელემენტი (ლოუერქეისში) შეიცავს საფილტრო სიტყვას (ლოუერქეისში).

დარწმუნდით, რომ ჩვენი ფაიფი დამატებულია შესაბამისი კომპონენტის იმპორტების სიაში, რათა მისი გამოყენება შევძლოთ:

/* ... */
import { MyFilterPipe } from "./my-filter.pipe";

@Component({
  selector: "app-root",
  standalone: true,
  imports: [CommonModule, MyFilterPipe],
})
export class AppComponent {
  /* ... */
}

ახლა შეგვიძლია ფილტრის გამოყენება:

<p>filter</p>
<input type="text" [(ngModel)]="filterKey" />
<hr />
<p *ngFor="let item of items | myFilter : filterKey">{{ item }}</p>

ჩვენი ფილტრის filter ფუნქციაში value არგუმენტი გამოდის items მასივი, ხოლო filterKey პარამეტრი გამოდის ჩვენს კომპონენტში არსებული filterKey, რომელსაც ngModel-ის საშუალებით მომხმარებლის მიერ შეყვანილი მნიშვნელობა ეძლევა და ანგულარ items-ს ტრანსფორმაციას უკეთებს და აბრუნებს მასივს მხოლოდ იმ ელემენტებიც, რომლებიც ველში შეყვანილ სიტყვას შეიცავენ.

შეჯამება

ამ თავში ჩვენ შევქმენით ფილტრის ფაიფი, რომლითაც ტექსტის მასივი გავფილტრეთ NgIf დირექტივში, input ველში შეყვანილი ტექსტის მიხედვით.

Dependency Injection

Dependency Injection, იგივე DI, არის ფუნდამენტური და ძალზედ მნიშვნელოვანი კონცეფცია ანგულარში. როცა ჩვენი აპლიკაცია იზრდება, რაღაც ეტაპზე საჭირო ხდება, რომ ლოგიკის გარკვეულ ნაწილს ერთ დაგილას მოვუყაროთ თავი და სხვადასხვა ადგილებში გამოვიყენოთ. სწორედ ამაში გვეხმარება DI.

ამ თავში ვისწავლით:

DI ზოგადად

ზოგადად პროგრამირებაში, DI არის დიზაინის პატერნი სადაც ობიექტი, კლასი თუ ფუნქცია იღებს იმ ობიექტებსა და ფუნქციებს რომლებზეც ის დამოკიდებულია. ასე კოდის ორგანიზება შეგვიძლია მათი მოვალეობების მიხედვით, სადაც კონკრეტული ფუნქცია, კლასი თუ ობიექტი ერთ დანიშნულებას ემსახურება. თუ ეს დანიშნულება რაიმე სხვა კლასს სჭირდება, იგი მასზე დამოკიდებულებას გამოაცხადებს.

სანიმუშოდ განვიხილოთ შემდეგი კლასები:

class Knight {
  defend() {
    return "defend the ruler";
  }
}
class General {
  command() {
    return "give commands";
  }
}

წარმოვიდგინოთ რომ ეს კლასები კონკრეტულ დანიშნულებას ასრულებენ დამოუკიდებლად. ახლა ვთქვათ, რომ გვაქვს ერთი Soldier კლასი, რომელსაც სრულფასოვნად ფუნქციონირებისთვის General კლასის ინსტანცია სჭირდება. General კლასის გამოყენების ერთი ვარიანტი იქნებოდა ასეთი:

class Soldier {
  general: General;
  orders = "no orders for now";
  constructor() {
    this.general = new General();
    this.orders = this.general.command();
  }
}

ასეთ კოდში მკაფიოდ არ ჩანს, რომ Soldier კლასს სჭირდება General. ეს მიტუმეტეს გაუგებარი იქნებოდა, თუ თითოეული კლასი ცალკეულ დიდ და კომპლექსურ ფაილში გვექნებოდა. როცა ჩვენ Soldier კლასის ინსტანციას შევქმნიდით, არავის ეცოდინებოდა, რომ ამ კლასს სჭირდებოდა General კლასი. მისი ინსტანცია შეიქმნება, მაგრამ ეს უკანა ფონზე, ჩუმად მოხდება.

const soldier1 = new Soldier();
const soldier2 = new Soldier();
const soldier3 = new Soldier();

აქ თითოეული Soldier-ის ინსტანციას ცალკეული General-ის ინსტანცია გააჩნია. რაც უფრო მეტია Soldier, მით უფრო მეტ General-ს შექმნის ის.

ამიტომაც კონსოლში ეს დაგვიბრუნებს false-ს.

console.log(soldier1.general === soldier2.general);

ამის გასაკეთებლად უკეთესი და უფრო დეკლარაციული გზა იქნებოდა შემდეგნაირი:

class Soldier {
  orders = "no orders for now";
  constructor(public general: General) {
    this.orders = this.general.command();
  }
}

ჩვენ კონსტრუქტორში მივუთითებთ, რომ Soldier დამოკიდებულია General ტიპის ობიექტზე. general-ს ასევე კონსტრუქტორშივე დეკლარაციას ვუკეთებთ, როგორც კლასის თვისებას public ან private სიტყვით. ასე არ გვჭირდება კლასში ცალკე ჯერ თვისების შექმნა და შემდეგ მისი გატოლება კონსტრუქტორის არგუმენტთან. ახლა განსხვავება ის არის, რომ Soldier კლასის შექმნისას ჩვენ მას აუცილებლად უნდა მივაწოდოთ General-ის ინსტანცია, იმის მაგივრად, რომ Soldier-მა ეს თავისით, ფარულად ქნას:

const general = new General();
const soldier1 = new Soldier(general);
const soldier2 = new Soldier(general);
const soldier3 = new Soldier(general);

ერთი მხრივ, ახლა მკაფიოა, რომ Soldier-ს სჭირდება General, მაგრამ მეორე მხრივ, ყოველ Soldier-ს ერთი და იგივე General-ის ინსტანცია აქვს. ასე ვთქვათ, ამ ჯარისკაცებს ერთი გენერალი მართავს, რომლის ინსტანციაც ჩვენ თავდაპირველად შევქმენით, და თითოეულ ჯარისკაცს მივაწოდეთ კონსტრუქტორში. ეს ასევე ბეფრად უფრო ეკონომიურია, რადგან ტყუილად არ ვქმნით იმდენივე General-ის ინსტანციას, რამდენი Soldier-იც დაგვჭირდება.

ახლა ეს შედეგად მოგვცემს true-ს

console.log(soldier1.general === soldier2.general);

ეს არის Dependency Injection-ის მარტივი მაგალითი. კლასი აცხადებს, თუ რაზეა ის დამოკიდებული, ანუ აცხადებს თავის dependency-ს, რომელსაც შემდგომ ინსტანციის შექმნისას იღებს.

ახლა ასპარეზზე შემოვიყვანოთ მეფე: King კლასი, რომელიც საჭიროებს ყველა შემდეგ კლასს: Knight, Soldier, General.

class King {
  constructor(
    public knight: Knight,
    public general: General,
    public soldier: Soldier
  ) {}
}

მაშინ ამ კლასის ინსტანციის შესაქმნელად ჩვენ შემდეგნაირად უნდა მოვიქცეთ:

const knight = new Knight();
const general = new General();
const soldier = new Soldier(general);
const king = new King(knight, general, soldier);

ჯერ დამოუკიდებელ Knight და General კლასებს ვქმნით, ხოლო შემდგომ Soldier კლასს, რომელსაც General-ის ინსტანცია სჭირდება. შემდგომ ვქმნით King-ის ინსტანციას, რომელსაც სამივე სჭირდება. აქ აღსანიშნავია, რომ soldier-სა და king-ს ერთი და იგივე General-ის ინსტანცია გააჩნიათ: general. ასე კლასების ურთიერთდამოკიდებულება უფრო მკაფიო და გასაგებია. დეკლარაციული კოდის პლიუსი სწორედ ეს არის.

რაც უფრო მეტი კლასი გვიგროვდება, ამ dependency-ების მენეჯმენტი და ახალი კლასის ინსტანციების შექმნა უფრო გართულდება. ანგულარში სწორედ ამიტომ არსებობს DI კონტეინერი, რომელიც ამ ყველაფერს ჩვენ მაგივრად აგვარებს. ჩვენ კონსტრუქტორში უბრალოდ ის უნდა გამოვაცხადოთ, თუ რაზე არის დამოკიდებული ჩვენი კლასი და დანარჩენს DI კონტეინერი მოაგვარებს.

DI ანგულარში

ამ ქვეთავში პრაქტიკულ მაგალითზე დაყრდნობით განვიხილავთ ანგულარში Dependency Injection-ს.

ამ თავში:

საწყისი კოდი

განვიხილოთ პრიმიტიული აპლიკაციის მაგალითი, რომელიც შესაძლებელია DI-ს გარეშეც მუშაობდეს, თუმცა მისი დანიშნულების მარტივად გააზრების საშუალებას მოგვცემს. წარმოვიდგინოთ, რომ გვერდიგვერდ გვაქვს ორი კომპონენტი: გმირების სია, სადაც ჩამოწერილია გმირთა სახელები და გმირის დეტალები, სადაც სიიდან არჩეული გმირის შესახებ დეტალური ინფორმაცია ჩნდება.

ეს არის გმირის ინტერფეისი, hero.ts:

export interface Hero {
  name: string;
  description: string;
}

ჩვენი AppComponent ასე გამოიყურება:

import { Component } from "@angular/core";
import { CommonModule } from "@angular/common";
import { HeroListComponent } from "./hero-list.component";
import { HeroDetailsComponent } from "./hero-details.component";

@Component({
  selector: "app-root",
  standalone: true,
  imports: [CommonModule, HeroListComponent, HeroDetailsComponent],
  template: `
    <div class="container">
      <app-hero-list></app-hero-list>
      <app-hero-details></app-hero-details>
    </div>
  `,
  styles: [
    `
      .container {
        max-width: 600px;
        display: grid;
        grid-template-columns: 300px 300px;
        grid-auto-rows: minmax(300px, auto);
      }

      .container > * {
        border: 2px solid black;
        padding: 8px;
      }
    `,
  ],
})
export class AppComponent {}

აქ ყველა კომპონენტის მარკაპი, სტილები და ლოგიკა ერთ ფაილში გვაქვს მოქცეული. ეს მხოლოდ თვალსაჩინოებისა და სიმარტივისთვისაა. როგორც ხედავთ, ორი კომპონენტი გვაქვს გვერდი-გვერდ განთავსებული: app-hero-list და app-hero-details. ჩვენი ამოცანაა, რომ სიიდან არჩეული გმირის დეტალები გამოვაჩინოთ მეორე კომპონენტში.

DI-ს გარეშე

ამის გაკეთების ერთი ვარიანტი არის Input და Output დეკორატორების გამოყენება, სადაც ლოგიკის ძირითად ნაწილს AppComponent-ში შევინახავდით, და ეს ორი კომპონენტი უბრალოდ ინფორმაციას გამოსახავდა და ივენთებს დააემითებდა. ეს კარგი მიდგომაა, როცა კომპონენტები პრიმიტიულია: ჩვენ მძიმე ლოგიკა მშობელი კომპონენტებიდან უნდა მოვაგვაროთ, მათ უბრალოდ ვიზუალებსა და მონაცემების განთავსებაზე უნდა იზრუნონ.

ასეთი მიდგომით კოდი შემდეგნაირი იქნებოდა:

app.component.ts

import { Component } from "@angular/core";
import { CommonModule } from "@angular/common";
import { Hero } from "./types/hero";
import { HeroListComponent } from "./hero-list.component";
import { HeroDetailsComponent } from "./hero-details.component";

@Component({
  selector: "app-root",
  standalone: true,
  imports: [CommonModule, HeroListComponent, HeroDetailsComponent],
  template: `
    <div class="container">
      <app-hero-list
        [heroes]="heroes"
        (heroPicked)="onHeroPicked($event)"
      ></app-hero-list>
      <app-hero-details [hero]="pickedHero"></app-hero-details>
    </div>
  `,
  styles: [
    `
      .container {
        max-width: 600px;
        display: grid;
        grid-template-columns: 300px 300px;
        grid-auto-rows: minmax(300px, auto);
      }

      .container > * {
        border: 2px solid black;
        padding: 8px;
      }
    `,
  ],
})
export class AppComponent {
  heroes: Hero[] = [
    {
      name: "Tariel",
      description:
        "Tariel is distinguished by his wild character as symbolized by his wearing the panther's skin.The qualities associated with the cat, his dedication and courage, his hatred and violence could be extreme and uncontrollable.",
    },
    {
      name: "Avtandil",
      description:
        "Avtandil, the knight and commander-in-chief of Rostevan's armies. One day, Avtandil challenges King Rostevan to a hunting competition. After three days of shooting game, they encounter a knight crying by a river.",
    },
    {
      name: "Nuradin-Pridon",
      description:
        "Nuradin-Pridon, ruler of Mulgazanzar. He initially met Tariel after he survived a battle against traitors who tried to ambush him. After sharing their stories, Nuradin-Pridon gifted Tariel his trusty Arabian steed to aid him in his journey.",
    },
  ];
  // Initially set on first hero
  pickedHero: Hero = this.heroes[0];

  onHeroPicked(heroName: string) {
    const chosenHero = this.heroes.find((hero) => hero.name === heroName);
    if (chosenHero) {
      this.pickedHero = chosenHero;
    }
  }
}

გმირების შესახებ ინფორმაციის შენახვა, არჩეული გმირის სტატუსი და გმირის არჩევის ლოგიკა არის მშობელ კომპონენტში.

HeroListComponent უბრალოდ იღებს განსათავსებელი გმირების სიას და თუ რომელიმე გმირზე დავაკლიკეთ, ამ ივენთს აემითებს, რათა AppComponent მა onHeroPicked მეთოდი გააქტიუროს და pickedHero შეცვალოს.

hero-list.component.ts

import { Component, EventEmitter, Input, Output } from "@angular/core";
import { CommonModule } from "@angular/common";
import { Hero } from "../types/hero";

@Component({
  selector: "app-hero-list",
  standalone: true,
  imports: [CommonModule],
  template: `
    <h2>Pick the hero</h2>
    <ul>
      <li *ngFor="let hero of heroes" (click)="heroPicked.emit(hero.name)">
        {{ hero.name }}
      </li>
    </ul>
  `,
  styles: [
    `
      li {
        cursor: pointer;
      }
      li:hover {
        text-decoration: underline;
      }
    `,
  ],
})
export class HeroListComponent {
  @Input() heroes!: Hero[];
  @Output() heroPicked = new EventEmitter<string>();
}

როცა pickedHero იცვლება, ვინაიდან ის property binding-ით არის მიბმული HeroDetailsComponent-ის hero თვისებაზე, მაშინ ამ უკანასკნელ კომპონენტში განთავსებული დეტალებიც იცვლება.

hero-details.component.ts

import { Component, Input, OnChanges, SimpleChanges } from "@angular/core";
import { CommonModule } from "@angular/common";
import { Hero } from "../types/hero";

@Component({
  selector: "app-hero-details",
  standalone: true,
  imports: [CommonModule],
  template: `
    <div *ngIf="hero">
      <h2>{{ hero.name }}</h2>
      <p>{{ hero.description }}</p>
    </div>
  `,
})
export class HeroDetailsComponent {
  @Input() hero!: Hero;
}

და ასე, მშობელი კომპონენტი განაგებს შვილ კომპონენტებში განთავსებულ ინფორმაციას.

DI-ის გამოყენებით

ვთქვათ ეს ორი შვილი კომპონენტი გახდა კომპლექსური. მათ ბევრი ცალკეული შვილი კომპონენტი ჰყავთ, რომლებსაც მენეჯმენტი უნდა გაუკეთონ. ლოგიკის რაღაც ნაწილი, კერძოდ გმირებზე ინფორმაციის მანიპულაცია ძალიან განმეორებადი იქნებოდა თითოეულ კომპონენტში. ამ კომპონენტებს ისევ პრიმიტიულად დავტოვებთ, მაგრამ გამოვიყენებთ სერვისებს, რაც ჩვენი გმირების შესახებ ინფორმაციასთან დაკავშირებულ ლოგიკას ერთ ადგილას მოუყრის თავს, ხოლო მის გამოყენებას ბევრ სხვადასხვა კომპონენტში შევძლებთ.

app.component.ts ახლა ასე გმაოიყურება:

import { Component } from "@angular/core";
import { CommonModule } from "@angular/common";
import { HeroListComponent } from "./hero-list.component";
import { HeroDetailsComponent } from "./hero-details.component";

@Component({
  selector: "app-root",
  standalone: true,
  imports: [CommonModule, HeroListComponent, HeroDetailsComponent],
  template: `
    <div class="container">
      <app-hero-list></app-hero-list>
      <app-hero-details></app-hero-details>
    </div>
  `,
  styles: [
    `
      .container {
        max-width: 600px;
        display: grid;
        grid-template-columns: 300px 300px;
        grid-auto-rows: minmax(300px, auto);
      }

      .container > * {
        border: 2px solid black;
        padding: 8px;
      }
    `,
  ],
})
export class AppComponent {}

აქ არანაირი ლოგიკა აღარ გვექნება, სანაცვლოდ გმირებზე ლოგიკას გავიტანთ სერვისში. ვქმნით ფაილს hero.service.ts:

import { Injectable } from "@angular/core";
import { BehaviorSubject } from "rxjs";
import { Hero } from "./types/hero";

@Injectable({ providedIn: "root" })
export class HeroService {
  heroes: Hero[] = [
    {
      name: "Tariel",
      description:
        "Tariel is distinguished by his wild character as symbolized by his wearing the panther's skin.The qualities associated with the cat, his dedication and courage, his hatred and violence could be extreme and uncontrollable.",
    },
    {
      name: "Avtandil",
      description:
        "Avtandil, the knight and commander-in-chief of Rostevan's armies. One day, Avtandil challenges King Rostevan to a hunting competition. After three days of shooting game, they encounter a knight crying by a river.",
    },
    {
      name: "Nuradin-Pridon",
      description:
        "Nuradin-Pridon, ruler of Mulgazanzar. He initially met Tariel after he survived a battle against traitors who tried to ambush him. After sharing their stories, Nuradin-Pridon gifted Tariel his trusty Arabian steed to aid him in his journey.",
    },
  ];

  pickedHero$ = new BehaviorSubject<Hero>(this.heroes[0]);

  pickHero(hero: Hero) {
    this.pickedHero$.next(hero);
  }
}

კონვენციურად სახელში ასეთ კლასებსა და ფაილებს service ეწერებათ, რადგან ისინი, ასე ვთქვათ, სხვადასხვა კლასებს “ემსახურებიან”. სერვისები Injectable დეკორატორით იქმნებიან, რათა ანგულარის ე.წ Injector-მა ამ კლასების dependency injection შეძლოს. აქ შეგვიძლია providedIn კონფიგურაციის მიწოდება, რომელიც განსაზღვრავს თუ აპლიკაციის რა ნაწილებში უნდა იყოს ხელმისაწვდომი ეს სერვისი. root გულისხმობს აპლიკაციის ფესვს, ანუ ის app-root-ში, ესეიგი ყველგან იქნება ხელმისაწვდომი.

შესაძლებელია სერვისი მხოლოდ კონკრეტული მოდულის ფარგლებშიც გავხადოთ ხელმისაწვდომი. მაშინ providedIn კონფიგურაციის მაგივრად, ეს კლასი უნდა შევიტანოთ NgModule-ის providers მასივში:

import { HeroService } from './hero.service.ts'
@NgModule({
  // ... declarations, imports, etc.
  providers: [HeroService],
})

თუ მხოლოდ კონკრეუტლი კომპონენტებისთვის გვინდა ეს კლასი, მაშინ სეგვიძლია Component დეკორატორში მისი providers მასივში დამატებაც:

import { HeroService } from './hero.service.ts'
@Component({
  // ... selector, template, etc.
  providers: [HeroService]
})

ასე ანგულარი HeroService-ის უნიკალურ ინსტანციას შექმნის მხოლოდ AppComponent-ისთვის.

ამ შემთხვევაში ჩვენ{providedIn: 'root'}-ს დავტოვებთ. ანუ ეს სერვისი იქნება ე.წ “Singleton” სერვისი, სადაც მისი ერთი ინსტანცია იქნება ხელმისაწვდომი მთელი აპლიკაციისთვის.

გმირების მასივს ახლა ჩვენ აქ შევინახავთ და ასევე გმირის არჩევის ლოგიკასაც. სერვისში ვქმნით pickedHero$ თვისებას. ეს არის BehaviorSubject-ის ინსტანცია, რომელიც 'rxjs'-დან უნდა დავაიმპორტოთ. სტრიმებს კონვენციურად ბოლოში $-ს უწერენ ჩვენ სტრიმებს სხვა თავებში ვისწავლით, მაგრამ ახლა უბრალოდ უნდა ვიცოდეთ, რომ ეს ერთგვარი მოვლენების ნაკადია, რომელსაც ჩვენ შეგვიძლია მოვუსმინოთ და მასზე რეაგირება მოვახდინოთ. ჩვენ აღვნიშნავთ, რომ ეს საბჯექთი Hero ტიპის მნიშვნელობას გასცემს და მას ფუნქციის არგუმენტად ვატანთ მასივში პირველ გმირს, რომელიც მისი საწყისი მნიშვნელობა იქნება.

  pickedHero$ = new BehaviorSubject<Hero>(this.heroes[0]);

  pickHero(hero: Hero) {
    this.pickedHero$.next(hero);
  }

ყოველ ახალ გმირის არჩევაზე, ანუ როცა pickedHero-ს დავუძახებთ, ჩვენ პარამეტრში არსებულ გმირს გადავცემთ ამ pickedHero$ საბჯექთს. მასზე next მეთოდი ნაკადში ახალი გმირის მნიშვნელობას გადასცემს.

HeroListComponent-ში ჩვენ კონსტრუქტორში ვაცხადებთ, რომ კლასი დამოკიდებულია HeroService-ის ინსტანციაზე, რომელსაც ჩვენ public თვისება heroService-ში შევინახავთ. ეს კონსტრუქტორში თვისების შექმნის შემოკლებული ვარიანტია.

import { Component } from "@angular/core";
import { CommonModule } from "@angular/core";
import { HeroService } from "../hero.service";

@Component({
  selector: "app-hero-list",
  standalone: true,
  imports: [CommonModule],
  template: `
    <h2>Pick the hero</h2>
    <ul>
      <li
        *ngFor="let hero of heroService.heroes"
        (click)="heroService.pickHero(hero)"
      >
        {{ hero.name }}
      </li>
    </ul>
  `,
  styles: [
    `
      li {
        cursor: pointer;
      }
      li:hover {
        text-decoration: underline;
      }
    `,
  ],
})
export class HeroListComponent {
  constructor(public heroService: HeroService) {}
}

დაინჯექთების ალტერნატიული ვარიანტი არის inject ფუნქციის გამოყენება @angular/core-დან:

import { inject } from "@angular/core";
/* ... */
export class HeroListComponent {
  public heroService = inject(HeroService);
}

ხშირად ეს ფუნქციის გამოყენება უფრო მოკლე და მარტივი გზაა (განსაკუთრებით injection token-ების დროს), თუმცა ეს დეველოპერის გემოვნებაზეა დამოკიდებული. თქვენ ის მეთოდი გამოიყენეთ, რომელიც მოგესურვებათ.

heroService თვისება იმიტომ არის public, რომ იგი ხელმისაწვდომი იყოს კლასის გარეთ, კერძოდ თემფლეითში, სადაც პირდაპირ სერვისიდან ვიღებთ გმირების მასივს და მას თეფლეითში განვათავსებთ. დაკლიკების ივენთზე ჩვენ სერვისზე არსებულ pickHero მეთოდს დავუძახებთ.

HeroDetailsComponent კომპონენტშიც ჩვენ ამ სერვისზე ვაცხადებთ დამოკიდებულებას, მაგრამ მას ახლა private თვისებაში ვინახავთ, რადგან აქ მას (პორობითად) მხოლოდ კლასის შიგნით ვიყენებთ. აქ კონსტრუქტორშივე რაღაც საინტერესოს ვაკეთებთ:

import { Component } from "@angular/core";
import { CommonModule } from "@angular/common";
import { HeroService } from "../hero.service";
import { Hero } from "../types/hero";

@Component({
  selector: "app-hero-details",
  standalone: true,
  imports: [CommonModule],
  template: `
    <div *ngIf="hero">
      <h2>{{ hero.name }}</h2>
      <p>{{ hero.description }}</p>
    </div>
  `,
})
export class HeroDetailsComponent {
  hero: Hero | null = null;
  constructor(private heroService: HeroService) {
    this.heroService.pickedHero$.subscribe((hero) => {
      this.hero = hero;
    });
  }
}

ჩვენ heroService-ში არსებულ pickedHero$-ზე ვეძახით subscribe მეთოდს, ასე ფაქტობრივად მნიშვნელობების ნაკადს “გამოვიწერთ” და ამ ნაკადში ყოველ ახალ მნიშვნელობაზე ამ ფუნქციაში ჩაწერილი ქოლბექი გააქტიურდება, სადაც ჩვენ ახალი გმირის მნიშვნელობას მივიღებთ. ჩვენ ამ მიღებულ hero-ს ვუტოლებთ ჩვენს კლასში არსებულ hero-ს, რომლის დეტალებიც თემფლეითში გამოისახება.

შევაჯამოთ აქ რა ხდება:

  • HeroListComponent-ში გმირზე დაკლიკებისას აქტიურდება HeroService-ში მეთოდი, რომელიც pickedHero საბჯექტზე ააქტიურებს next მეთოდს, რაც ნაკადში ახალი გმირის მნიშვნელობას აბრუნებს - იმ გმირის, რომელიც ჩვენ მეთოდს მივაწოდეთ არგუმენტად.
  • ვინაიდან ჩვენ HeroDetailsComponent-ში დავასუბსქრაიბეთ ამ საბჯექტზე, მასზე დაძახებული ყოველი next მეთოდი ააქტიურებს ჩვენ მიერ subscribe-ში განსაზღვრულ ქოლბექს, სადაც ახალი გმირის მნიშვნელობას ვიღებთ.
  • ამ ახალი გმირის მნიშვნელობას ჩვენ კლასის თვისებაში ვინახავთ და გამოვსახავთ თემფლეითში.

ეს არ არის BehaviorSubject-ების და ზოგადად სტრიმების გამოყენების იდეალური მაგალითი, მაგრამ აქ სტრიმები პირობითია. მთავარია დავინახოთ სერვისების ეფექტურობა: მათი საშუალებით გმირებთან დაკავშირებული ლოგიკა ერთ ადგილას გავაერთიანეთ და მასზე გავხადეთ დამოკიდებული ორი კომპონენტი. ეს ორივე კომპონენტი HeroService-ის ერთსა და იმავე ინსტანციას იყენებენ, და ასე ვთქვათ ერთმანეთთან სინქრონიზირებულები არიან. ორივეს შეუძლია არსებული გმირების სიისა და არჩეული გმირის შესახებ ინფორმაციის აღება ან მასზე რეაგირება.

ანგულარის უნიკალურობა სწორედ ამაში მდგომარეობს, dependency injection მისი უდიდესი პლიუსია. DI-ით უფრო მეტი საინტერესო რაღაცების გაკეთება შეიძლება, გაეცანით ოფიციალურ დოკუმენტაციას.

ფორმები ანგულარში

მომხმარებლის მიერ შეყვანილი მონაცემების მოხელთება ვებ აპლიკაციების ერთ-ერთი ყველაზე მნიშვნელოვანი ასპექტია. აპლიკაციებში გამოიყენება ფორმები, რათა მომხმარებლებმა შეძლონ ავთენტიფიკაცია, პირადი ინფორმაციის შეყვანა, წერილების გაგზავნა და ა.შ.

ანგულარში ფორმების აგებისა და შეყვანილი ინფორმაციის მოხელთების ორი გზა არსებობს: template-driven და reactive. ორივე მეთოდით შესაძლებელია მომხმარებლის მიერ წარმოებული მოვლენების მოსმენა, შეყვანილი ინფორმაციის ვალიდაცია, ფორმის მოდელების შექმნა და მათი ცვლილება-მონიტორინგი, თუმცა ამ ორ მიდგომას თავისი პლიუსები და მინუსები აქვს.

სანამ ორივე მიდგომას ვისწავლით პრაქტიკაში, განვიხილოთ თითოეული მათგანის თავისებურებები:

reactive forms: გვაძლევს უშუალო წვდომას ფორმის ობიექტის მოდელზე. template-driven ფორმებისგან განსხვავებით ის უფრო მძლავრია. შესაძლებელია მისი მასშტაბების გაფართოება, მოქნილად და მრავალჯერადად გამოყენება, და მარტივად დატესტვა. მისი ლოგიკის ძირითადი ნაწილი კლასში იწერება.

template-driven forms: საჭიროებს თემფლეითში დირექტივებს, ფორმის ობიექტის მოდელის შექმნისა და მანიპულაციისთვის. ეს მეთოდი ყველაზე ეფექტურია მარტივი ფორმების ასაგებად, თუმცა რთულია მისი მასშტაბების გაზრდა. თუ ჩვენ გვჭირდება ძალიან ელემენტარული ფორმა, რომლის ლოგიკაც პირდაპირ თემფლეითში იქნება, მაშინ ეს მიდგომა იდეალური არჩევანია.

მნიშვნელოვანი კლასები

ორივე ტიპის ფორმები დაშენებულია შემდეგ კლასებზე:

  • FormControl - თვალყურს ადევნებს თითოეული ფორმის კონტროლის მნიშვნელობასა და ვალიდურობას.
  • FormGroup - თვალყურს ადევნებს კონტროლთა ჯგუფის მნიშვნელობებსა და სტატუსს.
  • FormArray - თვალყურს ადევნებს კონტროლთა მასივში მნიშვნელობებსა და სტატუსს.
  • ControlValueAccessor - ქმნის ხიდს ანგულარის FormControl ინსტანციებსა და DOM-ის ელემენტებს შორის.

სწორედ ამ კლასების საშუალებით მუშაობს ფორმები ანგულარში, თუმცა template-driven ფორმებში ეს კლასები იმპლიციტურად გამოიყენება დირექტივებს მიერ, ხოლო reactive ფორმებში ამ კლასებთან უშუალო შეხება გვაქვს, რაც უფრო მეტ ძალაუფლებას გვაძლევს ფორმებზე.

ამ თავში ჩვენ ასევე განვიხილავთ ვალიდატორებსა და ვალიდაციას, რომელიც ფორმების მნიშვნელოვანი ნაწილია თანამედროვე აპლიკაციებში.

Template Driven Forms

მნიშვნელოვანი დირექტივები

template-driven ფორმები ეყრდნობიან FormsModule-ში არსებულ შემდეგ დირექტივებს:

  • NgModel - იგი ათანხმებს ჰოსტ ფორმის ელემენში შეცვლილ მნიშვნელობებს დატა მოდელთან, რაც საშუალებას გვაძლევს, რომ ვირეაგიროთ შეყვანილ ინფორმაციაზე.
  • NgForm - ქმნის ზედა დონის FormGroup-ის ინსტანციას და მას <form> ელემენტს აბამს, რათა თვალყური ადევნოს გაერთიანებული ფორმის მნიშვნელობასა და ვალიდაციის სტატუსს. როგორც კი ჩვენ FormsModule-ს ვაიმპორტებთ, ეს დირექტივი ავტომატურად აქტიურდება ყველა <form> ელემენტზე.
  • NgModelGroup - ქმნის და აბამს FormGroup-ის ინსტანციას DOM ელემენტს.

აუცილებელია FormsModule-ის დაიმპორტება საჭირო კომპონენტში (ან მოდულში), რათა ამ დირექტივებზე წვდომა გვქონდეს.

ფორმის აწყობა

ვთქვათ, გვინდა ფორმის აწყობა, სადაც ვეფხისტყაოსნის გმირებს დარეგისტრირება შეეძლებათ ახალი თავგადასავლის დასაწყებად, და სადაც ასევე შეძლებენ თანამგზავრი მეგობრის არჩევას.

მარტივი ფორმა

შევქმნათ კომპონენტი HeroFormComponent და მის თემფლეითში ავაგოთ მარტივი ფორმა:

<form>
  <h1>Start your journey</h1>

  <div class="form-group">
    <label for="name">Name</label>
    <input type="text" id="name" name="name" />
  </div>

  <div class="form-group">
    <label for="email">Email</label>
    <input type="email" id="email" name="email" />
  </div>

  <div class="form-group">
    <label for="friend">Friend</label>
    <select id="friend" name="friend">
      <option *ngFor="let friend of friends" [value]="friend">
        {{ friend }}
      </option>
    </select>
  </div>
</form>

ეს სტანდარტული ფორმის მარკაპია, უბრალოდ select-ში ვიყენებთ NgFor დირექტივს რათა ასარჩევი მეგობრების სია გამოვსახოთ, მათი მნიშვნელობები მივანიჭოთ value თვისებაზე და ასევე ეს მნიშვნელობა გამოვსახოთ ინტერპოლაციით.

friends ასე გამოიყურება კომპონენტის კლასი:

import { Component } from "@angular/core";
import { CommonModule } from "@angular/common";
import { FormsModule } from "@angular/forms";

@Component({
  selector: "app-hero-form",
  standalone: true,
  imports: [CommonModule, FormsModule],
  templateUrl: "./hero-form.component.html",
  styleUrl: "./hero-form.component.css",
})
export class HeroFormComponent {
  friends = ["Tariel", "Avtandil", "Nuradin-Pridon"];
  heroData = {
    name: "",
    email: "",
    friend: "",
  };

  submitted = false;

  onSubmit() {
    this.submitted = true;
  }
}

friends-ში ვინახავთ მეგობრების მასივს. heroData იქნება ის პირადი ინფორმაციის ობიექტი, სადაც მომხმარებლის შეყვანილ მონაცემებს შევინახავთ. ეს თვისებები თავიდან ცარიელია.

აქვე გვექნება submitted, რომელიც იმას განსაზღვრავს submit ღილაკზე დავაჭირეთ თუ არა. სწორედ ღილაკზე დაჭერისას უნდა გააქტიურდეს onSubmit მეთოდი.

NgModel-ის საშუალებით ფორმაში შეყვანილი მონაცემები შეგვიძლია შევინახოთ heroData-ს თვისებებში:

<form (ngSubmit)="onSubmit()">
  <h1>Start your journey</h1>

  <div class="form-group">
    <label for="name">Name</label>
    <input type="text" id="name" name="name" [(ngModel)]="heroData.name" />
  </div>

  <div class="form-group">
    <label for="email">Email</label>
    <input type="email" id="email" name="email" [(ngModel)]="heroData.email" />
    <span *ngIf="emailCtrl.invalid && !emailCtrl.pristine"
      >Email is required!</span
    >
  </div>

  <div class="form-group">
    <label for="friend">Friend</label>
    <select id="friend" name="friend" [(ngModel)]="heroData.friend">
      <option *ngFor="let friend of friends" [value]="friend">
        {{ friend }}
      </option>
    </select>
    <span *ngIf="friendCtrl.invalid">You shouldn't go alone!</span>
  </div>
  <button>Submit</button>
</form>

<div *ngIf="submitted">
  <h3>OUTPUT:</h3>
  <div>{{ heroData | json }}</div>
</div>

ახლა ღილაკზე დაჭერისას უნდა გამოჩნდეს შედეგი, რაც ადასტურებს იმას, რომ შეყვანილ მონაცემებს სწორად ვინახავთ. იდეაში ღილაკზე დაჭერის შემდეგ ჩვენ რაიმე HTTP მოთხოვნა უნდ აგავაგზავნოთ, თუმცა ახლა შედეგს უბრალოდ თემფლეითში გამოვსახავთ.

ვალიდაცია

საჭიროა, რომ ღილაკზე დაჭერა იქამდე არ იყოს შესაძლებელი, სანამ ყველა ველი სწორად არ შეივსება. ამისთვის ფორმის კონტროლებს უნდა ჩავწვდეთ და შევამოწმოთ მათი ვალიდურობა. ეს შესაძლებელია ლოკალური ცვლადებით და ვალიდატორებით:

<form (ngSubmit)="onSubmit()" #heroForm="ngForm">
  <h1>Start your journey</h1>
  <div class="form-group">
    <label for="name">Name</label>
    <input
      type="text"
      id="name"
      name="name"
      [(ngModel)]="heroData.name"
      required
      #nameCtrl="ngModel"
    />
    <span *ngIf="nameCtrl.invalid && !nameCtrl.pristine"
      >Name is required!</span
    >
  </div>
  <div class="form-group">
    <label for="email">Email</label>
    <input
      type="email"
      id="email"
      name="email"
      [(ngModel)]="heroData.email"
      required
      email
      #emailCtrl="ngModel"
    />
    <span *ngIf="emailCtrl.invalid && !emailCtrl.pristine"
      >Email is required!</span
    >
  </div>
  <div class="form-group">
    <label for="friend">Friend</label>
    <select
      id="friend"
      name="friend"
      [(ngModel)]="heroData.friend"
      required
      #friendCtrl="ngModel"
    >
      <option *ngFor="let friend of friends" [value]="friend">
        {{ friend }}
      </option>
    </select>
    <span *ngIf="friendCtrl.invalid">You shouldn't go alone!</span>
  </div>
  <button [disabled]="heroForm.invalid">Submit</button>
</form>

<div *ngIf="submitted">
  <h3>OUTPUT:</h3>
  <div>{{ heroData | json }}</div>
</div>

მივყვეთ ზემოდან ქვემოთ. ჩვენ ლოკალურ ცვლადს ვქმნით #-ით - heroForm, რომელსაც ngForm დირექტივს ვუტოლებთ. ასე თემფლეითში იქმნება ცვლადი, რომელიც FormGroup-ის ინსტანციას ინახავს. ღილაკს სწორედ ამ ცვლადში არსებული invalid თვისების მიხედვით ვთიშავთ. ფორმის ჯგუფი არავალიდურია მაშინ, თუ მასში ერთი კონტროლი მაინც არ არის ვალიდური.

თითოეულ ფორმის ელემენტს მივეცით ატრიბუტი required. ამის მიხედვით NgModel ადგენს, რომ თუ ფორმა ცარიელია, მაშინ ის არავალიდურია და სათანადო კონტროლს ანიჭებს invalid თვისებაზე true-ს, ხოლო valid თვისებაზე false-ს. ამ თვისებებს მსგავსი პრინციპით ვიღებთ: ლოკალურ ცვლადს ვქმნით და მას ვუტოლებთ ngModel-ს. ასე ჩვენ ცვლადებში FormControl-ის ისნტანციას ვინახავთ, რომელსაც გარკვეული თვისებები გააჩნია. თუ კონტროლი არავალიდურია, ჩვენ გვინდა რომ თითოეული ველის ქვეშ ამის შესახებ მინიშნება გამოჩნდეს. მაგრამ ეს მინიშნება არ გვინდა, რომ თავიდანვე ხილვადი იყოს, ფორმა კი თავიდან არავალიდურია. სწორედ ამიტომ ასევე ვიყენებთ pristine ცვლადს. ეს ნიშნავს, რომ მომხმარებელს ჯერ არეფერი შეუყვანია ველში. ჩვენ მინიშნება მაშინ გვინდა გამოვაჩინოთ, როცა კონტროლი არავალიდურია და მასში ცვლილება უკვე შეტანილი იყო. pristine-ის საპირისპირო თვისებაც არსებობს – dirty, რომელიც შეგვიძლია გამოვიყენოთ ამ პირველის ნეგაციის მაგივრად.

იმელის ველზე email ატრიბუტის მინიჭება ngModel-ს ანიშნებს, რომ ამ ველში მეილის პატერნი უნდა იყოს, და სანამ ეს პატერნი არ დაფიქსირდება, ფორმა არავალიდური იქნება.

შეჯამება

ამ თავში ჩვენ გამოვიყენეთ template-driven ფორმები მარტივი ფორმის შესაქმნელად, რომელსაც გააჩნია ვალიდაცია: იმის მიხედვით, არის თუ არა შევსებული ველი, ჩვენ ღილაკს ვაუქმებთ, ან ვაჩვენებთ მინიშნებებს. ჩვენ ასევე იმასაც ვაქცევთ ყურადღებას, რომ მინიშნება ტექსტის შეყვანის მცდელობის შემდეგ უნდა გამოჩნდეს. ამ ყველაფერს დირექტივებით და ლოკალური ცვლადებით ვაკეთებთ თემფლეითში, რაც უფრო მოხერხებულია პატარა ფორმებისთვის, მაგრამ ზოგჯერ ეს საკმარისი არ არის.

Reactive Forms

რეაქტიული ფორმების გამოსაყენებლად საჭიროა ReactiveFormsModule-ის დაიმპორტება @angular/forms-დან და მისი იმპორტების სიაში დამატება სათანადო კომპონენტში ან მოდულში.

მარტივი კონტროლი

სასრულველ კომპონენტში, სადაც ფორმა გვექნება, ჩვენ უნდა შევქმნათ კონტროლები. შესაძლებელია თითოეული ველისთვის FormControl ინსტანციის შექმნა. აქ ჩვენ კოპმონენტში ვქმნით ახალ კონტროლს, სადაც მომხმარებლის სახელს შევინახავთ.

import { Component } from "@angular/core";
import { CommonModule } from "@angular/common";
import { ReactiveFormsModule, FormControl } from "@angular/forms";

@Component({
  selector: "app-signup-form",
  standalone: true,
  imports: [CommonModule, ReactiveFormsModule],
  templateUrl: "./signup-form.component.html",
  styleUrl: "./signup-form.component.css",
})
export class SignupFormComponent {
  name = new FormControl("");
}

ფორმა ჩვეულებრივ იწყობა. განსხვავება ის არის, რომ ჩვენ formControl თვისებაზე ახლა property binding-ით ვაწვდით ჩვენი FormControl-ის ინსტანციას.

<form>
  <div class="form-block">
    <label for="name">Name</label>
    <input type="text" id="name" [formControl]="name" />
  </div>
</form>
<div>
  <h3>Result:</h3>
  <div>{{ name.value }}</div>
</div>

დანარჩენს ანგულარი თავისით აგვარებს და კონტროლის თვისებებს მოდიფიკაციას უკეთეს შეყვანილი მნიშვნელობების მიხედვით. შეყვანილ მნიშვნელობის სნეპშოტს ვწვდებით კონტროლზე value თვისებით, რომელსაც გამოვსახავთ თემფლეითში.

კონტროლთა ჯგუფი

ხშირად ჩვენ ფორმაში ერთზე მეტი კონტროლი გვექნება. შესაძლებელია ცალკეული კონტროლების ინსტანციების შექმნა და მათი დაკავშირება ფორმის ველებთან, თუმცა ასევე შესაძლებელია ფორმის კონტროლების დაჯგუფების ერთიანად შექმნაც. ამისთვის ანგულარს აქვს FormBuilder სერვიცი, რომელიც შეგვიძლია კლასში დავაინჯექთოთ.

import { Component, inject } from "@angular/core";
import { CommonModule } from "@angular/common";
import { ReactiveFormsModule, FormControl } from "@angular/forms";

@Component({
  selector: "app-signup-form",
  standalone: true,
  imports: [CommonModule, ReactiveFormsModule],
  templateUrl: "./signup-form.component.html",
  styleUrl: "./signup-form.component.css",
})
export class SignupFormComponent {
  private fb = inject(FormBuilder);

  signupForm = this.fb.group({
    name: [""],
    email: [""],
    password: [""],
  });

  onSubmit() {
    console.log(this.signupForm.value);
  }
}

group მეთოდით და მასში ობიექტის მიწოდებით შეგვიძლია კონტროლთა დაჯგუფების შექმნა. თვისების სახელი იქნება კონტროლის სახელი, ხოლო მათი მნიშვნელობა უნდა იყოს მასივი. მასივში პირველი ელემენტი იქნება მათი საწყისი მნიშვნელობა. მასივის შემდეგი ელემენტები საწყისი მნიშვნელობის შემდეგ უნდა იყვნენ ვალიდატორები. ვალიდატორებზე მოგვიანებით ვისაუბრებთ. ჩვენ აქ კიდევ ორი დამატებითი ველი email და password დავამატეთ.

თემფლეითში ჩვენ ახლა არა ცალკეულ კონტროლებს ვაკავშირებთ ველებთან, არამედ მთლიან ფორმას formGroup თვისებაზე ვაბამთ ჩვენი FormGroup-ის ინსტანციას:

<form [formGroup]="signupForm" (ngSubmit)="onSubmit()">
  <div class="form-block">
    <label for="name">Name</label>
    <input type="text" id="name" formControlName="name" />
  </div>
  <div class="form-block">
    <label for="email">Email</label>
    <input type="email" id="email" formControlName="email" />
  </div>
  <div class="form-block">
    <label for="password">Password</label>
    <input type="password" id="password" formControlName="password" />
  </div>
  <button>Submit</button>
</form>
<div>
  <h3>Result:</h3>
  <div>{{ signupForm.value | json }}</div>
</div>

შემდგომ თითოეულ ველს formControlName-ზე მივაწვდით სათანადო კონტროლის სახელს, რომელიც ჩვენ FormGroup-ზე შევქმენით. ასე კონტროლების ჯგუფი დაკავშირებულია თემფლეითთან. form-ზე onSubmit ივენთზე მოსმენით შეგვიძლია ვირეაგიროთ ფორმის დასაბმითების ივენთზე, რომელიც ავტომატურად აქტიურდება, როცა ფორმის ელემენტის შიგნით არსებულ ღილაკზე დაკლიკება ფიქსირდება. შედეგს უბრალოდ კონსოლში ვლოგავთ. აქვე თემფლეითში მთლიანი ფორმის ჯგუფის მნიშვნელობას გამოვსახავთ. როგორც ხედავთ, ვიღებთ ობიექტს, სადაც key არის კონტროლის სახელი, ხოლო value ამ კონტროლის ველში შეყვანილი მონაცემები.

რეაქტიული ფორმების პლიუსი ის არის, რომ ჩვენ მაქსიმალური კონტროლი გვაქვს ამ ფორმაზე. ჩვენ შეგვიძლია კონტროლებში მნიშვნელობების მოდიფიკაცია ან განსაკუთრებული გზით ვალიდაცია. ჯერ მოდიფიკაციას მივხედოთ.

მნიშვნელობების მანიპულაცია

ვთქვათ გვინდა, რომ სახელის ველს ერთი ღილაკის დაჭერით შევუცვალოთ მნიშვნელობა. მაშინ ჩვენ შევქმნით ღილაკს:

<!-- form ending -->
</form>
<button (click)="onUpdateName()">Update name</button>
<div>
  <h3>Result:</h3>
  <div>{{ signupForm.value | json }}</div>
</div>

რომელზე დაკლიკების შემდეგაც გავააქტიურებთ ფორმის კონტროლზე არსებულ setValue მეთოდს.

  onUpdateName() {
    this.signupForm.controls['name'].setValue('Thelonious Monk');
  }

FormGroup-ს გააჩნია controls ობიექტი, საიდანაც ვიღებთ კონტროლის ინსტანციას, რომელიც name key-ში ინახება. კონტროლზე setValue მეთოდი ცვლის ამ კონტროლის და შესაბამის ველში შეყვანილი მონაცემის მნიშვნელობას.

შესაძლებელია setValue-ს მთლიან ჯგუფზე გამოყენებაც:

  onUpdateName() {
    // this.signupForm.controls['name'].setValue('Thelonious Monk');
    this.signupForm.setValue({
      name: 'John Doe',
      email: 'john@doe.com',
      password: 'badpassword123',
    });
  }

აქ აუცილებელია, რომ ყველა კონტროლს დავუწეროთ მნიშვნელობა, სხვა მხრივ ანგულარ დაქომფაილებაზე უარს იტყვის.

თუ გვინდა რომ ჯგუფში კონკრეტული ველები შევცვალოთ და დანარჩენი ისე დავტოვოთ, როგორც იყო, მაშინ ვიყენებთ patchValue-ს:

  onUpdateName() {
    // this.signupForm.controls['name'].setValue('Thelonious Monk');
    this.signupForm.patchValue({
      name: 'John',
      email: 'John@mail.com',
    });
  }

აქ ჩვენ password გამოვტოვეთ, თუმცა ამის გამო პრობლემა არ შეგვექმნება.

დასაბმითებისას ხშირა ფორმის დაცარიელება ხოლმე. ამისთვის შეგვიძლია ჯგუფზე ან კონტროლებზე reset მეთოდის დაძახება.

  onSubmit() {
    console.log(this.signupForm.value);
    this.signupForm.reset();
  }

ეს მეთოდი დააბრუნებს ფორმას პირვანდელ მნიშვნელობებზე.

კონტროლების მასივი

ზოგჯერ გვაქვს ფორმის ისეთი ნაწილები, რომლებიც დინამიკურად უნდა შეიქმნას და მათთვის კონტროლის სახელი არ გვჭირდება. ვთქვათ ჩვენს სარეგისტრაციო ფორმას გვინდა რომ მომხმარებელმა იმდენი პოზიცია დაუწეროს, რამდენიც მას მოესურვება. მაშინ ჩვენ უნდა შევქმნათ კონტროლების მასივი, FormArray-ს ინსტანცია. შეგვიძლია პირდაპირ ამ კლასის ინსტანცია გამოვიყენოთ, მაგრამ რადგან FormBuilder გვაქვს, მისი მეთოდი გამოვიყენოთ.

export class SignupFormComponent {
  private fb = inject(FormBuilder);

  signupForm = this.fb.group({
    name: [""],
    email: [""],
    password: [""],
    positions: this.fb.array([this.fb.control("")]),
  });

  get positions() {
    return this.signupForm.controls["positions"];
  }

  onSubmit() {
    console.log(this.signupForm.value);
  }

  onUpdateName() {
    // this.signupForm.controls['name'].setValue('Thelonious Monk');
    // this.signupForm.setValue({
    //   name: 'John',
    //   email: 'John@mail.com',
    //   password: 'badpassword123',
    // });
  }

  addPosition() {
    this.positions.push(this.fb.control(""));
  }
}

ჩვენ ცალკე თვისებაშიც შეგვიძლია FormArray-ს შექმნა, მაგრამ უკვე არსებული ჯგუფის თვისების position-ის ქვეშ შევქმნათ ეს მასივი. array მეთოდს მასივში ვატანთ ბილდერით შექმნილ კონტროლს. თავდაპირველად გვქონდეს მხოლოდ ერთი კონტროლი.

აქვე getter მეთოდს ვქმნით რათა positions-ის კონტროლებს უფრო მოკლედ და მარტივად ჩავწვდეთ. ანუ როცა ჩვენ positions-ს გამოვიყენებთ, ეს სინამდვილეში იქნება this.signupForm.controls["positions"]. ჩვენ დაგვჭირდება მეთოდი, რომლის საშუალებითაც ამ მასივს ახალი კონტროლი დაემატება. სწორედ ამას აკეთებს addPosition მეთოდი. იგი არსებული კონტროლების მასივს ახალი კონტროლის ისნტანციას ამატებს. ახლა საჭიროა მისი თემფლეითთან დაკავშირება.

<div class="form-block" formArrayName="positions">
  <div class="positions-title">
    <h3>Positions</h3>
    <button type="button" (click)="addPosition()">+</button>
  </div>
  <input
    type="text"
    *ngFor="let position of positions.controls; let i = index"
    [formControlName]="i"
  />
</div>

გაითვალისწინეთ, რომ positions არის signupForm-ის წევრი. აქ formArrayName-ის საშუალებით რაიმე მშობელ კონტეინერზე სათანადო მასივის სახელს ვუთითებთ. ახლა ანგულარმა იცის, რომ ამ ბლოკში positions ველში არსებულ კონტროლებთან გვაქვს საქმე. ჩვენ NgFor დირექტივით ვლუპავთ პოზიციის კონტროლებზე და თითოეული პოზიციისთვის ვიღებთ ინდექსს, რომელსაც ფროფერთი ბაინდინგით ვაკავშირებთ formControlName-ზე. FormArray-ში კონტროლის სახელი მასივის ინდექსია. + ღილაკზე დაჭერით ახალი კონტროლი იქმნება და შესაბამისად ახალი ველიც გამოჩნდება. მათი მნიშვნელობებს ქვემოთაც დავინახავთ, სადაც ფორმის მნიშვნელობას გამოვსახავთ.

შეჯამება

ამ თავში ჩვენ ვისაუბრეთ რეაქტიულ ფორმებზე, შევქმენით ფორმის კონტროლები ერთ დაჯგუფებად FormBuilder-ის საშუალებით და დავაკავშირეთ ის თემფლეითთან. ჩვენ ასევე განვიხილეთ მეთოდები, რომლითაც შეგვიძლია კონტროლების და მთლიანი ფორმის ჯგუფების მნიშვნელობების მოდიფიკაცია. ჩვენ ასევე განვიხილეთ FormArray სადაც დინამიკურად შევქმენით უსახელო კონტროლები. შემდეგ თავში ჩვენ განვიხილავთ რეაქტიული ფორმების ვალიდაციას.

ვალიდაცია და ვალიდატორები

რეაქტიულ ფორმებში ფორმების ვალიდაციაზე საკმაოდ დიდი კონტროლი გვაქვს. ვალიდატორები ერთგვარი ფუნქციებია, რომლებიც ან ვალიდაციის ერორს იწვევენ, ან აბრუნებენ null-ს, რაც წარმატებულ ვალიდაციას ნიშნავს. ამის მიხედვით ფორმის კონტროლებს ეცვლებათ თვისება valid და invalid. გარდა ამისა, თითოეული ვალიდატორი უნიკალურ მნიშვნელობას აძლევს კონტროლის ინსტანციაზე errors თვისებას, რომელის შეგვიძლია ვალიდაციის მესიჯებისთვის გამოვიყენოთ. შესაძლებელია წინასწარ შექმნილი ვალიდატორების გამოყენება ან სულაც ახალი ვალიდატორის შექმნა.

ამ თავში ვისწავლით:

ჩაშენებული ვალიდატორები

ჯერ განვიხილოთ ანგულარში არსებული ვალიდატორები. ჩვენი პროექტი ასე გამოიყურება: გვაქვს შექმნილი SignupFormComponent, სადაც გვაქვს მარტივი კონტროლების ჯგუფი, რომელიც დაკავშირებულია თემფლეითთან.

import { Component } from "@angular/core";
import { CommonModule } from "@angular/common";
import { ReactiveFormsModule, FormBuilder } from "@angular/forms";

@Component({
  selector: "app-signup-form",
  standalone: true,
  imports: [CommonModule, ReactiveFormsModule],
  templateUrl: "./signup-form.component.html",
  styleUrls: "./signup-form.component.css",
})
export class SignupFormComponent {
  signupForm = this.fb.group({
    name: [""],
    email: [""],
    password: [""],
  });

  constructor(private fb: FormBuilder) {}

  onSubmit() {
    console.log(this.signupForm.value);
  }
}
<form [formGroup]="signupForm" (ngSubmit)="onSubmit()">
  <div class="form-block">
    <label for="name">Name</label>
    <input type="text" id="name" formControlName="name" />
  </div>
  <div class="form-block">
    <label for="email">Email</label>
    <input type="email" id="email" formControlName="email" />
  </div>
  <div class="form-block">
    <label for="password">Password</label>
    <input type="password" id="password" formControlName="password" />
  </div>
  <button type="submit">Submit</button>
</form>
<div>
  <h3>Result:</h3>
  <div>{{ signupForm.value | json }}</div>
</div>

ვალიდატორებს ჩვენ ფორმის კონტროლის შექმნისას ვარეგისტრირებთ. ვაიმპორტებთ Validators კლასს, და აქედან ვარჩევთ სასურველ ვალიდატორს:

signupForm = this.fb.group({
  name: ["", Validators.required],
  email: ["", [Validators.required, Validators.email]],
  password: ["", Validators.required],
});

მასივში პირველი ელემენტის შემდეგ შეგვიძლია ვალიდატორის დამატება. აქვე გზად email კონტროლს email ვალიდატორიც მივცეთ. თუ ვალიდატორი ერთზე მეტია, ისინი ცალკე მასივში უნდა მოთავსდნენ. ახლა, ყოველ ცვლილებაზე კონტროლებში, ანგულარი ამ ვალიდატორებით შეამოწმებს კონტროლების ვალიდურობას და მთლიან ჯგუფს მიანიჭებს valid და invalid თვისებებზე რაიმე ბულიან მნიშვნელობას. required ვალიდატორი საჭიროებს რომ ველი არ იყოს ცარიელი. იმეილის ვალიდატორი საჭიროებს იმეილის პატერნს.

საპასუხოდ შეგვიძლია ღილაკი ჩავაქროთ disabled თვისებაზე ბაინდინგით, თუკი მთლიანი ჯგუფი არის invalid. თუ ერთი კონტროლი მაინც არ არის ვალიდური, მთლიანი ჯგუფიც არავალიდურია.

<button type="submit" [disabled]="signupForm.invalid">Submit</button>

არის უფრო ზოგადი ხასიათის ვალიდატორიც, სახელად pattern. მასში მივუთითოთ ის სასურველი პატერნი, რომელსაც მომხმარებლის შეყვანილი ინფორმაცია უნდა ექვემდებარებოდეს. მასში შეგვიძლია სტრინგის მიწოდება, და შედეგად შეყვანილი ინფორმაცია უნდა შეიცავდეს ამ სტრინგს. ასევე შეგვიძლია ამ ვალიდატორში რეგექსის გამოყენება.

signupForm = this.fb.group({
  name: ["", [Validators.required, Validators.pattern(/^[A-z]/)]],
  email: ["", [Validators.required, Validators.email]],
  password: ["", Validators.required],
  confirmPassword: ["", Validators.required],
});

სახელს ვაძლევთ ვალიდატორს, სადაც მხოლოდ ისეთ ტექსტს დავუშვებთ, რომელიც არ იწყება რიცხვით ან სხვა განსაკუთრებული სიმბოლოთი, გარდა უბრალო ასოებისა.

ასევე არსებობს minLength/maxLength ვალიდატორებიც, რომლებიც სტრინგის მინიმალურ და მაქსიმალურ ზომას გულისხმობენ.

name: ["", [Validators.required, Validators.pattern(/^[A-z]/), Validators.minLength(2)]],

აქ მივუთითებთ, რომ სახელი ორ ასოზე მოკლე არ უნდა იყოს.

errors თვისება

ვალიდაციის ერორის არსებობის შემთხვევაში თითოეული ვალიდატორი უნიკალურ თვისებას ამატებს კონტროლის ინსტანციის errors თვისებაზე. მაგალითად, Validators.minLength ვალიდატორს, არავალიდურობის შემთხვევაში, შეუძლია კონტროლს მიანიჭოს controls.errors.minLength თვისებაზე ობიექტი. ვალიდატორის სახელი ემთხვევა ერორის თვისების სახელს. ობიექტის თვისებები დამოკიდებულია თვითონ ვალიდატორზე, ამიტომ უშუალოდ ამ ვალიდატორის დოკუმენტაციას ან იმპლემენტაციას უნდა ჩახედოთ.

ვალიდატორის შექმნა

ვთქვათ არ გვინდა რომ სახელი რაიმე სიტყვას შეიცავდეს. შევქმნათ ამისთვის ჩვენი ვალიდატორი.

კლასში შევქმნათ შემდეგი ფუნქცია:

  badNameValidator(pattern: string): ValidatorFn {
    return (control: AbstractControl): ValidationErrors | null => {
      return control.value.includes(pattern)
        ? { badName: 'This is a bad name!' }
        : null;
    };
  }

აქ ValidatorFn ტიპის ფუნქციას ვქმნით, რომლის პარამეტრიც იქნება ის პატერნი, რომელსაც ჩვენ არგუმენტად მივაწვდით ვალიდაციის დამატებისას. ჩვენ ვაბრუნებთ ანონიმურ ფუნქციას, რომელსაც ანგულარი კონფიგურაციას გაუკეთებს. მისი პარამეტრი უნდა იყოს AbstractControl ტიპის. ეს სწორედ ის კონტროლი იქნება, რომელზეც ჩვენ ამ ვალიდატორს დავაყენებთ. ასე ჩვენ ამ კონტროლზე გვაქვს წვდომა. ვალიდატორის ფუნქცია უნდა აბრუნებდეს ValidationErrors, რომელიც პირობითი ობიექტია, ან null, ამ უკანასკნელს იმ შემთხვევაში, თუ კონტროლი ვალიდურია. თუ კონტროლის მნიშვნელობა ამ პატერნს შეიცავს, ჩვენ ვაბრუნებთ ობიექტს, სხვა შემთხვევაში null-ს ობიექტის სტრუქტურია პირობითია. ჯობია თუ თვისების სახელი დაემთხვევა ფუნქციის სახელს, მინუს Validator. მისი მნიშვნელობა ის იქნება, რაც მოგვესურვება. ამ შემთხვევაში ეს იყოს მესიჯი, რომელიც შეგვიძლია შემდგომ თემფლეითში გამოვსახოთ.

დავამატოთ ეს ვალიდატორი კონტროლზე

signupForm = this.fb.group({
  name: [
    "",
    [
      Validators.required,
      Validators.pattern(/^[A-z]/),
      Validators.minLength(2),
      this.badNameValidator("badname"),
    ],
  ],
  email: ["", [Validators.required, Validators.email]],
  password: [
    "",
    Validators.required,
    this.matchingPasswordsValidator(this.confirmPassword),
  ],
});

ახლა ვალიდატორი იმუშავებს, ღილაკი გამქრალი იქნება თუ სახელი შეიცავს არასასურველ პატერნს.

ვალიდატორის მიერ დაბრუნებულ ობიექტში არსებული თვისება badName, კონტროლის არავალიდურობის შემთხვევაში, ხელმისაწვდომი იქნება signupForm.controls.name.errors.badName თვისებაზე.

ვალიდაციის მესიჯები

ახლა განვათავსოთ მესიჯები. ჯერ კლასსში შევქმნათ გეთერი, რომ თითოეულ კონტროლს უფრო მოკლედ ჩავწვდეთ:

  get controls() {
    return this.signupForm.controls;
  }

შემდეგ თითოეული კონტროლის მიხედვით განვათავსოთ მესიჯი.

<form [formGroup]="signupForm" (ngSubmit)="onSubmit()">
  <div class="form-block">
    <label for="name">Name</label>
    <input type="text" id="name" formControlName="name" />
    <span *ngIf="controls['name'].invalid && controls['name'].dirty">
      Must provide a proper name!
    </span>
    <span *ngIf="controls['name']?.errors?.['badName']">
      {{ controls['name'].errors?.['badName']}}
    </span>
  </div>
  <div class="form-block">
    <label for="email">Email</label>
    <input type="email" id="email" formControlName="email" />
    <span *ngIf="controls['email'].invalid && controls['email'].dirty">
      Must provide a proper email!
    </span>
  </div>
  <div class="form-block">
    <label for="password">Password</label>
    <input type="password" id="password" formControlName="password" />
    <span *ngIf="controls['password'].invalid && controls['password'].dirty">
      Password is required!
    </span>
  </div>
  <button type="submit" [disabled]="signupForm.invalid">Submit</button>
</form>
<div>
  <h3>Result:</h3>
  <div>{{ signupForm.value | json }}</div>
</div>

თუ კონტროლი არ არის ვალიდური, ჩვენ მესიჯი უნდა გამოვაჩინოთ, მაგრამ ასე მესიჯი თავიდანვე ხილვადი იქნება. ჩვენ მომხმარებელს ჯერ უნდა ვაცადოთ ინფორმაციის შეყვანა. ამიტომ ასევე უნდა დავრწმუნდეთ, რომ არავალიდურობასთან ერთად, მომხმარებელი უკვე შეხებია და ცვლილება შეუტანია ამ ველში. სწორედ ამაზე მიუთითებს dirty თვისება. მისი საპირისპირო თვისებაა pristine.

აქვე სახელის ველში კონტროლის ერორებს ვწვდებით. ეს ერორები მაშინ ჩნდება, როცა ვალიდატორებში ვაბრუნებთ ერორებს. ცუდი სახელის პატერნის შემთხვევვაში ეს ერორი იარსებებს და შესაბამისად ჩვენ მას შეგვიძლია ჩავწვდეთ და ის მესიჯი გამოვსახოთ, რომელიც ვალიდატორის ფუნქციაში შევქმენით. ? გვჭირდება ყოველი თვისების წინ, რადგან შესაძლოა ისინი არ არსებობდნენ.

შეჯამება

ამ თავში ჩვენ ვისწავლეთ ანგულარის ვალიდატორების შესახებ, შევქმენით ჩვენი ვალიდატორიც და გამოვსახეთ ვალიდაციის მესიჯები.

Routing

Single page application-ში ჩვენ მომხმარებლის გვერდებს ვცვლით არა ასერვერიდან ახალი გვერდის გამოგზავნით, არამედ აპლიკაციის რაღაც ნაწილების გამოჩენით და გაქრობით. ეს იმიტომ ხდება, რომ SPA-ს აზრი არის ერთი html გვერდის ჩამოტვირთვა კოდით, რომელიც მეხსიერებაში შეინახება და სწრაფად იმუშავებს. როცა მომხმარებელი რაღაც მოქმედებებს აწარმოებს, ჩვენ გვჭირდება სხვადასხვა განსაზღვრულ view-ებზე გადაადგილება.

ერთი view-დან მეორეზე ნავიგაცია შესაძლებელია ანგულარის Router-ით. Router ინტერპრეტაციას უკეთებს ბრაუზერის URL-ს როგორც ინსტრუქციას, რომ შეიცვალოს view.

ამ თავში ვისწავლით:

მარტივი routing

შევქმნათ ახალი ანგულარის აპლიკაცია და ორი მთავარი კომპონენტი, რომელზეც გადავინაცვლებთ url-ის შეცვლით.

ng generate component first
ng generate component second

ახლა ისე ვქნათ, რომ /first მისამართზე გამოვაჩინოთ FirstComponent, ხოლო /second მისამართზე - SecondComponent.

თუ დააკვირდებით, ანგულარის ვორქსფეისში უნდა გვქონდეს app.routes.ts ფაილი, სადაც ჩვენ რაუთინგის კონფიგურაცია შეგვიძლია. სწორედ აქ დავაიმპორტებთ ჩვენ კომპონენტებს.

import { Routes } from "@angular/router";
import { FirstComponent } from "./first/first.component";
import { SecondComponent } from "./second/second.component";

export const routes: Routes = [];

routes არის ცვლადი, სადაც ჩვენ რაუთების კონფიგურაციას შევინახავთ. ეს მასივი არის Routes ტიპის. ეს ცვლადი უნდა დავაექსპორტოთ რათა ის გადავცეთ აპლიკაციის კონფიგურაციას, რომელიც ამ რაუთინგის კონფიგურაციას აამუშავებს.

app.config.ts-ში პროვაიდერების მასივში არის ჩასმული provideRouter ფუნქცია, რომელიც იღებს ჩვენთვის უკვე ნაცნობ routes ცვლადს. ამ კოდს ანგულარის CLI წინასწარ გვიმზადებს, თუმცა თუ აპლიკაცია CLI-ით არ შეგვიქმნია, ეს ხელით უნდა დავწეროთ.

import { ApplicationConfig } from "@angular/core";
import { provideRouter } from "@angular/router";

import { routes } from "./app.routes";

export const appConfig: ApplicationConfig = {
  providers: [provideRouter(routes)],
};

დავუბრუნდეთ app.routes.ts-ს და რაუთები განვსაზღვროთ.

import { Routes } from "@angular/router";
import { FirstComponent } from "./first/first.component";
import { SecondComponent } from "./second/second.component";

const routes: Routes = [
  { path: "first", component: FirstComponent },
  { path: "second", component: SecondComponent },
];

routes-ის მასივში ვამატებთ ობიექტებს, სადაც შეგვიძლია შემდეგი თვისებების განსაზღვრა:

  • path: მისამართი, რომელზეც უნდა ჩავტვირთოთ სასურველი კომპონენტი. მიაქციეთ ყურადღება, რომ აქ თავიდან დახრილ ხაზს არ ვწერთ, თუმცა საუბარი გვაქვს სწორედ /first მისამართზე.
  • component: აქ მივუთითებთ იმ კომპონენტის კლასს, რომელიც გვინდა რომ მოცემულ მისამართზე ჩაიტვირთოს.

ახლა გვჭირდება AppComponent-ის თემფლეითში router-outlet-ის განთავსება. ასე ანგულარს ეცოდინება რომ აქ უნდა მისამართის მიხედვით განვათავსოთ კომპონენტები.

<router-outlet></router-outlet>

რადგან router-outlet standalone კომპონენტია, ის უნდა AppComponent-ის იმპორტების მასივში დავამატოთ:

import { RouterOutlet } from "@angular/router";

@Component({
  standalone: true,
  imports: [RouterOutlet],
  /* ... */
})
export class AppComponent {}

ახლა თავიდან ბრაუზერში არაფერი გამოჩნდება, მაგრამ თუ ხელით შევცვლით მისამართს http://localhost:4200/first-ზე ან http://localhost:4200/second-ზე, სათანადო კომპონენტების მარკაპს დავინახავთ.

რა თქმა უნდა, ამის ხელით შეცვლა არ გვინდა. უნდა არსებობდეს ნავიგაციის ლინკები. AppComponent-ში შევქმნათ მინიმალური ჰედერი ნავიგაციით.

<header>
  <nav>
    <ul>
      <li><a routerLink="/first" routerLinkActive="active">First</a></li>
      <li><a routerLink="/second" routerLinkActive="active"> Second</a></li>
    </ul>
  </nav>
</header>
<router-outlet></router-outlet>

routerLink არის ანგულარის დირექტივი, როგორც ერთგვარი href-ის ალტერნატივა. კომპონენტის იმპორტებში საჭირო იქნება RouterLink-ის დამატება @angular/router-დან. თუ ანგულარის რაუტერიდან კომპონენტში ბევრ სხვადასხვა კლასს ვიყენებთ, შეგვიძლია პირდაპირ RouterModule-ის შემოტანა, რათა ყველა დირექტივი, კომპონენტი თუ პროვაიდერი ერთიანად ხელმისაწვდომი გახდეს:

import { RouterModule } from "@angular/router";
@Component({
  imports: [RouterModule],
})

routerLink-ში ყურადღება მიაქციეთ, რომ მისამართები /-ით იწყება. ამით ვაზუსტებთ, რომ გვინდა root მისამართს + /first ლინკზე გადასვლა და არა მიმდინარე მისამართს + first. ეს არის ის მისამართები, რომლებიც როუთის კონფიგურაციაში მივუთითეთ path თვისებაზე.

ჰედერი ერთ ადგილას რჩება, ხოლო მის ქვეშ, აუთლეტის საშუალებით, კომპონენტები იცვლება routerLink-ის წყალობით. აქვე შეგვიძლია გამოვიყენოთ routerLinkActive თვისება, რომელსაც ვუწერთ ერთ ან მეტ კლასის სახელს რომელიც ამ ელემენტს უნდა მიენიჭოს, როცა მომხმარებელი ამ მისამართზეა. ჩვენ ელემენტებს active კლასი მიენიჭებათ.

დავუბრუნდეთ app.routes.ts-ს და ჩვენი როუთების სიას. ვთქვათ გვსურს, მომხმარებლის გადამისამართება: როცა მომხმარებელი აპლიკაციას ხსნის, გვინდა რომ ის გადავამისამართოთ /first გვერდზე.

export const routes: Routes = [
  { path: "first", component: FirstComponent },
  { path: "second", component: SecondComponent },
  { path: "", redirectTo: "first", pathMatch: "full" },
];

ჩვენ ახლა მივუთითებთ, რომ მთავარ path-ზე, მომხმარებელი უნდა გადავამისამართოთ first-ზე. რადგან first როუთი უკვე დარეგისტრირებული გვაქვს, მისი კომპონენტი ჩაიტვირთება. pathMatch გულისხმობს path-ის რა ნაწილი უნდა ემთხვეოდეს მიმდინარე მისამართზე. ვინაიდან ჩვენ აქ ცარიელ სტრინგს მივუთითებთ, რაც მთავარი გვერდია, pathMatch-ის full-ზე დაყენების გარეშე ანგულარი ნებისმიერი მისამართიდან გადაგვიყვანდა first-ზე თუკი ის path-ში მითითებულ პატერნს შეიცავს. ცარიელ სტრინგს ყველა მისამართი შეიცავს, ამიტომ ეს პრობლემური იქნებოდა. pathMatch: "full" -ით ვამბობთ, რომ თუკი მთლიანი მისამართი არის "", მაშინ მოხდეს გადამისამართება.

ახლა, როგორც კი აპლიკაციას გავხსნით, მაშინვე /first-ზე ვიქნებით.

გაითვალისწინეთ, რომ როუთების თანმიმდევრობას მნიშვნელობა აქვს. ჩვენ უფრო სპეციფიკური მისამართები უნდა მოვათავსოთ თავში, და მას უნდა მოჰყვეს ნაკლებად სპეციფიკური მისამართები. მაგალითად, ჯერ first/hello და შემდეგ first. ეს იმიტომ არის საჭირო, რომ ანგულარი პრიოერიტეტს ანიჭებს იმ მისამართს, რომელიც მას მასივში პირველად შეხვდება.

Wildcard Route

ზოგჯერ გვაქვს ისეტი მისამართი, რომელზეც არანაირი კომპონენტის ჩატვირთვა არ გვაქვს გათვლილი. ამ დროს მომხმარებელს უნდა ვაცნობოთ რომ გვერდი ვერ მოიძებნა. ამისათვის შეგვიძლია შევქმნათ page-not-fount კომპონენტი:

ng g c page-not-found

და მის თემფლეითში რაღაც ასეთი დავწეროთ:

<h1>Page Not Found</h1>

ახლა რაუთინგის კონფიგურაციაში აღვნიშნოთ, რომ ყველა რაუთზე ჩაიტვირთოს ეს კომპონენტი:

export const routes: Routes = [
  { path: "first", component: FirstComponent },
  { path: "second", component: SecondComponent },
  { path: "", redirectTo: "first", pathMatch: "full" },
  { path: "**", component: NotFoundComponent },
];

** გულისხმობს ე.წ wildcard რაუთს. ანგულარის რაუთერი მასივში ზემოდან ქვემოთ ჩამოუყვება მომხმარებლის მიერ გახსნილ რაუთს და შეამოწმებს თუ რომელი ჩვენ მიერ განსაზღვრული path ემთხვევა მას. თუ არც ერთი path არ დაემთხვა, უკანასკნელი ვარიანტი, რომელიც ** არის მას გარანტირებულად დაიჭერს და NotFoundComponent-ს გახსნის.

Lazy Loading

ზოგჯერ რესურსების დასაზოგად საშუალება გვაქვს რომ კომპონენტის კოდი იქამდე არ ჩავტვირთოთ ბრაუზერში, სანამ მომხმარებელი ამ კომპონენტისთვის საჭირო რაუთზე არ გადაინაცვლებს, ანუ ჩვენ შეგვიძლია კომპონენტების “ზარმაცად ჩატვირთვა”. ამისთვის ვიყენებთ loadComponent თვისებას და ჯავასკრიპტისimport ფუნქციით ვაიმპორტებთ სათანადო მისამართიდან კომპონენტს, რომელსაც ფრომისის ფორმით ვაბრუნებთ. import ფუნქცია შემოიტანს ნეიმსფეისს, სადაც კომპონენტის კლასი მისი ერთ-ერთი თვისებაა, ამიტომ ამ კლასის გამოტანა მოგვიწევს then მეთოდით:

export const routes: Routes = [
  {
    path: "first",
    loadComponent: () =>
      import("./first/first.component").then((m) => m.FirstComponent),
  },
  {
    path: "second",
    loadComponent: () =>
      import("./second/second.component").then((m) => m.SecondComponent),
  },
  { path: "", redirectTo: "first", pathMatch: "full" },
  { path: "**", component: NotFoundComponent },
];

ასე კომპონენტები მხოლოდ კონკრეტული მისამართების მიხედვით დაიმპორტდებიან.

გასათვალისინებელია, თუ როგორ არის დაექსპორტებული კომპონენტის კლასი. თუ ჩვენ კლასს დავაექსპორტებთ default ქივორდით:

export default class FirstComponent {}

მაშინ აღარ დაგვჭირდება then მეთოდი, რათა ნეიმსფეისიდან ამოვიღოთ კომპონენტის კლასი. import-ის დაბრუნებული შედეგი პირდაპირ ჩვენთვის სასურველი კლასი იქნება:

loadComponent: () => import("./first/first-component");

შეჯამება

ამ თავში ჩვენ ვისაუბრეთ მარტივ როუთინგზე. ანგულარში გვაქვს საშუალება, რომ მისამართის ცვლილებებზე რეაგირება მოვახდინოთ და შევცვალოთ view, ანუ ის კომპონენტები გამოვსახოთ ან გავაქროთ. ჩვენ როუთებს ვარეგისტრირებთ Routes ტიპის ცვლადში, სადაც ვუთითებთ, რა მისამართზე რა კომპონენტი უნდა ჩაიტვირთოს. იმ კომპონენტში, სადაც გვინდა რომ view-ები შეიცვალოს, ვათავსებთ router-outlet ელემენტს, სადაც ანგულარი მისამართის მიხედვით განათავსებს სათანადო კომპონენტებს. ჩვენ ასევე საშუალება გვაქვს, რომ კომპონენტები ზარაცად ჩავტვირთოდ მხოლოდ მაშინ, როცა ისინი მომხმარებელს სჭირდება, loadComponent თვისების და import ფუნქციის დახმარებით.

Child Routing

მისამართის ცვლილება არ ხდება მხოლოდ ზედა დონეზე. არის შემთხვევები, როცა გვინდა კონკრეტული მისამართის შიგნით ნავიგაცია. აქ, წინასწარ გამზადებულ აპლიკაციაში გვაქვს ორი კომპონენტი: FirstComponent LastComponent და ისინი სათანადო მისამართებზე იტვირთება, როგორც ეს როუთინგის კონფიგურაციაშია:

app.routes.ts-ში:

import { Routes } from "@angular/router";
import { FirstComponent } from "./first/first.component";
import { SecondComponent } from "./second/second.component";

export const routes: Routes = [
  { path: "first", component: FirstComponent },
  { path: "second", component: SecondComponent },
  { path: "", redirectTo: "first", pathMatch: "full" },
];

router-outlet გვაქვს განთავსებული AppComponent-ში. აქვე გვაქვს ჰედერი, საიდანაც ვაკეთებთ ნავიგაციას:

<header>
  <nav>
    <ul>
      <li><a routerLink="/first" routerLinkActive="active">First</a></li>
      <li><a routerLink="/second" routerLinkActive="active"> Second</a></li>
    </ul>
  </nav>
</header>
<router-outlet></router-outlet>

შენიშვნა: არ დაგავიწყდეთ კომპონენტში RouterModule-ის შემოტანა.

ახლა ვთქვათ გვინდა, რომ Second კომპონენტის შიგნით შევძლოთ კიდევ სხვადასხვა კომპონენტზე ნავიგაცია. შევქმნათ ორი კომპონენტი SecondComponent-ის შიგნით.

ng g c second/child-one
ng g c second/child-two

ამ კომპონენტებს უკვე აქვთ თემფლეითში მარკაპი, რომ შედეგი დავინახოთ. ახლა კონფიგურაციას მივხედოთ app.routes.ts-ში:

import { Routes } from "@angular/router";
import { FirstComponent } from "./first/first.component";
import { ChildOneComponent } from "./second/child-one/child-one.component";
import { ChildTwoComponent } from "./second/child-two/child-two.component";
import { SecondComponent } from "./second/second.component";

export const routes: Routes = [
  { path: "first", component: FirstComponent },
  {
    path: "second",
    component: SecondComponent,
    children: [
      { path: "child-one", component: ChildOneComponent },
      { path: "child-two", component: ChildTwoComponent },
    ],
  },
  { path: "", redirectTo: "first", pathMatch: "full" },
];

იმ როუთისთვის, რომლის შიგნითაც გვინდა დამატებითო როუთების შექმნა, ვწერთ თვისება childern-ს სადაც გვექნება იგივე როუთის ობიექტების მასივი. აქ უკვე ჩვენ ახალ შექმნილ კომპონენტებს მივუთითებთ. დააკვირდით, რომ აქ პირდაპირ second როუთის შემდეგ რა მისამართი უნდა მოვიდეს იმას ვწერთმ და არა პირდაპირ second/child-one-ს, თუმცა ბრაუზერის მისამართში ეს სწორედ ასე იქნება.

ახლა SecondComponent-ში შევქმნათ ნავიგაციის ლინკები და, რა თქმა უნდა, router-outlet.

<p>second works!</p>
<nav>
  <ul>
    <li><a routerLink="child-one">Child One</a></li>
    <li><a routerLink="child-two">Child Two</a></li>
  </ul>
</nav>
<router-outlet></router-outlet>

დააკვირდით, რომ ახლა ვიყენებთ ფარდობით მისამართებს, ისინი არ იწყებიან /-ით. ეს ნიშნავს, რომ მიმდინარე მისამართის შიგნით მოხდება ნავიგაცია routerLink-ში ნაწილზე. ანუ შედეგად გვექნება second/child-one და second/child-two. თუ მას წინ დახრილ ხაზს დავუწერდით, ეს იქნებოდა გლობალური მისამართი, ანუ localhost:4200/child-one, რომელიც ჩვენ პროექტში არ არსებობს.

ახლა second მისამართის შიგნით child routing უნდა მუშაობდეს.

რამდენიმე რაუთის ერთდროულად ზარმაცად ჩატვირთვა

ჩვენ შეგვიძლია მთლიანი რაუთების კონფიგურაცია ჩავტვირთოთ ზარმაცად. წარმოვიდგინოთ რომ პროექტში გვაქვს ფოლდერი admin სადაც ინახება ადმინისტრატორის გვერდის კომპონენტები. ესენია: admin-home.component.ts, სადაც ადმინისტრატორის მთავარი გვერდია (/admin/home) და admin-users.component.ts, სადაც ადმინისტრატორი მომხმარებლებს მართავს (/admin/users). ამავე ფოლდერში შეიძლება გვქონდეს რაუთების კონფიგურაცია:

// admin/admin.routes.ts

export const ADMIN_ROUTES: Route[] = [
  { path: "home", component: AdminHomeComponent },
  { path: "users", component: AdminUsersComponent },
  // ...
];

მაშინ შეგვიძლია მთავარი რაუთების კონფიგურაციის ობიექტში (app.routes.ts-ში) ის შემოვიტანოთ loadChildren თვისების ქვეშ:

// app.routes.ts
export const routes: Route[] = [
  {
    path: "admin",
    loadChildren: () =>
      import("./admin/routes").then((mod) => mod.ADMIN_ROUTES),
  },
  // ...
];

Dynamic Routes

ამ თავში განვიხილავთ დინამიკურ რაუთებს, რომლის საშუალებითაც შესაძლებელია მისამართიდან მონაცემების, როგორც პარამეტრების აღება და მათი გამოყენება აპლიკაციაში.

დინამიკური რაუთების ორი ძირითადი სახეობა გვაქვს:

Route Params

რაუთის რაღაც ნაწილები ხშირად დინამიკურია. მაგალითად შეიძლება მისამართი შეიცავდეს პროდუქტის აიდის, რომლის საფუძველზეც აპლიკაციამ უნდა სათანადო პროდუქტის დეტალები გამოსახოს. ამ მაგალითში სწორედ ასეთ სცენარს განვიხილავთ.

წინასწარ გვაქვს გამზადებული აპლიკაცია როუთინგით. app.component.html-ში გვაქვს router-outlet.

app.routes.ts-ში გვაქვს ასეთი კონფიგურაცია:

import { Routes } from "@angular/router";
import { ProductDetailsComponent } from "./product-details/product-details.component";
import { ProductsComponent } from "./products/products.component";

export const routes: Routes = [
  { path: "products", component: ProductsComponent },
  { path: "products/:id", component: ProductDetailsComponent },
  { path: "", redirectTo: "products", pathMatch: "full" },
];

ეს ორი გვერდი დანიშნულებით განსხვავდება. პირველი გვერდი შეიცავს პროდუქტების სიას, ხოლო მეორე გვერდი ერთი კონკრეტული პროდუქტის დეტალებს. თუ რომელ პროდუქტს გამოსახავს, განსაზღვრული იქნება id-ის მიხედვით. მის path თვისებაში სწორედ ამიტომ გვიწერია :id ეს მიუთითებს დინამიკურ პარამეტრზე. მაგალითად ჩვენ უნდა შევძლოთ products/123 გვერდზე გადასვლა და იქ იმ პროდუქტის ნახვა, რომლის აიდიც არის 123.

პროდუქტის მოდელი ასე გამოიყურება:

product.model.ts

export interface Product {
  id: number;
  name: string;
  description: string;
  image: string;
  price: number;
}

პროდუქტს უნდა ჰქონდეს აიდი, სახელი, აღწერა, სურათი და ფასი. ასეთი ტიპის პროდუქტების სია და მათი აიდის მიხედვით მოპოვების ლოგიკა გვაქვს products.service.ts-ში:

import { Injectable } from "@angular/core";
import { Product } from "./product.model";

@Injectable({
  providedIn: "root",
})
export class ProductsService {
  private products: Product[] = [
    {
      id: 0,
      name: "Lenovo ThinkPad T14",
      price: 899,
      description:
        'Gen 2 14" FHD (Intel 4-Core i5-1135G7, 16GB RAM, 512GB SSD, UHD Graphics) IPS Business Laptop, Backlit, Fingerprint, 2 x Thunderbolt 4, Webcam, 3-Year Warranty, Windows 11 Pro ',
      image: "https://example.com",
    },
    {
      id: 1,
      name: "Dell XPS 13 9310",
      price: 1200,
      description:
        "Touchscreen Laptop - 13.4-inch UHD+ Display, Thin and Light, Intel Core i5-1135G7, 8GB LPDDR4x RAM, 512GB SSD, Intel Iris Xe, Killer Wi-Fi 6 with Dell Service, Win 11 Home - Silver ",
      image: "https://example.com",
    },
    {
      id: 2,
      name: 'HP Envy 17.3"',
      price: 1246,
      description:
        "FHD Touchscreen Laptop, Intel Core i7-1165G7, 64GB RAM, 2TB SSD, Backlit Keyboard, Intel Iris Xe Graphics, Fingerprint Reader, Webcam, Windows 11 Pro, Silver, 32GB USB Card ",
      image: "https://example.com",
    },
  ];

  getAllProducts() {
    return this.products;
  }

  getProductById(id: number) {
    return this.products.find((product) => product.id === id);
  }
}

ჩვენ აქ ვინახავთ პროდუქტების მასივს, რომლებსაც აქვთ უნიკალური აიდი. ჩვენ საშუალება გვაქვს რომ ყველა პროდუქტი ერთიანად ავიღოთ, ან მხოლოდ ერთი პროდუქტი აიდის მიხედვით.

ამ სერვისს იყენებს ჩვენი ორი კომპონენტი. ProductsComponent უბრალოდ იღებს ამ პროდუქტების მასივს და თემფლეითში განათავსებს:

import { Component } from "@angular/core";
import { CommonModule } from "@angular/common";
import { ProductsService } from "../products.service";
import { RouterLink } from "@angular/router";

@Component({
  selector: "app-products",
  standalone: true,
  imports: [CommonModule, RouterLink],
  templateUrl: "./products.component.html",
  styleUrls: ["./products.component.css"],
})
export class ProductsComponent {
  constructor(public productsService: ProductsService) {}
}
<div class="container">
  <ul>
    <li
      class="product"
      *ngFor="let product of productsService.getAllProducts()"
    >
      <h3>{{ product.name }}</h3>
      <img [src]="product.image" [alt]="product.name" />
      <p>{{ product.price | currency }}</p>
      <a routerLink="/products/{{ product.id }}">Details</a>
    </li>
  </ul>
</div>

ჩვენ უბრალოდ NgFor დირექტივით განვათავსებთ სერვისიდან აღებულ პროდუქტებს და ასევე მათთვის ვქმნით სანავიგაციო ლინკებს routerLink-ით (კომპონენტში საჭიროა RouterLink დირექტივის დაიმპორტება). თითოეული პროდუქტის ლინკს ექნება ექნება თავისი აიდი, path-ის იმ ადგილას, სადაც როუთის კონფიგურაციაში :id გვიწერია.

პროდუქტის ლინკზე დაჭერა გადაგვიყვანს products/:id მისამართზე, მაგრამ პროდუქტები ჯერ არ გამოჩნდება. როგორმე ProductDetails კომპონენტში ჩვენ უნდა ჩავწვდეთ როუთის პარამეტრებს და ამის მიხედვით მოვიძიოთ კონკრეტული პროდუქტი.

import { Component } from "@angular/core";
import { CommonModule } from "@angular/common";
import { ActivatedRoute } from "@angular/router";
import { Product } from "../product.model";
import { ProductsService } from "../products.service";

@Component({
  selector: "app-product-details",
  standalone: true,
  imports: [CommonModule],
  templateUrl: "./product-details.component.html",
  styleUrls: ["./product-details.component.css"],
})
export class ProductDetailsComponent {
  product!: Product;

  constructor(
    private productsService: ProductsService,
    private route: ActivatedRoute
  ) {
    this.route.params.subscribe((params) => {
      const product = this.productsService.getProductById(+params["id"]);
      if (product) {
        this.product = product;
      }
    });
  }
}

ჩვენ პროდუქტს შევინახავთ product ცვლადში. კონსტრუქტორში ProductsService-ის გარდა ვაინჯექთებთ ActivatedRoute, სწორედ მისი დახმარებით ვიღებთ ინფორმაციას აქტიურ როუთზე კონსტრუქტორის სხეულშივე აღვწერთ მონაცემების აღების ლოგიკას. კონსტრუქტორში ჩაწერილი ლოგიკა აქტიურდება კომპონენტის ინიციალიზაციისას.

ჩვენ გვაინტერესებს params თვისება, რომელიც აბრუნებს observable-ს, შესაბამისად ჩვენ უნდა დავასუბსქრაიბოთ მასზე. ქოლბექში ვიღებთ პარამეტრებს. ავიღოთ პარამეტრებიდან id თვისება და მივაწოდოთ ის არგუმენტად productService.getProductById-ს. params-ს წინ ვუწერთ +-ს, რადგან შედეგი ყოვეთვის სტრინგია, თუმცა ჩვენ getProductById მეთოდში რიცხვი გვჭირდება, ასე მას რიცხვად გარდავქმნით. ყოველი პარამეტრის ცვლილება observable-ში ახალ მნიშვნელობას გასცემს, რომლის საპასუხოდაც ჩვენ ახალ პროდუქტს ავითებთ. პროდუქტის არსებობის შემთხვევაში ჩვენ მას კლასის თვისებაში ვინახავთ და თემფლეითში გამოვსახავთ:

<div class="container">
  <a routerLink="/products">Back to products</a>
  <div *ngIf="product">
    <h1>{{ product.name }}</h1>
    <img [src]="product.image" [alt]="product.name" />
    <h3>{{ product.price | currency }}</h3>
    <p>{{ product.description }}</p>
  </div>
  <div *ngIf="!product">
    <h1>Error, product not found.</h1>
  </div>
</div>

ჩვენ პროდუქტს მაშინ ვაჩენთ, თუკი ის არსებობს, სხვა შემთხვევაში გამოვსახავთ ერორის მესიჯს.

ასე აპლიკაცია ხელმძღვანელობს როუთის პარამეტრებით. აქვე რადგან კლასში საბსქრიფშენს ვიყენებთ, ჯობია თუკი მას გავაუქმებთ, როცა კომპონენტი განადგურდება. ამისთვის შეგვიძლია გამოვიყენოთ takeUntilDestroyed ოპერატორი, რომელსაც @angular/core/rxjs-interop გვთავაზობს.

import { Component } from "@angular/core";
import { CommonModule } from "@angular/common";
import { ActivatedRoute } from "@angular/router";
import { Product } from "../product.model";
import { ProductsService } from "../products.service";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";

@Component({
  selector: "app-product-details",
  standalone: true,
  imports: [CommonModule],
  templateUrl: "./product-details.component.html",
  styleUrls: ["./product-details.component.css"],
})
export class ProductDetailsComponent {
  product!: Product;

  constructor(
    private productsService: ProductsService,
    private route: ActivatedRoute
  ) {
    this.route.params.pipe(takeUntilDestroyed()).subscribe((params) => {
      const product = this.productsService.getProductById(+params["id"]);
      if (product) {
        this.product = product;
      }
    });
  }
}

ჩვენ ვიყენებთ route.params-ზე ჯერ pipe მეთოდს, subscribe-მდე და მანდ ვაწვდით takeUntilDestroyed ოპერატორს. ამით ვეუბნებით ამ საბსქრიბშენს, რომ იქამდე იმუშაოს, სანამ კომპონენტი არ განადგურდება. როცა კლასში საბსქრიბშენები გვაქვს, ბევრი მათგანი კომპონენტის განადგურების შემდეგაც შეიძლება მუშაობდეს და ეს გარანტიას გვაძლევს, რომ აპლიკაცია ზედმეტ რესურსებს არ დახარჯავს. ჩვენ არ გვჭირდება პარამეტრებისთვის მოსმენა, როცა სხვა გვერდზე ვიმყოფებით.

გაითვალისწინეთ, რომ takeUntilDestroyed-ის გამოყენება შესაძლებელია მხოლოდ კონსტრუქტორში, ან, სხვა შემთხვევაში, მისთვის destroyRef-ის მიწოდებით.

შეჯამება

ამ თავში ჩვენ ვისაუბრეთ როუთის მარტივ პარამეტრებზე. ჩვენ განვსაზღვრეთ როუთების კონფიგურაციაში path, რომელსაც ჩვენთვის სასურველი პარამეტრი ექნება. შემდეგ ამ პარამეტრის მნიშვნელობებს ჩავწვდით ActivatedRoute-ის დაინჯექთებით კომპნენტში და მასში params თვისებაზე დასუბსქრაიბებით. პარამეტრებიდან ჩვენ ავიღედ id და სათანადო პროდუქტი გამოვსახეთ თემფლეითში.

QueryParams

წინა ქვეთავში ჩვენ მარტივი პარამეტრები გამოვიყენეთ, მაგრამ ეს განსხვავდება Query პარამეტრებისგან. მისი საშუალებით შეგვიძლია აპლიკაციის სთეითი, იგივე მდგომარეობა შევინახოთ მისამართში. ამ მაგალითში განვიხილავთ როგორ შეგვიძლია პროდუქტების სორტირების მიმართულება შევინახოთ QueryParams-ში.

ჩვენ ფაქტობრივად წინა თავის იდენტური აპლიკაცია გვაქვს, თუმცა ყურადღებას მხოლოდ ერთ კომპონენტს მივაქცევთ: ProductsComponent რომელიც იყენებს ProductsService-ს რათა პროდუქტები განათავსოს სთეითში.

სერვისში ინახება პროდუქტების მასივი, რომლებსაც გააჩნიათ აიდი, სახელი, აღწერა, სურათი და ფასი. აქვე არსებობს მეთოდი ამ პროდუქტების მისაღებად.

import { Injectable } from "@angular/core";
import { Product } from "./product.model";

@Injectable({
  providedIn: "root",
})
export class ProductsService {
  private products: Product[] = [
    {
      id: 0,
      name: "Lenovo ThinkPad T14",
      price: 899,
      description:
        'Gen 2 14" FHD (Intel 4-Core i5-1135G7, 16GB RAM, 512GB SSD, UHD Graphics) IPS Business Laptop, Backlit, Fingerprint, 2 x Thunderbolt 4, Webcam, 3-Year Warranty, Windows 11 Pro ',
      image: "https://example.com",
    },
    {
      id: 1,
      name: "Dell XPS 13 9310",
      price: 1200,
      description:
        "Touchscreen Laptop - 13.4-inch UHD+ Display, Thin and Light, Intel Core i5-1135G7, 8GB LPDDR4x RAM, 512GB SSD, Intel Iris Xe, Killer Wi-Fi 6 with Dell Service, Win 11 Home - Silver ",
      image: "https://example.com",
    },
    {
      id: 2,
      name: 'HP Envy 17.3"',
      price: 1246,
      description:
        "FHD Touchscreen Laptop, Intel Core i7-1165G7, 64GB RAM, 2TB SSD, Backlit Keyboard, Intel Iris Xe Graphics, Fingerprint Reader, Webcam, Windows 11 Pro, Silver, 32GB USB Card ",
      image: "https://example.com",
    },
  ];

  getAllProducts() {
    return this.products;
  }

  getProductById(id: number) {
    return this.products.find((product) => product.id === id);
  }
}

პროდუქტის ინტერფეისი product.model.ts-ში ასე გამოიყურება:

export interface Product {
  id: number;
  name: string;
  description: string;
  image: string;
  price: number;
}

როუთინგი კონფიგურირებული გვაქვს, რომ თავიდანვე /products გვერდზე გადავიდეთ და გავხსნათ ProductsComponent. ეს კომპონენტი ასე გამოიყურება:

import { Component } from "@angular/core";
import { CommonModule } from "@angular/common";
import { ProductsService } from "../products.service";
import { RouterLink } from "@angular/router";
import { Product } from "../product.model";

@Component({
  selector: "app-products",
  standalone: true,
  imports: [CommonModule, RouterLink],
  templateUrl: "./products.component.html",
  styleUrl: "./products.component.css",
})
export class ProductsComponent {
  products: Product[] = this.productsService.getAllProducts();

  constructor(private productsService: ProductsService) {}
}

აპლიკაციის ინიციალიზაციის დროს ჩვენ პროდუქტების სიას ვიღებთ, და კომპონენტში ვინახავთ. ამ პროდუქტებს უბრალოდ თემფლეითში განვათავსებთ:

<div class="container">
  <ul>
    <li class="product" *ngFor="let product of products">
      <h3>{{ product.name }}</h3>
      <img [src]="product.image" [alt]="product.name" />
      <p>{{ product.price | currency }}</p>
      <a routerLink="/products/{{ product.id }}">Details</a>
    </li>
  </ul>
</div>

ჩვენი მიზანია, რომ მომხმარებელს პროდუქტების გაფილტვრის საშუალება მივცეთ.

ProductsComponent-ში დავამატოთ შემდეგი თვისებები:

  sortBy: 'cheapest' | 'expensive' = 'cheapest';
  sortOptions = ['cheapest', 'expensive'];

ეს იქნება სორტირების მიმართულებები: ჯერ იაფი პროდუქტები გამოჩნდეს თუ ძვირი. თავდაპირველად სორტირება cheapest-ზე იქნება. ასევე გვინდა სორტირების ვარიანტების მასივი, რომლითაც ფორმას ავაგებთ.

კომპონენტის იმპორტებში შემოვიტანოთ FormsModule რათა ფორმების გამოყენება შევძლოთ:

import { FormsModule } from "@angular/forms";

@Component({
  imports: [/* ... */ FormsModule],
})

ახლა უბრალოდ შევქმნათ select ელემენტი NgModel დირექტივით, რომელსაც დავაკავშირებთ sortBy თვისებასთან. ngModelChange არის ივენთი, რომელსაც ფორმის ელემენტი დააემითებს, როცა მასში მომხმარებელი რამეს შეცვლის. ამ დროს გვინდა changeSort მეთოდით რეაგირება, რომელსაც ტაიპსკრიპტის ნიმუშში ვნახავთ. NgFor დირექტივით სორტირების ვარიანტები განვათავსოთ და მათი მნიშვნელობები მივაბათ ელემენტს. ასევე ის ინტერპოლაციით გამოვსახოთ. დაკლიკებაზე უნდა გააქტიურდეს მეთოდი, რომლითაც სორტირება მოხდება.

<div class="container">
  <label for="sort-select">Sort by</label>
  <select
    id="sort-select"
    [ngModel]="sortBy"
    (ngModelChange)="changeSort($event)"
  >
    <option *ngFor="let sortOption of sortOptions" [value]="sortOption">
      {{ sortOption }}
    </option>
  </select>
  <ul>
    <li class="product" *ngFor="let product of products">
      <h3>{{ product.name }}</h3>
      <img [src]="product.image" [alt]="product.name" />
      <p>{{ product.price | currency }}</p>
      <a routerLink="/products/{{ product.id }}">Details</a>
    </li>
  </ul>
</div>

შევქმნათ changeSort მეთოდი. ჩვენ არსებულ სიას ასე პირდაპირ არაფერს გავუკეთებთ. ჯერ უნდა მოხდეს ნავიგაცია, რომლის საფუძველზეც შეიცვლება მისამართში query პარამეტრები და ჩვენ შემდგომ მასზე უნდა ვირეაგიროთ.

import { Component } from "@angular/core";
import { CommonModule } from "@angular/common";
import { ProductsService } from "../products.service";
import { ActivatedRoute, Router, RouterLink } from "@angular/router";
import { Product } from "../product.model";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormsModule } from "@angular/forms";

@Component({
  selector: "app-products",
  standalone: true,
  imports: [CommonModule, RouterLink, FormsModule],
  templateUrl: "./products.component.html",
  styleUrl: "./products.component.css",
})
export class ProductsComponent {
  sortBy: "cheapest" | "expensive" = "cheapest";
  sortOptions = ["cheapest", "expensive"];
  products: Product[] = [];

  constructor(
    private productsService: ProductsService,
    private router: Router,
    private route: ActivatedRoute
  ) {
    this.route.queryParams
      .pipe(takeUntilDestroyed())
      .subscribe((queryParams) => {
        const unsortedProducts = this.productsService.getAllProducts();
        const sortBy = queryParams["sortBy"];
        this.sortBy = sortBy;
        this.products = this.sortProducts(unsortedProducts, sortBy);
      });
  }

  sortProducts(products: Product[], sortBy: "cheapest" | "expensive") {
    if (sortBy === "cheapest") {
      return products.sort((a, b) => a.price - b.price);
    } else {
      return products.sort((a, b) => b.price - a.price);
    }
  }

  changeSort(newSortBy: string) {
    this.router.navigate(["products"], {
      queryParams: { sortBy: newSortBy },
    });
  }
}

changeSort მეთოდი იყენებს კომპონენტში დაინჯექტებული Router-ის ინსტანციას და მასზე უძახებს navigate მეთოდს. ეს არის routerLink-ის მსგავსი ხელსაწყო, ოღონდ კლასში. პირველ არგუმენტად ეს მეთოდი იღებს მისამართის ცალკეული ერთეულების მასივს. ჩვენ იმავე გვერდზე, prodocts-ზე გვინდა ნავიგაცია, უბრალოდ უნდა შევცვალოთ პარამეტრები. მეორე არგუმენტად ფუნქცია იღებს კონფიგურაციის ობიექტს. აქ ერთ-ერთი თვისებაა queryParams სადაც შეგვიძლია პარამეტრების ობიექტის შექმნა, რომელსაც ანგულარი გარდასახავს პარამეტრებში მისამართის სტრინგად.

აქ ჩენ შევქმენით sortBy პარამეტრი, რომლის მნიშვნელობაც იქნება მომხმარებლის მიერ არჩეული სორტირების მიმართულება. მისამართში ეს გამოჩნდება, როგორც /products?sortBy=cheap.

შემდეგ ჩვენ გვინდა ამ რაუთის ცვლილებაზე რეაგირება. სწორედ ამიტომ constructor-ში ვიყენებთ დაინჯექთებულ ActivatedRoute-ს, რათა დავასუბსქრაიბოთ მასში არსებულ queryParams-ზე. ჩვენ პროდუქტების სიას ვინახავთ ლოკალურ ცვლადში და ასევე queryParams-იდან ვიღებთ ქოლბექში ხელმისაწვდომ პარამეტრების ობიექტს. აქ ჩვენი შექმნილი პარამეტრის მნიშვნელობა შეგვიძლია ავიღოთ - sortBy. ჩვენ ამ სორტირების ვარიანტს კლასის თვისებაში ვანახლებთ, რათა გვერდის დარეფრეშებაზე ფორმაში ისევ სწორი ვარიანტი იყოს არჩეული. რაც მთავარია, ახლა სორტირების მიმართულების მიხედვით ვასორტირებთ პროდუქტებს და მათ კლასის თვისებაში ვინახავთ. სორტირებას ვაკეთებთ sortProducts ფუნქციით. აქ პროდუქტებს და სორტირების მიმართულებას ვიღებთ და ამის მიხედვით სათანადოდ სორტირებულ პროდუქტებს ვაბრუნებთ.

ზემოთხსენებული ლოგიკის გაშვება ngOnInit-შიც არის შესაძლებელი, თუმცა takeUntilDestroyed-ის მარტივად გამოსაყენებლად იგივეს გაკეთება კონსტრუქტორშიც შეიძლება.

ყოველ ნავიგაციაზე queryParams-ის საბსქრიფშენშში არსებული მეთოდი აქტიურდება და ანახლებს:

  • მიმდინარე სორტირების მიმართულება: this.sortBy,
  • პროდუქტების მასივს ამ სორტირების მიხედვით: this.products.

შედეგად შეგვიძლია პროდქტების სორტირება queryParams-ით. რა თქმა უნდა შესაძლებელია იმდენი queryParams-ის შექმნა, რამდენიც მოგვესურვება.

შეჯამება

ამ თავში ჩვენ ვისწავლეთ კლასში Router-ის გამოყენება queryParams-ით და ActivatedRoute-ზე queryParams-ის ცვლილებებზე რეაგირება. ჩვენ მომხმარებლის მიერ სორტირების მიმართულების შეცვლის საფუძველზე ვახორციელებთ ნავიგაციას განახლებული queryParams-ით. queryParams-ის ცვლილებას ამავე კომპონენტში ვუსმენთ და სათანადოდ ვრეაგირებთ: ვანახლებთ სორტირების მიმართულებას და ვასორტირებთ პროდუქტების სიას.

HTTP მოთხოვნებთან მუშაობა

აპლიკაციათა უმეტესობას სჭირდება სერვერთან კომუნიკაცია HTTP პროტოკოლით, რათა ჩამოტვირთოს ან ატვირთოს მონაცემები, ან ისარგებლოს ხვა ბექენდ სერვისებით. ანგულარს გააჩნია API, რომლითად HTTP მოთხოვნების გაგზავნაა შესაძლებელი.

სანამ ანგულარის API-ს გამოვიყენებთ, მოკლედ აღვწეროთ რა HTTP მოთხოვნების გაგზავნაა შესაძლებელი:

  • GET: მონაცემების მიღების მოთხოვნა.
  • POST: ახალი მონაცემების ატვირთვის მოთხოვნა.
  • DELETE: მონაცემების წაშლის მოთხოვნა.
  • PATCH: არსებული მონაცემის ნაწილს შეცვლა.
  • PUT: არსებული მონაცემის ახლით ჩანაცვლება.

ახლა ამ მეთოდების გამოყენება ვცადოთ შემდეგ თავებში.

HTTP Client

HTTP მოთხოვნებთან სამუშაოდ ვიყენებთ ანგულარში ჩაშენებულ პროვადერებს, რომლებიც უნდა აპლიკაციის პროვაიდერებში დავარეგისტრიროთ. app.config.ts-ში პროვაიდერების მასივში ვიყენებთ provideHttpClient()-ს @angular/common/http-დან.

import { ApplicationConfig } from "@angular/core";
import { provideHttpClient } from "@angular/common/http";

export const appConfig: ApplicationConfig = {
  providers: [/* ... */ provideHttpClient()],
};

ამ გაკვეთილში ბექენდის სიმულაციისთვის ვისარგებლებთ dummyjson.com-ით, რომელიც ონლაინ მაღაზიის სერვერის სიმულაციას აკეთებს. მის გამოსაყენებლად აუცილებელია რომ ამ API-ს დოკუმენტაციას შევხედოთ. ჩვენ გამოვიყენებთ პროდუქტებთან დაკავშირებულ ენდფოინთებს.

დოკუმენტაციიდან გამომდინარე შეგვიძლია შევქმნათ პროდუქტის, და პროდუქტის მიღების მოთხოვნაზე მოცემული პასუხის ინტერფეისი:

product.model.ts

export interface Product {
  id: number;
  title: string;
  description: string;
  price: number;
  discountPercentage: number;
  rating: number;
  stock: number;
  brand: string;
  category: string;
  thumbnail: string;
  images: string[];
}

export interface GetProductsResponse {
  products: Product[];
  total: number;
  skip: number;
  limit: number;
}

export type AddProduct = Partial<Product>;

დააკვირდით, რომ GET მოთხოვნაზე გვიბრუნდება ობიექტი სადაც ერთ-ერთი თვისება არის პროდუქტების მასივი და დანარჩენი - დამატებითი ინფორმაცია პროდუქტების რაოდენობის შესახებ. ეს pagination-ისთვის არის საჭირო, თუმცა ამას აქ არ განვიხილავთ.

აქვე პროდუქტის დამატების დოკუმენტაციას თუ შევხედავთ, როგორც ჩანს შესაძლებელია არასრული პროდუქტის ობიექტის აწყობა და მისი გაგზავნა POST მოთხოვნით. მაშინ შევქმნათ AddProduct ინტერფეისი, რომელიც იქნება ნაწილობრივი Product ინტერფეისი, სადაც ყველა მისი თვისება არასავალდებულო გახდება.

HTTP მოთხოვნების ლოგიკისთვის ხშირად ცალკეულ სერვისს ვიყენებთ ხოლმე. ამიტომ შევქმნათ ProductsService და შემოვიტანოთ პირველი მეთოდი:

import { HttpClient } from "@angular/common/http";
import { Injectable } from "@angular/core";
import { AddProduct, GetProductsResponse, Product } from "./product.model";

@Injectable({ providedIn: "root" })
export class ProductsService {
  baseUrl = "https://dummyjson.com";

  constructor(private http: HttpClient) {}

  getAllProducts() {
    return this.http.get<GetProductsResponse>(`${this.baseUrl}/products`);
  }
}

კლასში ვაინჯექთებთ HttpClient-ს რომლითაც შეგვიძლია მოთხოვნების გაგზავნა. აქვე baseUrl-ში ვინახავთ ჩვენი სერვერის მისამართის ძირითად ნაწილს. getAllProducts მეთოდში ჩვენ ვაბრუნებთ ამ HttpClient-ზე დაძახენულ ჩვენთვის სასურველ მეთოდს. ამ შემთხვევაში get-ს. ეს ხდება დოკუმენტაციაში მითითებულ ენდფოინთზ. ეს მეთოდი აბრუნებს generic ტიპს, კერძოდ Observable-ს. ამიტომ ჩვენ შეგვიძლია აქ დავაზუსტოთ რა ტიპის შედეგს მოგვცემს ეს Observable. ჩვენ ვიცით რომ ის იქნება ჩვენ მიერ შექმნილი GetProductsResponse ტიპის.

ახლა სასურველ კომპონენტში შეგვიძლია ამ მეთოდს დავუძახოთ. app.component.ts:

import { Component, OnInit } from "@angular/core";
import { CommonModule } from "@angular/common";
import { AddProduct, Product } from "./product.model";
import { ProductsService } from "./products.service";

@Component({
  selector: "app-root",
  standalone: true,
  imports: [CommonModule],
  templateUrl: "./app.component.html",
  styleUrls: ["./app.component.css"],
})
export class AppComponent implements OnInit {
  loading = true;
  products: Product[] = [];

  constructor(private productsService: ProductsService) {}

  ngOnInit() {
    this.productsService.getAllProducts().subscribe((response) => {
      this.loading = false;
      this.products = response.products;
    });
  }
}

წინასწარ აპლიკაცია იქნება ჩატვირთვის რეჟიმში, და შეგვიძლია ეს ავსახოთ loading თვისებაში. აქვე შევქმნათ პროდუქტების სია, რომელიც თავიდან იქნება ცარიელი. კონსტრუქტორში ვაინჯექთებთ ProductsService-ს და ngOnInit-ში მას ვუძახებთ. მხოლოდ დაძახება საკმარისი არ არის, რადგან მოთხოვნა არ გაიგზავნება, თუ ჩვენ მასზე არ დავასუბსქრაიბეთ. დასუბსქრაიბებისას შეგვიძლია უკვე ჩავწვდეთ დაბრუნებულ პასუხს. როცა პასუხი დაბრუნდება (რომელიც უკვე ვიცით რა ტიპის არის), შეგვიძლია loading-ის მდგომარეობა განვაახლოთ და ჩვენი პროდუქტების მასივში შევინახოთ დაბრუნებული პროდუქტები.

ისინი თემფლეითში გამოვსახოთ:

<div *ngIf="products.length">
  <div class="product-card" *ngFor="let product of products">
    <img [src]="product.thumbnail" [alt]="product.title" />
    <h3>{{ product.title }}</h3>
    <p>{{ product.description }}</p>
    <p>{{ product.price | currency }}</p>
  </div>
</div>

<div *ngIf="loading">loading...</div>

როგორც ხედავთ აქ ქვემოთ ჩატვირთვის ინდიკატორიც გვაქვს, რომელიც თავიდან გამოჩნდება, მაგრამ მაშინ გაქრება როცა მოთხოვნა პასუხს დაგვიბრუნებს.

პროდუქტებს უბრალოდ NgFor დირექტივით გამოვსახავთ. ბრაუზერს თუ გავხსნით, დავინახავთ, რომ მომენტალურად loading... ტექსტი გამოჩნდება და შემდეგ მის ადგილას პროდუქტები გამოჩნდება.

გაითვალისწინეთ, რომ http-ის საბსქრიბშენზე unsubscribe-ის გაკეთება არ გვჭირდება, რადგან ამას ანგულარის HttpClient თავისით აგვარებს.

ახლა სერვისში სხვა მეთოდებს მივხედოთ:

import { HttpClient } from "@angular/common/http";
import { Injectable } from "@angular/core";
import { AddProduct, GetProductsResponse, Product } from "./product.model";

@Injectable({ providedIn: "root" })
export class ProductsService {
  baseUrl = "https://dummyjson.com";

  constructor(private http: HttpClient) {}

  getAllProducts() {
    return this.http.get<GetProductsResponse>(`${this.baseUrl}/products`);
  }

  addProduct(product: AddProduct) {
    return this.http.post<Product>(`${this.baseUrl}/products/add`, product);
  }

  deleteProduct(id: number) {
    return this.http.delete<Product>(`${this.baseUrl}/products/${id}`);
  }

  editProduct(updatedProduct: Partial<Product>) {
    return this.http.put<Product>(
      `${this.baseUrl}/products/${updatedProduct.id}`,
      updatedProduct
    );
  }
}

პროდუქტის დამატებისას ჩვენ პარამეტრში მივიღებთ ახალ პროდუქტს და მას გავგზავნით სათანადო ენდფოინთზე. post მეთოდს მეორე არგუმენტად ვაწვდით სწორედ ამ ობიექტს. მასზე JSON.stringify არ გვიჭირდება, რადგან ამას HttpClient გააკეთებს.

წაშლის მოთხოვნის შემთხვევაში ჩვენ მხოლოდ პროდუქტის id გვჭირდება და delete მოთხოვნის გაგზავნა ამ id-ის მქონე ენდფოინთზე.

პროდუქტის განახლებისთვის სერვერი იღებს put მოთხოვნას. ჩვენ განახლებულ პროდუქტს ვიღებთ პარამეტრში და მას ვაგზავნით ამ პროდუქტის აიდის მქონე ენდფოინთზე.

ჩვენი app.component.ts ახლა ასე უნდა გამოიყურებოდეს:

import { Component, OnInit } from "@angular/core";
import { CommonModule } from "@angular/common";
import { AddProduct, Product } from "./product.model";
import { ProductsService } from "./products.service";

@Component({
  selector: "app-root",
  standalone: true,
  imports: [CommonModule],
  templateUrl: "./app.component.html",
  styleUrls: ["./app.component.css"],
})
export class AppComponent implements OnInit {
  loading = true;
  products: Product[] = [];

  constructor(private productsService: ProductsService) {}

  ngOnInit() {
    this.productsService.getAllProducts().subscribe((response) => {
      this.loading = false;
      this.products = response.products;
    });
  }

  addNewProduct() {
    const newProduct: AddProduct = {
      title: "New Product",
      description: "This is a new test product!",
      price: 399,
      thumbnail: "https://angular.io/assets/images/logos/angular/angular.svg",
    };

    this.productsService.addProduct(newProduct).subscribe((newProduct) => {
      this.products.unshift(newProduct);
    });
  }

  deleteProduct(id: number) {
    this.productsService.deleteProduct(id).subscribe((deletedProduct) => {
      this.products = this.products.filter((p) => p.id !== deletedProduct.id);
    });
  }

  editProduct(product: Product) {
    const updatedProduct = {
      ...product,
      title: "This title was edited",
      description: "New updated description",
    };

    this.productsService
      .editProduct(updatedProduct)
      .subscribe((editedProduct) => {
        // dummyjson-ის API აბრუნებს იმავე არამოდიფიცირებულ ობიექტს,
        // ამიტომ მას გარდავქმნით.
        this.products = this.products.map((product) =>
          product.id === editedProduct.id ? updatedProduct : product
        );
      });
  }
}

აქაც თითოეული მოთხოვნის დასაძახებლად ცალკეული მეთოდი გვაქვს.

  • პროდუქტის დამატებისას ჩვენ აქ პირდაპირ ახალი პროდუქტის ობიექტს ვქმნით, თუმცა რეალურ აპლიკაციაში ამ პროდუქტს მომხმარებლის მიერ შევსებული ფორმიდან ავაგებდით. ამ პროდუქტს სერვისზე დაძახებულ addProduct მეთოდს ვაწვდით და მასზე ვასუბსქრაიბებთ. შედეგად დაბრუნებულ ახალ პროდუქტს აქ ვამატებთ სიის თავში.
  • წაშლის დროს ჩვენ deleteProduct-ს ვაწვდით ფუნქციის პარამეტრად მიღებულ id-ს და მასზე ვასუბსქრაიბებთ. შედეგად წაშლილ პროდუქტს ვიღებთ და ჩვენ არსებულ სიას ვფილტრავთ, რათა იქ აღარ იყოს წაშლილი პროდუქტი.
  • პროდუქტის დაედითებისას ჩვენ აქაც პირობითად ვცვლით სათაურს და აღწერას. ამ ახალ პროდუქტს ვაწვდით editProduct მეთოდს და დაბრუნებული პასუხის მიხედვით ვცვლით ამ ობიექტს მასივში.

თემფლეითში თითოეული პროდუქტის ბარათში გვაქვს შექმნილი ღილაკები სათანადო მეთოდებისთვის და ასევე პროდუქტის დამატების ღილაკი გვაქვს სიის თავში:

<button (click)="addNewProduct()">Add new product</button>
<div *ngIf="products.length">
  <div class="product-card" *ngFor="let product of products">
    <img [src]="product.thumbnail" [alt]="product.title" />
    <h3>{{ product.title }}</h3>
    <p>{{ product.description }}</p>
    <p>{{ product.price | currency }}</p>
    <button (click)="deleteProduct(product.id)">delete</button>
    <button (click)="editProduct(product)">Edit</button>
  </div>
</div>

<div *ngIf="loading">loading...</div>

ასე ჩვენი აპლიკაცია დაკავშირებულია ბექენდთან და ჩვენ შეგვიძლია:

  • პროდუქტების სიის მიღება,
  • ახალი პროდუქტის დამატება,
  • პროდუქტის წაშლა,
  • არსებული პროდუქტის განახლება.

შეჯამება

ამ თავში ჩვენ ვისწავლეთ ანგულარში მარტივი HTTP მოთხოვნების გაგზავნა და შედეგის აპლიკაციის სთეითში განთავსება. ჩვენ HTTP მოთხოვნებისთვის ცალკე შევქმენით სერვიცი სადაც დავაინჯექთეთ HttpClient და მასზე დავუძახეთ სხვადასხვა ტიპის მეთოდებს. ჩვენ ამ მეთოდების მიერ დაბრუნებული ტიპების განსაზღვრის საშუალებაც გვაქვს. კომპონენტში ამ მეთოდებზე აუცილებლად ვასუბსქრაიბებთ რათა ერთი მხრივ, მოთხოვნა გაიგზავნოს და, მეორემხრივ, რათა შედეგი მივიღოთ და ის სთეითში გამოვსახოთ.

Authentication

მომხმარებელს საშუალება უნდა ჰქონდეს შექმნას ექაუნთი, ექაუნთზე შეინახოს სხვადასხვა მონაცემები და ისარგებლოს გარკვეული პრივილეგიებით რაც ამ ექაუნთთან არის დაკავშირებული. ამისთვის საჭიროა ავთენტიფიკაცია.

ამ თავში ვისწავლით:

JWT Authentication

ავთენტიფიკაცია გულისხმობს არა მხოლოდ ანგარიშში შესასვლელი მოთხოვნის გაგზავნას, არამედ შედეგად დაბრუნებული საავთენტიფიკაციო ინფორმაციის შენახვას და მის გამოყენებას ისეთი ენფოინთებზე, რომელიც სათანადო პრივილეგიებს საჭიროებს, ამ ავთენტიფიკაციის ინფორმაციის ჰედერებში მიწოდებით. ჩვეულებრივ ეს ინფორმაცია გულისხმობს ტოკენებს, კერძოდ JSON Web Token-ებს (JWT). ჩვენ სწორედ ასეთ ტოკენებთან ვიმუშავებთ.

ამ ნიმუშში ვიხელმძღვანელებთ ბიბლიოთეკით @auth0/angular-jwt, რომელიც JWT ტოკენებთან მუშაობას უფრო ამარტივებს.

npm install @auth0/angular-jwt

ჩვენი ანგულარის კონფიგურაცია შემდეგნაირად გამოიყურება. app.config.ts-ში პროვაიდერებში გვაქვს შემოტანილი provideHttpClient და JwtModule (რომელიც npm-ით დავაინსტალირეთ). provideHttpClient-ს უნდა მივაწოდოთ პარამეტრი withInterceptorsFromDi(), რადგან JwtModule ინტერსეპტორებს იყენებს ტოკენის დასამატებლად. ვინაიდან JwtModule მოდულზე დაფუძნებული ბიბლიოთეკაა, მისი დარეგისტრირება საჭიროა importProvidersFrom ფუნქციაში:

import { ApplicationConfig, importProvidersFrom } from "@angular/core";
import {
  provideHttpClient,
  withInterceptorsFromDi,
} from "@angular/common/http";
import { JwtModule } from "@auth0/angular-jwt";

export const appConfig: ApplicationConfig = {
  providers: [
    provideHttpClient(withInterceptorsFromDi()),
    importProvidersFrom(
      JwtModule.forRoot({
        config: {
          tokenGetter: () => localStorage.getItem("access_token"),
          allowedDomains: ["dummyjson.com"],
        },
      })
    ),
  ],
};

ამ მოდულს forRoot ში უნდა მივაწოდოთ კონფიგურაცია. ერთი მხრივ, ტოკენის გეთერი ფუნქცია - ჩვენ მას შევინახავთ და ავიღებთ ლოკალური მეხსიერებიდან, ამიტომ ანონიმურ ფუნქციაში დავაბრუნოთ localStorage.getItem და ჩვენი ტოკენის key. allowdDomains არასავალდებულო კონფიგურაციაა, სადაც შეგვიძლია განვსაზღვროთ, მოდულმა რომელ დომეინებზე უნდა იმუშაოს. შესაძლოა გვაქვს რომელიმე დომეინი, სადაც ტოკენის მიწოდება არ გვჭირდება. ჩვენ აქ უბრალოდ ჩვენი ბექენდის დომეინს ჩავწერთ.

კონკრეტულად რას აკეთებს ეს ბიბლიოთეკა? მარტივად რომ ვთქვათ, ის მეხსიერებიდან იღებს ჩვენ ტოკენს და მას Http ჰედერებს ავტომატურად აბამს, რომ ჩვენ ამის გაკეთება არ მოგვიწიოს ყოველ მოთხოვნაზე. ამას ეს ბიბლიოთეკა HttpInterceptor-ის საშუალებით აკეთებს.

ჩვებულებრივ ამას ასე გავაკეთებდით:

import { Injectable } from "@angular/core";
import { ShoppingCart } from "../types/cart.model";
import { AuthService } from "./auth.service";

@Injectable({ providedIn: "root" })
export class CartService {
  constructor(private http: HttpClient, private authService: AuthService) {}

  getCartsForUser() {
    return this.http.get<{ carts: ShoppingCart[] }>(
      `https://dummyjson.com/auth/carts/user/`,
      {
        headers: {
          Authentication: `Bearer ${authService.getToken()}`,
        },
      }
    );
  }
}

ეს უბრალოდ ნიმუშია და არ წარმოადგენს ჩვენი აპლიკაციის ნაწილს. ჩვეულებრივ ვაგზავნით მოთხოვნას რაღაც ენდფოინთზე, და დამატებით ვაწვდით მეორე არგუმენტად კონფიგურაციის ობიექტს, სადაც ჰედერებს ვაყენებთ, კერძოდ Authentication ჰედერს, რომელიც არის Bearer და ამას მოყვება მეხსიერებაში შენახული ტოკენი, რომელიც შეიძლება ავტენტიფიკაციის სერვისით ავიღოთ. JwtModule-ის დახმარებით ამის გაკეთება არ გვჭირდება. მეტიც, ამ ბიბლიოთეკით ტოკენის დეკოდირება და მისი ვადის შემოწმებაც შეგვიძლია.

შევხედოთ ჩვენი როუთინგის კონფიგურაციას app.routes.ts-ში:

import { Routes } from "@angular/router";
import { AuthComponent } from "./auth/auth.component";
import { LogoutComponent } from "./logout/logout.component";
import { ShoppingCartComponent } from "./shopping-cart/shopping-cart.component";

export const routes: Routes = [
  { path: "auth", component: AuthComponent },
  { path: "logout", component: LogoutComponent },
  {
    path: "cart",
    component: ShoppingCartComponent,
  },
  { path: "", redirectTo: "cart", pathMatch: "full" },
];

ჩვენ გვაქვს სამი ძირითადი მისამართი და სამი შესაბამისი კომპონენტი. auth მისამართზე ჩვენ გვექნება კომპონენტი სადაც მომხმარებელი თავის ინფორმაციას შეიყვანს და საპასუხოდ მიიღებს ტოკენს. logout მისამართზე მომხმარებელი ანგარიშიდან გავა. cart მისამართზე მომხმარებელმა უნდა შეძლოს თავის ექაუნთზე არსებული საშოპინგო კალათის ნახვა. თავიდანვე გადამისამართება მოხდება ამ კალათის გვერდზე, თუმცა მომხმარებელი რადგან ავთენტიფიცირებული არ არის ის შედეგს ვერ დაინახავს.

ასე გამოიყურება ჩვენი AppComponent-ის თემფლეითი:

<header>
  <nav>
    <ul>
      <li>
        <a routerLink="/auth">Login/Register</a>
      </li>
      <li>
        <a routerLink="/logout">logout</a>
      </li>
    </ul>
  </nav>
</header>
<router-outlet></router-outlet>

გვაქვს ლინკები auth და logout გვერდებზე, და, რა თქმა უნდა, აუთლეტი.

types ფოლდერში გვაქვს შექმნილი პროდუქტის და კალათის მოდელი.

product.model.ts

export interface Product {
  id: number;
  title: string;
  description: string;
  price: number;
  discountPercentage: number;
  rating: number;
  stock: number;
  brand: string;
  category: string;
  thumbnail: string;
  images: string[];
}

types/cart.model.ts

import { Product } from "./product.model";

export interface ShoppingCart {
  id: number;
  products: Product[];
}

ცალკე სერვისების ფოლდერში მოვათავსებთ სერვისებს. ჯერ მივხედოთ ავთენტიფიკაციის ლოგიკას.

types/services/auth.service.ts

import { HttpClient } from "@angular/common/http";
import { inject, Injectable } from "@angular/core";
import {
  ActivatedRouteSnapshot,
  CanActivateFn,
  Router,
  RouterStateSnapshot,
} from "@angular/router";
import { tap } from "rxjs";

interface LoginResponse {
  id: number;
  username: string;
  email: string;
  firstName: string;
  lastName: string;
  gender: string;
  image: string;
  token: string;
}

@Injectable({ providedIn: "root" })
export class AuthService {
  constructor(private http: HttpClient, private router: Router) {}

  login(credentials: { username: string; password: string }) {
    return this.http
      .post<LoginResponse>(
        "https://dummyjson.com/auth/login",
        JSON.stringify(credentials),
        { headers: { "Content-Type": "application/json" } }
      )
      .pipe(
        tap((response) => {
          localStorage.setItem("access_token", response.token);
          localStorage.setItem("user", JSON.stringify(response));
          this.router.navigate(["/"]);
        })
      );
  }

  logout() {
    localStorage.removeItem("access_token");
    localStorage.removeItem("user");
    this.router.navigate(["/"]);
  }

  getUserId() {
    const user = localStorage.getItem("user");
    if (user) {
      return JSON.parse(user).id;
    } else {
      return null;
    }
  }
}

ჩვენ ამ სერვისში არა მხოლოდ HTTP მოთხოვნებს გავგზავნით, არამედ რაუთინგსაც ვაწარმოებთ და ტოკენებსაც ვმართავთ.

login მეთოდით მომხმარებელს შევიყვანთ ექაუნთში. ავიღებთ პაროლსა და მეილს და მას სათანადო ენდფოინთზე გავგზავნით post მოთხოვნით. აქ მესამე არგუმენტად ჩვენ HTTP ჰედერებს ვაკონკრეტებთ. ანუ მოთხოვნის შესახებ დამატებით ინფორმაციას. ამ სპეციფიკური ბექენდისთვის საჭიროა, რომ მივუთითოთ Content-Type რომელიც იქნება application/json. ამ მოთხოვნაზე ვიყენებთ pipe მეთოდს და მასში ვუძახებთ tap ოპერატორს (rxjs-დან). ეს ოპერატორი საშუალებას გვაძლევს რომ შედეგს ჩავწვდეთ სტრიმის მოდიფიკაციის გარეშე და რაიმე გვერდითი მოვლენის მსგავსი ოპერაციები ჩავატაროთ.

ჩვენ LoginResponse ტიპის პასუხს ვიღებთ და აქედან ტოკენსა და მომხმარებლის ინფორმაციას ვინახავთ ლოკალურ მეხსიერებაში access_token-ისა და user-ის სახელების ქვეშ. იდეაში მარტო ტოკენიც საკმარისია, რადგან მისი დეკოდირებული ვერსია შეიცავს მოხმარებლის მონაცემებს, თუმცა ზედმეტი დეკოდირებისგან თავი რომ ავირიდოთ, პირდაპირ მომხმარებელიც შევინახოთ. შემდგომ ჩვენ ნავიგაციას ვაკეთებთ მთავარ გვერდზე, რომელიც მოხმარებელს საშოპინგო კალათაზე გადაიყვანს, ახლა უკვე ავთენტიფიცირებულს.

აქვე ვქმნით logout მეთოდს, რომელიც მეხსიერებიდან ავთენტიფიკაციის ინფორმაციას წაშლის და მომხმარებელს მთავარ გვერდზე გადაიყვანს. რაუტინგმა უნდა მოაგვაროს ის, თუ ავთენტიფიკაციის მიხედვით რომელ გვერდზე შეუძლია მოხმარებელს გადასვლა. ამას სხვა თავში მივხედავთ (სახელდობრ CanActivate თავში).

ბოლოს ვქმნით მეთოდს getUserId, რომლითაც შეგვიძლია მოხმარებლის აიდის აღება ლოკალური მეხსიერებიდან. ეს უკანასკნელი დაგვჭირდება სწორი საშოპინგო კალათის მისაღებად.

ახლა AuthComponent-ში ეს სერვისი გამოვიყენოთ:

import { Component } from "@angular/core";
import { CommonModule } from "@angular/common";
import { ReactiveFormsModule, FormBuilder, Validators } from "@angular/forms";
import { AuthService } from "../services/auth.service";

@Component({
  selector: "app-auth",
  standalone: true,
  imports: [CommonModule, ReactiveFormsModule],
  templateUrl: "./auth.component.html",
  styleUrls: ["./auth.component.css"],
})
export class AuthComponent {
  loginForm = this.fb.nonNullable.group({
    username: ["", Validators.required],
    password: ["", Validators.required],
  });

  constructor(private fb: FormBuilder, private authService: AuthService) {}

  login() {
    if (this.loginForm.valid) {
      this.authService
        .login(this.loginForm.getRawValue())
        .subscribe((response) => {
          console.log(response);
        });
    }
  }
}

ჩვენ უბრალოდ ფორმას ვქმნით FormBuilder-ით და მის მნიშვნელობას ვაწვდით AuthService-ზე login მეთოდს. შედეგს სანიმუშოდ კონსოლში ვლოგავთ.

ფორმის ჯგუფს თემლფეითთან ვაკავშირებთ:

<form [formGroup]="loginForm" (ngSubmit)="login()">
  <h1>Log In</h1>
  <div>
    <label for="username">Username</label>
    <input type="text" id="username" formControlName="username" />
  </div>
  <div>
    <label for="password">Password</label>
    <input type="password" id="password" formControlName="password" />
  </div>
  <button type="submit" [disabled]="loginForm.invalid">log in</button>
</form>

ასე API-ის დოკუმენტაციაში არსებული მოხმარებლების მონაცემებს თუ შევიყვანთ ველში და დავასაბმითებთ, ჩვენ პასუხად უნდა მივიღოთ მოხმარებლის მონაცემები და ტოკენი, რომელიც ლოკალურ მეხსიერებაშიც უნდა განთავსდეს. ჩვენ ასევე გადამისამართებულები ვიქნებით მთავარ გვერდზე, რომელიც თავის მხრივ cart მისამართზე გადაგვიყვანს.

LogoutComponent-ს მივხედოთ, რომელზეც მაშინ გადავალთ, როცა logout სანავიგაციო ღილაკს დავაჭერთ.

import { Component, OnInit } from "@angular/core";
import { CommonModule } from "@angular/common";
import { AuthService } from "../services/auth.service";

@Component({
  selector: "app-logout",
  standalone: true,
  imports: [CommonModule],
  templateUrl: "./logout.component.html",
  styleUrls: ["./logout.component.css"],
})
export class LogoutComponent implements OnInit {
  constructor(private authService: AuthService) {}

  ngOnInit(): void {
    this.authService.logout();
  }
}

ინიციალიზაციის დროს ჩვენ უბრალოდ სერვისზე დავუძახებთ logout-ს, რათა ინფორმაცია წაიშალოს და ჩვენ მთავარ გვერდზე გადავიდეთ. აქ შეიძლება უკუთვლა გამოვაჩინოთ და მომხმარებელს ვანიშნოთ, რომ შეუძლია ამ მოქმედების გაუქმება. ეს სურვილისამებრ თქვენით დაამატეთ.

ახლა მოხმარებლის კალათა ავამუშავოთ. ჯერ შევქმნათ სერვისი, საიდანაც მონაცემებს მივიღებთ:

import { HttpClient } from "@angular/common/http";
import { Injectable } from "@angular/core";
import { ShoppingCart } from "../types/cart.model";
import { AuthService } from "./auth.service";

@Injectable({ providedIn: "root" })
export class CartService {
  constructor(private http: HttpClient, private authService: AuthService) {}

  getCartsForUser() {
    return this.http.get<{ carts: ShoppingCart[] }>(
      `https://dummyjson.com/auth/carts/user/${this.authService.getUserId()}`
    );
  }
}

აქ ჩვენ ასევე ვაინჯექთებთ AuthService-ს და მისი საშუალებით მოხმარებლის აიდის ვიღებთ, რომელსაც ენდფოინთის ბოლოში ვამატებთ. /auth მისამართზე არსებული მონაცემები ხელმისაწვდომია მხოლოდ ტოკენის საშუალებით. ამ ტოკენს JwtModule ჩვენ მაგივრად მისცემს ამ მოთხოვნას. ამ მოდულმა უკვე იცის, საიდან უნდა აიღოს ტოკენი. საბოლოოდ getCartsForUser მეთოდი მოგვცემს Obsevable-ს რომელიც დააბრუნებს ობიექტს. ამ ობექტის ერთ-ერთი თვისებაა cart სადაც ჩვენთვის საჭირო მონაცემებია.

ამ მეთოდს დავუძახებთ ShoppingCart კომპონენტში:

import { Component, OnInit } from "@angular/core";
import { CommonModule } from "@angular/common";
import { CartService } from "../services/cart.service";
import { ShoppingCart } from "../types/cart.model";

@Component({
  selector: "app-shopping-cart",
  standalone: true,
  imports: [CommonModule],
  templateUrl: "./shopping-cart.component.html",
  styleUrls: ["./shopping-cart.component.css"],
})
export class ShoppingCartComponent implements OnInit {
  carts: ShoppingCart[] = [];
  constructor(private cartService: CartService) {}

  ngOnInit(): void {
    this.cartService.getCartsForUser().subscribe((response) => {
      this.carts = response.carts;
    });
  }
}

ჩვენ უბრალოდ ვაინჯექთებთ CartService-ს და მისი საშუალებით კალათის ინფორმაციას ვიღებთ. ამ ინფორმაციას კლასის თვისებაში ვინახავთ. შემდგომ ამ ყველაფერს თემფლეითში განვათავსებთ:

<div *ngFor="let cart of carts">
  <h1>Shopping Cart:</h1>
  <div *ngFor="let product of cart.products">
    <h2>{{ product.title }}</h2>
    <h3>{{ product.price | currency }}</h3>
  </div>
</div>

დავლუპავთ კალათებზე (რადგან ის მასივშია, ანუ ერთზე მეტი შეიძლება იყოს) და მის შიგნით არსებულ პროდუქტებზეც. აქ მარტივად სათაურს და ფასს გამოვსახავთ.

ასე აპლიკაცია სანახევროდ მუშაობს. პრობლემა ის არის, რომ ჩვენ თუ ანგარიშიდან გავედით, მაინც შევძლებთ /cart მისამართზე გადასვლას, მოთხოვნა ტყუილად და წარუმატებლად გაიგზავნება. როგორმე მოხმარებელს არ უნდა მივცეთ საშუალება, რომ ამ გვერდზე გადავიდეს, თუ ის ავთენტიფიცირებული არ არის. ამაზე ვისაუბრებთ შემდეგ თავში.

შეჯამება

ჩვენ ამ თავში ანგულარში ვისწავლეთ ავთენტიფიკაცია JWT-ის საშუალებით. ჩვენ გამოვიეყნეთ @auth0/angular-jwt რომელიც ჩვენ მაგივრად ამატებს ტოკენს მოთხოვნების ჰედერებში. ჩვენ შევქმენით ავთენტიფიკაციის სერვისი, სადაც ვმართავთ ანგარიშში შესვლას, ანუ ტოკენისა და მომხმარებლის მონაცემების მიღებასადა შენახვას, და ანგარიშიდან გასვლას, ანუ მოხმარებლის მონაცემებისა და ტოკენის მეხსიერებიდან წაშლას. ხშირად ამ დროს შეიძლება დაგვჭირდეს მოხმარებლის სხვადასხვა გვერდზე გადამისამართება. ამ სერვისს ვიყენებთ არა მხოლოდ კომპონენტებში, არამედ იმ სერვისებშიც, სადაც ავთენტიფიკაციის შესახებ ინფორმაცია გვჭირდება.

CanActivate (RouteGuards)

ზოგჭერ საჭიროა, რომ მომხმარებელს რაღაც მისამართებზე წვდომა არ მივცეთ. ამისთვის გვჭირდება CanActivate ტიპის ფუნქციები.

შენიშვნა: ანგულარის ახალ ვერსიებში გამოიყენება პირდაპირ CanActivateFn ტიპის ფუნქციები, თუმცა ძველ ვერსიებში ამის მაგივრად იყენებდნენ “Guard” კლასებს. ჩვენ უახლეს მეთოდს ვისწავლით, თუმცა მოგვიანებით ძველ მეთოდსაც შევხედავთ.

ამ თავში ვიყენებთ წინა თავში არსებულ კოდს.

CanActivateFn

ჩვეულებრივ ლოგიკა იმის თაობაზე, გააქტიურდეს თუ არა რაღაც მისამართი, ავთენტიფიკაციის ნაწილში ინახება. ჩვენ შეგვიძლია ის AuthService-ში შევინახოთ.

import { HttpClient } from "@angular/common/http";
import { inject, Injectable } from "@angular/core";
import {
  ActivatedRouteSnapshot,
  CanActivateFn,
  Router,
  RouterStateSnapshot,
} from "@angular/router";
import { JwtHelperService } from "@auth0/angular-jwt";
import { tap } from "rxjs";

interface LoginResponse {
  id: number;
  username: string;
  email: string;
  firstName: string;
  lastName: string;
  gender: string;
  image: string;
  token: string;
}

@Injectable({ providedIn: "root" })
export class AuthService {
  constructor(
    private http: HttpClient,
    private router: Router,
    private jwtHelper: JwtHelperService
  ) {}

  login(credentials: { username: string; password: string }) {
    return this.http
      .post<LoginResponse>(
        "https://dummyjson.com/auth/login",
        JSON.stringify(credentials),
        { headers: { "Content-Type": "application/json" } }
      )
      .pipe(
        tap((response) => {
          localStorage.setItem("access_token", response.token);
          localStorage.setItem("user", JSON.stringify(response));
          this.router.navigate(["/"]);
        })
      );
  }

  logout() {
    localStorage.removeItem("access_token");
    localStorage.removeItem("user");
    this.router.navigate(["/"]);
  }

  getUserId() {
    const user = localStorage.getItem("user");
    if (user) {
      return JSON.parse(user).id;
    } else {
      return null;
    }
  }

  isTokenExpired() {
    return this.jwtHelper.isTokenExpired();
  }

  canActivate() {
    if (this.isTokenExpired()) {
      this.router.navigate(["/auth"]);
      return false;
    } else {
      return true;
    }
  }
}

export const canActivateCart: CanActivateFn = (
  route: ActivatedRouteSnapshot,
  state: RouterStateSnapshot
) => {
  return inject(AuthService).canActivate();
};

ყურადღება მივაქციოთ კლასში ბოლო ორ ფუნქციას. ჩვენ ამ სერვისში ვაინჯექთებთ JwtHelperService-ს, რომელსაც ჩვენ ტოკენზე აქვს წვდომა და მისი დეკოდიებაც შეუძლია. შესაბამისად შეგვიძლია გავიგოთ, ტოკენს ვადა გაუვიდა თუ არა. თუ ტოკენი საერთოდ არ არსებობს, ის მაშინაც true-ს დაგვიბრუნებს. შესაბამისად შეგვიძლია შევქმნათ მეთოდი, რომელიც დაადგენს, შეიძლება თუ არა რაიმე მისამართის გააქტიურება. ამას ტოკენის არსებობისა და მისი ვადის მიხედვით დავადგენთ. თუ ის ამოწურულია, ჩვენ logout მეთოდს დავუძახებთ, რათა ძველი ტოკენი წაიშალოს და მომხმარებელი გადავამისამართოთ ანგარიშში შესვლის გვერდზე.

ახლა იმავე ფაილიდან (თუმცა ეს ცალკე ფაილშიც შეგვიძლია) დავაექსპორტოთ ცვლადი, რომელიც იქნება CanActivateFn ტიპის. ამ ტიპის ფუნქციაში ხელმისაწვდომია ActivatedRouteSnapshot და RouterStateSnapshot რომელიც შეიძლება კონკრეტულ ვითარებაში დაგვჭირდეს, თუმცა ამ მაგალითში - არა. ჩვენ ვაბრუნებთ inject() ფუნქციაზე დაძახებას, რომელიც @angular/core-იდან უნდა დავაიმპორტოთ. ეს მეთოდი აინჯექთებს სასურველი კლასის ინსტანციას და მასზე ახლა შეგვიძლია ჩვენთვის საჭირო მეთოდზე დაძახება, კერძოდ canActivate, რომელიც ტოკენს შეამოწმებს და სდათანადო boolean-ს დააბრუნებს.

როუთინგის კონფიგურაციაში ჩვენ ეს ფუნქცია უნდა დავამატოთ სასურველ მისამართზე canActivate თვისების მასივში. როგორც ალბათ უკვე მიხვდით, ჩვენ აქ ერთზე მეტი ფუნქციის დამატება შეგვიძლია და ისინი თანმიმდევრულად შეამოწმებენ შეიძლება თუ არა მოცემულ მისამართზე გადასვლა.

import { Routes } from "@angular/router";
import { AuthComponent } from "./auth/auth.component";
import { LogoutComponent } from "./logout/logout.component";
import { canActivateCart } from "./services/auth.service";
import { ShoppingCartComponent } from "./shopping-cart/shopping-cart.component";

export const routes: Routes = [
  { path: "auth", component: AuthComponent },
  { path: "logout", component: LogoutComponent },
  {
    path: "cart",
    component: ShoppingCartComponent,
    canActivate: [canActivateCart],
  },
  { path: "", redirectTo: "cart", pathMatch: "full" },
];

და ასე ჩვენი აპლიკაცია უფრო გამართული უნდა იყოს. ახლა თავიდანვე გარდი გადაგვამისამართებს ავთენტიფიკაციის გვერდზე. და თუ ანგარიშზე შევალთ, cart მისამართზე წვდომა გვექნება. ანგარიშიდან გასვლა გადაგვიყვანს მთავარ გვერდზე, რომელმაც cart-ზე უნდა გადაგვამისამართოს, მაგრამ გარდი თავის მხრივ დაგვაბრუნებს ავთენტიფიკაციის გვერდზე.

CanActivate Class

ახლა განვიხილოთ depricated მიდგომა, რომელიც მალე მოხმარებაში აღარ იქნება, თუმცა ძველ პროექტებში შეიძლება მაინც შეგვხვდეს. გარდის შესაქმნელად ვქმნით ფაილს რომელსაც კონვენციურად guard უნდა ჰქონდეს, ანუ auth.guard.ts. აქ ჩვენ Injectable დეკორატორით ვქმნით კლასს, რომელიც იმპლემენტაციას უკეთებს CanActivate ინტერფეისს. ამ ინტერფეისის თანახმად კლასს უნდა ჰქონდეს canActivate ფუნქცია. აქაც ფუნქციაში ხელმისაწვდომია ActivatedRouteSnapshot და RouterStateSnapshot. სხვა საჭირო კლასებს ჩვენ უბრალოდ კონსტრუქტორში ვაინჯექთებთ inject ფუნქციის გამოყენების მაგივრად. ახლა იგივე პრინციპით ვიყენებთ AuthService-ში არსებულ მეთოდს, რომ ვნახოთ ტოკენს ვადა გაუვიდა თუ არა, და ამის მიხედვით, ერთი მხრივ მოხმარებელს გადავამისამართებთ და მეორე მხრივ დავაბრუნებთ ბულიანს, მისამართი გააქტიურდეს თუ არა.

import { Injectable } from "@angular/core";
import {
  ActivatedRouteSnapshot,
  CanActivate,
  Router,
  RouterStateSnapshot,
  UrlTree,
} from "@angular/router";
import { AuthService } from "../services/auth.service";

@Injectable({
  providedIn: "root",
})
export class AuthGuard implements CanActivate {
  constructor(private authService: AuthService, private router: Router) {}

  canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) {
    if (this.authService.isTokenExpired()) {
      this.router.navigate(["/auth"]);
      return false;
    } else {
      return true;
    }
  }
}

ამ კლასს იმავე პრინციპით ვამატებთ როუთინგის კონფიგურაციაში, canActivate თვისების მასივში:

import { Routes } from "@angular/router";
import { AuthComponent } from "./auth/auth.component";
import { AuthGuard } from "./guards/auth.guard";
import { LogoutComponent } from "./logout/logout.component";
import { ShoppingCartComponent } from "./shopping-cart/shopping-cart.component";

export const routes: Routes = [
  { path: "auth", component: AuthComponent },
  { path: "logout", component: LogoutComponent },
  {
    path: "cart",
    component: ShoppingCartComponent,
    canActivate: [AuthGuard],
  },
  { path: "", redirectTo: "cart", pathMatch: "full" },
];

და ასე აპლიკაცია იგივენაირად იმუშავებს.

შეჯამება

ამ თავში ჩვენ განვიხილეთ CanActivate ტიპის ფუნქცია და კლასი, რომელიც იმას განსაზღვრავს, მომხმარებელს უნდა ჰქონდეს თუ არა საშუალება, რომ კონკრეტულ მისამართზე გადავიდეს.

RXJS Observables

RxJS არის ჯავასკრიპტის ბიბლიოთეკა, რომელიც ინტეგრირებულია ანგულარის ეკოსისტემაში. მისი დახმარებით გვაქვს საშუალება, რომ მოვიხელთოთ და ვმართოთ ასინქრონული ოპერაციები. ამ ბიბლიოთეკის კარგად ცოდნა საშუალებას მოგვცემს რომ ჩვენი აპლიკაცია გახდეს არა მხოლოდ უფრო რეაქტიული, არამედ დეკლარაციული, ანუ მარტივად წასაკითხი და გასააზრებელი.

ამ თავში ვისწავლით:

Observable Stream

RxJS-ის ფუნდამენტური კონცეფცია არის observable stream, ანუ დაკვირვებადი მონაცემის ნაკადი. განვიხილოთ observable-ების ელემენტარული მაგალითები. ამისთვის გამოვიყენებთ ანგულარის საწყის აპლიკაციას.

როგორ მოვუსმენდით დოკუმენტში მაუსის დაკლიკებას? ჯავასკრიპტში ვიცით ივენთ ლისენერის გამოყენება, მაგრამ rxjs-ს თავისი ალტერნატივა აქვს. ეს არის fromEvent ოპერატორი, რომელიც უნდა დავაიმპორტოთ “rxjs”-დან.

import { Component, OnInit } from "@angular/core";
import { CommonModule } from "@angular/common";
import { fromEvent } from "rxjs";

@Component({
  selector: "app-root",
  standalone: true,
  imports: [CommonModule],
  templateUrl: "./app.component.html",
  styleUrls: ["./app.component.css"],
})
export class AppComponent implements OnInit {
  // create an observable of mouse clicks on document
  myObservable$ = fromEvent(document, "click");

  ngOnInit(): void {}
}

მონაცემთა ნაკადი ნებისმიერი ინფორმაცია შეიძლება იყოს, თუმცა ყველაზე ხშირად ეს ივენთებია. fromEvent ოპერატორი შექმნის ჩვენთვის სასურველი ელემენტის რაიმე მოვლენათა სტრიმს, მათ შორის click-ს. ამ სტრიმს ჩვენ ვინახავთ myObservable$ თვისებაში. კონვენციურად ანგულარსი სტრიმებს ბოლოში დოლარის ნიშანს ვუწერთ. ჩვენ კი შევქმენით სტრიმი, თუმცა ის არაფერს გააკეთებს.

სტრიმების კარგი ანალოგიაა წყალგაყვანილობა. წყალი არის მონაცემების ნაკადი, თუმცა ის ჩვენამდე ვერ მოაღწევს, თუ ჩვენ ონკანს არ მოვუშვით. ონკანს რომ მოვუშვათ, ამისთვის საჭიროა subscribe მეთოდი, ანუ ამ სტრიმის გამოწერა.

import { Component, OnInit } from "@angular/core";
import { CommonModule } from "@angular/common";
import { Subscription, fromEvent } from "rxjs";

@Component({
  selector: "app-root",
  standalone: true,
  imports: [CommonModule],
  templateUrl: "./app.component.html",
  styleUrls: ["./app.component.css"],
})
export class AppComponent implements OnInit {
  // create an observable of mouse clicks on document
  myObservable$ = fromEvent(document, "click");

  ngOnInit(): void {
    // When component is initialized, start reacting
    this.myObservable$.subscribe((event) => {
      console.log(event);
    });
  }
}

ასე ჩვენ სტრიმს მივაწოდეთ subscribe-ში ფუნქცია, რომელიც უნდა განხორციელდეს ნაკადში ყოველი ახალი მონაცემის გამოჩენის დროს. ანუ ჩვენ ასე განვსაზღვრავთ როგორ ვირეაგიროთ კონკრეტულ მოვლენებზე. ეს კონკრეტული სტრიმი მოვლენის შესახებ ინფორმაციას გვაწვდის. ის უბრალოდ კონსოლში დავლოგოთ.

აქამდე observable-ის მაგალითი ნანახი გექნებათ HttpClient-თან მუშაობის დროს:

this.http.get("https://example.com").subscribe((response) => {
  console.log(response);
});

რომელიც იგივე პრინციპით მუშაობს.

სუბსქრაიბ ფუნქციაში კონკრეტული ტიპის ობიექტის მიწოდებაც შეიძლება, სადაც უფრო კონკრეტულად შეგვიძლია ვუთხრათ რა მომენტში რაზე გვინდა რეაგირება. მაგალითად next არის ნაკადში მონაცემის წარმატებით გაცემის დროს განხორციელებული ფუნქცია. error არის ნაკადში რაიმე ერორის აღმოცენების დროს საპასუხო ფუნქცია და complete არის ფუნქცია, რომელიც უნდა განხორციელდეს როცა observable დასრულდება.

// instead of a function, we will pass an object with next, error, and complete methods
myObservable.subscribe({
  // on successful emissions
  next: (event) => console.log(event),
  // on errors
  error: (error) => console.log(error),
  // called once on completion
  complete: () => console.log("complete!"),
});

სტრიმზე subscribe მეთოდი აბრუნებს subscription-ს, რომელიც სურვილისამებრ შეგვიძლია შევინახოთ ცვლადში. შესაძლებელია ერთზე მეტი subscription-ის შექმნა:

import { Component, OnInit } from "@angular/core";
import { CommonModule } from "@angular/common";
import { Subscription, fromEvent } from "rxjs";

@Component({
  selector: "app-root",
  standalone: true,
  imports: [CommonModule],
  templateUrl: "./app.component.html",
  styleUrls: ["./app.component.css"],
})
export class AppComponent implements OnInit {
  // create an observable of mouse clicks on document
  myObservable$ = fromEvent(document, "click");
  mySubscription$!: Subscription;
  mySubscriptionTwo$!: Subscription;

  ngOnInit(): void {
    this.mySubscription$ = this.myObservable$.subscribe((event) => {
      console.log("subscription one", event);
    });

    this.mySubscriptionTwo$ = this.myObservable$.subscribe((event) => {
      console.log("subscription two", event);
    });
  }
}

ჩვენ ორ ცალკეულ თვისებაში, რომლებიც subscription ტიპის არიან, ვინახავთ საბსქრიფშენებს. აქ სტრიმებსა და მათ საბსქრიფშენებს შორის მიმართება რის ერთი ერთთან. ანუ ეს იგივეა რაც ორი addEventListener-ის გამოყენება და მასში ორი სხვადასხვა ფუნქციის მიწოდება. თითოეულ საბსქრიბშენს ყავს თავისი ობზერვებლი. თუ გვსურს რომ ერთ სტრიმზე დავასუბსქრაიბოთ სხვადასხვა ადგილას, მაშინ გვჭირდება Subject-ების გამოყენება, რომელზეც მოგვიანებით ვისაუბრებთ.

subscription-ის ინსტანცია შეიძლება მაშინ გამოგვადგეს, როცა მაგალითად მოლენებისთვის მოსმენა აღარ გვიჭირდება. ამისთვის შეგვიძლია მასზე unsubscribe მეთოდს დავუძახოთ. ეს ხშირად კომპონენტის განადგურების დროს არის საჭირო, რათა მეხსიერება და სისტემის რესურსები დავზოგოთ. სტრიმების უყურადღებოდ დატოვება აპლიკაციაში მეხსიერების პრობლემებს ქმნის.

import { Component, OnDestroy, OnInit } from "@angular/core";
import { CommonModule } from "@angular/common";
import { Subscription, fromEvent } from "rxjs";

@Component({
  selector: "app-root",
  standalone: true,
  imports: [CommonModule],
  templateUrl: "./app.component.html",
  styleUrls: ["./app.component.css"],
})
export class AppComponent implements OnInit, OnDestroy {
  // create an observable of mouse clicks on document
  myObservable$ = fromEvent(document, "click");
  mySubscription$!: Subscription;
  mySubscriptionTwo$!: Subscription;

  ngOnInit(): void {
    this.mySubscription$ = this.myObservable$.subscribe((event) => {
      console.log("subscription one", event);
    });

    this.mySubscriptionTwo$ = this.myObservable$.subscribe((event) => {
      console.log("subscription two", event);
    });
  }

  ngOnDestroy(): void {
    this.mySubscription$.unsubscribe();
    this.mySubscriptionTwo$.unsubscribe();
  }
}

ასე რომ არ მოვიქცეთ, სტრიმებზე მაშინაც გვექნება აქტიური სუბსქრაიბი, როცა სხვა კომპონენტზე ვიმყოფებით და ისინი რეალურად არ გვჭირდება.

RxJS-ის პლიუსი ის არის, რომ შეგვიძლია სტრიმების მრავალფეროვნად მანიპულაცია, მათი გაცემული მნიშვნელობების გარდაქმნა და სხვადასხვა სტრიმების გარკვეული გზებით კომბინაცია. ამისთვის ჩვენ ოპერატორებს ვიყენებთ.

Operators

როცა RxJS-ის დახმარებით რაიმე ამოცანის ამოხსნა გვჭირდება, ხშირ შემთხვევაში ამისთვის უკვე არსებობს ოპერატორი. RxJS-ს გააჩნია ოპერატორების ძალზედ დიდი არსენალი, რაც ერთი შეხედვით ძალიან დამთრგუნველია, თუმცა ნუ შეგეშინდებათ. არის ოპერატორების რაღაც ძირითადი ნაწილი, რომელიც ყველაზე ხშირად დაგვჭირდება და თუ ჩვენ შემდგო მრაიმე კონკრეტული ახალი ოპერატორის შერჩევა დაგვჭირდება, შეგვიძლია მოვიშველიოთ ოპერატორების ასარჩევი კითხვარი რომელიც დაგვეხმარება სწორი ოპერატორის შერჩევაში.

არსებობს ოპერატორების ორი ზოგადი კატეგორია:

Creation Operators

შექმნის ოპერატორების საშუალებით შესაძლებელია სტრიმების შექმნა რაიმე წყაროდან, ეს შეიძლება იყოს http მოთხოვნა, მომხმარებლის რაიმე ივენთი ან სულაც სტატიკური მონაცემები. ჩვენ ფაქტობრივად ყველაფრისგან შეგვიძლია სტრიმის შექმნა.

fromEvent

fromEvent-ის საშუალებით შეგვიძლია რაიმე ელემენტზე (ან მთლიან დოკუმენტზე) არსებული მოვლენისგან სტრიმის შექმნა, სადაც ქოლბექში ფუნქცია დაგვიბრუნებს ამ ივენთის მოვლენის ინფორმაციას.

პირველ არგუმენტად ამ ფუნქციას ვაწვდით სასურველ ელემენტს ხოლო მეორე არგუმენტად მოვლენის სახელის სტრინგის ფორმით:

import { Component, OnInit } from "@angular/core";
import { CommonModule } from "@angular/common";
import { fromEvent } from "rxjs";

@Component({
  selector: "app-root",
  standalone: true,
  imports: [CommonModule],
  templateUrl: "./app.component.html",
  styleUrls: ["./app.component.css"],
})
export class AppComponent implements OnInit {
  // create an observable of mouse clicks on document
  myObservable$ = fromEvent(document, "click");

  ngOnInit(): void {
    // When component is initialized, start reacting
    this.myObservable$.subscribe((event) => {
      console.log(event);
    });
  }
}

from

from ოპერატორით შესაძლებელია array-ის, promise-ის ან iterable მონაცემისგან სტრიმის შექმნა. არგუმენტად ეს ოპერატორი სწორედ ასეთ მონაცემებს იღებს.

import { Component, OnInit } from "@angular/core";
import { CommonModule } from "@angular/common";
import { from } from "rxjs";

@Component({
  selector: "app-root",
  standalone: true,
  imports: [CommonModule],
  templateUrl: "./app.component.html",
  styleUrls: ["./app.component.css"],
})
export class AppComponent implements OnInit {
  // emit array as a sequence of values
  numbers$ = from([1, 2, 3, 4, 5]);

  ngOnInit(): void {
    // listen to the emission and log each value
    this.numbers$.subscribe((val) => console.log(val));
  }
}

ესე შევქმნით სტრიმს, რომელიც თანმიმდევრულად გასცემს მასივის თითოეული წევრის მნიშვნელობას. ანუ კონსოლში ჯერ დაილოგება 1, შემდეგ 2, 3 და ასე 5-ის ჩათვლით. წარმოიდგინეთ ქარხანაში კონვეიერი, სადაც თანმიმდევრულად ვალაგებთ მასივის ელემენტებს. დანიშნულების ადგილზეც ისინი შესაბამისად არა ერთდროულად, არამედ ერთი მეორის შემდეგ მივლენ.

of

of ოპერატორი თანმიმდევრულად გასცემს ცვალებადი რაოდენობის მნიშვნელობებს, რომელიც მას არგუმენტად მიეწოდება.

import { Component, OnInit } from "@angular/core";
import { CommonModule } from "@angular/common";
import { of } from "rxjs";

@Component({
  selector: "app-root",
  standalone: true,
  imports: [CommonModule],
  templateUrl: "./app.component.html",
  styleUrls: ["./app.component.css"],
})
export class AppComponent implements OnInit {
  //emits values of any type
  values$ = of({ name: "Brian" }, [1, 2, 3], function hello() {
    return "Hello";
  });

  ngOnInit(): void {
    //output: {name: 'Brian'}, [1,2,3], function hello() { return 'Hello' }
    this.values$.subscribe((val) => console.log(val));
  }
}

შეჯამება

ამ თავში ჩვენ განვიხილეთ შექმნის ოპერატორები, რომლებითაც საშუალება გვაქვს სტრიმები შევქმნათ ივენთებისა და სტატიკური მონაცემებისგან.

Pipeable Operators

ფაიფის ოპერატორებით ჩვენ საშუალება გვაქვს რომ არსებულ სტრიმს ბევრნაირად გავუკეთოთ მოდიფიკაცია, ან სულაც ისე ჩავწვდეთ მის მნიშვნელობას, რომ სტრიმის შედეგზე გავლენა არ ვიქონიოთ. ამ ოპერატორებს თანმიმდევრულად ვაწვდით სტრიმზე დაძახებულ pipe მეთოდს:

someObservable = source.pipe(/* operators go here */);

map

map ოპერატორის საშუალებით შეგვიძლია შევცვალოთ სტრიმის მიერ დაბრუნებული მნიშვნელობები. მის ქოლბექში პარამეტრად ვიღებთ სტრიმის მიერ დაბრუნებულ შედეგს, რომელზეც შეგვიძლია სასურველი ოპერაციების ჩატარება. აუცილებელია, რომ ამ ოპერატორმა რაღაც დააბრუნოს.

მაგალითად თუ გვინდა, რომ შევქმნათ სტრიმი, რომელიც კონკრეტულად დოკუმენტში მაუსის კოორდინატებს დააემითებს მაუსის მოძრაობაზე:

import { Component, OnInit } from "@angular/core";
import { CommonModule } from "@angular/common";
import { fromEvent, map } from "rxjs";

@Component({
  selector: "app-root",
  standalone: true,
  imports: [CommonModule],
  templateUrl: "./app.component.html",
  styleUrls: ["./app.component.css"],
})
export class AppComponent implements OnInit {
  // Create an observable that emits mousemove events
  mouseMove$ = fromEvent<MouseEvent>(document, "mousemove");

  // Create a new observable that emits mouse coordinates,
  // based on mouseMove$ observable.
  mouseCoordinates$ = this.mouseMove$.pipe(
    map((event) => {
      if (event) {
        const coordinates = {
          x: event.pageX,
          y: event.pageY,
        };
        return coordinates;
      } else {
        return null;
      }
    })
  );

  ngOnInit(): void {
    // Subscribe and react to emissions of mouseCoordinates$
    this.mouseCoordinates$.subscribe((coordinates) => {
      if (coordinates) {
        console.log(coordinates.x, coordinates.y);
      }
    });
  }
}

ჩვენ ჯერ მაუსის მოძრაობის სტრიმს ვქმნით და შემდგომ ცალკე სტრიმს, სადაც პირველ სტრიმზე ვეძახით pipe ფუნქციას და მას ვაწვდით map ოპერატორს. map ოპერატორით ვიღებთ ივენთის შესახებ ინფორმაციას (თუ ის არსებობს) და მხოლოდ ივენთის კოორდინატებს ვაბრუნებთ. საპირისპირო შემთხვევაში ვაბრუნებთ null-ს. სტრიმებმა პოტენციურად შეიძლება ეს მნიშვნელობა დააბრუნონ, თუ დასაემითებელი არაფერი აქვთ.

ასე ჩვენ ცალკე სტრიმი შევქმენით, რომელიც მაუსის მოძრაობაზე გასცემს მაუსის კოორდინატებს. მასზე ვასუბსქრაიბებთ და შედეგს კონსოლში ვლოგავთ.

დასუბსქრაიბების ალტერნატივა განვიხილოთ. ანგულარში არსებობს async ფაიფი. ჩვენ თუ უბრალოდ სტრიმის მიერ დაბრუნებული მნიშვნელობის გამოსახვა გვინდა თემფლეითში, იმის მაგივრად რომ კლასსში დავასუბსქრაიბოთ სტრიმს და შემდეგ მისი მნიშვნელობა ამოვიღოთ ცალკე კლასის თვისებაში, შეგვიძლია პირდაპირ ეგ სტრიმი გავიტანოთ თემფლეითში.

წავშალოთ სუბსქრაიბი და დავტოვოთ სტრიმი:

import { Component } from "@angular/core";
import { CommonModule } from "@angular/common";
import { fromEvent, map } from "rxjs";

@Component({
  selector: "app-root",
  standalone: true,
  imports: [CommonModule],
  templateUrl: "./app.component.html",
  styleUrls: ["./app.component.css"],
})
export class AppComponent {
  // Create an observable that emits mousemove events
  mouseMove$ = fromEvent<MouseEvent>(document, "mousemove");

  // Create a new observable that emits mouse coordinates,
  // based on mouseMove$ observable.
  mouseCoordinates$ = this.mouseMove$.pipe(
    map((event) => {
      if (event) {
        const coordinates = {
          x: event.pageX,
          y: event.pageY,
        };
        return coordinates;
      } else {
        return null;
      }
    })
  );
}

თემფლეითში გავატარპთ სტრიმი async ფაიფში და შემდგომ json ფაიფში, რათა შედეგი დავაფორმატოთ.

<h1>{{ mouseCoordinates$ | async | json }}</h1>

ასე ლავში გამოვსახავთ მაუსის კოორდინატებს ისე, რომ ჩვენით სუბსქრაიბის დაწერა არ გვიწევს. პლიუსი ის არის, რომ async ფაიფი ჩვენ მაგივრად გააკეთებს unsubscribe-ს კომპონენტის განადგურების დროს.

tap

tap ოპერატორით შეგვიძლია სტრიმის კონკრეტულ მონაკვეთში შევიჭრათ, ჩავწვდეთ მის ინფორმაციას და თვითონ სტრიმში არანაირი ცვლილება არ შევიტანოთ. tap-ს ვიყენებთ ხოლმე გვერდითი მოვლენებისთვის.

ჩვენი წინა მაგალითის სტრიმს map ოპერატორის შემდეგ დავამატოთ tap.

import { Component } from "@angular/core";
import { CommonModule } from "@angular/common";
import { fromEvent, map, tap } from "rxjs";

@Component({
  selector: "app-root",
  standalone: true,
  imports: [CommonModule],
  templateUrl: "./app.component.html",
  styleUrls: ["./app.component.css"],
})
export class AppComponent {
  // Create a observable that emits mousemove events
  mouseMove$ = fromEvent<MouseEvent>(document, "mousemove");

  // Create an new observable that emits mouse coordinates,
  // based on mouseMove$ observable.
  mouseCoordinates$ = this.mouseMove$.pipe(
    map((event) => {
      if (event) {
        const coordinates = {
          x: event.pageX,
          y: event.pageY,
        };
        return coordinates;
      } else {
        return null;
      }
    }),
    tap((coordinates) => {
      console.log(coordinates);
    })
  );
}

როგორც ხედავთ, ჩვენ სტრიმს მოდიფიკაციას არ ვუკეთებთ, თუმცა როცა ის მნიშვნელობას გასცემს, პარალელურად tap-ში არსებული ლოგიკაც აქტიურდება. ეს შეიძლება პარალელურად http მოთხოვნების გასაგზავნად ან მონაცემების მეხსიერებაში შესანახად დაგვჭირდეს.

ყურადრება მიაქციეთ, რომ რადგან ჩვენ tap დავწერეთ map ფუნქციის შემდეგ, ჩვენ ვიღებთ map-ის მიერ მოდიფიცირებულ შედეგს. ჩვენ რომ ის pipe-ის თავში დაგვეწერა, ქოლბექში მივიღებდით MouseEvent ტიპის ინფორმაციას. ანუ ფაიფის ოპერატორები თანმიმდევრულად მუშაობენ.

filter

filter ოპერატორო, როგორც ამაზე მისი სახელი მიგვანიშნებს, ფილტრავს სტრიმს. იმის მიხედვით, ფილტრის დაბრუნებული მნიშვნელობა არის თუ არა ჭეშმარიტისებრი, განისაზღვრება სტრიმში მნიშვნელობა ამ ფილტრის შემდეგ გაიცემა თუ არა.

mouseCoordinates$ სტრიმი გავფილტროთ და გავაცემინოთ მხოლოდ ის მნიშვნელობები, სადაც კოორდინატები არსებობს და ამ კოორდინატებში x მეტია 300-ზე.

import { Component } from "@angular/core";
import { CommonModule } from "@angular/common";
import { fromEvent, map, tap, filter } from "rxjs";

@Component({
  selector: "app-root",
  standalone: true,
  imports: [CommonModule],
  templateUrl: "./app.component.html",
  styleUrls: ["./app.component.css"],
})
export class AppComponent {
  // Create an observable that emits mousemove events
  mouseMove$ = fromEvent<MouseEvent>(document, "mousemove");

  // Create an new observable that emits mouse coordinates,
  // based on mouseEventSource observable.
  mouseCoordinates$ = this.mouseMove$.pipe(
    map((event) => {
      if (event) {
        const coordinates = {
          x: event.pageX,
          y: event.pageY,
        };
        return coordinates;
      } else {
        return null;
      }
    }),
    // only emit if these conditions are met
    filter((coordinates) => coordinates !== null && coordinates.x > 300),
    tap((coordinates) => {
      console.log(coordinates);
    })
  );
}

შედეგად სტრიმი მხოლოდ მაშინ გასცემს მნიშვნელობას როცა მაუსის კოორდინატები აღემატება 300-ს.

switchMap

switchMap ძალზედ სპეციფიკური ოპერატორია, რომლის საშუალებითაც შეგვიძლია ერთი სტრიმიდან გადავეროთ მეორეზე. ვთქვათ გვაქვს სტრიმი, რომელიც რაუთის პარამეტრებს გასცემს და ჩვენ ყოველ უახლეს გაცემულ პარამეტრზე გვინდა საპასუხოდ HTTP მოთხოვნა გავაგზავნოთ, რომელიც ამ პარამეტრიდან მონაცემს გამოიყენებს. ჩვენ შეგვიძლია, რომ პარამეტრების სტრიმი “გადავაბათ” პროდუქტის HTTP მოთსოვნის სტრიმს.

იმავე პროექტში შევქმნათ products კომპონენტი ng g c products ბრძანებით, შემდეგ app.routes.ts-ში დავამატოთ რაუთები:

import { ProductsComponent } from "./products/products.component";
import { Routes } from "@angular/router";

export const routes: Routes = [
  { path: "products/:id", component: ProductsComponent },
];

ჩვენ პროდუქტების მისამართზე გვექნება id პარამეტრი.

app.config.ts-ში შემოვიტანოთ provideHttpClient:

import { ApplicationConfig } from "@angular/core";
import { provideHttpClient } from "@angular/common/http";

export const appConfig: ApplicationConfig = {
  providers: [/* ... */ provideHttpClient()],

AppComponent-ში მოვათავსოთ აუთლეტი არ დაგავიწყდეთ RouterOutlet-ის დამატება კომპონენტის იმპორტებში:

<h1>{{ mouseCoordinates$ | async | json }}</h1>
<router-outlet></router-outlet>

და მივხედოთ ProductsComponent-ს:

import { Component } from "@angular/core";
import { CommonModule } from "@angular/common";
import { HttpClient } from "@angular/common/http";
import { ActivatedRoute } from "@angular/router";
import { switchMap } from "rxjs";

@Component({
  selector: "app-products",
  standalone: true,
  imports: [CommonModule],
  templateUrl: "./products.component.html",
  styleUrls: ["./products.component.css"],
})
export class ProductsComponent {
  product$ = this.route.params.pipe(
    switchMap((params) => this.getProductById(params["id"]))
  );
  constructor(private http: HttpClient, private route: ActivatedRoute) {}

  getProductById(id: string) {
    return this.http.get<{ title: string; description: string; price: string }>(
      "https://dummyjson.com/products/" + id
    );
  }
}

კონსტრუქტორში შემოგვაქვს HttpClient და ActivatedRoute. აქვე ვქმნით მეთოდს რომლითაც მოკლედ გავგზავნით GET მოთხოვნას ჩვენთვის უკვე ცნობილ მისამართზე, სადაც ბოლოს საჭიროა id-ს მიწოდება, რომელსაც ფუნქციის პარამეტრში მივიღებთ. მოთხოვნის დაბრუნებულ ტიპში მხოლოდ ამ სამ თვისებას, სათაურს, აღწერასა და ფასს მივუთითებთ, რადგან დემონსტრაციისთვის ესეც საკმარისია.

შემდეგ ჩვენ ვქმნით product$ სტრიმს, სადაც ვიღებთ route-ის params სტრიმს, და მასზე pipe-ში ვაწარმოებთ switchMap ოპერაციას. ვიღებთ დაბრუნებულ პარამეტრებს და შედეგად ვაბრუნებთ http მოთხოვნას სადაც ჩვენ პარამეტრებიდან აღებულ id-ს ვაწვდით. ასე ერთი სტრიმიდან ვინაცვლებთ მეორეზე. ყოველი ახალი params-ის დაემითებულ მნიშვნელობაზე ჩვენ ავიღებთ ამ მნიშვნელობას და გადავერთვებით ახალ სტრიმზე, რომელიც პროდუქტის მიღების http მოთხოვნაა. map ოპერატორისგან განსხვავებით, switchMap-ში საჭიროა, რომ დავაბრუნოთ სტრიმი. ანუ ჩვენ არა უბრალოდ შედეგის მოდიფიკაციას ვაკეთებთ, არამედ მთლიანად სტრიმის გადამისამართებას, ერთიდან მეორეზე.

თემფლეითში ახლა შეგვიძლია async ფაიფით მისი გამოსახვა:

<div *ngIf="product$ | async as product">
  <h1>{{ product.title }}</h1>
  <p>{{ product.description }}</p>
  <p>{{ product.price }}</p>
</div>

ჩვენ async ფაიფი შეგვიძლია ngIf დირექტივშიც გამოვიყენოთ. შესაბამისად ეს ბლოკი მაშინ გამოჩნდება, როცა სტრიმი შედეგს დააბრუნებს, ანუ როცა http მოთხოვნაზე პასუხს მივიღებთ. as product არის ანგულარის მეთოდი, რომ ამოვიღოთ სტრიმის დაბრუნებული მნიშვნელობა, როგორც ლოკალური ცვლადი თემფლეითში. ამ ბლოკის შიგნით ამ ცვლადზე წვდომა გვაქვს, და შეგვიძლია მისი თვისებები გამოვსახოთ.

შედეგად თუ გადავინაცვლებთ მისამართზე localhost:4200/products/1 და მოვიცდით, პროდუქტი უნდა გამოჩნდეს. id-ის შეცვლისას ყოველ ჯერზე მიმდინარე მისამართზე params იცვლება, და შესაბამისად ჩვენი სტრიმის იცვლება ახალ http მოთხოვნად, რომელიც sync ფაიფის წყალობით აქტიურდება და გამოსახება აპლიკაციაში.

takeUntilDestroyed

takeUntilDestroyed ანგულარის მიერ მოწოდებული ოპერატორია, რომელიც სტრიმს იქამდე ამუშავებს, სანამ კომპონენტი არ განადგურდება:

import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
/* ... */
export class AppComponent {
  constructor(private route: ActivatedRoute) {
    this.route.params
      .pipe(takeUntilDestroyed())
      .subscribe((params) => console.log(params));
  }
}

ეს ოპერატორი არგუმენტების გარეშე იმუშავებს მხოლოდ კონსტრუქტორის შიგნით, რადგან იქ პირდაპირ ხელმისაწვდომია კომპონენტის DestroyRef, სხვა შემთხვევაში ის ჩვენით უნდა მივაწოდოთ ამ ოპერატორს.

შეჯამება

ამ თავში ჩვენ განვიხილეთ ფაიფის ოპერატორები, რომლებითაც შესაძლებელია სტრიმების ბევრნაირად მოდიფიკაცია. ეს არის ყველაზე გამოყენებადი ოპერატორები, რომლებიც დამწყები ანგულარ დეველოპერის ამოცანათა უმეტესობას ამოხსნის, თუმცა ნუ შემოიფარგლებით მხოლოდ ამ ცოდნით. დაათვალიერეთ სხვა ოპერატორებიც და გაეცანით გამოცდილი ანგულარ დეველოპერების ბლოგებს, სადაც მრავალ პრაქტიკულ რჩევა-დარიგებებს ნახავთ.

Subjects

საბჯექთი მნიშვნელოვანი ხელსაწყოა ანგულარის ეკოსისტემაში, რომელიც გვეხმარება, რომ აპლიკაციის მდგომარეობა ვმართოთ რეაქტიულად.

საბჯექთები არიან ე.წ “hot observables” ან “multicast observables”. ჩვეულებრივი სტრიმი არის ცივი (ან unicast), რაც იმას ნიშნავს, რომ სტრიმი, როგორც ერთი პროდიუსერი, უკავშირდება მხოლოდ ერთ კონსუმერს. ანუ იქმნება ერთი წყარო და ამ წყაროს ერთი მიმღები. საბჯექთები, რომლებიც unicast, ანუ ცხელი სტრიმები არიან წარმოადგენენ პროდიუსერს, რომელსაც შეიძლება გააჩნდეს ერთზე მეტი კონსუმერი. ანუ საბჯექთით შეგვიძლია შევქმნათ ერთი სტრიმი, რომელსაც ბევრი სხვადასხვა ადგილიდან დავაკვირდებით.

ჩვეულებრივი Subject

შევქმნათ ანგულარის საწყის აპლიკაციაში მარტივი საბჯექთი:

import { Component } from "@angular/core";
import { CommonModule } from "@angular/common";
import { Subject } from "rxjs";

@Component({
  selector: "app-root",
  standalone: true,
  imports: [CommonModule],
  templateUrl: "./app.component.html",
  styleUrls: ["./app.component.css"],
})
export class AppComponent {
  mySubject$ = new Subject<string>();

  onClick() {
    this.mySubject$.next("hello!");
  }
}

ჩვენ ვაიმპორტებთ rxjs-დან Subject-ს და მის ინსტანციას ვინახავთ mySubject$ თვისებაში. აქ ასევე შეგვიძლია მივუთითოთ საბჯექთი რა ტიპის მნიშვნელობებს გასცემს. ეს სანიმუშოდ იყოს სტრინგი. საბჯექთების საშუალებით ჩვენ შეგვიძლია ჩვენით გავცეთ სტრიმში მნიშვნელობები. მაგალითად onClick მეთოდში სტრიმში გავცეთ სტრინგი, რომელსაც ჩვენ next მეთოდის საშუალებით ვაკეთებთ.

თემფლეითში ეს ფუნქცია ღილაკს დავუკავშიროთ:

<button (click)="onClick()">Click me</button>

ასე ჯერჯერობით არაფერი მოხდება. ჩვენ საბჯექთში კი გავცემთ ახალ მონაცემს, მაგრამ ჩვენ მას არსად არ ვუსმენთ! მასზე შეგვიძლია დავასუბსქრაიბოთ თემფლეითში async ფაიფით, თუმცა ჯერ უბრალოდ ngOnInit შემოვიტანოთ და მასზე აქ დავასუბსქრაიბოთ.

import { Component, OnInit } from "@angular/core";
import { CommonModule } from "@angular/common";
import { Subject } from "rxjs";

@Component({
  selector: "app-root",
  standalone: true,
  imports: [CommonModule],
  templateUrl: "./app.component.html",
  styleUrls: ["./app.component.css"],
})
export class AppComponent implements OnInit {
  mySubject$ = new Subject<string>();

  ngOnInit(): void {
    this.mySubject$.subscribe((result) => {
      console.log(result);
    });
  }

  onClick() {
    this.mySubject$.next("hello!");
  }
}

ახლა ყოველ დაკლიკებაზე ჩვენ დავანექსთებთ სტრიმში “hello” მნიშვნელობას, რომელიც კონსოლში დაილოგება.

ახლა მისი მნიშვნელობა თემფლეითშიც გავიტანოთ async ფაიფით.

<button (click)="onClick()">Click me</button>
<h1>{{ mySubject$ | async }}</h1>

ასე ღილაკზე დაკლიკება h1 ელემენტშიც გამოსახავს საბჯექთისთვის next მეთოდში მიწვდილ მნიშვნელობას. ჩვენ გვაქვს ერთი წყარო, mySubject რომელსაც ვუსმენთ ორ ადგილას: კომპონენტის კლასში, ngOnInit ჰუკში და კომპონენტის თემფლეითში, async ფაიფით. ანუ ერთ წყაროს ჰყავს ორი კონსუმერი.

არსებობს უფრო კონკრეტული დანიშნულების მქონე საბჯექთები: ReplaySubject და BehaviorSubject. ჩვენ ვისაუბრებთ მხოლოდ BehaviorSubject-ზე, რადგან ReplaySubject საკმაოდ სპეციფიკურია და ის იშვიათად თუ დაგჭირდებათ. მის შესახებ შეგიძლიათ თქვენით მოიძიოთ ინფორმაცია.

BehaviorSubject

BehaviorSubject არის იგივე საბჯექთი, თუმცა მას ჩვეულებრივი საბჯექთისგან ის განასხვავებს, რომ გააჩნია საწყისი მნიშვნელობა. მას ინიციალიზაციის დროს კონსტრუქტორში ვაწვდით ამ მნიშვნელობას:

import { Component, OnInit } from "@angular/core";
import { CommonModule } from "@angular/common";
import { BehaviorSubject } from "rxjs";

@Component({
  selector: "app-root",
  standalone: true,
  imports: [CommonModule],
  templateUrl: "./app.component.html",
  styleUrls: ["./app.component.css"],
})
export class AppComponent implements OnInit {
  mySubject$ = new BehaviorSubject<string>("Initial Value");

  ngOnInit(): void {
    this.mySubject$.subscribe((result) => {
      console.log(result);
    });

    setTimeout(() => {
      this.mySubject$.subscribe((result) => {
        console.log("Delayed subscription", result);
      });
    }, 5000);
  }

  onClick() {
    this.mySubject$.next("hello!");
  }
}

ახლა ამ საბჯექთზე დასუბსქრაიბებისას კონსოლსა და თემფლეითში მივიღებთ “Initial Value” ტექსტს, ხოლო ღილაკზე დაკლიკებით ამ მნიშვნელობას მოყვება ახალი მნიშვნელობა “Hello”.

ამ ტიპის საბჯექთზე დასუბსქრაიბება თავდაპირველად ნებისმიერ შემთხვევაში დაგვიბრუნებს ბოლოს გაცემულ მნიშვნელობას, ასეთის არსეობის შემთხვევაში. ჩვეულებრივ საბჯექთზე დასუბსქრაიბებისას შედეგს ამ საბსქრიბშენში მხოლოდ მაშინ მივიღებთ, თუ საბჯექთზე next მეთოდი გააქტიურდება დასუბსქრაიბების შემდეგ. BehaviorSubject-ით, თუ next მეთოდი დასუბსქრაიბებამდე გააქტიურდა, დასუბსქრაიბებისას მასში მიწოდებული უკანასკნელი მონაცემი დაგვიბრუნდება.

აქ ჩვენ ვქმნით დამატებით საბსქრიბშენს, რომელიც აპლიკაციის ინიციალიზებიდან 5 წამის დაგვიანებით შეიქმნება. თუ ჩვენ მაქამდე დავაკლიკებთ ღილაკს, მიმდინარე საბსქრიფშენები მას დააფიქსირებენ, მაგრამ როგორც კი დაგვიანებული საბსქრიფშენი შეიქმნება, სტრიმში უკანასკნელ მნიშვნელობას ისიც დალოგავს.

ჩვეულებრივი საბჯექთის შემთხვევაში ეს არ მოხდებოდა და დაგვიანებული საბსქრიბშენი ახალი მონაცემის დაემიტების მოლოდინის რეჟიმში იქნებოდა.

აპლიკაციის მდგომარეობის მართვა Subject-ებით

აპლიკაციის state-ის, ანუ მისი მდგომარეობის მენეჯმენტი გულისხმობს ისეთი ცვლადების მართვას, რომელიც აპლიკაციაში მუდმივად იცვლება სხვადასხვა ფაქტორების გამო.

მარტივად რომ ვთქვათ, აპლიკაციის სთეითის ნაწილია ყველაფერი, რაც შეიძლება შეიცვალოს. აპლიკაცია რომ ლიფტი იყოს, რომელსაც ჩვენი გადაყვანა შეუძლია ერთი სართულიდან მეორეზე, მაშინ მისი სთეითი, ანუ მდგომარეობა შეიძლება იყოს შემდეგი:

  • მოძრავია თუ უძრავი
  • თუ უძრავია, რომელ სართულზეა გაჩერებული
  • თუ მოძრავია, ზემოთ მიდის თუ ქვემოთ
  • დატვირთულია თუ არა ის ადამიანებით
  • რამდენი ადამიანი იმყოფება მასში
  • და სხვა

აპლიკაციაშიც, შესაბამისად სთეითი შეიძლება იყოს მომხმარებლის პირადი ინფორმაცია, რომელიც გვერდზეა განთავსებული, პროდუქტები, რომლებიც მის საშოპინგო კალათშია გამოსახული, კატეგორია, რომლის მიხედვითაც პროდუქტებია გაფილტრული და ა.შ.

ვინაიდან ეს ყველაფერი მომხმარებლის მოქმედებების მიხედვით შეიძლება შეიცვალოს, ანგულარ დეველოპერები მივმართავთ RxJS-ს რათა აპლიკაცის სთეითი (ან მისი ცალკეული ნაწილები) გავხადოთ სტრიმი, რომელიც მომხმარებლის მოქმედებების მიხედვით ახალ მნიშვნელობებს გასცემს. ეს არ არის ერთადერთი მიდგომა სთეით მენეჯმენტისთვის, შესაძლებელია საერთოდ არ გამოვიყენოთ RxJS თუმცა ანგულარში ყველაზე გავრცელებული მიდგომა ეს არის. ამ მიდგომას ხშირად მოიხსენებენ როგორც “რეაქტიულ პარადიგმას”.

ამ თავში შევქმნით მარტივ “To Do” აპლიკაციას, სადაც გასაკეთებელი საქმეების სიის სთეითს ვმართავთ RxJS-ის Subject-ების საშუალებით.

პროექტის მომზადება

ამ პრიექტის დასრულებული ვერსია შეგიძლიათ ნახოთ აქ, თუმცა რეკომენდირებულია, რომ ჯერ გაკვეთილს მიყვეთ და დასრულებულ კოდს მხოლოდ იმ შემთხვევაში ჩახედოთ, თუკი გაიჭედებით.

გავცეთ ბრძანება ახალი პროექტის შესაქმნელად:

ng new todo-rxjs

აპლიკაციაში არ გამოვიყენებთ რაუთინგს. მარკაპისთვის გამოვიყენებთ ბუტსტრაპს. მას index.html-ში შემოვიტანთ CDN-ის საშუალებით:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Todo Rxjs</title>
    <base href="/" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <link rel="icon" type="image/x-icon" href="favicon.ico" />
    <link
      href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/css/bootstrap.min.css"
      rel="stylesheet"
      integrity="sha384-KK94CHFLLe+nY2dmCWGMq91rCGa5gtU4mk92HdvYe+M/SXH301p5ILy+dN9+nJOZ"
      crossorigin="anonymous"
    />
  </head>
  <body>
    <app-root></app-root>
    <script
      src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/js/bootstrap.min.js"
      integrity="sha384-Y4oOpwW3duJdCWv5ly8SCFYWqFDsfob/3GkgExXKV4idmbt98QcxXYs9UoXAB7BZ"
      crossorigin="anonymous"
    ></script>
  </body>
</html>

app.config.ts-ში შემოვიტანოთ provideHttpClient:

import { ApplicationConfig } from "@angular/core";
import { provideHttpClient } from "@angular/common/http";

export const appConfig: ApplicationConfig = {
  providers: [provideHttpClient()],
};

AppComponent-ის იმპორტებში შემოვიტანოთ FormsModule:

import { FormsModule } from "@angular/forms";
@Component({
  imports: [/* ... */ FormsModule]
})

ახლა პატარა ბექენდის სიმულატორი გვჭირდება. ამ პროექტში გამოვიყენებთ json-server ბიბლიოთეკას, რომელიც ლოკალურად უნდა დავაინსტალიროთ პროექტში:

npm install json-server

ეს ბიბლიოთეკა საშუალებას მოგვცემს რომ მარტივი მონაცემთა ბაზა შევქმნათ, როგორც JSON-ის ფაილი და გავუშვათ ბექენდ სერვერი.

აპლიკაციის ზედა დონეზე შევქმნათ ფაილი database.json და მასში რამდენიმე სატესტო ობიექტი შევიტანოთ todo თვისების შიგნით მასივში:

{
  "todos": [
    {
      "title": "Walk the dog",
      "done": false,
      "id": 1
    },
    {
      "title": "Call grandma",
      "done": true,
      "id": 2
    },
    {
      "title": "Buy groceries",
      "done": false,
      "id": 3
    }
  ]
}

ახლა package.json-ში დავამატოთ სკრიპტი, რათა ჩავრთოთ json-srrver და მას გამოვაყენებინოთ database.json-ს. სკრიპტს scripts ველში ვამატებთ server სახელით.

  "scripts": {
    "ng": "ng",
    "start": "ng serve",
    "build": "ng build",
    "watch": "ng build --watch --configuration development",
    "test": "ng test",
    "server": "json-server database.json"
  },

ახლა თუ ჩვენ ტერმინალში გავუშვებთ ბრძანებას npm run server ის გააქტიურებს json-server-ს. ეს სერვერი უნდა აქტიური იყოს, რათა ფრონტენდმა მასთან კავშირი დაამყაროს.

ტერმინალის მეორე ინსტანცია გავხსნათ და მანდ გავუშვათ npm run start ან ng serve. ახლა ფრონტენდის და ბექენდის სერვერები გააქტიურებულია და მზად ვართ რომ პროექტზე მუშაობა დავიწყოთ.

შევუდგეთ სთეითის ინიციალიზაციას!

სთეითში მონაცემების ინიციალიზაცია

ჯერ შევქმნათ სერვისი todo.service.ts, რომელიც გასაკეთებელი საქმეების სიის მენეჯმენტზე იზრუნებს, ისევე როგორც ბექენდიდან ამ სიის მიღება-მოდიფიკაციაზე. მივხედოთ სთეითის ინიციალიზაციის ლოგიკას. ანუ ჩვენ გვინდა, რომ როცა აპლიკაცია გაიხსნება, ჩაიტვირთოს გასაკეთებელი საქმეები.

import { HttpClient } from "@angular/common/http";
import { Injectable } from "@angular/core";
import { BehaviorSubject, catchError, of, tap } from "rxjs";

export interface TodoItem {
  id: number;
  title: string;
  done: boolean;
}

@Injectable({ providedIn: "root" })
export class TodoService {
  private url = "http://localhost:3000/todos";

  private todos$ = new BehaviorSubject<TodoItem[]>([]);

  constructor(private http: HttpClient) {}

  get todos() {
    return this.todos$.asObservable();
  }

  public init() {
    this.http
      .get<TodoItem[]>(this.url)
      .pipe(
        tap((todos) => {
          this.todos$.next(todos);
        })
      )
      .subscribe();
  }
}

ჩვენ აქვე ვქმნით TodoItem-ის ინტერფეისს, რომელიც იმ ტიპს შეესაბამება, რა ტიპის ობიექტებიც დავამატეთ database.json-ში.

სერვისში ვინახავთ მისამართს, რომელიც ენდფოინთის მიხედვით აცნობებს json-server-ს JSON ფაილში რომელი თვისებიდან ამოიღოს ინფორმაცია. ვქმნით todo$-ს რომელიც იქნება BehaviorSubject, ის დააბრუნებს TodoItem მასივის ტიპის მონაცემებს და ის თავიდან იქნება ცარიელი მასივი.

კონსტრუქტორში შემოგვაქვს HttpClient რომლითაც მოთხოვნებს განვახორციელებთ.

აქვე ვმქნით გეთერს, რომელიც ამ ჩვენ todo$ სტრიმს დააბრუნებს asObservable მეთოდით, ეს საბჯექთის მაგივრად აბრუნებს Observable ინსტანციას, რათა მასზე კომპონენტებიდან next მეთოდის დაძახება არ იყოს შესაძლებელი. ჩვენ გვინდა, რომ next მეთოდის დაძახება შეიძლებოდეს მხოლოდ სერვისიდან, ამით გაუგებრობებს ავირიდებთ თავიდან რადგან სხვადასხვა კომპონენტებიდან მისი დაძახება რაღაც ეტაპზე ქაოსს გამოიწვევს.

ჩვენ ვქმნით init მეთოდს, რომელსაც შეგვიძლია კომპონენტიდან დავუძახოტ. ის HTTP მოთხოვნით მიიღებს მონაცემებს და მას tap ოპერატორით todos$ სტრიმში დაანექსთებს. ასე GET მოთხოვნას მოყვება ეფექტი, რომელიც სთეითის სტრიმში ახალ მნიშვნელობას გასცემს. მასზე აქვე ვასუბსქრაიბებთ, რადგან კომპონენტში მონაცემებს მაინც todos$ სტრიმიდან ავიღებთ. ჩვენთვის მთავარია, რომ ამ მეთოდზე დაძახებამ უბრალოდ მოთხოვნა გაგზავნოს და tap-ში არსებული ეფექტი გამოიწვიოს. ანსუბსქრაიბი აქ არ დაგვჭირდება, რადგან ამას HttpClient აგვარებს.

ახლა AppComponent-ში გამოვიყენოთ ეს სერვისი:

import { ChangeDetectionStrategy, Component, OnInit } from "@angular/core";
import { CommonModule } from "@angular/common";
import { FormsModule } from "@angular/forms";
import { TodoItem, TodoService } from "./todo.service";

@Component({
  selector: "app-root",
  standalone: true,
  imports: [CommonModule, FormsModule],
  templateUrl: "./app.component.html",
  styleUrls: ["./app.component.css"],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppComponent implements OnInit {
  todos$ = this.todoService.todos;

  constructor(private todoService: TodoService) {}

  ngOnInit(): void {
    this.todoService.init();
  }
}

აქ ჩვენ @Component დეკორატორში საინტერესო რაღაცას ვამატებთ: changeDetection-ის სტრატეგიას ვცვლით onPush-ზე (რომელიც უნდა დავაიმპორტოთ). ეს ეუბნება ანგულარის აპლიკაციას, რომ ავტომატურად არ დააფიქსიროს ცვლილებები და არ დაარენდეროს თემფლეითი. ამის გამოყენება უაცილებელი არ არის, მაგრამ რეაქტიული მიდგომის დროს ეს რესურსებს ზოგავს, რადგან ჩვენ შეგვიძლია ცვლილებების დეტექტორი მაშინ დავატრიგეროთ, როცა ამის საჭიროება ნამდვილად არსებობს. ეს მეთოდი სირთულეებს შექმნის, თუ ჩვენ სტრიმებს და async ფაიფებს არ ვიყენებთ, თუმცა მათი გამოყენების შემთხვევაში აპლიკაცია შედარებით უფრო სწრაფია და ნაკლებ რესურსებს საჭიროებს. ანგულარში რეაქტიული პროგრამირების დროს ფაქტობრივად ყველა კომპონენტი onPush-ზე გვაქვს.

ჩვენ ვაინჯექთებთ TodoService-ს და კომპონენტის თვისებაში ვინახავთ ნივთების სტრიმს, რომელსაც გეთერი გვიბრუნებს.

აპლიკაციის ინიციალიზაციისას სერვისზე ვეძახით init მეთოდს, რომელის შედეგადაც ჩვენ კომპონენტის todos$ სტრიმში მონაცემები უნდა მივიღოთ, რა თქმა უნდა ეს იმ შემთხვევაში, თუ ამ სტრიმზე დავასუბსქრაიბებთ. კონვენციურად ყოველთვის უნდა ვცადოთ თემფლეითში async ფაიფით დასუბსქრაიბება იმის მაგივრად, რომ მსაზე კლასში დავასუბსქრაიბოთ.

<div class="container" style="max-width: 500px">
  <h1>Your List:</h1>
  <ul class="list-group" *ngIf="todos$ | async as todos">
    <p *ngIf="todos.length === 0">Your list will show here...</p>
    <li
      class="list-group-item d-flex justify-content-between align-items-center"
      *ngFor="let item of todos"
    >
      <div class="d-flex align-items-center">
        <input type="checkbox" [checked]="item.done" />
        <span class="ms-2">{{ item.title }}</span>
      </div>
    </li>
  </ul>
</div>

ჩვენ გამოვსახავთ სიას ngIf დირექტივით, სადაც async ფაიფით სტრიმზე ვასუბსქრაიბებთ და მისი შედეგი გამოგვაქვს, როგორც todo ცვლადი. შემდეგ ამ ცვლადზე ვაკეთებთ ლუპს და მათ გამოვსახავთ. ჩვენ ვქმნით ჩექბოქსს ყოველი ნივთისთვის, რომელიც მინიშნული იქნება, თუ მისი done თვისება ჭეშმარიტია. თუ მასივი ცარიელია, ჩვენ ტექსტით ამაზე მივანიშნებთ. async ფაიფის ერთ-ერთი პლიუსი ის არის, რომ ის view-ს ხელახლა დარენდერებას მაშინ აიძულებს, როცა მასში გატარებული სტრიმი ახალ მნიშვნელობას გასცემს.

თუ ბრაუზერს შევხედავთ, ნივთების სია უნდა გამოისახოს. ახლა ახალი ნივთების დამატებას მივხედოთ.

სთეითში მონაცემების დამატება

მონაცემების დასამატებლად დაგვჭირდება სერვისში სათანადო ლოგიკის შემოტანა:

  public addItem(title: string) {
    const itemToAdd = {
      title: title,
      done: false,
    };

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

    this.http
      .post<{ id: number }>(this.url, itemToAdd, {
        headers,
      })
      .pipe(
        tap((response) => {
          const newItem = {
            id: response.id,
            ...itemToAdd,
          };
          this.todos$.next([...this.todos$.value, newItem]);
        })
      )
      .subscribe();
  }

ჩვენ პარამეტრში მივიღებთ ახალი ნივთის აღწერას. მისგან ვქმნით ობიექტს, რომელსაც ექნება სათაური და done, რომელიც თავიდან მცდარი უნდა იყოს. id თვისებას json-server თავისით მიანიჭებს უნიკალური მნიშვნელობით.

აქვე ვქმნით HttpHeaders-ის ინსტანციას, სადაც საჭიროა რომ მივუთითოთ Content-Type როგორც application/json რათა მოთხხოვნამ მონაცემები მიიღოს (ეს json-server-ის სპეციფიკური საჭიროებაა). მოღხოვნას ვაგზავნით და ვაწვდით ახალ ნივთს (რომელიც სტრინგად ავტომატურად დაკონვერტირდება). მესამე არგუმენტად ვაწოდებთ ობიექტში (შემოკლებულად) headers თვისებაში ჩვენი HttpHeaders-ის ინსტანციას.

საპასუხოდ სერვერი გვიბრუნებს ობიექტს, სადაც გვაქვს რიცხვის ტიპის აიდი. ამის საფუძველზე შეგვიძლია დავწეროთ ეფექტი tap ოპერატორში, სადაც ამ აიდის ავიღებთ, ჩავსვამთ ობიექტში, რომელსაც ასევე ექნება ის დანარჩენი თვისებები, რაც თავიდან შექმნილ ახალ ნივთს მივეცით და დავანექსთებთ ახალ მასივს, სადაც ჩავყრით სპრედ ოპერატორით todos$ სტრიმის უკანასკნელ მნიშვნელობას, პლიუს ახლად შექმნილ ნივთს. ასე სტრიმში ვანექსთებთ ძველ მასივს მასში დამატებული ახალი ნივთით.

კომპონენტის კლასში შემოვიტანოთ თვისება newItemTitle სადაც შეყვანილ ტექსტს შევინახავთ და addItem მეთოდი, რომლითაც დავუძახებთ სერვისზე დამატების მეთოდს და მას შეყვანილ ტექსტს გავატანთ. ამის შემდეგ ტექსტს ვაცარიელებთ.

import { ChangeDetectionStrategy, Component, OnInit } from "@angular/core";
import { CommonModule } from "@angular/common";
import { FormsModule } from "@angular/forms";
import { TodoItem, TodoService } from "./todo.service";

@Component({
  selector: "app-root",
  standalone: true,
  imports: [CommonModule, FormsModule],
  templateUrl: "./app.component.html",
  styleUrls: ["./app.component.css"],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppComponent implements OnInit {
  newItemTitle = "";

  todos$ = this.todoService.todos;

  constructor(private todoService: TodoService) {}

  ngOnInit(): void {
    this.todoService.init();
  }

  addItem() {
    this.todoService.addItem(this.newItemTitle);
    this.newItemTitle = "";
  }
}

თემფლეითში ul ელემენტამდე ჩავსვათ კონტეინერი, სადაც მოვათავსებთ input-სა და ღილაკს. input დავაკავშიროთ კლასის თვისებასთან NgModel-ით, ხოლო ღილაკზე დაკლიკების მოვლენა მივაბათ addItem მეთოდს. ღილაკი გაუქმებული იქნება, თუ newItemTitle ცარიელი სტრინგია.

<div class="row mb-2 gap-2 p-2">
  <input
    type="text"
    placeholder="Add a new item..."
    [(ngModel)]="newItemTitle"
    class="col-12"
  />
  <button
    class="btn btn-primary col-12"
    [disabled]="!newItemTitle"
    (click)="addItem()"
  >
    <span>Add</span>
  </button>
</div>

ტექსტის შეყვანა და ღილაკზე დაკლიკება სერვისზე მეთოდს გააქტიურებს რაც HTTP მოთხოვნას განახორციელებს და სტრიმს განაახლებს ახალი დამატებული ნივთით. ვინაიდან სტრიმზე უკვე თემფლეითში ვასუბსქრაიბებთ, შედეგი გამოჩნდება.

ახლა სთეითში მოლოდინის რეჟიმისა და ერორების ასახვაზე გადავინაცვლოთ.

სთეითში მოლოდინის რეჟიმისა და ერორის ასახვა

API-სთან დაკავშირებულ ოპერაციებს რაღაც დრო სჭირდებათ, ამიტომ კარგი იქნება, თუ მომხმარებელს ვაცნობებთ, რომ რაღაც მოლოდინის პროცესი მიმდინარეობს. ამისთვის სერვისში დავამატოთ BehaviorSubject რომელსაც ერქმევა todosLoading$. ეს იქნება ბულიანი.

აუცილებელია გავითვალისწინოთ, რომ შესაძლებელია მოხდეს რაიმე ერორი. ამიტომ მისი მესიჯის შესახებ ინფორმაციაც სადმე უნდა შევინახოთ. ერორის არსებობის შემთხვევაში ჩვენ შეგვიძლია შევქმნათ error$ BehaviorSubject და მანდ დავანექსთოთ ამ ერორის ობიექტი ან null, იმ შემთხვევაში თუ ერორი არ არსებობს.

import {
  HttpClient,
  HttpErrorResponse,
  HttpHeaders,
} from "@angular/common/http";
import { Injectable } from "@angular/core";
import { BehaviorSubject, catchError, of, tap } from "rxjs";

export interface TodoItem {
  id: number;
  title: string;
  done: boolean;
}

@Injectable({ providedIn: "root" })
export class TodoService {
  private url = "http://localhost:3000/todos";

  private todos$ = new BehaviorSubject<TodoItem[]>([]);
  private todosLoading$ = new BehaviorSubject<boolean>(false);
  private error$ = new BehaviorSubject<HttpErrorResponse | null>(null);

  constructor(private http: HttpClient) {}

  get error() {
    return (
      this.error$
        .asObservable()
        // If error has emitted a value, loading has finished
        .pipe(tap(() => this.todosLoading$.next(false)))
    );
  }

  get loading() {
    return (
      this.todosLoading$
        .asObservable()
        // If loading state is retriggered, reset the error stream
        .pipe(tap((loading) => loading && this.error$.next(null)))
    );
  }

  get todos() {
    return (
      this.todos$
        .asObservable()
        // Every time this observable emits value, it means that loading has finished
        .pipe(tap(() => this.todosLoading$.next(false)))
    );
  }

  public init() {
    this.todosLoading$.next(true);

    this.http
      .get<TodoItem[]>(this.url)
      .pipe(
        tap((todos) => {
          this.todos$.next(todos);
        }),
        catchError((error: HttpErrorResponse) => {
          this.error$.next(error);
          return of(null);
        })
      )
      .subscribe();
  }

  public addItem(title: string) {
    this.todosLoading$.next(true);

    const headers = new HttpHeaders({
      "Content-Type": "application/json",
    });

    const itemToAdd = {
      title: title,
      done: false,
    };

    this.http
      .post<{ id: number }>(this.url, itemToAdd, {
        headers,
      })
      .pipe(
        tap((response) => {
          const newItem = {
            id: response.id,
            ...itemToAdd,
          };
          this.todos$.next([...this.todos$.value, newItem]);
        }),
        catchError((error: HttpErrorResponse) => {
          this.error$.next(error);
          return of(null);
        })
      )
      .subscribe();
  }
}

თითოეული საბჯექთისთვის ვქმნით გეთერებს, სადაც ამ საბჯექთებს, როგორც observable-ებს ვაბრუნებთ. მათზე აქვე ფაიფით ეფექტებს ვამატებთ tap ოპერატორით, რომელიც გააქტიურდება ყოველ ჯერზე, როცა ამ საბჯექთებზე next მეთოდს დავუძახებთ. ეს შემდეგი ეფექტებია:

  • get error-ში: როცა ერორი მნიშვნელობას დააემითებს, ეს ნიშნავს, რომ მოთხოვნა დასრულდა და ესეიგი მოლოდინის რეჟიმში აღარ ვართ, ამიტომ todosLoading$-ში ეს ახალი მდგომარეობა უნდა გავცეთ.
  • get loading-ში: თუ მოლოდინის რეჟიმი აქტიურდება, ესეიგი მოთხოვნა ხელახლა იგზავნება, რაც იმას ნიშნავს, რომ წინა ერორის ობიექტი (თუ ის აქამდე არსებობდა) ახლა უნდა გაქრეს, რათა თუ ამ სტრიმიდან მესიჯს გამოვსახავთ, ისიც გაუჩინარდეს.
  • get todos-ში: თუ ეს სტრიმი მნიშვნელობას აემითებს, ესეიგი პასუხი მივიღეთ, და მოლდონის რეჟიმი შეგვიძლია გამოვრთოთ, ანუ todosLoading$ სტრიმში გავცეთ false.

ყოველი მოთხოვნის გაგზავნის წინ, ჩვენ გავცემთ todosLoading$ სტრიმში true-ს.

თუთოეულ HTTP მოთხოვნას ასევე ვამატებთ catchError ოპერატორს. ეს ოპერატორი დაიჭერს სტრიმში არსებულ ერორებს და მასზე წვდომას მოგვცემს. ეს ერორები იქნება HttpErrorResponse ტიპის და ჩვენ მას გადავცემთ error$ სტრიმს, next მეთოდით. ამ ოპერატორში აუცილებელია რომ რაღაც დავაბრუნოთ, რადგან ოპერაცია წარუმატებალია, თუ ეს ოპერატორი გააქტიურდა, მაშინ შეგვიძლია უბრალოდ null დავაბრუნოთ, რომელსაც of ოპერატორით observable-ად ვაქცევთ. ეს საჭიროა რადგან catchError თვითონ შედეგს სტრიმის ფორმით არ აბრუნებს. ეს ოპერატორი გათვლილია იმაზე, რომ ერორის შემთხვევაში სანაცვლოდ დავვაბრუნოთ ახალი სტრიმი, მაგალითად HTTP მოთხოვნა ალტერნატიულ ენდფოინთზე, თუმცა ჩვენ ეს არ გვჭირდება.

ახლა შეგვიძლია ეს ყელაფერი თემფლეითში გავიტანოთ. AppComponent-ში შევქმნათ loading$ და error$ თვისებები, სადაც სათანადო სერვისის სტრიმებს შევინახავთ. თემფლეითში რომ ისინი პირდაპირ გავიტანოთ, მოგვიწევს ბევრგან async ფაიფის გამოყენება. ეს ყოველთვის პრობლემური არ არის, მაგრამ ხშირად გავრცელებული პრაქტიკაა ისეთი სტრიმების, რომლებიც თემფლეითში გამოიყენება, ერთ სტრიმად გაერთიანება და მხოლოდ ამ უკანასკნელზე დასუბსქრაიბება.

import { ChangeDetectionStrategy, Component, OnInit } from "@angular/core";
import { CommonModule } from "@angular/common";
import { FormsModule } from "@angular/forms";
import { combineLatest, map } from "rxjs";
import { TodoItem, TodoService } from "./todo.service";

@Component({
  selector: "app-root",
  standalone: true,
  imports: [CommonModule, FormsModule],
  templateUrl: "./app.component.html",
  styleUrls: ["./app.component.css"],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppComponent implements OnInit {
  newItemTitle = "";

  todos$ = this.todoService.todos;
  loading$ = this.todoService.loading;
  error$ = this.todoService.error;

  vm$ = combineLatest([this.todos$, this.loading$, this.error$]).pipe(
    map(([todos, loading, error]) => ({ todos, loading, error }))
  );

  constructor(private todoService: TodoService) {}

  ngOnInit(): void {
    this.todoService.init();
  }

  addItem() {
    this.todoService.addItem(this.newItemTitle);
    this.newItemTitle = "";
  }
}

აქ ჩვენ სწორედ ამიტომ ვიყენებთ combineLatest ოპერატორს, სადაც მასივში გადავცემთ ჩვენ კომპონენტში არსებულ სტრიმებს. ეს ოპერატორი აერთიანებს მიღებული სტრიმების უკანასკნელ მნიშვნელობებს და აბრუნებს ერთ სტრიმს, სადაც მასივის სახით ინახება გაერთიანებული სტრიმების უკანასკნელი მნიშვნელობები. ჩვენ მსაზე pipe-ს ვუძახებთ და ვიყენებთ map ოპერატორს, რათა ქოლბექიდან ავიღოთ ეს მასივი, მსაზე დესტრუქტურიზაცია მოვახდინოთ და დავაბრუნოთ ობიექტი, რომელიც თითოეული სტრიმის მნიშვნელობას შეინახავს ჩვენთვის სასურველი სახელის ქვეშ. აქ გაითვალისწინეთ, რომ რა ინდექსზეც მივაწოდეთ თითოეული სტრიმი combineLatests-ს, იმ ინდექსზე მივიღებთ ამ სტრიმების მნიშვნელობებს map-ის ქოლბექში.

შედეგად ვიღებთ ერთ დიდ სტრიმს სახელად vm$ რომელიც არის “View Model”-ის აბრივიაცია. ეს მთელი ჩვენი კომპონენტის გამოსახვადი მონაცემების მოდელია.

გაითვალისწინეთ, რომ combineLatest მნიშვნელობას იქამდე არ დააემითებს, სანამ ყველა მასში არსებული სტრიმი არ გასცემს, სულ მცირე ერთხელ, მნიშვნელობას. ჩვენ შემთხვევაში ეს პრობლემა არ არის, რადგან BehaviorSubject-ები ყოველთვის აბრუნებენ უკანასკნელ მნიშვნელობას.

ახლა თემფლეითში ყველაზე ზედა დონის კონტეინერზე ვასუბსქრაიბებთ მხოლოდ ამ vm$ სტრიმზე:

<div *ngIf="vm$ | async as vm" class="container" style="max-width: 500px">
  <h1>Your List:</h1>
  <div class="row mb-2 gap-2 p-2">
    <input
      type="text"
      placeholder="Add a new item..."
      [(ngModel)]="newItemTitle"
      class="col-12"
    />
    <button
      class="btn btn-primary col-12"
      [disabled]="!newItemTitle"
      (click)="addItem()"
    >
      <span *ngIf="!vm.loading">Add</span>
      <div
        class="spinner-border spinner-border-sm"
        role="status"
        *ngIf="vm.loading"
      >
        <span class="visually-hidden">Loading...</span>
      </div>
    </button>
  </div>
  <ul class="list-group">
    <div class="card text-bg-danger" *ngIf="vm.error">
      <div class="card-body">
        <p>Error: {{ vm.error.message }}</p>
      </div>
    </div>
    <p *ngIf="vm.todos.length === 0">Your list will show here...</p>
    <li
      class="list-group-item d-flex justify-content-between align-items-center"
      *ngFor="let item of vm.todos"
    >
      <div class="d-flex align-items-center">
        <input type="checkbox" [checked]="item.done" />
        <span class="ms-2">{{ item.title }}</span>
      </div>
    </li>
  </ul>
</div>

შედეგს ვინახავთ როგორც vm ობიექტს, და ამ ობიექტიდან ვიღებთ სათანადო თვისებებს:

  • ნივთის დამატების ღილაკის შიგნით გამოვაჩინოთ ტექსტი, თუ vm.loading არის მცდარი.
  • იმავე ღილაკის შიგნით გამოვაჩინოთ (ბუტსტრაპის) სპინერი, თუ vm.loading არის ჭეშმარიტი.
  • სიის თავში, ბუტსტრაპის ბარათში გამოვაჩნიოთ ერორის მესიჯი, თუკი ის არსებობს.
  • თუ vm.todos ცარიელი მასივია, მაშინ გამოვაჩინოთ მინიშნების ტექსტი.
  • და ბოლოს vm.todos-ზე ლუპით გამოვსახოთ თითოეული ნივთი.

გადავამოწმოთ, რომ აპლიკაცია მუშაობს. ახლა ჩატვირთვის სპინერი ყოველი მოთხოვნის დროს უნდა აქტიურდებოდეს და ყოველი მოთხოვნის დასრულების შემდეგ ქრებოდეს. ასევე, თუ ჩვენ ერორს გამოვიწვევთ (მაგალითად სერვისში არასწორი URL-ის გამოყენებით), ჩატვირთვის სპინერის გაქრობასთან ერთად შედეგად გამოგვიჩნდება ერორის მესიჯი. რა თქმა უნდა, ეს მომხმარებლისთვის მარტივად გასაგები მესიჯი არ არის, თუმცა ჯერჯერობით ესეც საკმარისია.

ახლა დროა მონაცემების განახლებასა და წაშლაზე ვიზრუნოთ.

წაშლა და მონიშვნა

ახლა ისღა დაგვრჩენია, რომ შევძლოთ ნივთების დასრულებულად მონიშვნა და წაშლა. სერვისში დავამატოთ მათი მეთოდები deleteItem და updateItem:

 public deleteItem(id: number) {
    this.todosLoading$.next(true);

    this.http
      .delete<void>(`${this.url}/${id}`)
      .pipe(
        tap(() => {
          this.todos$.next(this.todos$.value.filter((item) => item.id !== id));
        }),
        catchError((error: HttpErrorResponse) => {
          this.error$.next(error);
          return of(null);
        })
      )
      .subscribe();
  }

  public updateItem(updatedItem: TodoItem) {
    this.todosLoading$.next(true);

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

    this.http
      .patch<TodoItem>(`${this.url}/${updatedItem.id}`, updatedItem, {
        headers,
      })
      .pipe(
        tap((updatedItem) => {
          const updatedList = this.todos$.value.map((item) =>
            item.id === updatedItem.id ? updatedItem : item
          );
          this.todos$.next(updatedList);
        }),
        catchError((error: HttpErrorResponse) => {
          this.error$.next(error);
          return of(null);
        })
      )
      .subscribe();
  }

პრინციპი აქ იგივეა.

deleteItem-ში წასაშლელი ნივთის აიდის მივიღებთ პარამეტრში. ჯერ loadingTodos$ სთეითს განვაახლებთ და შემდეგ გავგზავნი DELETE მოთხოვნას ამ აიდის ენდფოინთზე. საპასუხოდ მონაცემი არ გვიბრუნდება, თუმცა HTTP მოთხოვნის წარმატების შემთხვვაში. tap მაინც გააქტიურდება. ჩვენ წაშლილი ნივთის აიდის მიხედვით ვფილტრავთ უახლეს მასივს, რათა ის წაშლილ ნივთს არ შეიცავდეს და ამ გაფილტრულ მასივს გადავცემთ todos$ სტრიმს. ერორის შემთხვევაში ერორის ობიექტს მოვიხელთებთ catchError ოპერატორით და გადავცემთ მას error$ სტრიმს.

updateItem-ში პარამეტრად ვიღებთ თვითონ განახლებულ ნივთს და მას გადავცემთ PATCH მოთხოვნას ამ ნივთის აიდის მქონე ენდფოინთზე. მაქამდე loading$ სთეითს ვაწვდით trues-ს და HttpHeaders-ის ინსტანციას ვქმნით, ისევე როგორც POST მოთხოვნაზე – მას მესამე არგუმენტად ობიექტში ვაწვდით. ეს მოთხოვნა აბრუნებს განახლებული ნივთის ობიექტს, რომელსაც ჩვენ tap ოპერატორში ვიღებთ და საპასუხოდ ვქმნით განახლებულ სიას, სადაც აიდით ვპულობთ იმ ძველ ნივთს, რომელიც ბექენდში განვაახლეთ და მას ვანაცვლებთ ახალი ნივთით. ამ მასივს გადავცემთ todos$ სტრიმს.

კომპონენტში შევქმნათ სათანადო მეთოდები მათ დასაძახებლად:

  changeDone(itemToChange: TodoItem) {
    const updatedItem = {
      ...itemToChange,
      done: !itemToChange.done,
    };

    this.todoService.updateItem(updatedItem);
  }

  deleteItem(id: number) {
    this.todoService.deleteItem(id);
  }

როცა ნივთს მოვნიშნავთ, ჩვენ პარამეტრში მივიღებთ სამიზნე ობიექტს და მას შევუცვლით done თვისებას არსებულის საპირისპიროთი. შემდეგ ამ ობიექტს გავატანთ სერვისის მეთოდს.

წაშლის შემთხვევაში პირდაპირ აიდის მივიღებთ პარამეტრში, რომელსაც მეთოდს გავატანთ.

სადაც თითოეულ ნივთს ვარენდერებთ NgFor დირექტივით, შემდეგი ცვლილებები შეგვაქვს:

<li
  class="list-group-item d-flex justify-content-between align-items-center"
  *ngFor="let item of vm.todos"
>
  <div class="d-flex align-items-center">
    <input type="checkbox" [checked]="item.done" (click)="changeDone(item)" />
    <span class="ms-2">{{ item.title }}</span>
  </div>
  <button class="btn btn-danger" (click)="deleteItem(item.id)">
    <svg
      xmlns="http://www.w3.org/2000/svg"
      width="16"
      height="16"
      fill="currentColor"
      class="bi bi-trash3-fill"
      viewBox="0 0 16 16"
    >
      <path
        d="M11 1.5v1h3.5a.5.5 0 0 1 0 1h-.538l-.853 10.66A2 2 0 0 1 11.115 16h-6.23a2 2 0 0 1-1.994-1.84L2.038 3.5H1.5a.5.5 0 0 1 0-1H5v-1A1.5 1.5 0 0 1 6.5 0h3A1.5 1.5 0 0 1 11 1.5Zm-5 0v1h4v-1a.5.5 0 0 0-.5-.5h-3a.5.5 0 0 0-.5.5ZM4.5 5.029l.5 8.5a.5.5 0 1 0 .998-.06l-.5-8.5a.5.5 0 1 0-.998.06Zm6.53-.528a.5.5 0 0 0-.528.47l-.5 8.5a.5.5 0 0 0 .998.058l.5-8.5a.5.5 0 0 0-.47-.528ZM8 4.5a.5.5 0 0 0-.5.5v8.5a.5.5 0 0 0 1 0V5a.5.5 0 0 0-.5-.5Z"
      />
    </svg>
  </button>
</li>

input-ზე ვამატებთ დაკლიკების ივენთ ბაინდინგს, რომელსაც ვაკავშირებთ changeItemDone ფუნქციასთან და მას არგუმენტად ვაწვდით მთლიან ობიექტს.

გვერდით ვამატებთ წაშლის აიქონიან ღილაკს, რომელსაც ივენთ ბაინდინგით ვაკავშირებთ deleteItem მეთოდთან და მას ვაწვდით ნივთის აიდის.

ახლა ჩვენი აპლიკაცია სრულფასოვნად უნდა მუშაობდეს. უნდა შესაძლებელი იყოს:

  • არსებული ნივთების ნახვა
  • ახალი ნივთის დამატება
  • ნივთის წაშლა
  • ნივთის მონიშვნა
  • ყოველი მოთხოვნის გაგზავნის დროს სპინერის გამოჩენა
  • ერორის შემთხვევაში ერორის მესიჯის გამოჩენა

დროა შევაჯამოთ, რა ვისწავლეთ ამ გაკვეთილში?

შეჯამება

ამ გაკვეთილში ჩვენ ვისწავლეთ RxJS-ის დახმარებით მარტივი აპლიკაციის სთეითის მართვა, სადაც აპლიკაციის სთეითის კონკრეტული ნაწილები არიან საბჯექთები, რომლებსაც რაღაც მოვლენების მიხედვით გავაცემინებთ ახალ მნიშვნელობებს. სთეითიდან ინფორმაციას ვიღებთ observable-ების ფორმით და მათზე ვასუბსქრაიბებთ თემფლეითში async ფაიფის მეშვეობით. ეს უზრუნველყოფს იმას, რომ unsubscribe ავტომატურად მოხდება და, ამასთანავე, თუ changeDetection სტრატეგიას შევცვლით OnPush-ზე, ანგულარი აპლიკაციის view-ს მხოლოდ იმ შემთხვევაში დაარენდერებს ხელახლა, თუ async ფაიფში გატარებული სტრიმები ახალ მნიშვნელობას გასცემენ, შედეგად რესურსების ეკონომიას ვაკეთებთ. ჩვენ ასევე შევქმენით ერთიანი vm$ სტრიმი, რომელიც სთეითის სხვადასხვა ნაწილს აერთიანებს და კომპონენტში მხოლოდ ამ ერთ სტრიმზე დავასუბსქრაიბეთ. ეს არ არის აუცილებელი მიდგომა, თუმცა კარგია ზოგჯერ სტრიმების ლოგიკურად დაჯგუფება. შესაძლებელია რომ აპლიკაციის თითოეულ გვერდს ჰქონდეს თავისი უნიკალური View Model სტრიმი.

ამ აპლიკაციის ბევრნაირად გაუმჯობესება არის შესაძლებელი, რაც ახლა დამოუკიდებლად შეგიძლიათ გააკეთოთ:

  • ნივთებიისთვის შექმნის თარიღის შესახებ ინფორმაციის მინიჭება
  • ნივთების სიაში სათაურის მოდიფიკაციის საშუალება
  • ნივთების სიის გაფილტვრა მათი სტატუსის მიხედვით (Routing Params-ის გამოყენებით)

ამ ბმულზე ნახავთ დასრულებული კოდის ნიმუშს.

NgModule

NgModule კონფიგურაციას უკეთებს ანგულარის აპლიკაციას და ეხმარება სხვადასხვა ბიბლიოთეკების ორგანიზებაში.

NgModule არის კლასი, რომელიც მონიშნულია @NgModule დეკორატორით. @NgModule იღებს ინფორმაციას ობიექტის ტიპად, რომელიც აღწერს თუ როგორ უნდა დააკომპილიროს კომპონენტის თიმფლეითი და როგორ მოხდეს სხვადასხვა პროცესების გაშვება runtime დროს. მოდულში აღიწერება: კომპონენტები, დირექტივები, და ფაიფები. ასევე NgModule-ში აღიწერება სერვისები და მისი პროვაიდერებიც, რაც DI გამოყენების საშუალებას გვაძლევს.

მოდული

მოდულები კარგი გზა არის აპლიკაციის ორგანიზებისათვის. მოდულის კონფიგურია არის შემდეგნაირი:

@NgModule({
  declarations: [
    // კომპონენტები, დირექტივები, და ფაიფები
  ],
  imports: [
    // ბიბლიოთეკები და მოდულები
  ],
  providers: [
    // სერვისები და პროვაიდერები
  ],
  bootstrap: [
    /*
      კომპონენტი, რომელსაც ანგულარი მოათავსებს index.html-ში აპლიკაციის გაშვების დროს.
      იგივე კომპონენტი აუცილებელია, ეწეროს დეკლარაციის მასივშიც.
    */
  ],
})
export class AppModule {}

მოდული ასრულებს შემდეგ მოქმედებებს:

  • აღწერს კომპონენტებს, დირექტივებს და ფაიფებს, რაც ეკუთვნის მოდულს.
  • ასაჯაროვებს სხვადასხვა კომპონენტებს, დირექტივებს და ფაიფებს, იმისათვის, რომ სხვა კომპონენტმა გამოიყენონ ისინი.
  • აიმპორტებს სხვა მოდულის ფუნქციონალს.
  • უზრუნველყოფბს სერვისებს აპლიკაციისთვის, რაც უნდა გამოიყენოს კომპონენტმა.

17 ვერსიამდე ანგულარის CLI მოდულებზე დაფუძვნებულ აპლიკაციას ქმნიდა. აპლიკაციის ძირეული მოდული იყო AppModule. 17-ს ზემოთ არსებული CLI გამოყენებით, ავტომატურად აპლიკაცია იქმნება standalone-ზე. თუ გვსურს მოდულზე დაფუძვნებული აპლიკაციის შექმნა მაშინ გამოვიყენოთ შემდგომი ბრძანება:

ng new app-name --standalone=false

პროექტში დაგენერირებული app.module.ts ერთად კრავს აპლიკაციისთვის საჭირო საშენ ბლოკებს:

import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";

import { AppRoutingModule } from "./app-routing.module";
import { AppComponent } from "./app.component";

@NgModule({
  declarations: [AppComponent],
  imports: [BrowserModule, AppRoutingModule],
  providers: [],
  bootstrap: [AppComponent],
})
export class AppModule {}

ანგულარის CLI-ის საშუალებით კომპონენტების, დირექტივებისა თუ ფაიფების შექმნისას მათი დეკლარაციები აქ დაემატება:

@NgModule({
  declarations: [AppComponent, ExampleComponent, ExamplePipe, ExampleDirective],
  imports: [BrowserModule, AppRoutingModule],
  providers: [],
  bootstrap: [AppComponent],
})
export class AppModule {}

ეს იმას ნიშნავს, რომ ყველა კომპონენტში ხელმისაწვდომი იქნება მისივე მოდულში დეკლარირებული ყველა სხვა საშენი ბლოკი. დეკლარაციები, იმპორტები და პროვაიდერები ვრცელდება ყველაფერზე, რაც ამ მოდულშია კონფიგურირებული. providers თვისების კონფიგურაცია, საჭიროებისამებრ, შესაძლებელია უშუალოდ კომპონენტებში, ფაიფებსა თუ დირექტივებშიც, და მათ დეკორატორებში მიწოდებული ეს კონფიგურაცია მოდულზე უპირატესი იქნება.

გასათვალისწინებელია, რომ შეიძლება standalone კომპონენტების შემოტანა მოდულზე დაფუძნებულ აპლიკაციაში, და პირიქით, მოდულების შემოტანა standalone კომპონენტებში.

Custom მოდული

ძირეული მოდულის გარდა, ასევე შესაძლებელი არის ცალკე მოდულების შექმნა. ცალკე მოდულის საშუალებით შესაძლებელია სხვადასხვა feature-ები ავკინძოთ ერთ მოდულში, ხოლო სადაც მოხდება მისი დაიმპორტება, იქ დაემატება მისი ფუნქციონალი. მაგალითისთვის ავაწყოთ მოდული, რომელშიც გამოყენებული იქნება მატერიალის კომპონენტები.

პირველ რიგში CLI გამოყენებით, დავაგენერიროთ მოდული:

ng g m material

დაგენერირებულ ფაილში, შევიტანოთ შემდგომი მოდიფიცირება (საჭიროა ანგულარ მატერიალის დაინსტალირება).

import { NgModule } from "@angular/core";
import { CommonModule } from "@angular/common";

import { MatFormFieldModule } from "@angular/material/form-field";
import { MatInputModule } from "@angular/material/input";
import { MatButtonToggleModule } from "@angular/material/button-toggle";

@NgModule({
  exports: [MatFormFieldModule, MatInputModule, MatButtonToggleModule],
  imports: [CommonModule],
})
export class MaterialModule {}

შეგვიძლია სხვა მოდულში, დავამატოთ ჩვენს მიერ შექმნილი MaterialModule, რაც საშუალებას მოგვცემს, გამოვიყენოთ მასში არსებული მატერიალის მოდულები, ესენია: MatFormFieldModule, MatInputModule, MatButtonToggleModule.

გამოყენებისთვის დავამატოთ AppModule-ში.

import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { AppComponent } from "./app.component";

import { MaterialModule } from "./material/material.module";

@NgModule({
  declarations: [AppComponent],
  imports: [BrowserModule, MaterialModule],
  providers: [],
  bootstrap: [AppComponent],
})
export class AppModule {}

Feature Modules

მოდულარულ აპლიკაციებში გავრცელებული პრაქტიკაა აპლიკაციის ნაწილების feature მოდულებად დაყოფა. ეს გულისხმობს აპლიკაციის რაუთებისა და ფუნქციონალების მოდულებად დაჯგუფებას. წარმოიდგინეთ აპლიკაცია, სადაც გვაქვს ონლაინ მაღაზიის ფუნქციონალი (მომხმარებლისთვის) და ასევე ადიმინისტრატორის ფუნქციონალი, სადაც ადმინისტრატორები მართავენ მომხმარებლებს, პროდუქციას და ა.შ.

მაშინ ჩვენი პროექტის არქიტექტურა ასე შეიძლება გამოიყურებოდეს:

src/app
├── admin/
│   ├── admin.module.ts
│   ├── dashboard/
│   ├── product-manager/
│   └── user-manager/
├── app.component.css
├── app.component.html
├── app.component.spec.ts
├── app.component.ts
├── app.module.ts
├── app-routing.module.ts
└── shop/
    ├── browse/
    ├── cart/
    └── shop.module.ts

აქ ადმინისტრატორის ფოლდერია, ცალკე თავისი მოდულით და კომპონენტებით (dashboard, product-manager, user-manager), რომლებიც დეკლარირებულია admin.module.ts-ში:

import { NgModule } from "@angular/core";
import { CommonModule } from "@angular/common";
import { UserManagerComponent } from "./user-manager/user-manager.component";
import { ProductManagerComponent } from "./product-manager/product-manager.component";
import { RouterModule, Routes } from "@angular/router";
import { DashboardComponent } from "./dashboard/dashboard.component";

const adminRoutes: Routes = [
  { path: "product-manager", component: ProductManagerComponent },
  { path: "user-manager", component: UserManagerComponent },
  { path: "", component: DashboardComponent, pathMatch: "full" },
];

@NgModule({
  declarations: [
    UserManagerComponent,
    ProductManagerComponent,
    DashboardComponent,
  ],
  imports: [CommonModule, RouterModule.forChild(adminRoutes)],
})
export class AdminModule {}

აქვე შეგვიძლია რაუთების კონფიგურაციაც და მათი დარეგისტრირება RouterModule.forChild-ით, რადგან ესენი ერთგვარი შვილობილი რაუთებია, რომლებსაც ძირეულ რაუთინგში დავარეგისტრირებთ და ერთი კონკრეტული რაუთის ქვეშ მოვათავსებთ, მაგალითად /admin-ის ქვეშ, და შესაბამისად რაუთები გამოვა /admin/product-manager, admin/user-manager და უბრალოდ /admin (რომელიც DashBoardComponent-ს ჩატვირთავს).

იგივე ეხება shop-ის მოდულს, სადაც browse და cart კომპონენტები გვაქვს გაერთიანებული shop.module.ts-ში:

import { NgModule } from "@angular/core";
import { CommonModule } from "@angular/common";
import { BrowseComponent } from "./browse/browse.component";
import { CartComponent } from "./cart/cart.component";
import { Routes } from "@angular/router";

const shopRoutes: Routes = [
  { path: "cart", component: CartComponent },
  { path: "", component: BrowseComponent, pathMatch: "full" },
];

@NgModule({
  declarations: [BrowseComponent, CartComponent],
  imports: [CommonModule],
})
export class ShopModule {}

შემდეგ ეს მოდულები, თავიანთი რაუთინგით, შეგვიძლია შევიტანოთ AppModule-ში.

Lazy Loading

მოდულების “ზარმაცად” ჩატვირთვა გავრცელებული პრაქტიკაა, რათა რესურსები დავზოგოთ და მოდულის კოდი მხოლოდ მაშინ ჩავტვირთოთ ბრაუზერში, როცა მათთვის საჭირო რაუთებზე ვიმყოფებით. რათა ზემოთ ხსენებული მოდულები ზარმაცად ჩავტვირთოთ, ჩვენ გვჭირდება ძირეული რათუინგის კონფიგურაციაში (app-routing.module.ts), მათი დაიმპორტება loadChildren თვისების ქვეშ:

import { NgModule } from "@angular/core";
import { RouterModule, Routes } from "@angular/router";

const routes: Routes = [
  {
    path: "admin",
    loadChildren: () =>
      import("./admin/admin.module").then((m) => m.AdminModule),
  },
  {
    path: "shop",
    loadChildren: () => import("./shop/shop.module").then((m) => m.ShopModule),
  },
  { path: "", redirectTo: "shop", pathMatch: "full" },
];

@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule],
})
export class AppRoutingModule {}

ჩვენ უშუალოდ მოდულებს ვაიმპორტებთ, რომლებიც, თუ გაიხსენებთ, შეიცავენ RouterModule.forChild-ს იმპორტების სიაში. საბოლოოდ ამ რაუთების კონფიგურაცია რეგისტრირდება RouterModule.forRoot-ში, და ეს ჩვენი AppRoutingModule შედის AppModule-ის იმპორტების სიაში:

import { AppRoutingModule } from "./app-routing.module";
@NgModule({
  /* ... */
  imports: [AppRoutingModule],
})
export class AppModule {}

ბრაუზერში დეველოპერის ხელსაწყოებში ნეთვორქის ტაბს თუ დავაკვირდებით, მოდულის კოდი იტვირთება მათი სათანადო რაუთების მიხედვით. ხოლო feature მოდულების რაუთები კონფიგურირებულია ძირეულ რაუთებთან მიმართებაში, ანუ გვაქვს /admin/user-manager, /shop/cart და ა.შ.

შეჯამება

ამ თავში ვისაუბრეთ ანგულარის მოდულებზე დაფუძნებულ აპლიკაციებზე, რომელიც ჩვენი აპლიკაციის ფუნქციონალის ორგანიზაციის ერთ-ერთ გზას წარმოადგენს. ჩვენ შეგვიძლია feature მოდულების შექმნა, სადაც აპლიკაციის ცალკეული კომპონენტები თუ სხვა ელემენტები ერთად არის შეკრული, თავიანთი რაუთის კონფიგურაციით. ეს მათი ზარმაცად ჩატვირთვის საშუალებას გვაძლევს, რათა მოდულლები მაშინ ჩაიტვირთოს, როცა სათანადო რაუთებზე ვიმყოფებით.

ინტერნაციონალიზაცია

ამ თავში გავეცნობით ინტერნაციონალიზაციის ძირითად საკითხებს და პრობლემებს. თუ გვსურს, რომ ჩვენს აპლიკაციას გააჩნდეს მრავალი ენის მხარდაჭერა, ამისათვის გვჭირდება ახალი ტერმინის შემოტანა კერძოდ, ინტერნაციონალიზაციის. ინტერნაციონალიზაციის იდეა მდგომარეობს შემდგომში: ჩვენი აპლიკაცია შეგვიძლია წარმოვადგინოთ სხვადასხვა ენაზე. თუმცა ამ წარმოდგენისთავის ჯერ უნდა გავიაზროთ თუ რომელი მიდგომა სჭირდება ჩვენს აპლიკაციას. მიდგომები შემდგომია: დეველოპმენტის პროცესში ჩვენ შეგვიძლია დავბილდოთ, თითოეული ენისათვის, ცალკეული აპლიკაციის ვერსია, შესაბამისად მომხარებელი ისარგებლებს ჩვენი აპლიკაციით სხვადასხვა ენის შესაბამის მისამართზე, მაგალითად: educata.ge (ქართულისთვის) და educata.dev (ინგლისურისთვის), მაგრამ ასევე შესაძლებელია მომხარებელს დასჭირდეს რეალურ დროში ენის ცვლილება და არა მთლიანად სხვა აპლიკაციის გახსნა. ორივე მიდგომას თავისი პლიუსები და მინუსები აქვს. ამ თავში, განვიხილავთ:

ანგულარის i18n

როცა გვსურს ანგულარში სხვადასხვა ენების შემოტანა, ამ დროს წავაწყდებით ორ ტერმინს: ინტერნაციონალიზაციას და ლოკალიზაციას. ეს ტერმინები ერთმანეთთან კავშირში არიან თუმცა ისინი ერთს და იმავეს არ ნიშნავენ. ინტერნაციონალიზაცია მეტწილადად მოიხსენიება, როგორც i18n. ეს არის პროცესი, როცა ვამზადებთ და დიზაინს ვარჩევთ ჩვენი აპლიკაციისთვის, რომელსაც ექნება მრავალი ენის მხარდაჭერა. ინტერნაციონალიზაციის ნაწილი მოიცავს შემდგომ ეტაპებს:

  • დავყოთ ჩვენი აპლიკაციის კონტენტები თარგმანისთვის და მოვამზადოთ რენდერის პროცესისთვის.
  • განვანაახლოთ ჩვენი აპლიკაცია ორმხრივი ტექსტებისთვის: ltr (მარცხნიდან მარჯვნივ) ან rtl (მარჯვნიდან მარცხნივ).

შემდგომ უნდა გადავიდეთ ლოკალიზაციის პროცესზე. ლოკალიზაცია ეს არის პროცესი, როცა ვქმნით ვერსიებს სხვადასხვა ტიპის ლოკალისთვის. ლოკალში (locale) იგულისხმება, ამ შემთხვევაში, შემდგომი ასპექტები:

  • სხვადასხვა ტექსტების თარგმანი.
  • სხვადასხვა ადგილმდებარეობის მიხედვით განსხვავებული დროის ფორმატები, მაგალითად: 7/22/2002 ან 22/7/2022.
  • სხვადასხვა ტიპის რიცხვები და ვალუტები.

ლოკალის ფორმატია: {ენის_id}-{ენის_გაფართოვება}

მაგალითად:

  • en-US (English, The United States)
  • ka-GE (ქართული, საქართველო)
  • fr-CA (French, Canada)

ასერომ, თუ ინტერნაციონალიზაცია გულისხმობს ზოგადად აპლიკაციაში სხვადასხვა ენებისთვის მხარდაჭერის შემოტანას, ლოკალიზაცია გულისხმობს აპლიკაციის კონკრეტულ ენაზე მომზადებას.

i18n დიაგრამა

ინტერნაციონალიზაციის პროცესი საჭიროა რეალურად მოხდეს ერთხელ და შემდგომ მთლიანი აპლიკაცია შედგიძლიათ გახადოთ ლოკალური იმდენი ენისთვის, რამდენი ენისთვისაც გჭირდებათ, კოდის ცვლილების გარეშე. თუ გაზრდით თქვენს აპლიკაციას, აუცილებლად უნდა გაატაროთ ინტერნაციონალიზაციაში ახალი გვერდები.

ინსტალიაცია

ინტერნაციონალიზაციის პაკეტის დასაინსტალირებლად, დაგვჭირდება ანგულარის CLI-ს გამოყენება:

ng add @angular/localize

ინსტალაციის დროს ამოგვიგდებს ეთანხმებით თუ არა ანგულარის პაკეტის დაინსტალირებას, რასაც უნდა დავეთანხმოთ.

შემდგომ შევუდგეთ angular.json-ში build-ის ქვეშ i18n ველის დამატებას.

{
  "projects": {
    // ...
    "project_name": {
      // ...
      "i18n": {
        "sourceLocale": "en-US",
        "locales": { "ka-GE": "src/locale/messages.ka.xlf" }
      },
      // ...
      "architect": {
        // ...
        "build": {
          // ...
          "options": {
            // ...
            "localize": ["ka-GE"]
          }
        }
      }
    }
  }
}

ამ კოდის დამატებით ვამბობთ თუ რომელი ლოკალების ჩატვირთვის შესაძლებლობა გვექნება. sourceLocale არის აპლიკაციის მთავარი ენა, ანუ რა ენაზე დავიწყეთ აპლიკაციის აწყობა. locale ობიექტი ამ შემთხვევაში განსაზღვრავს თუ ჩვენს აპლიკაციას რომელი ლოკალების მხარდაჭერა გააჩნია და ამავდროულად სად იქნება კონკრეტული ლოკალი მოთავსებული. ამ შემთხვევაში დავამატეთ “ka-GE”, რომელიც შესაძლებლობას გვაძლევს, რომ ქართული ლოკალი მოვზადოთ, ხოლო მასზე მინიჭებული მისამართი მიუთითებს თუ სად იქნება მოთავსებული ინფორმაცია ამ ლოკალის შესახებ, ეს ფაილი ჯერ არ შეგვიქმნია მაგრამ მალე შევქმნით. localize მასივის დამატებით ანგულარს ვეუბნებით, თუ რომელი ლოკალი შექმნას, როცა მოხდება ლოკალიზაციის პროცესი აპლიკაციაში.

მარტივად დავარედაქტიროთ app.component.html:

<h1>Page header</h1>
<img
  src="https://raw.githubusercontent.com/educata/everrest/main/assets/images/educata-bg-white.png"
  alt="educata project logo"
/>

ახლა უნდა დავიწყოთ ლოკალიზაციისთვის მომზადება ანუ იმ ადგილების მონიშვნა, რომლის ლოკალიზაცია გვსურს, ამას html-ში ვაკეთებთ i18n ატრიბუტის დამატებით.

<h1 i18n>Page header</h1>

ზოგჯერ დაგვჭირდება თვითონ ატრიბუტების გადათარგმნაც, რაც მეტწილად გვეხმარება accsessibility ნაწილში, მაგალითად: alt, aria-label და ა.შ.

<img
  i18n-alt
  src="https://raw.githubusercontent.com/educata/everrest/main/assets/images/educata-bg-white.png"
  alt="educata project logo"
/>

ხოლო app.component.ts მხარეს, შევიტანოთ მცირედი მოდიფიკაციები. პირველ რიგში დავაინჯექტოთ კონსტრუქტორში Title და გამოვიყენოთ დაინჯექტებული სერვისი იმისათვის, რომ შევცვალოთ დოკუმენტის title.

title = 'Test page';
constructor(private titleService: Title) {
   this.titleService.setTitle(this.title);
}

ამ ნაწილით განახლებას ვუკეთბთ ჩვენს title მაგრამ გვჭირდება ცოტა განსხვავებული მიდგომა, რომ როცა მოხდება ლოკალიზირება ეს ცვლადი შეიცვალოს იმ კონკრეტული ენის მნიშვნელობით.

constructor(private titleService: Title) {
   this.titleService.setTitle($localize`${this.title}`);
}

$localize თეგის დამატებით ვნიშნავთ სტრინგებს ლოკალიზაციისთვის. მაშასადამე, ნებისმიერი ცვლადი, რომელიც გვინდა რომ ტაიპსკრიპტის ფაილში გადაითარგმნოს, მოინიშნება ამ თეგით:

export class MyComponent {
  someString = $localize`This value can be localized!`;
  messages = [$localize`hello`, $localize`goodbye`];
}

ამ პროცესების გავლის შემდგომ გვჭირდება საშუალება, რომლითაც მონიშნულ ელემენტებს ერთმანეთისგან განვაცალკევებთ თარგმანისათვის.

ng extract-i18n --output-path src/locale

ბრძანების გაშვების შემდგომ უნდა დაგენერირდეს messages.xlf ფაილი src/locale-ში, რომელიც არის თავდაპირველი ენისათვის, ჩვენს შემთხვევაში - ინგლისური. დავაკოპიროთ ეს ფაილი, მოვათავსოთ იგივე დირექტორიაში და დაკოპირებულ ფაილს გადავარქვათ სახელი messages.ka.xlf-ზე, აქ შევინახავთ ქართულ თარგმანს. ფაილის გახსნის შევამჩნევთ, რომ თითოეულ მესიჯის ფაილს გააჩნია სექცია, ამ სექციაში არის თარგმანის აიდი და ორიგინალი ტექსტი.

<source> თეგი მიუთითებს სათარგმნ ტექსტს, რომელსაც უნდა მივამატოთ <target> თეგი სადაც კონკრეტულად თარგმანი იქნება.

ჩვენ შემთხვევაში:

<source>Page header</source>
<target>გვერდის სათაური</target>

<source>educata project logo</source>
<target>educata პროექტის ლოგო</target>

შევამოწმოთ ჩვენს მიერ ლოკალიზირებული აპლიკაცია, გავუშვათ ბრძანება:

ng serve --o

გაიხსნება ბრაუზერში ჩვენი აპლიკაცია, რომელიც არის გადათარგმნილი ქართულ ენაზე, თუმცა ng serve მხოლოდ ლოკალურად აწყობს ანგულარის აპლიკაციას, რომელიც არის უბრალოდ სადეველოპმენტო სერვერი, ჩვენ გვჭირდება ლოკალიზირებული აპლიკაციის მომზადება, იგივე ბილდის პროცესი:

ng build --localize

მომზდებულ ფაილში, ბილდის პროცესის შემდგომ შეამჩნევთ, რომ გვაქვს ორი ფოლდერი en-US და ka-GE, ორივე ფოლდერი არის ანგულარის აპლიკაცია, მაგრამ შეგიძლიათ სხვადასხვა ქვე დომეინებიდან გამომდიანრე ისე ატვირთოთ ფოლდერი, რომ მიიღოთ ორი გადათარგმნილი ვებ აპლიკაცია.

მაგალითად: educata.ge (ქართულისთვის) და educata.dev (ინგლისურისთვის).

დასკვნა

ანგულარის ჩაშენებული i18n პაკეტით, დავბილდეთ (build პროცესი) ორი სხვადასხვა ენის აპლიკაცია. ეს გვაძლევს საშუალებას, რომ ავტვირთოთ ორი ან მეტი გადათარგმნილი ვებ გვერდი სხვადასხვა ქვე დომეინზე. შესაძლებელია ძირითადი ენის აპლიკაცია არ გაუშვათ ქვე დომეინზე და დავტოვოთ ძირ დომეინზე (სტანდარტულ დომეინზე, ქვე დომეინების გარეშე).

შემდგომ თავში განვიხილავთ სხვა პაკეტის გამოყენებას, რომელიც არ არის ანგულარის მიერ შექმნილი თუმცა ისიც ფართოდ გამოიყენება. მცირედი განსხვავება ის არის, რომ ეს პაკეტი არის third-party პაკეტი, რომლის მიზანია რეალურ დროში მოხვდეს ენის ცვლილება.

ngx-translate

განსხვავებით ანგულარის i18n პაკეტისაგან, ngx-translate-ის იდეა არის რეალურ დროში მოხდეს თარგმანის გაშვება, რაც არიდებს მომხარებელს ვებ გვერდის რეფრეშისაგან და სხვა მისამართზე გადასვლისაგან. შესაბამისად, ის არ საჭიროებს ბილდის პროცესში სხვადასხვა ტიპის ლოკალიზაციის გაშვებას.

პირველ რიგში დავიწყოთ პაკეტების ინსტალაციით:

npm install @ngx-translate/core @ngx-translate/http-loader

პაკეტების ინსტალაციის შემდგომ საჭიროა მათი გამართვა app.module.ts:

// ...
import { HttpClientModule, HttpClient } from '@angular/common/http';
import { TranslateModule, TranslateLoader } from '@ngx-translate/core';
import { TranslateHttpLoader } from '@ngx-translate/http-loader';

export function HttpLoaderFactory(http: HttpClient) {
  return new TranslateHttpLoader(http, './assets/i18n/', '.json');
}

@NgModule({
  declarations: [/* ... */],
  imports: [
    BrowserModule,
    HttpClientModule,
    TranslateModule.forRoot({
      loader: {
        provide: TranslateLoader,
        useFactory: HttpLoaderFactory,
        deps: [HttpClient]
      }
    })
  ],
  bootstrap: [/* ... */]
})
export class AppModule { }

ngx-translate-ის გამოყენების დროს გვჭირდება ანგულარის ჩაშენებული კლასების (HttpClientModule, HttpClient) გამოყენება, რაც გვეხმარება ფაილების ჩატვირთვაში. HttpLoaderFactory ფუნქცია ემსახურება ფაქტორის შექმნას, რომელიც ჩატვირთავს იმ ფაილს, რომლის თარგმანიც უნდა გამოვიყენოთ. TranslateModule.forRoot-ის საშუალებით კი ვტვირთავთ იმ ფუნქციონალის ნაკრებს, რომელიც მოგვაწოდა ngx-translate-მა.

app.module.ts-ის გამართვის შემდგომ, გვჭირდება assets ფოლდერში დავამატოთ ახალი ფოლდერი i18n და მასში შევქმნათ კონკრეტული ლოკალის ფაილები, ჩვენს შემთხვევაში en.json და ka.json.

en.json-ში დავამატოთ ახალი მნიშვნელობები:

{
  "page_header": "Page header",
  "alt_text_for_image": "Educata project image",
  "update_button_text": "Change language to georgian"
}

ka.json-შიც დავამატოთ იგივე მნიშვნელობები ოღონდ ქართული თარგმანით:

{
  "page_header": "გვერდის სათაური",
  "alt_text_for_image": "Educata პროექტის სურათი",
  "update_button_text": "შეცვალეთ ენა ინგლისურზე"
}

ამ ფაილებში nesting-ის გამოყენებაც შეიძლება:

{
  "homepage": {
    "title": "მთავარი გვერდი"
  }
}

შემდგომ app.component.html-ში დავიწყოთ ცვლილებების შეტანა:

<h1>{{"page_header" | translate }}</h1>
<img
  src="https://raw.githubusercontent.com/educata/everrest/main/assets/images/educata-bg-white.png"
  alt="{{'alt_text-for_image' | translate }}"
>
<button (click)="updateLangauge()">{{"update_button_text" | translate }}</button>

როგორც ვხედავთ, თიმფლეითში იდენტიფიკატორად გაგვაქვს ის სტრინგები, რომლებსაც თვისებებად ვინახავთ ლოკალის json-ში, ამ იდენტიფიკატორს ვატარებთ translate ფაიფში, რომელიც შედგომ გვიბრუნებს თარგმანს. ეს ფაიფი მოდის TranslateModule-დან. ვიზუალის დამატების შემდგომ დავიწყოთ app.component.ts მოდიფიცირება:

import { TranslateService } from '@ngx-translate/core';
...
constructor(private translateService: TranslateService) {
  this.initLanguage();
}

private initLanguage() {
  const prevLanguage = localStorage.getItem('language') || 'en';
  this.translateService.use(prevLanguage);
}

updateLangauge() {
   const nextLang = this.translateService.currentLang === 'en' ? 'ka' : 'en';
   this.translateService.use(nextLang);
   localStorage.setItem('language', nextLang);
}

constructor-ში ვაინჯექტებთ TranslateService, @ngx-translate/core-დან. სერვისს საკმაოდ ბევრი კარგი მეთოდი და თვისებები აქვს, გირჩევთ გაეცნოთ. initLanguage ჩვენ მიერ შექმნილი ფუნქციაა, რომელიც ამოწმებს ლოკალურ მეხსიერებაში ხომ არ არის რაიმე ენა შენახული. თუ არ არის, მაშინ ნაგულისხმევად ინგლისური ენა ჩავტვირთოთ. updateLanguage მეთოდი კი პასუხისმგებელია განახლებაზე და ახალი ენის ინფორმაციის შენახვაზე ლოკალურ მეხსიერებაში. translateService-დან გამოვიყენეთ currentLang, რაც აბრუნებს ამჟამინდელ ენას და use მეთოდი, რომელსაც პარამეტრებში გადაეცემა თუ რა ენა უნდა ჩატვირთოს, გაითვალისწინეთ გადაცემული მნიშვნელობის .json ფაილი უნდა არსებობდეს assets/i18n ფოლდერში, რომ ის ენა ჩაიტვროთს. ეს კოდი მარტივი მაგალითი არის და შესაძლებელია მისი ბევრნაირად გაუმჯობესება, მაგალითისთვის: ამ და მსგავსი მეთოდების მთლიანად სერვისში გატანა, რომლებიც შეიქმენება ინტერნაციონალიზაციისთვის. ასევე, შესაძლებელია ენის enum-ის შექმნა, რაც უფრო მეტად დახვეწავს ჩვენს კოდს. ბონუსად კი შეგიძლიათ მომხარებლისთვის სასურველი (ამჟამინდელი) ენაც კი ჩატვირთოთ getBrowserLang მეთოდის გამოყენებით TranslateService-დან. TranslateService-ს ასევე გააჩნია instant მეთოდი, რომლის საშუალებითაც შეგვიძლია პარამეტრად key გადავცეთ და მივიღოთ თარგმანი. შეგვიძლია უკვე შევამოწმოთ ვიზუალზე თუ როგორ მუშაობს რეალურ დროში ენის ცვლილება.

ასეთი მიდგომით მივიღეთ რეალურ დროში ენის ცვლილება ngx-translate გამოყენებით. მსგავსი მიდგომა ეფექტურია ისეთი ტიპის ვებგვერდებზე, რომლებზეც ხშირად გვჭირდება ენის ცვლილება, მაგალითისთვის როგორიცა სასწავლებელი ვებგვერდები.

რომელი ხელსაწყო ავარჩიოთ ჩვენი ვებგვერდისთვის? ამას განვიხილავთ შეჯამებაში.

შეჯამება

წინა თავებში განვიხილეთ ორი მიდოგმა, ანგულარის მიერ შემოთავაზებული მიდგომა და third-party პაკეტი ngx-translate. მსგავსი დანიშნულებისთვის არსებობს კიდევ სხვა third-party პაკეტეტები, რომელთა განხილვაც შეიძლება თუმცა ყველაზე პოპულარული არის ngx-translate. ორივე მიდგომას გააჩნია თავისი უარყოფითი და დადებითი მხარეები, მოდით განვიხილოთ ისინი:

დადებითი მხარე ანგულარის შემოთავაზებულ პაკეტში ის არის, რომ პირდაპირ ანგულარის გუნდის მიერ არის შემოთავაზებული და უსაფრთხოების მხრივ უფრო დაცული პაკეტია. ხშირ შემთხვევაში მომხარებელს მხოლოდ ერთი ენის აპლიკაციის გამოყენება სჭირდება და არა რეალურ დროში რამდენიმეჯერ ცვლილება. რაც საშალებას გვაძლევს, რომ აპლიკაცია წინასწარ ვთარგმნოთ და რესურსები დავზოგოთ. მიუხედავად ამისა არის მომენტი როცა მომხარებელს სჭირდება ენის ხშირად ცვლილება, ამ შემთხვევაში ჩვენი ვებგვერდი უნდა დარეფრეშდეს და გადავიდეს სხვა მისამართზე, რაც რეალუარდ არ არის იდეალური მიდგომა მომხარებლისათვის. სწორედ ასეთი შემთხვევების გამოიყენება ngx-translate, რაც გვაძლევს შესაძლებლობას, რომ რეალურ დროში ვცვალოთ ენები მარტივად, ბევრი კოდის წერის გარეშე. ანგულარის ჩაშენებულ i18n-ის ერთ-ერთი მინუსი არის ბევრი boilerplate კოდი, ამიტომ ბევრი ეტაპის გავლა გვიწევს სასურველი შედეგის მისაღებად. როცა განვიხილავდით მის გამოყენებას, არ აღვნიშნეთ, რომ შესაძლებელია json გაფართოვების გამოყენება ლოკალის ფაილებისთვის. თუმცა უშუალოდ როცა დაგჭირდებათ კოდის ატვირთვა და სხვადასხვა დეტალების გაზიარება ორი ვებგვერდისთვის, ისევ დამატებითი მუშაობა დაგვჭირდება მსგავსი ტიპის ენის ცვლილების გამო. ngx-transalte-დან გამომდინარე ენა იცვლება პირდაპირ იმავე ვებგვერდზე და არ საჭიროებს მის გადაყვანას, თუმცა ngx-translate საც გააჩნია უარყოფითი მხარეები:

  • თუ ბევრი თარგმანი და ენების ლოკალის ფაილები მოგვიგროვდება, ის ამძიმებს ჩვენს აპლიკაციას მცირედად.
  • შესაძლოა ngx-translate დეველოპერებმა შეწყვიტოს პაკეტის განახლება.

ამგვარად წინა თავებში განხილული შედეგებიდან ვიხილეთ თუ როგორი სახითაც შეგვიძლია ინტერნაციონალიზაციის დამატება ანგუალრში, რომელ მეთოდს გამოიყენებთ ეს თქვენზეა დამოკიდებული და თქვენს აპლიკაციაზე.

რა არის სიგნალები?

Angular Signals არის სისტემა, რომელიც თვალს ადევნებს სად არის ჩვენი აპლიკაციის სთეითი და როგორ იცვლება ის. შედეგად ანგულარი ოპტიმიზირებულად არენდერებს თემფლეითებს.

სიგნალი არის ერთგვარი მნიშვნელობის შემფუთველი, რომელიც აცნობებს დაინტერესებულ კონსუმერებს, როდის იცვლება ეს მნიშვნელობა. სიგნალებში შესაძლებელია ნებისმიერი მნიშვნელობის შენახვა, მარტივი პრიმიტივებიდან კომპლექსური მონაცემის სტრუქტურებამდე.

Writable Signals

მოდიფიცირებადი სიგნალები გვაწვდიან API-ს მათი მნიშვნელობების გასაახლებლად. სიგნალები იქმნება signal ფუნქციით (angular/core-დან), რომელსაც საწყისი მნიშვნელობა უნდა მივაწოდოთ.

count = signal(0);

ასეთი სიგნალის ტიპი არის WritableSignal.

თემფლეითში ამ მნიშვნელობის გამოსატანად ჩვენ count-ს ფუნქციასავით ვეძახით:

<button>Count: {{ count() }}</button>

სიგნალის მნიშვნელობის შესაცვლელად შეგვიძლია მასზე set მეთოდს დავუძახოთ:

<button (click)="count.set(3)">Count: {{ count() }}</button>

თუ მნიშვნელობის განახლება გვინდა, მაგრამ ამისთვის სიგნალის წინა მნიშვნელობა გვჭირდება, მაშინ update მეტოდი გამოგვადგება, სადაც ქოლბექში წინა მნიშვნელობას ვიღებთ და შეგვიძლია ახალი დავაბრუნოთ:

count.update((value) => value + 1)

Computed Signals

გამოთვლილი სიგნალები არიან არამოდიფიცირებადი სიგნალები, რომლებიც მნიშვნელობას სხვა სიგნალებიდან გამომდინარე ატარებენ:

@Component({
  template: `
    <button (click)="increment()">Count: {{ count() }}</button>
    <p>Is even: {{ isCountEven() }}</p>
  `,
})
export class AppComponent {
  count = signal(0);
  isCountEven = computed(() => this.count() % 2 === 0);

  increment() {
    this.count.update((value) => value + 1);
  }

ამ მაგალითში isCountEven ეყრდნობა count სიგნალს. count-ის მნიშვნელობის შეცვლასთან ერთად სათანადოდ შეიცვლება isCountEven.

გასათვალისწინებელია, რომ გამოთვლილი სიგნალი არ არის მოდიფიცირებადი, ComputedSignal ტიპიზე არ არსებობს set და update მეთოდები.

გამოთვლილი სიგნალები ზარმაცად მუშაობენ. ისინი იმ შემთხვევაში აწარმოებენ კალკულაციას, თუ სიგნალი, რომელზეც ისინი ეყრდნობიან, შეიცვალა. სხვა შემთხვევაში ხდება პირვანდელი გამოთვლილი მნიშვნელობის ქეშირება და, სიგნალზე დაძახების შემთხვევაში, ამ ქეშის აღდგენა.

Effects

თუ სიგნალები ხელსაყრელია, რადგან ისინი დაინტერესებულ კონსუმერებს აცნობებენ ცვლილებებს, ეფექტები გამოსადეგია მაშინ, როცა ჩვენ ერთი ან მეტი სიგნალის ცვლილებაზე გვინდა ისე ვირეაგიროთ, რომ აპლიკაციის სთეითი არ შეიცვალოს (გამოთვლილი სიგნალებისგან განსხვავებით).

export class AppComponent {
  count = signal(0);

  constructor() {
    effect(() => {
        console.log(`The count was updated to ${this.count()}`)
    })
  }
}

აქ ჩვენ ეფექტს ვქმნით, რომელიც გაეშვება, როცა count სიგნალი მნიშვნელობას შეიცვლის. ეფექტები ყოველთვის ეშვება მინიმუმ ერთხელ. როცა ეფექტი ეშვება, ის თვალყურს ადევნებს ყველა მისთვის საჭირო სიგნალს და ყოველ ცვლილებაზე ხელახლა ეშვება.

ეფექტები ფუნქციონირებას წყვეტენ, როცა კომპონენტი ნადგურდება.

HostListener

HostListener არის დეკორატორი რომელიც დეკლარაციას უკეთებს DOM-ის ივენთს, იქნება ეს ჰოსსტ ელემენტსა თუ გლობალურ window-ზე, რომელსაც გვინდა რომ მოვუსმინოთ. ეს დეკორატორი გამოიყენება კომპონენტის კლასებში, და, კომპონენტებზე ხშირად, დირექტივებში.

შევქმნათ უბრალო დირექტივი რომელზეც HostListener-ს გამოვიყენებთ.

ng g d example
import { Directive, HostListener } from "@angular/core";

@Directive({
  selector: "[appExample]",
  standalone: true,
})
export class ExampleDirective {
  constructor() {}

  @HostListener("click") onClick() {
    console.log("click detected");
  }
}

HostListener-ში პირველ არგუმენტად ვაწვდით ივენთის სახელს, ხოლო შემდეგ დეკლარაციას ფუკეთებთ ფუნქციას, რომელიც უნდა გააქტიურდეს ამ ივენთის საპასუხოდ.

ამ მაგალითში, თუ დავაკლიკებთ ელემენტზე, რომელზეც appExample დირექტივი იქნება, კონსოლში დავლოგავთ ტექსტს.

შესაძლებელია window-ზე არსებული მოვლენების მოსმენაც, ამისთვის HostListener-ში არგუმენტს ვუწერთ პრეფიქსს window:. ამ მაგალითში ჩვენ AppComponent ში enter ღილაკზე დაჭერას ვუსმენთ და საპასუხოდ კომპონენტის კლასში counter თვისებას ვზრდით, რომელსაც ინტერპოლაციით გამოვსახავთ.

import { HostListener, Component } from "@angular/core";
import { CommonModule } from "@angular/common";

@Component({
  selector: "app",
  standalone: true,
  imports: [CommonModule],
  template: ` <h1>
      Hello, you have pressed enter {{ counter }} number of times!
    </h1>
    Press enter key to increment the counter.`,
})
export class AppComponent {
  counter = 0;
  @HostListener("window:keydown.enter")
  handleKeyDown() {
    this.counter++;
  }
}

HostListener-ში მეორე არგუმენტად მასივში შეგვიძლია დავწეროთ ის, თუ რა მივაწოდოთ არგუმენტად handler მეთოდმა. მათ შორის არის $event, რომელიც ჩვენ event binding-ში უკვე გვინახავს.

  @HostListener('click', ['$event'])
  onClick(event: MouseEvent) {
    console.log(`click detected on X:${event.x}, Y: ${event.y}`);
  }

$event ამ მოვლენის შესახებ შეიცავს ინფორმაციას, რომელზეც ჩვენ ახლა onClick მეთოდის პარამეტრებში გვაქვს წვდომა. ჰენდლერ ფუნქციის პარამეტრში მას უნდა მივუთითოთ სათანადო ტიპი. ‘click’ ივენთი არის MouseEvent-ის ტიპის.

შეჯამება

ამ თავში ჩვენ განვიხილეთ HostListener დეკორატორი, რომლის საშუალებითაც შეგვიძლია ჰოსტზე ივენეთების მოსმენა და მოხელთება. პირველ არგუმენტად HostListener იღებს, ხოლო მეორე არგუმენტად ჰენდლერ ფუნქციაში გასატარებელ არგუმენტს. ასე ჩვენ ივენთზე რეაგირება და ამ ივენთში არსებული ინფორმაციის მოხელთებაც შეგვიძლია.

TypeScript-ის მოკლე შესავალი

ტაიპსკრიპტი არის ჯავასკრიპტზე დაშენებული, ძლიერად ტიპიზირებული ენა. მარტივი სიტყებით რომ ვთქვათ, ტაიპსკრიპტი არის იგივე ჯავასკრიპტი, ოღონდ ტიპებით - ტაიპსკრიპტში საჭიროა, რომ ყველა ცვლადს, კლასს, ფუნქციის არგუმენტებსა თუ ფუნქციის დაბრუნებულ მნიშვნელობებს ჰქონდეთ კონკრეტული ტიპი. ტაიპსკრიპტის საშუალებით კოდის წერის პროცესშივე შეგვიძლია იმ პრობლემების აღმოფხვრა, რომლებმაც ჯავასკრიპტის დეველოპმენტის დროს შეიძლება ძალიან გვიან იჩინონ თავი.

ტაიპსკრიპტი არ არის ჯავასკრიპტის ჩამნაცვლებელი, რადგან ბრაუზერში (ან ნოუდში) ტაიპსკრიპტი არ მუშაობს. მაგრამ ტაიპსკრიპტს მოყვება თავისი ქომფაილერი, რომელიც ტაიპსკრიპტის კოდს ჩვეულებრივ ჯავასკრიპტად გარდაქმნის. ტაიპსკრიპტით დეველოპმენტის შედეგად, ბრაუზერი ისევ ჯავასკრიპტს იყენებს, მაგრამ პლიუსი ის არის, რომ ტაიპსკრიპტის ქომფაილერი (ან მისი ინტეგრირებული ვარიანტი კოდის ედიტორში) პრობლემებს დაქომფაილების პროცესში აფიქსირებს. შედეგად კოდის წერის პროცესში - ედიტორშივე - დავინახავთ თუ კოდი ხარვეზიანია.

ტაიპსკრიპტი ფართოდ გამოიყენება ვებ დეველოპმენტში. ის Angular ფრეიმვორქის ეკოსისტემის განუყოფელი ნაწილია და ასევე ხშირად გამოიყენება React-სა და Vue-შიც.

ამ თავში ვისწავლით:

ინსტალაცია და გამოყენება

ტაიპსკრიპტის ქომფაილერის დასაინსტალირებლად დაგვჭირდება ნოუდის პაკეტის მენეჯერი npm.

npm install -g typescript

ტაიპსკრიპტის ქომფაილერის გამოყენება შეგვიძლია tsc ბრძანებით და შემდეგ ტაიპსკრიპტის ფაილის მითითებით. გვაქვს გამზადებული პირობითი ტაიპსკრიპტის ფაილი main.ts კოდით:

function log(text: string): void {
  console.log(text);
}

const hello: string = "Hello world!";

log(hello);

ტიპების გამოყენებას განვიხილავთ შემდეგ ნაწილში. ამჯერად ზოგადად შეგვიძლია ვთქვათ, რომ რამდენიმე ადგილას სპეციალურად გვაქვს მითითებული ტიპები: log ფუნქციაში პარამეტრი აუცილებლად უნდა იყოს string. თვითონ ფუნქცია არაფერს აბრუნებს (void). შემდეგ ვქმნით სტრინგის ცვლადს და log ფუნქციაში მას არგუმენტად ვაწვდით.

tsc main.ts

ეს იმავე სახელის მქონე ჯავასკრიპტის ფაილს შექმნის, რომელიც ახლა ჩვეულებრივად, ტიპების გარეშე იმუშავებს. დავაკავშიროთ ჯავასკრიპტის ფაილი HTML დოკუმენტში და შევამოწმოთ.

თუ ჩვენ ტიპებს არასწორად გამოვიყენებთ - ვთქვათ, გვინდა, რომ log ფუნქციაში მხოლოდ number ტიპის არგუმენტები მივიღოთ:

function log(text: number): void {
  console.log(text);
}

const hello: string = "Hello world!";

log(hello);

და დაქომფაილებას ვცდით:

tsc main.ts

მაშინ ქომფაილერი ერორს დაგვიბრუნებს და გვაცნობებს, რომ log ფუნქცია არგუმენტად ელოდება რიცხვს, ჩვენ კი მას შეცდომით სტრინგს ვაწვდით. ეს ერორი ამ მაგალითში ტერმინალში ვნახეთ, თუმცა წინასწარ მოწყობილ სადეველოპმენტო გარემოში, ასეთი ერორები ედიტორშივე ჩნდება (მაგალითად თუ Angular-ს ვიყენებთ vscode-ში).

რომ შევაჯამოთ, ტაიპსკრიპტის საშუალებით ჩვენ შეგვიძლია უფრო უსაფრთხო და ნაკლები ბაგის მქონე კოდის წერა, რაც განსაკუთრებით მნიშვნელოვანი ხდება, როცა ჩვენი პროექტი შედარებით დიდია და შეიძლება კოდის შესახებ რაღაცები დაგვავიწყდეს ან გამოგვრჩეს.

კონფიგურაცია tsconfig.json-ით

ფოლდერში tsconfig.json-ის არსებობა მიუთითებს, რომ ეს ფოლდერი არის ტაიპსკრიპტის პროექტის root (ფესვი, პროექტის ამოსავალი წერტილი). ამ ფაილში კონკრეტული ველების შევსებით შეგვიძლია შევცვალოთ ტაიპსკრიპტის ქომფაილერის ქცევა.

tsconfig.json შეიძლება ასე გამოიყურებოდეს:

{
  "compilerOptions": {
    "module": "commonjs",
    "noImplicitAny": true,
    "removeComments": true,
    "preserveConstEnums": true,
    "sourceMap": true
  },
  "files": [
    "core.ts",
    "sys.ts",
    "types.ts",
    "scanner.ts",
    "parser.ts",
    "utilities.ts",
    "binder.ts",
    "checker.ts",
    "emitter.ts",
    "program.ts",
    "commandLineParser.ts",
    "tsc.ts",
    "diagnosticInformationMap.generated.ts"
  ]
}

კონფიგურაციის ვარიანტებისთვის გაეცანით tsconfig-ის დოკუმენტაციას. ყველაზე საყურადღებო თავდაპირველად არის compilerOptions-ში module. ჩვეულებრივ ფრონტენდ დეველოპმენტისთვის იყენებენ არა commonjs-ს, არამედ ES5-ს ან უფრო ახალ ვერსიას. files-ს მასივი განსაზღვრავს, თუ რა ფაილები უნდა დააქომფაილოს ქომფაილერმა.

ტიპები

Type Inference

ტიპი აღწერს მონაცემის თავისებურებას. ჩვეულებრივ, ტაიპსკრიპტი თავისით ამოიცნობს ტიპებს (type inference) და ჩვენ ყოველთვის არ გვჭირდება ექსპლიციტურად ტიპების განსაზღვრა.

const myStr = "hello"; // string
const anotherStr = "1234"; // string
const myNum = 74; // number
const anotherNum = -12.54; // number
let myBool = true; // boolean

function greet() {
  return "Hello there!"; // ტაიპსკრიპტი მიხვდება, რომ ფუნქცია string-ს აბრუნებს
}

myBool = "a string"; // ეს გამოიწვევს ერორს, რადგან boolean ტიპის ცვლადში ვინახავთ string-ს.

რადგან ჩვენ თავიდან myBool ცვლადი შევქმენით boolean ტიპით, ტაიპსკრიპტი უფლებას არ გვაძლევს, რომ ამ ცვლადში შემდგომ სხვა ტიპის მნიშვნელობა შევინახოთ.

იგივე ეხება ობიექტებს:

const user = {
  id: 588,
  name: "John",
};

user.id = "98"; // გამოიწვევს ერორს, რადგან user.id უნდა იყოს number და არა string

მასივების შემთხვევაში მისი ელემენტების ტიპი ავტომატურად არის any, რაც ნებისმიერ ტიპს ნიშნავს, ამიტომ მათი შეცვლა ერორს არ გამოიწვევს.

ექსპლიციტური ტიპები

ჩვენ ტიპების განსაზღვრა შეგვიძლია დეკლარაციის დროს.

const typescript: string = "cool"; // ცვლადი მხოლოდ string იქნება
let myBool: boolean; // ცვლადი მხოლოდ boolean იქნება

if (typescript === "cool") {
  myBool = true; // ვალიდურია
}

let fluidVar: any = "hello";
fluidVar = 99;
fluidVar = false;
fluidVar = { name: "john" };

any ტიპი გულისხმობს ნებისმიერ ტიპს. ასეთი ტიპის წყალობით ტაიპსკრიპტი არანაირ მნიშვნელობაზე არ გამოაცხადებს პრეტენზიას.

მასივების შემთხვევაში უნდა მივუთითოთ რა ტიპის ელემენტები იქნება მასში.

const myArr: string[] = ["one"];

myArr.push("two");
myArr.push(3); // გამოიწვევს ერორს

ასე ჩვენ დეკლარაციას ვუკეთებთ მასივს, რომელშიც მხოლოდ სტრინგის ტიპის მნიშვნელობები იქნება.

ფუნქციებში შეგვიძლია განვსაზღვროთ პარამეტრის ტიპები და დაბრუნებული შედეგის ტიპი:

function multiply(a: number, b: number): number {
  return a * b;
}

multiply(12, "wrong"); // გამოიწვევს ერორს, რადგან b არგუმენტად არ ვაწვდით number ტიპს

აღსანიშნავია, რომ ფუნქციის დაბრუნებულ ტიპს ტაიპსკრიპტი ისედაც მიხვდება, თუ მას ჩვენ ექსპლიციტურად არ დავუწერთ, მაგრამ ხშირად ჯობია, რომ ტიპები მივუთითოთ.

შეგვიძლია ფუნქციის ტიპები არასავალდებულო გავხადოთ ? სიმბოლოს გამოყენებით:

function log(text?: number): void {
  if (text) {
    console.log(text);
  } else {
    console.log("No text was provided");
  }
}

log(); // არ გამოიწვევს ერორს და ამობეჭდავს "No text was provided".

რამდენიმე პარამეტრის შემთხვევაში, არასავალდებულო პარამეტრები აუცილებლად უნდა იყოს ბოლოში.

Union Types

შესაძლებელია ტიპების გაერთიანება | სიმბოლოთი:

let myVar: string | number | { id: number } = 85;

myVar = { id: 999 }; // ვალიდურია
myVar = { name: "John", lastName: "Doe" }; // გამოიწვევს ერორს

ჩვენ დეკლარაციას ვუკეთებთ ცვლადს, რომელიც შეიძლება იყოს string, number ან ობიექტი, რომლის თვისებაც არის id რიცხვის ტიპით.

Interface

ჩვენ შეგვიძლია ობიექტის სტრუქტურის ექსპლიციტურად აღწერა ინტერფეისის საშუალებით:

interface User {
  name: string;
  id: number;
  email?: string;
}

const someUser: User = {
  name: "John",
  id: 0,
  // email-ის არ არსებობა არ გამოიწვევს ერორს.
};

interface გასაღებით განვსაზღვრეთ User ტიპი, რომელიც კონვენციურად “UpperCamelCase”-ით იწერება, როგორც კლასი. ამ ინტერფეისში შემდგომ ჩამოწერილია ცალკეული თვისებების ტიპები. ნებისმიერი ცვლადი, რომლის ტიპიც იქნება User, ამ სტრუქტურას უნდა დაემორჩილოს. ? სიმბოლოთი აღწერილი ტიპები არის არასავალდებული - მათი არ არსებობის შემთხვევაში ერორი არ მოხდება, ტუმცა მათი შექმნის შემთხვევაში, აუცილებელია, რომ სწორი ტიპის მნიშვნელობა შევქმნათ.

შესაძლებელია ინტერფეისების (ან კლასების) გავრცობა სხვა ინტერფეისებით, extends გასაღების მეშვეობით:

interface User {
  name: string;
  id: number;
  email?: string;
}

interface Admin extends User {
  canEdit: boolean;
}

const admin: Admin = {
  name: "John",
  id: 0,
  canEdit: true,
};

Admin ინტერფეისი User ინტერფეისისგან მიიღებს თვისებების ტიპებს და თავის მხრივ ახალი თვისებების ტიპებითაც იხელმძღვაბელებს. მაშასადამე ცვლადს, რომელიც Admin ტიპის იქნება, დასჭირდება არა მხოლოდ Admin-ის ინტერფეისში არსებული ტიპის თვისებები, არამედ User-ში არსებული ტიპის თვისებებიც (არასავალდებულო ტიპების გამოკლებით).

Tuples

თუფლები გამოიყენება მასივებში კონკრეტულ ინდექსებზე არსებული ელემენტების ტიპის განსაზღვრისთვის. მოცემულ მაგალითში 0 ინდექსზე ყოველთვის იქნება number, 1-ზე string, ხოლო 2-ზე boolean ტიპის მნიშვნელობები.

let myTuple: [number, string, boolean] = [4, "Tuple this, tuple that", true];

myTuple[1] = false; // გამოიწვევს ერორს, რადგან string-ს ვანაცვლებთ boolean-ით.

როგორც მასივების შემთხვევაშია, შესაძლებელია თუფლების დესტრუქტურიზაციაც. ახალი ცვლადები შესაბამის ტიპებს მიიღებენ.

let myTuple: [number, string, boolean] = [4, "Tuple this, tuple that", true];

const [a, b, c] = myTuple;

a ცვლადი იქნება number, b ცვლადი იქნება string, ხოლო c იქნება boolean.

კლასები TypeScript-ში

კლასების თვისებებზე ტიპიზირება ისევე მუშაობს, როგორც ობიექტის თვისებებზე:

class Point {
  x: number;
  y: number;
}

const pt = new Point();
pt.x = 0; // valid
pt.y = false; // error

მაგრამ გარდა ამისა, გვაქვს დამატებითი უსაფრთხოების ზომებიც. თუ tsconfig.json-ში ჩვენ დავამატებთ ველს "strict": true, მაშინ აუცილებელი გახდება რომ კლასის ველები იყოს ინიციალიზირებული (ან დეკლარაციის დროს, ან კონსტრუქტორში).

class BadGreeter {
  name: string; // Error: Property 'name' has no initializer and is not definitely assigned in the constructor.
}

ამის გამოსასწორებლად უბრალოდ მისი ინიციალიზაციაა საჭირო:

class GoodGreeter {
  name: string;

  constructor() {
    this.name = "hello";
  }
}

ეს სიმკაცრე უზრუნველყოფს, რომ კლასში მნიშვნელობის გარეშე არც ერთი ველი არ დარჩება.

Readonly

ველების წინ readonly-ის დაწერით, ველის მნიშვნელობის შეცვლა შესაძლებელი იქნება მხოლოდ კონსტრუქტორში, ანუ კლასის ინსტანციის შექმნის დროს. ნებისმიერ სხვა შემთხვევაში ტაიპსკრიპტი ამის უფლებას არ მოგვცემს:

class Hero {
  readonly name: string = "Tariel";

  constructor(otherName?: string) {
    if (otherName !== undefined) {
      this.name = otherName;
    }
  }

  err() {
    this.name = "Avtandil"; // can't modify
  }
}

const g = new Hero();
g.name = "Pridon"; // can't modify here either

public

ჩვეულებრივ ყველა კლასის წევრის ხილვადობა აირს დაყენებული public-ზე, რაც ნიშნავს, რომ ის კლასის გარედან ხელმისაწვდომია, მაგრამ ზოგჯერ კარგი აზრია რომ მისი ხილვადობა ექსპლიციტურად დავწეროთ:

class Greeter {
  public greet() {
    console.log("hi!");
  }
}
const g = new Greeter();
g.greet();

protected

protected წევრები ხილვადია მხოლოდ იმ კლასების ქვეკლასებისთვის, რომლებშიც ეს წევრებია დეკლარირებული:

class Greeter {
  public greet() {
    console.log("Hello, " + this.getName());
  }

  protected getName() {
    return "hi";
  }
}

class SpecialGreeter extends Greeter {
  public howdy() {
    // protected member visible here
    console.log("Howdy, " + this.getName());
  }
}

const g = new SpecialGreeter();
g.greet(); // visible public member
g.getName(); // protected member not visible here

private

private ჰგავს protected-ს, მაგრამ იგი არ არის ხილვადი ქვეკლასებისთვისაც კი, ანუ private წევრისადმი წვდომა შესაძლებელია მხოლოდ კლასის შიგნით.

class UnknownKnight {
  private name = "Tariel";
  public crying = true;

  introduce() {
    console.log(this.name); // visible
  }
}

const someone = new UnknownKnight();
console.log(someone.crying); // visible
console.log(someone.name); // error

class King extends UnknownKnight {
  showName() {
    console.log(this.name); // error
  }
}

Tests

წარმოიდგინეთ, რომ ხართ ფრონტენდ დეველოპერი კომპანიაში, სადაც სხვა დეველოპერებთან ერთად აწყობთ ვებ-აპლიკაციას. აპლიკაცია საკმაოდ დიდია და შედგება ბევრი სხვადასხვა მოდულისგან. თქვენ დაგევალათ, რომ ერთ-ერთ მოდულში შეგესწორებინათ რაღაც ხარვეზი. რამდენიმე საათის შემდეგ თქვენ ეს ხარვეზი იპოვეთ, გამოსაწორეთ და აპლიკაციაში შეამოწმეთ ეს მოდული სწორად მუშაობდა თუ არა. შესწორებული კოდი ფროდაქშენში დაიმერჯა და რამდენიმე დღის მერე აღმოჩნდა რომ თქვენმა შესწორებულმა კოდმა სხვა მოდულში არსებული ფუნქციონალი გააფუჭა, რომელიც თქვენ არ შეგიმოწმებიათ.

ვინ არის ამ შემთხვევაში დამნაშავე? თქვენ, როგორც დეველიპერი, რომელმაც ყოველი პატარა ცვლილების გამო მთლიანი აპლიკაციის ფუნქციონალი არ შეამოწმა მთელი თავისი პატარა დეტალებით, თუ თქვენი მთლიანი გუნდი, რომელიც ავტომატიზირებულ ტესტებს არ იყენებს?

ავტომატიზირებული ტესტები არის პროგრამა, რომელიც ამოწმებს სხვა პროგრამებს. ჩვენ შეგვიძლია გავწეროთ აპლიკაციის რა ფუნქციონალი უნდა შემოწმდეს ყოველ ჯერზე, როცა ახალ ცვლილებებს შევიტანთ, იმის მაგივრად რომ ეს ჩვენით ვაკეთოთ.

არსებობს ტესტების ორი ძირითადი ტიპი: unit tests და end-to-end (e2e) tests. unit ტესტები ამოწმებენ კოდის ბლოკების სწორ ფუნქციონირებას, ხოლო e2e ტესტები მოხმარებლის პერსპექტივიდან აპლიკაციის გამართულობას, რომელიც სრულიად განცალკევებულია აპლიკაციის კოდისგან. ამ თავში გავეცნობით unit ტესტებს ანგულარში.

Jasmine & Karma

ანგულარი იყენებს Jasmine ბიბლიოთეკას იუნით ტესტებისთვის. ეს ბიბლიოთეკა გვთავაზობს ფუნქციების რეპერტუარს, რომლითაც ტესტების დაწერა შეგვიძლია. ანგულარი ასევე იყენებს Karma-ს, რაც Jasmine-ზე დაშენებული პროგრამაა, რომელიც ტესტების შედეგს ბრაუზერში გვიჩვენებს.

Component Unit Tests

unit ტესტების ჩატარება შესაძლებელია კომპონენტებსა და სერვისებზე. კომპონენტების ტესტები ამოწმებს, ერთი მხრივ, კომპონენტის კლასში ლოგიკას, ხოლო მეორე მხრივ, კომპონენტის თემფლეითში გარკვეულ პატერნებს.

კომპონენტების დასატესტად უნდა შევქმნათ იმავე კომპონენტის სახელის მქონდე ფაილი + spec.ts. ასეთი ფაილები CLI-ით შექმნილ კომპონენტებს ავტომატურად მოყვება. spec იმიტომ, რომ ტესტის ფაილი ასევე ერთგვარი სპეციფიკაციის ფაილია. ანუ ასეთ ფაილებში აღვწერთ როგორ უნდა მუშაობდეს კომპონენტი.

შევხედოთ ანგულარის CLI-ით შექმნილ აპლიკაციაში წინასწარ გამზადებულ app.component.spec.ts ფაილს.

import { TestBed } from "@angular/core/testing";
import { AppComponent } from "./app.component";

describe("AppComponent", () => {
  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [AppComponent],
    }).compileComponents();
  });

  it("should create the app", () => {
    const fixture = TestBed.createComponent(AppComponent);
    const app = fixture.componentInstance;
    expect(app).toBeTruthy();
  });

  it(`should have as title 'tests'`, () => {
    const fixture = TestBed.createComponent(AppComponent);
    const app = fixture.componentInstance;
    expect(app.title).toEqual("tests");
  });

  it("should render title", () => {
    const fixture = TestBed.createComponent(AppComponent);
    fixture.detectChanges();
    const compiled = fixture.nativeElement as HTMLElement;
    expect(compiled.querySelector(".content span")?.textContent).toContain(
      "tests app is running!"
    );
  });
});

აქ გვაქვს ერთი დიდი describe ფუნქცია. ეს ფუნქცია თავს უყრის ერთი კონკრეტული კოდის მონაკვეთისთვის განკუთვნილ ტესტებს, ამ შემთხვევაში AppComponent-ისთვის. პირველ არგუმენტად ამ ფუნქციას ვაწვდით სტრინგს, რასაც შეიძლება ჰქონდეს ნებისმიერი მნიშვნელობა. როცა Karma-ს გავხსნით, ამ describe-ში არსებული ტესტების ბლოკი ერთად იქნება შეკრული AppComponent სახელის ქვეშ. describe-ის ქოლბექში ვწერთ ამ ცალკეულ ტესტებს it ფუნქციებით.

it ფუნქციებში აღვწერთ კომპონენტის შესახებ კონკრეტულ მოლოდინებს, ანუ რას უნდა აკეთებდეს კომპონენტი. ეს აღწერა ეძლევა მას პირველ არგუმენტად, ხოლო ქოლბექში იწერება კოდი, რომელიც ამ მოლოდინს ამოწმებს.

beforeEach არის ფუნქცია, რომელიც ყოველი ჩვენს მიწოდებულ ქოლბექს გააქტიურებს ყოველი it ფუნქციისთვის. აქ საჭიროა რომ ანგულარის TestBed-ს დახმარებით წინასწარ დავაქომფაილოთ კომპონენტი, რათა მასზე წვდომა გვქონდეს ტესტირების დროს.

როგორც ხედავთ აქ წინასწარ გვაქვს 3 სპეციფიკაცია. ყოველ სპეციფიკაციაში TestBed-ის დახმარებით ვიღებთ სასურველი კომპონენტის ინსტაციას და Jasmine-ის ფუნქციებს ვიყენებთ მის შესამოწმებლად.

expect ფუნქციას ვაწვდით იმ მონაცემს, რომლის რაღაც მნიშვნელობა თუ თვისება გინვდა რომ შევამოწმოთ და შემდეგ გვიბრუნდება JasmineMatchers-ის ინსტანცია, რომელზეც შესამოწმებელი მეთოდების დაძახება შეგვიძლია. isTruethy გულისხმობს არის თუ არა მნიშვნელობა ჭეშმარიტისებრი (ანუ ის არსებობს და არ არის null, undefined და ა.შ).

მეორე ბლოკში ხდება კომპონენტის თვისების შემოწმება, მოლოდინია, რომ title იყოს test.

ბოლო ბლოკში მოწმდება კომპონენტის თემფლეითი, სადაც DOM ელემენტს ვიღებთ და ვამოწმებთ შეიცავს თუ არა მოსალოდნელ სათაურს. ვინაიდან სათაური კომპონენტის თვისებიდან რენდერდება, ჯერ კომპონენტის fixture-ზე უნდა detectChanges მეთოდს დავუძახოთ რათა კომპონენტის თვისება თემფლეითში აისახოს (ამას აპლიკაციაში ანგულარი ავტომატურად აკეთებს, მაგრამ ტესტში ეს სპეციალურად უნდა გამოვიწვიოთ).

ტესების გაშვება შესაძლებელია ბრძანებით:

npm run test

ეს გაუშვებს Jasmine-ს და გაგვიხსნის ბრაუზერით karma-ს, სადაც ვნახვთ, რომ ყველა ტესტი წარმატებულია. თუ კომპონენტის კლასში title-ს შეცვლით, ერთ-ერთი ტესტი ახლა წარუმატებელი იქნება.

დავწეროთ ჩვენი ტესტიც. ვთქვათ კომპონენტის კლასში გვინდა შემოვიტანოთ მეთოდი changeTitle, რომელიც სათაურს შეცვლის. მაშინ ჯერ დავწეროთ ჩვენი მოლოდინი:

it("should change title with new value", () => {
  const fixture = TestBed.createComponent(AppComponent);
  const app = fixture.componentInstance;
  app.changeTitle();
  expect(app.title).toEqual("changed");
});

ავიღოთ კომპონენტის fixture და შევქმნათ ამ კომპონენტის ინსტანცია. კომპონენტზე დავუძახოთ changeTitle-ს და შემდეგ შევამოწმოთ, რომ ამ კლასის სათაური არის რაღაც ახალი მნიშვნელობა, მაგალითად changed.

ახლა ტესტი ჩავარდება, რადგან ეს მეთოდი არც კი არსებობს კომპონენტში, ამიტომ ის დავამატოთ:

import { Component } from "@angular/core";

@Component({
  selector: "app-root",
  templateUrl: "./app.component.html",
  styleUrls: ["./app.component.css"],
})
export class AppComponent {
  title = "tests";

  changeTitle() {
    this.title = "changed";
  }
}

და ასე ახლა ტესტი წარმატებით ჩაივლის.

შეჯამება

ეს არის იუნით ტესტების ძირითადი იდეა: ის ჩვენ მაგივრად ამოწმებს კოდს, ჩვენ უბრალოდ ფოკუსს ვაკეთებთ აპლიკაციის სტრუქტურასა და სპეციფიკაციებზე. ტესტები ძალიან დიდი სფეროა და ჩვენ მხოლოდ ზედაპირულად გავეცანით მის პრაქტიკას. მის შესახებ მეტის გასაგებად შეგიძლიათ გაეცნოთ შემდეგ რესურსებს:

Hosting (GitHub + Netlify)

პროექტი დასრულებული არ რის, სანამ მის მუშა ვერსიას არ დავჰოსტავთ, რათა ის ყველამ ნახოს ინტერნეტში. მაშ როგორ განვათავსოთ ანგულარის აპლიკაცია ინტერნეტში, როგორც რეალური ვებ აპლიკაცია?

ამისთვის დაგვჭირდება ორი რამ:

  1. ადგილი, სადაც კოდს შევინახავთ: GitHub
  2. და ვებ სერვისის პროვაიდერი: Netlify

ესენი არ არის ერთადერთი მეთოდი თუ ხელსაწყოების კრებული, რომლითაც აპლიკაციის დაჰოსტვა შეიძლება, თუმცა ეს ყველაზე პოპულარული და მარტივი გზაა (თანაც სრულიად უფასო!).

კოდის შენახვა GitHub-ზე

ყველა ანგულარის აპლიკაცია ავტომატურად ინიციალიზებულია როგორც გითის რეპოზიტორია. ესეიგი ჩვენ პირდაპირ შეგვიძლია კოდის დაქომითება და push. ამის გაკეთება შეიძლება ჯერ გითჰაბზე რეპოზიტორიის შექმნით და შემდეგ ამ რეპოზიტორიის ლოკალურ პროექტთან დაკავშირებით. თუ visual studio code-ში უკვე მაიქროსოფთის ექაუნთი გვაქვს დაკავშირებული ჩვენ პირდაპირ შეგვიძლია source control პანელზე გადასვლა და დაქომითების შემდეგ publish branch ღილაკზე დაჭერა, რომელიც რეპოზიტორიას თავისით შექმნის.

დაჰოსტვა Netlify-ზე

მას შემდეგ რაც Netlify-ზე ანგარიშს შექმნით (და რეკომენდირებულია რომ ეს GitHub-ის ექაუნთით გააკეთოთ) შეგიძლიათ გადაინაცვლოთ overview გვერდზე სადაც ნახავთ თქვენი (თავდაპირველად ცარიელი) საიტების სიას.

აქ დააჭერთ add new site > import existing project > GitHub და რეპოზიტორიების ჩამონათვალიდან აირჩევთ სასურველს. შემდგომ netlify ავტომატურად მიხვდება, რომ ეს ანგულარის პროექტია და გვერდის დაბილდვის ბრძანებას შეავსებს npm run build-ით. შემდეგ dist ფოლდერს გამოიყენებს გამოსაქვეყნებელ ფოლდერად, ანუ იმ ფოლდერს, სადაც საბოლოოდ დაბილდული კოდია. აქ აუცილებელია ჩვენი პროექტის სახელის ფოლდერის დაზუსტება, ისე, რომ რაღაც ასეთი მისამართი გამოვიდეს: dist/my-app. ჩვენ შეგვეძლო პირდაპირ ეს ფოლდერი აგვეტვირთა, მას შემდეგ რაც ლოკალურად გავუშვებდით დაბილდვის ბრძანებას, თუმცა ამ მეთოდის უპირატესობა არის ის, რომ რეპოზიტორიაში ყოველ ახალ ქომითზე ჩვენი დაჰოსტილი აპლიკაცია ავტომატურად დაიბილდება ხელახლა და ჩვენ არ მოგვიწევს ყოველ ჯერზე განახლებული ფაილების ატვირთვა. Deploy ღილაკზე დაჭერის შემდეგ ფაილები აიტვირთება, რამდენიმე წუთში საიტი დაიბილდება და Netlify ამ საიტს მიანიჭებს თავის URL-ს.

დაბილდვის პროცესს შეგვიძლია უშუალოდ დავაკვირდეთ, ისე როგორც ლოკალური ბილდის აუთფუთს ტერმინალში.

რაღაც დროის შემდეგ შეგვიძლია ვნახოთ ჩვენი ვებ აპლიკაცია. აქ პატარა პრობლემას წავაწყდებით. თუ გვერდს კონკრეტული მისამართიდან. მაგალითად /products-დან დავარეფრეშებთ, მივიღებთ 404 ერორს. ამ დროს Netlify ეძებს ჩვენს dist ფოლდერში products სახელის მქონე ფოლდერს, როგორც სტატიკურ ვებსაიტზე – ის კი ა რა რსებობს. ჩვენ აპლიკაციაში რაუთინგს განაგებს ანგულარი, რომელიც ერთ გვერდიანი აპლიკაციაა. მაშინ ჩვენს პროექტში (ზედა დონეზე) უნდა დავამატოთ Netlify-ს კონფიგურაცია, netlify.toml ფაილში:

[[redirects]]
  from = "/*"
  to = "/index.html"
  status = 200

აქ ვეუბნებით Netlify-ს, რომ ყველა მისამართზე მოთხოვნამ გადაიყვანოს მოხმარებელი მთავარ გვერდზე და დანარჩენი ანგულარის აპლიკაციას მიანდოს.

თუ ამ ცვლილებას დავაქომითებთ და დავასინქრონიზირებთ, ვებსაიტი ხელახლა დაიბილდება და რაღაც დროის შემდეგ 404 პრობლემა აღარ შეიქმნება.

შეჯამება

ამ გაკვეთიშლი ჩვენ ვისწავლეთ, როგორ დავჰოსტოთ ანგულარის პროექტი Netlify-ზე, GitHub-ის რეპოზიტორიის შუამავლობით, საიდანაც Netlify არა მხოლოდ პროექტის ფაილებს იყენებს, არამედ ყოველ ცვლილებაზე რეპოზიტორიაში, ის საიტს ხელახლა ბილდავს. ჩვენ ასევე დავამატეთ გადამისამართების კონფიგურაცია, რაც რაუთინგს მთლიანად ანგულარის აპლიკაციას მიანდობს, რათა საიტი ექვემდებარებოდეს Single Page Application ლოგიკას.

Standalone Components

მე-14 ვერსიიდან ანგულარში შემოვიდა ე.წ “დამოუკიდებელი კომპონენტები”. დამოუკიდებელი კომპონენტები ამარტივებენ ანგულარის აპლიკაციების აწყობას. ამ სისტემაში უკვე აღარ არის საჭირო NgModule, სადაც კომპონენტებს, დირექტივებს, ფაიფებსა და სერვისებს ვუკეთებთ დეკლარაციას. არსებულ აპლიკაციებში შესაძლებელია დამოუკიდებელი კომპონენტების ეტაპობრივად შემოტანა ისე, რომ აპლიკაციაში არაფერი გავაფუჭოთ.

ამ თავში ვისწავლით:

უფრო მეტი დეტალებისთვის გაეცანით ოფიციალურ დოკუმენტაციას, რომელზეც ეს თავია დაფუძნებული.

შექმნა და გამოყენება

კომპონენტების (ისევე, როგორც დირექტივებისა და ფაიფების) შექმნა შეიძლება მათი შემქმნელი დეკორატორის კონფიგურაციის ობიექტში standalone თვისებისtrue-ზე დაყენებით:

@Component({
  standalone: true,
  selector: "image-grid",
  imports: [ImageGridComponent],
  templateUrl: "./image-grid.component.ts",
})
export class ImageGridComponent {
  // component logic
}

თუ ამ კომპონენტის სადმე გამოყენება დაგვჭირდება, მას არ ვარეგისტრირებთ NgModule-ში, არამედ პირდაპირ ვაიმპორტებთ იმ დამოუკიდებელ კომპონენტში, რომელშიც ის დაგვჭირდება:

@Component({
  standalone: true,
  selector: "photo-gallery",
  imports: [ImageGridComponent],
  template: ` ... <image-grid [images]="imageList"></image-grid> `,
})
export class PhotoGalleryComponent {
  // component logic
}

imports ველში ასევე შეიძლება დამოუკიდებელი ფაიფებისა და დირექტივების შემოტანაც.

NgModule-ების შემოტანა დამოუკიდებელ კომპონენტებში

თუ ჩვენ აპლიკაციაში გვჭირდება ფუნქციონალი, რომლებიც არ არიან standalone და მოდულებში არიან შეკრული, მაშინ შეგვიძლია ამ მოდულების კომპონენტში პირდაპირ შემოტანა imports მასივში:

@Component({
  standalone: true,
  selector: "photo-gallery",
  // an existing module is imported directly into a standalone component
  imports: [MatButtonModule],
  template: `
    ...
    <button mat-button>Next Page</button>
  `,
})
export class PhotoGalleryComponent {
  // logic
}

ასე MatButtonModule-ში არსებული ყველა დაექსპორტებული კომპონენტი, ფაიფი თუ დირექტივი ხელმისაწვდომია PhotoGalleryComponent-ში.

დამოუკიდებელი კომპონენტის შეტანა NgModule-ში

ანალოგიურად, შესაძლებელია დამოუკიდებელი კომპონენტების შეტანა NgModule-ზე დაფუძნებულ კონტექსტშიც. ეს უზრუნველყოფს შესაძლებლობას, რომ ძველი აპლიკაციები ეტაპობრივად და მარტივად გადავიყვანოთ NgModule სისტემიდან standalone სისტემაზე.

@NgModule({
  declarations: [AlbumComponent],
  exports: [AlbumComponent],
  imports: [PhotoGalleryComponent],
})
export class AlbumModule {}

თუმცა PhotoGalleryComponent არის standalone, მისი დაიმპორტება ძველებური მეთოდითაც შეიძლება.

ასერომ, დამოუკიდებელი კომპონენტები არ მოდიან კონფლიქტში ანგულარის წინა ვერსიის მოდულებთან. რაღაც თვალსაზრისით, ახლა თითოეული კომპონენტი არის თვითკმარი მოდული.

Bootstrapping

ასეთი სისტემით main.ts-ში bootstrap განსხვავებულად ხდება. იმის მაგივრად, რომ ეს მოხდეს მთლიან მოდულზე, გამოიყენება ფუნქცია bootstrapApplication რომელიც აპლიკაციის მთავარ დამოუკიდებელ კომპონენტს იღებს:

import { bootstrapApplication } from "@angular/platform-browser";
import { PhotoAppComponent } from "./app/photo.app.component";

bootstrapApplication(PhotoAppComponent);

მეორე არგუმენტად ობიექტში, providers მასივში შესაძლებელია ისეთი მოდულების შემოტანა რომლებიც (ძველი მიდგომით) forRoot ფუნქციაზე დაძახებას საჭიროებენ:

import { LibraryModule } from "ngmodule-based-library";

bootstrapApplication(PhotoAppComponent, {
  providers: [importProvidersFrom(LibraryModule.forRoot())],
});

აქვე შეიძლება DI-ს კონფიგურაციაც:

bootstrapApplication(PhotoAppComponent, {
  providers: [
    {
      provide: BACKEND_URL,
      useValue: "https://photoapp.looknongmodules.com/api",
    },
    // ...
  ],
});

Routing

Standalone სისტემაში შემოტანილია რაუთერის გამარტივებული API. ახლა შესაძლებელია რაიმე რაუთების შექმნა ცალკე ფაილში:

export const ROUTES: Route[] = [
  { path: "admin", component: AdminPanelComponent },
  // ... other routes
];

და მისი დამატება main.ts-ში bootstrapApplication-ის კონფიგურაციაში, კერძოდ providers მასივში provideRouter ფუნქციის დახმარებით:

import { ROUTES } from "./app/admin/admin.routes.ts";

bootstrapApplication(AppComponent, {
  providers: [
    provideRouter([ROUTES]),
    // ...
  ],
});

Lazy-loading

შესაძლებელია რაუთების “ზარმაცად” ჩატვირთვაც. ამისთვის გამოიყენება loadComponent:

export const ROUTES: Route[] = [
  {
    path: "admin",
    loadComponent: () =>
      import("./admin/panel.component").then((mod) => mod.AdminPanelComponent),
  },
  // ...
];

თუ რამდენიმე რაუთის “ზარმაცად” ჩატვირთვა გვინდა, შეგვიძლია ცალკე რაუთების ფაილის დაიმპორტება loadChildren-ით:

// In the main application:
export const ROUTES: Route[] = [
  {
    path: "admin",
    loadChildren: () =>
      import("./admin/routes").then((mod) => mod.ADMIN_ROUTES),
  },
  // ...
];

// In admin/routes.ts:
export const ADMIN_ROUTES: Route[] = [
  { path: "home", component: AdminHomeComponent },
  { path: "users", component: AdminUsersComponent },
  // ...
];

ეს მეთოდი მხოლოდ მაშინ მუშაობს, როცა ყველა ჩატვირთული კომპონენტი არის standalone.

რაუთების ჯგუფისთვის სერვისის მიწოდება

თუ არსებობს სერვისი, რომელიც გვინდა რომ მხოლოდ /admin-ის ფარგლებში ფუნქციონირებდეს, ეს შეგვიძლია რაუთების სიაშივე გავაკეთოთ, providers თვისებით:

export const ROUTES: Route[] = [
  {
    path: "admin",
    providers: [AdminService, { provide: ADMIN_API_KEY, useValue: "12345" }],
    children: [
      { path: "users", component: AdminUsersComponent },
      { path: "teams", component: AdminTeamsComponent },
    ],
  },
  // ... other application routes that don't
  //     have access to ADMIN_API_KEY or AdminService.
];

აქ admin რაუთსა და მის შვილებს (children-ში არსებულ რაუთებს) წვდომა აქვთ AdminService-სა და ADMIN_API_KEY-ზე.

ბოლოსიტყვაობა ავტორისგან

თუ აქამდე ისე მოაღწიეთ, რომ ზემოთ არსებული ყველა თემა საფუძვლიანად დაამუშავეთ, მაშინ გილოცავთ! თქვენ აითვისეთ ყველაფერი რაც საჭიროა დამწყები ანგულარ დეველოპერისთვის. ახლა მზად ხართ, რომ განვითარება დამოუკიდებლად გააგრძელოთ ოფიციალური დოკუმენტაციებით, გამოცდილი დეველოპერების ბლოგებითა თუ ვიდეოებით, და, რა თქმა უნდა, ნამდვილი აპლიკაციების დამოუკიდებლად აწყობით!

ამ გზამკვლევის შედგენაში ძალიან დიდი შრომაა ჩადებული და თუკი ის ოდნავ მაინც დაგეხმარათ ცოდნა-უნარების განვითარებაში, მაშინ ჩემი მისია შესრულებულია.

დიდ მადლობას ვუხდი ყველა კონტრიბუტორს, რაოდენ პატარა წვლილიც არ უნდა შეგეტანათ ამ პროექტში. თითოეული ასოს ჩასწორება თუ ახალი სტრიქონის დამატება მნიშვნელოვნად მიწყობს ხელს. ეს პროექტი მუდმივად განვითარებისა და დახვეწის პროცესში არის, ამიტომ თუ გაქვთ იდეები, შენიშვნები, კონტრიბუციისა თუ მხარდაჭერის სურვილი, არ მოგერიდოთ, ეწვიეთ პროექტის გითჰაბს!

წარმატებები!

ელფი გოგონა კითხულობს ანგულარის გზამკვლევს