Jak radzić sobie z zasadami SOLID w Vue.js

Jak radzić sobie z zasadami SOLID w Vue.js

Architektura FrontendVue.jsFront-end

Zasady SOLID to fundamentalne wzorce projektowe, które pomagają programistom tworzyć kod wysokiej jakości. Wykorzystanie tych zasad w projektach Vue.js może znacząco podnieść poziom naszego kodu i sprawić, że aplikacje będą bardziej solidne, łatwiejsze w utrzymaniu i gotowe na przyszłe zmiany.

Czym są zasady SOLID?

SOLID to akronim stworzony przez Roberta C. Martina (znanego też jako Uncle Bob), który reprezentuje pięć kluczowych zasad projektowania obiektowego:

  1. Single Responsibility Principle - Zasada Jednej Odpowiedzialności
  2. Open/Closed Principle - Zasada Otwarte/Zamknięte
  3. Liskov Substitution Principle - Zasada Podstawienia Liskov
  4. Interface Segregation Principle - Zasada Segregacji Interfejsów
  5. Dependency Inversion Principle - Zasada Odwrócenia Zależności

Choć zasady te powstały z myślą o programowaniu obiektowym, świetnie sprawdzają się również w ekosystemie Vue.js. Ich stosowanie prowadzi do bardziej modułowego, testowalnego i łatwiejszego w utrzymaniu kodu.

W tym artykule omówimy każdą z zasad SOLID i pokażemy, jak praktycznie zastosować je w projektach Vue.js, używając rzeczywistych przykładów kodu. Niezależnie od tego, czy tworzysz małą aplikację, czy duży projekt firmowy, zrozumienie i stosowanie tych zasad pomoże ci pisać lepszy kod i uniknąć wielu typowych problemów związanych z rozwijaniem aplikacji w czasie.

Przyjrzyjmy się każdej z tych zasad w kontekście Vue.js i zobaczmy, jak mogą poprawić architekturę naszych aplikacji.

Single Responsibility Principle (SRP)

Single Responsibility Principle to pierwsza i najważniejsza spośród zasad SOLID. Mówi ona, że każdy element naszego kodu - czy to klasa, moduł czy komponent - powinien mieć tylko jeden powód do zmiany. W kontekście Vue.js oznacza to, że każdy komponent powinien być odpowiedzialny tylko za jedną konkretną funkcjonalność.

Zrozumienie problemu

Spójrzmy na przykład komponentu , który narusza tę zasadę:

Co jest nie tak z tym komponentem?

Nasz komponent ma zdecydowanie za dużo na głowie:

  1. Za dużo odpowiedzialności - ten komponent zajmuje się:
    • Pobieraniem i wyświetlaniem danych profilu użytkownika
    • Wyświetlaniem postów użytkownika
    • Zarządzaniem edycją i usuwaniem postów
    • Obsługą stanów ładowania i błędów
    • Formatowaniem dat
  2. Problem z utrzymaniem - jeśli chcemy zmienić którąkolwiek z tych funkcjonalności, musimy grzebać w tym samym komponencie.
  3. Trudności z testowaniem - testowanie takiego komponentu to koszmar, bo trzeba testować wiele funkcjonalności naraz.
  4. Ograniczona możliwość ponownego wykorzystania - nie możemy użyć fragmentu funkcjonalności w innym miejscu bez przenoszenia całego komponentu.
  5. Pomieszanie logiki biznesowej z prezentacją - cała logika jest bezpośrednio w komponencie, co utrudnia jej reużycie.

Jak to naprawić?

Rozbijmy ten duży komponent na mniejsze, bardziej wyspecjalizowane kawałki:

1. Stwórzmy funkcje pomocnicze (composables)

Najpierw wyciągnijmy logikę biznesową:

useUser.ts

usePostDelete.ts

useFormatters.ts

2. Stwórzmy mniejsze komponenty

LoadingSpinner.vue

ErrorMessage.vue

ProfileHeader.vue

UserPosts.vue

3. Złóżmy to wszystko w głównym komponencie

Co zyskujemy dzięki takiemu podejściu?

  1. Każdy komponent ma swoją rolę:
    • Komponenty skupiają się na prezentacji
    • Logika biznesowa siedzi w oddzielnych funkcjach (composables)
    • Kod staje się bardziej czytelny
  2. Łatwiejsze utrzymanie:
    • Chcesz zmienić coś w logice biznesowej? Edytujesz tylko odpowiednią funkcję
    • Zmiany w UI dotyczą tylko konkretnego komponentu
  3. Testowanie staje się proste:
    • Możesz testować logikę biznesową niezależnie od UI
    • Komponenty można testować z zaślepionymi funkcjami (mockami)
    • Mniejsze jednostki = prostsze testy
  4. Możliwość wielokrotnego użycia:
    • Funkcje (composables) można używać w wielu komponentach
    • Komponenty można stosować z różnymi źródłami danych
    • Większa elastyczność całej aplikacji
  5. Jasny podział odpowiedzialności:
    • Wyraźne rozgraniczenie między UI a logiką
    • Lepiej zorganizowany kod
    • Jaśniejsza struktura folderów
  6. Łatwiejsze debugowanie:
    • Problemy można izolować do konkretnych części
    • Jaśniejsze komunikaty błędów
    • Prostsze zarządzanie stanem

Podsumowanie

Stosowanie Zasady Jednej Odpowiedzialności daje nam czystszą architekturę z lepszym podziałem zadań. Wydzielając logikę biznesową do oddzielnych funkcji, osiągamy wyższy poziom organizacji kodu i możliwości ponownego wykorzystania.

Takie podejście sprawia, że aplikacje są łatwiejsze w utrzymaniu, testowaniu i rozwijaniu w dłuższym okresie. Nawet jeśli na początku wymaga to więcej pracy, korzyści szybko zaczynają przeważać nad nakładem, szczególnie gdy projekt rośnie.

Open/Closed Principle (OCP)

Open/Closed Principle mówi, że jednostki oprogramowania (klasy, moduły, funkcje itp.) powinny być otwarte na rozszerzanie, ale zamknięte na modyfikacje. W kontekście Vue.js oznacza to, że powinniśmy projektować komponenty tak, aby nowe funkcjonalności dało się dodawać bez konieczności zmiany ich istniejącego kodu.

Zrozumienie problemu

Przyjrzyjmy się przykładowi komponentu , który narusza tę zasadę:

Problemy z komponentem nie spełniającym OCP

  1. Gdy musimy dodać nowy typ przycisku (np. 'warning'), musimy zmienić kod źródłowy komponentu.
  2. Szablon zawiera wiele instrukcji warunkowych dla różnych typów przycisków.
  3. Dodawanie nowych wariantów lub modyfikowanie istniejących wymaga zmian w wielu miejscach.
  4. Użytkownicy komponentu mają ograniczone możliwości dostosowania wyglądu lub zachowania.
  5. Każdy nowy typ przycisku wymaga aktualizacji definicji typów.

Zastosowanie Open/Closed Principle

Przeprojektujmy ten komponent, aby był zgodny z OCP:

Kluczowe ulepszenia w komponencie zgodnym z OCP

  1. Dynamiczne klasy CSS:
    • Zamiast warunków używamy interpolacji:
    • Nowe typy przycisków będą automatycznie działać bez zmiany kodu
  2. Elastyczne parametry:
    • Większość właściwości jest opcjonalna z sensownymi wartościami domyślnymi
    • Typ jest teraz zwykłym stringiem, więc można użyć dowolnej wartości
  3. System slotów:
    • - Dla zawartości przed głównym tekstem
    • - Niestandardowy wskaźnik ładowania
    • Domyślny slot - Dla głównej zawartości przycisku
    • - Dla zawartości po głównym tekście

Przykłady użycia zgodnego z OCP

Podstawowe użycie

Dodawanie nowego typu przycisku bez modyfikowania komponentu

Wystarczy dodać w CSS i zadziała bez zmiany kodu komponentu!

Dostosowywanie zawartości za pomocą slotów

Niestandardowy stan ładowania

Korzyści z podejścia zgodnego z OCP

  1. Rozszerzanie bez modyfikacji:
    • Nowe typy przycisków można dodawać bez zmiany kodu komponentu
    • Zachowanie można dostosować poprzez właściwości i sloty
  2. Łatwiejsze utrzymanie:
    • Brak skomplikowanych warunków w szablonie
    • Zmiany jednego aspektu nie wpływają na inne
  3. Większa możliwość ponownego użycia:
    • Ten sam komponent może obsłużyć wiele różnych przypadków
    • Elastyczne opcje dostosowania dla różnych kontekstów
  4. Lepsza komponowalność:
    • Łatwo integruje się z innymi komponentami
    • Sloty pozwalają na wstrzykiwanie dowolnej zawartości
  5. Przyjemniejsze API:
    • Intuicyjne API z rozsądnymi wartościami domyślnymi
    • Bezpieczne typowanie z elastycznymi ograniczeniami

Podsumowanie

Stosowanie Open/Closed Principle w Vue.js tworzy bardziej elastyczne i łatwiejsze w utrzymaniu komponenty. Projektując komponenty, które są otwarte na rozszerzanie, ale zamknięte na modyfikacje, tworzymy elementy UI, które mogą ewoluować razem z potrzebami naszej aplikacji bez ciągłego przepisywania istniejącego kodu.

Kluczowe techniki, których użyliśmy:

  1. Dynamiczne klasy CSS zamiast warunków
  2. Elastyczne właściwości z sensownymi wartościami domyślnymi
  3. System slotów do niestandardowej zawartości
  4. Przekazywanie zdarzeń

Pamiętaj, że celem jest minimalizacja zmian w istniejącym kodzie przy jednoczesnym umożliwieniu dodawania nowych funkcji. To zmniejsza ryzyko błędów i sprawia, że komponenty są bardziej stabilne i adaptowalnie z czasem.

Liskov Substitution Principle (LSP)

Liskov Substitution Principle mówi, że obiekty klasy bazowej powinny być zastępowalne przez obiekty klas pochodnych bez wpływu na poprawność programu. W kontekście Vue.js oznacza to, że komponent, który rozszerza lub dziedziczy po innym komponencie, powinien być używalny wszędzie tam, gdzie używany jest komponent bazowy, bez wiedzy konsumenta o różnicy.

Zrozumienie problemu

Przyjrzyjmy się przykładowi hierarchii komponentów, która narusza tę zasadę:

Teraz przyjrzyjmy się komponentowi, który rozszerza BaseInput, ale narusza LSP:

Problemy z komponentami nie spełniającymi LSP

  1. Niespójne typy: BaseInput oczekuje stringa dla , NumericInput oczekuje liczby.
  2. Różne emitowane zdarzenia: NumericInput dodaje nowe zdarzenie 'error', którego nie ma w BaseInput.
  3. Zmienione zachowanie: NumericInput ma wbudowaną walidację danych wejściowych, czego BaseInput nie posiada.
  4. Różna struktura DOM: NumericInput dodaje element komunikatu o błędzie, którego nie ma w BaseInput.
  5. Wymuszony atrybut type: NumericInput ma na stałe ustawiony typ "number", ignorując prop .

Te różnice sprawiają, że NumericInput nie może być użyty jako zamiennik BaseInput, co narusza Liskov Substitution Principle.

Zastosowanie Liskov Substitution Principle

Przeprojektujmy te komponenty, aby były zgodne z LSP:

1. Udoskonalmy komponent bazowy

2. Teraz stwórzmy zgodny NumericInput

3. Stwórzmy komponent używający dowolnego typu inputu

Korzyści z podejścia zgodnego z LSP

  1. Kompozycja zamiast duplikacji: NumericInput wykorzystuje BaseInput zamiast powielać jego kod.
  2. Adaptery typów: Obsługujemy konwersję między string i number, zachowując oczekiwany interfejs.
  3. Spójne zdarzenia: Oba komponenty emitują ten sam zestaw zdarzeń w spójny sposób.
  4. Przewidywalne zachowanie: Logika walidacji jest częścią API komponentu, a nie ukrytą implementacją.
  5. Dziedziczenie atrybutów: Oba komponenty prawidłowo przekazują dodatkowe atrybuty HTML.
  6. Stabilny interfejs: Użytkownicy tych komponentów mogą polegać na spójnym interfejsie.

Przykłady użycia

Używanie BaseInput

Używanie NumericInput

Używanie FormField z dowolnym typem inputu

Podsumowanie

Liskov Substitution Principle jest kluczowy dla tworzenia utrzymywalnych hierarchii komponentów w Vue.js. Zapewniając, że wyspecjalizowane komponenty mogą zastąpić komponenty bazowe, tworzymy przewidywalny i niezawodny system komponentów.

Kluczowe strategie implementacji LSP w komponentach Vue to:

  1. Kompozycja zamiast duplikacji: Wykorzystywanie istniejących komponentów zamiast kopiowania ich kodu.
  2. Spójne interfejsy: Zapewnienie, że komponenty pochodne zachowują ten sam kontrakt co bazowe.
  3. Adaptery typów: Używanie properties computed do konwersji między różnymi typami danych.
  4. Właściwa delegacja zdarzeń: Przekazywanie zdarzeń i atrybutów, by zachować spójne zachowanie.
  5. Jawne rozszerzenia: Dodawanie nowych funkcji w sposób, który nie łamie istniejących wzorców użycia.

Stosując te zasady, możesz tworzyć komponenty, które są łatwiejsze w ponownym użyciu, testowaniu i utrzymaniu w całej aplikacji.

Interface Segregation Principle (ISP)

Interface Segregation Principle mówi, że żaden klient nie powinien być zmuszony do zależności od metod, których nie używa. W kontekście Vue.js oznacza to, że komponenty powinny mieć precyzyjne i skupione właściwości (props) oraz emitować tylko te zdarzenia, których naprawdę potrzebują, zamiast mieć ogólne, rozbudowane interfejsy zmuszające użytkowników do dostarczania niepotrzebnych danych.

Zrozumienie problemu

Przyjrzyjmy się przykładowi komponentu, który narusza ISP:

Problemy z komponentem nie spełniającym ISP

  1. Zbyt rozbudowany interfejs: Komponent ma mnóstwo właściwości, z których wiele jest opcjonalnych i może nigdy nie być używanych w prostych przypadkach.
  2. Wymuszone funkcje: Użytkownicy muszą dostarczać opcje konfiguracyjne dla funkcji, których nie potrzebują (np. eksport czy paginacja).
  3. Pomieszane odpowiedzialności: Komponent zajmuje się wyszukiwaniem, filtrowaniem, sortowaniem, paginacją, selekcją i eksportem - zdecydowanie za dużo jak na jeden komponent.
  4. Skomplikowane renderowanie warunkowe: Szablon zawiera mnóstwo sekcji warunkowych zależnych od aktywnych funkcji.
  5. Nieefektywny rozmiar: Nawet jeśli część funkcji nie jest używana, ich kod nadal trafia do paczki.

Zastosowanie Interface Segregation Principle

Podzielmy ten komponent na mniejsze, wyspecjalizowane komponenty:

1. Podstawowy komponent tabeli

2. Komponent tabeli z sortowaniem

3. Komponent tabeli z możliwością selekcji wierszy

4. Komponent wyszukiwania

5. Komponent filtrów

6. Komponent paginacji

7. Komponowanie tych komponentów

Teraz możemy komponować te specjalistyczne komponenty, aby stworzyć różne rozwiązania tabelaryczne według potrzeb:

Korzyści z podejścia zgodnego z ISP

  1. Skupione interfejsy: Każdy komponent ma minimalny, skoncentrowany zestaw właściwości i zdarzeń, które służą konkretnemu celowi.
  2. Elastyczność i kompozycja: Użytkownicy mogą wybrać dokładnie te komponenty, których potrzebują i komponować je według wymagań.
  3. Lepsza utrzymanie kodu: Zmiany w jednej funkcji (np. paginacji) nie wpływają na komponenty, które jej nie używają.
  4. Możliwość ponownego użycia: Mniejsze komponenty mogą być ponownie wykorzystane w różnych kontekstach, nie tylko w tabelach.
  5. Optymalizacja rozmiaru paczki: Dzięki tree-shakingowi nieużywane komponenty nie będą uwzględniane w końcowej paczce.
  6. Łatwiejsze testowanie: Każdy komponent może być testowany niezależnie z prostszymi przypadkami testowymi.
  7. Lepsze doświadczenie programisty: Właściwości i zdarzenia są jasno zdefiniowane dla każdego konkretnego przypadku użycia.

Przykłady użycia

Prosta tabela

Tabela z sortowaniem i wyborem wiersza

Kompletna tabela z wieloma funkcjami

Podsumowanie

Interface Segregation Principle jest kluczową zasadą dla tworzenia elastycznych i łatwych w utrzymaniu komponentów Vue.js. Dzieląc duże interfejsy na mniejsze, bardziej skoncentrowane, tworzymy komponenty, które są:

  1. Łatwiejsze w utrzymaniu: Zmiany w jednej funkcji nie wpływają na komponenty, które jej nie używają.
  2. Bardziej wielokrotnego użytku: Mniejsze komponenty mogą być komponowane na różne sposoby w zależności od konkretnych potrzeb.
  3. Bardziej wydajne: Tylko kod dla faktycznie używanych funkcji jest uwzględniany w paczce.
  4. Łatwiejsze do testowania: Każdy komponent ma jasny, skoncentrowany cel, który łatwiej przetestować.

Projektując API komponentów, zawsze należy dążyć do najmniejszego możliwego interfejsu, który spełnia bieżące wymagania. Łatwiej jest później rozszerzyć niż usunąć funkcje, które są już używane.

Pamiętaj, że w Vue.js właściwości i zdarzenia komponentów tworzą "interfejs" komponentu, więc stosowanie ISP oznacza tworzenie komponentów z minimalnymi, spójnymi zestawami właściwości i zdarzeń, zamiast dużych, ogólnych interfejsów.

Dependency Inversion Principle (DIP)

Dependency Inversion Principle mówi, że moduły wysokiego poziomu nie powinny zależeć od modułów niskiego poziomu; oba powinny zależeć od abstrakcji. Ponadto abstrakcje nie powinny zależeć od szczegółów; szczegóły powinny zależeć od abstrakcji. W kontekście Vue.js oznacza to, że komponenty powinny zależeć od interfejsów lub abstrakcji, a nie od konkretnych implementacji, co czyni je bardziej elastycznymi i łatwiejszymi do testowania.

Zrozumienie problemu

Przyjrzyjmy się przykładowi komponentu, który narusza Dependency Inversion Principle:

Problemy z komponentem nie spełniającym DIP

  1. Bezpośrednia zależność od axios: Komponent jest na stałe związany z konkretną biblioteką do żądań HTTP.
  2. Sztywno zakodowane ścieżki API: Komponent ma na stałe wpisane konkretne endpointy API.
  3. Trudność z testowaniem: Testowanie wymaga mockowania axios i jego odpowiedzi.
  4. Wymieszane odpowiedzialności: Komponent obsługuje zarówno renderowanie UI, jak i pobieranie danych.
  5. Powtarzający się kod: Podobny kod jest używany do pobierania zarówno danych użytkownika, jak i aktywności.

Zastosowanie Dependency Inversion Principle

Przeprojektujmy ten komponent, aby był zgodny z DIP:

1. Stworzenie abstrakcji (interfejsów)

Najpierw zdefiniujmy abstrakcje dla naszych serwisów danych:

2. Implementacja konkretnych serwisów

Teraz zaimplementujmy te interfejsy:

3. Stworzenie funkcji pomocniczych dla korzystania z tych serwisów

4. Funkcja pomocnicza do formatowania

5. Zaktualizowany komponent używający wstrzykiwania zależności

6. Użycie komponentu ze wstrzykiwaniem serwisów

7. Alternatywnie: Użycie provide/inject

Dla większych aplikacji możemy użyć mechanizmu provide/inject do bardziej scentralizowanego zarządzania zależnościami:

Rejestracja serwisów w głównym komponencie:

Użycie w komponencie:

8. Mockowe serwisy do testów

Jedną z głównych zalet DIP jest łatwość testowania. Oto przykład mockowych serwisów:

Przykład testu komponentu

Korzyści z podejścia zgodnego z DIP

  1. Luźne powiązania: Komponent zależy od abstrakcji, a nie od konkretnych implementacji.
  2. Łatwiejsze testowanie: Możliwość łatwego mockowania serwisów do testów.
  3. Elastyczność: Możliwość wymiany implementacji serwisów bez zmian w komponentach.
  4. Podział odpowiedzialności: Komponent koncentruje się na UI, a serwisy na logice biznesowej.
  5. Reużywalne funkcje pomocnicze: Logika pobierania danych jest wydzielona do reużywalnych funkcji.
  6. Jawne zależności: Zależności są jasno określone jako propsy lub wstrzyknięte przez system DI.

Przykłady użycia

Bezpośrednie wstrzykiwanie serwisów:

Serwisy zależne od środowiska

Podsumowanie

Dependency Inversion Principle to potężna koncepcja, która pomaga tworzyć bardziej utrzymywalne i testowalne aplikacje Vue.js. Przez zależność od abstrakcji zamiast konkretnych implementacji, komponenty stają się bardziej elastyczne i łatwiejsze do testowania.

Kluczowe strategie do implementacji DIP w Vue.js to:

  1. Definiowanie interfejsów: Tworzenie jasnych abstrakcji dla serwisów.
  2. Wydzielanie implementacji: Implementacja konkretnych serwisów zgodnych z tymi interfejsami.
  3. Używanie funkcji pomocniczych: Tworzenie funkcji pomocniczych, które przyjmują serwisy jako parametry.
  4. Wstrzykiwanie zależności: Wykorzystanie propsów, provide/inject lub dedykowanego kontenera DI.
  5. Tworzenie mocków do testowania: Przygotowanie testowych implementacji serwisów.

Stosowanie DIP prowadzi do bardziej modułowych, testowalnych i utrzymywalnych aplikacji, szczególnie gdy rosną one w rozmiarze i złożoności. Może to wydawać się dodatkową pracą dla mniejszych projektów, ale korzyści stają się wyraźne wraz ze skalowaniem aplikacji.

RD

Rafał Drożdż LinkedIn - Rafał Drożdż

Software Engineer @ Booksy