Nazad na blog
14 min čitanja

Kreiranje pouzdanih web aplikacija sa Angular Signalima

Uvod

U ovom članku ćemo kreirati jednostavnu eCommerce platformu koristeći Signale, ulaze/modele (Inputs/Models) bazirane na Signalima, Linked Signal (Povezani signal) i Effect, reaktivnu funkciju koja prati zavisnosti signala.

Takođe, dok iščekujemo forme bazirane na Signalima, u današnjem tekstu iskoristićemo Template Driven forme koje se odlično i prirodno uklapaju uz Signale.

UI Store (Skladište Korisničkog Interfejsa)

UI Store služi kao jedinstven izvor istine za dati deo aplikacije. On upravlja stanjem specifičnim za korisnički interfejs (UI) i opisuje ponašanje aplikacije. Jedna veb aplikacija može imati više store-ova (lokalni ili tzv. feature store).

Signali (Signals)

Signali, kao nova reaktivna primitiva, postali su prirodan i najnoviji način upravljanja reaktivnošću u Angular veb aplikacijama. U poređenju sa RxJS-om, Signali su veoma jednostavni. Signali mogu sadržati jednu vrednost.

Čitanje vrednosti signala je prosto - samo se pozove getter funkcija ovako:

const value = signal(0);
console.log('Pročitaj vrednost pozivom getera: ', value());
// Rezlutat: 0

A postavljanje ili ažuriranje vrednosti radi se ovako:

value.set(1);
console.log('Vrednost nakon set: ', value());
// Rezlutat: 1

value.update(v => v + 1);
console.log('Vrednost nakon update: ', value());
// Rezultat: 2

Za razliku od standardnih getter funkcija, Signali su memoizovani, što znači da poziv u templejtu neće ponovo trigerovati nov ciklus change detection-a osim ako se vrednost zapravo i nije promenila.

<p>Trenutna vrednost: {{ value() }}</p>

Sloj Servisa (Service Layer)

Naša aplikacija mora da dobavlja prave podatke sa nekog API-ja. Ovde ćemo mokovati proces izvlačenja "Products" (Proizvoda). Hajde da kreiramo servis:

ng generate service services/products

Kada ga generišemo, kreiramo naš mockovani API:

import { Injectable } from '@angular/core';
import { of } from 'rxjs';
import { Product } from '../interfaces/product.interface';

const PRODUCTS: Product[] = [
  {
    id: 'prod-001',
    name: 'Wireless Headphones',
    description:
      'Noise-cancelling over-ear Bluetooth headphones with 30 hours battery life.',
    price: 129.99,
    inStock: true,
  },
  {
    id: 'prod-002',
    name: 'Smart Water Bottle',
    description:
      'Tracks your water intake and glows to remind you to stay hydrated.',
    price: 49.99,
    inStock: true,
  },
  // ...
  {
    id: 'prod-009',
    name: 'Laptop Stand',
    description: 'Adjustable aluminum stand for laptops up to 17 inches.',
    price: 22.75,
    inStock: true,
  },
];

@Injectable({
  providedIn: 'root',
})
export class ProductsService {
  getAll() {
    return of(PRODUCTS);
  }
}

Naravno definisaćemo Product interfejs pokretanjem CLI komande:

ng generate interface interfaces/product

I popunite fajl:

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

Stranica Liste Proizvoda

Zarad ovog demona, app će biti jako prost. Koristiću App komponentu samo za dodavanje proizvoda u korpu bez nekih Master/Details flow-ova.

Za početak injektujemo ProductsService:

@Component({
  selector: 'app-root',
  template: `
    <h1>eCommerce Demo</h1>
  `,
})
export class AppComponent {
  readonly productsService = inject(ProductsService);

U prošlosti, možda bismo koristili metodu ngOnInit sa pretplatom na Observable ovako:

export class App implements OnInit {
  readonly productsService = inject(ProductsService);
  private readonly destroyRef = inject(DestroyRef);
  
  products: Product[] = [];

  ngOnInit(): void {
    this.productsService.getAll()
      .pipe(takeUntilDestroyed(this.destroyRef))
      .subscribe(products => this.products = products);
  }

Međutim, danas imamo bolji način pomoću toSignal:

import { Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';

@Component({
  selector: 'app-root',
  template: `
    <h1>eCommerce Demo</h1>
  `,
})
export class AppComponents {
  readonly productsService = inject(ProductsService);

  readonly products = toSignal(this.productsService.getAll(), {
    initialValue: [],
  });

Moćan trik sa toSignal iz paketa @angular/core/rxjs-interop jeste što radi kompletno unsubscribe (odjavljivanje) za nas. I manje koda (boilerplate).

Kartica Proizvoda (Product Card Komponenta)

Radićemo prezentacionu komponentu za Proizvod.

ng generate component components/product-card

Definišemo klasu:

import { Component, input, computed, output } from '@angular/core';
import { Product } from '../../interfaces/product.interface';

@Component({
  selector: 'app-product-card',
  imports: [],
  templateUrl: './product-card.component.html',
  styleUrl: './product-card.component.css',
})
export class ProductCardComponent {
  readonly product = input.required<Product>();
  readonly addToCart = output<Product>();

  protected readonly stockAvailibility = computed(
    () => this.product().inStock ? 'In Stock' : 'Out of Stock'
  );
}

Iskoristili smo input kontrolisan signalom uz pomoć input.required() i naravno computed funkciju.

A ovo ide u templejt:

<div class="product-card">
  <h2 class="product-name">{{ product().name }}</h2>
  <p class="product-description">{{ product().description }}</p>
  <p class="product-price">${{ product().price }}</p>
  <p class="product-stock" [class.out-of-stock]="!product().inStock">
    {{ stockAvailibility() }}
  </p>
  <button
    class="add-button"
    (click)="addToCart.emit(product())"
    [disabled]="!product().inStock"
  >
    Dodaj u korpu
  </button>
</div>

Prikazivanje Proizvoda

Sećate se kako smo pripremili signale preko RxJS-a i toSignal u koracima ranije? Sada ćemo izlistati kartice u AppComponent.

@Component({
  selector: 'app-root',
  imports: [
    ProductCardComponent,
  ],
  template: `
    <h1>eCommerce Demo</h1>
    
    <div>
      @for (product of products(); track product.id) {
        <app-product-card [product]="product" (addToCart)="false"/>
      }
    </div>
  `,
})
export class AppComponents {...}

Skladište Korpe (Shopping Cart Store)

Ranije, ako ste preskočili NgRx, pisali biste BehaviorSubject u RxJS stilu. Sada slična filozofija prelazi na Signale:

ng generate service stores/shopping-cart-store

Ideja je da je servisu isključiva odgovornost da čuva podatke, dakle čist Store pattern bez sporednih efekata (side-efekata).

export interface CustomerDetails {
  firstName: string;
  lastName: string;
  street: string;
  city: string;
  postalCode: string;
}

export interface ShoppingCartItem {
  product: Product;
  quantity: number;
}

export interface ShoppingCartStore {
  items: ShoppingCartItem[];
  totalPrice: number;
  customerDetails: CustomerDetails;
  discount: number;
}

Centralno skladište narudžbina (Cart) sadržaće informacije koje stavke su ubačene, informacije kupca i naravno totalnu cenu. Definisaćemo bazne oblike i ključne reči.

const INITIAL_ITEMS: ShoppingCartItem[] = [];

const INITAL_CUSTOMER_DETAILS: CustomerDetails = {
  firstName: '',
  lastName: '',
  street: '',
  city: '',
  postalCode: '',
};

@Injectable({
  providedIn: 'root',
})
export class ShoppingCartStoreService {
  readonly #items = signal<ShoppingCartItem[]>(INITIAL_ITEMS);
  readonly #customerDetails = signal<CustomerDetails>(INITAL_CUSTOMER_DETAILS);
  readonly #discount = signal(0);
  readonly #totalPrice = computed(() => {
    return this.#items().reduce(
      (sum, item) => sum + item.product.price * item.quantity,
      0
    );
  });
}

Stavke korpe, kupčevi podaci i popust su redovni Writable Signals (Signali koje možemo menjati). Primećujete privatni prefiks "#"! On odlično funkcioniše. Totalna cena se odmah računa dinamično preko computed funkcije.

Možemo ove store elemente izlagati napolje kao read-only:

  // 1st pristup asReadonly()
  readonly customerDetails = this.#customerDetails.asReadonly();
  
  // 2nd pristup: spread operator uz computed()
  readonly customerDetails = computed(() => ({...this.#customerDetails()}));

Forma sa Detaljima Korisnika

Kreiraćemo je kako bismo simulirali neku logiku za submit korisnika.

ng generate component components/customer-details-form
@Component({
  selector: 'app-customer-details-form',
  imports: [FormsModule],
  templateUrl: './customer-details-form.component.html',
})
export class CustomerDetailsFormComponent {
  readonly customer = input.required<CustomerDetails>();
  readonly submit = output<CustomerDetails>();
}

Uz ngModel za template formu i dvosmernu vezu ka podacima bićemo spremni.

Metode Unutar Injektabilnog Store-a

Najpre je bitno implementirati dodavanje u korpu:

  addItem(product: Product, quantity: number = 1) {
    this.#items.update((items: ShoppingCartItem[]) => {
      const index = items.findIndex((item) => item.product.id === product.id);

      if (index > -1) {
        const updatedItems = [...items];
        const existingItem = updatedItems[index];
        updatedItems[index] = {
          ...existingItem,
          quantity: existingItem.quantity + quantity,
        };
        return updatedItems;
      }

      return [...items, { product, quantity }];
    });
  }

Kada ga inkorporiramo u glavnu AppComponent komponentu pozivom ove metode omogućavamo naš store sistem!

Detalji Narudžbine (Shopping Cart Details)

Završavamo celinu kreiranjem sumarnog pregleda onoga što korisnik kupuje.

@Component({
  selector: 'app-shopping-cart-details',
  imports: [CurrencyPipe],
  templateUrl: './shopping-cart-details.component.html',
  styleUrl: './shopping-cart-details.component.css',
})
export class ShoppingCartDetailsComponent {
  readonly shoppingCart = input.required<ShoppingCartStore>();

  protected readonly fullName = computed(() => {
    // ... spajamo ime i prezime
  });
}

LinkedSignal i Popusti

Ako želimo popust dugme koje važi samo jednokratko. linkedSignal dolazi u pomoć!

export class ShoppingCartStoreService {
  readonly #totalPrice = linkedSignal(() => {
    return this.#items().reduce( // Oslanja se na items
      (sum, item) => sum + item.product.price * item.quantity, 0
    );
  });

  applyDiscount() {
    if (this.#discount() === 0) {
      const discount = Math.floor(Math.random() * 21);
      this.#discount.set(discount);
      // Ručno prepisuje totalPrice iz reaktivnog linked formata unutar sebe
      this.#totalPrice.update((value) => (value * (100 - discount)) / 100);
    }
  }
}

Kao što vidite, Linked Signal je savršen kada imamo kalkulaciju, a isto dozvoljavamo i overwritovanje nezavisno u runtime-u!

Efekti (Effects)

Efekti se ne zloupotrebljavaju. Primer je usklađivanje vrednosti u localStorage-u:

    effect(() => {
      const customerDetails = this.#customerDetails();
      localStorage.setItem(
        CUSTOMER_DETAILS_STORAGE_KEY,
        JSON.stringify(customerDetails)
      );
    });

Zaključak

Angular Signal Stores nudi laganu, kontrolisanu alternativu gigantskim state management sistemima za reaktivni razvoj stabilnih Web Aplikacija!

  • Identifikujte uska grla u performansama
  • Uočite probleme u arhitekturi
  • Dobijte konkretne preporuke
Pokreni Besplatnu Analizu