Strona główna / Artykuły / Sześć wzorców projektowych w JavaScript do unikania kodu spaghetti

Sześć wzorców projektowych w JavaScript do unikania kodu spaghetti

Wyjaśnia sześć praktycznych wzorców JavaScriptu — Strategy, Factory, Observer, Adapter, Composition i Pipeline — które zastępują skomplikowany kod strukturą łatwą w utrzymaniu.

2146 słów

Zmiana chaotycznych skryptów w przewidywalne, łatwe w utrzymaniu systemy

Każdy, kto od dawna tworzy oprogramowanie, zna to niepokojące uczucie, gdy po kilku miesiącach od wdrożenia ponownie otwiera projekt i nie jest już w stanie zrozumieć, jak wszystko ze sobą powiązane.

Pogłębianie się chaosu rzadko zaczyna się celowo. Dodaje się szybki element interfejsu, potem funkcję pobierania danych, następnie rozwiązania dla przypadków krawędziowych, wskaźniki ładowania i wywołania do analizy. Kilka tygodni później kiedyś uporządkowany skrypt zamienia się w chaotyczną masę ponad 800 linii z nawarstwionymi funkcjami zwrotnymi, przypadkowymi zmiennymi globalnymi oraz kruchymi łańcuchami if/else.

To właśnie jest istota kodu spaghetti: zasady biznesowe i logika prezentacji są tak ze sobą splecione, że zmiana jednej części bezpowrotnie uszkadza dwie inne w różnych miejscach.

Budowanie JavaScript, który jest skalowalny, nie oznacza konieczności dodawania ciężkich abstrakcji w stylu korporacyjnym do każdej funkcji. Chodzi przede wszystkim o oddzielenie różnych aspektów problemu oraz poleganie na kilku niezawodnych wzorcach projektowych.

Poniżej przedstawiono sześć sprawdzonych wzorców projektowych i architektonicznych, które pomagają uporządkować skomplikowany JavaScript i utrzymać bazę kodu w zrozumiałej formie w miarę jej rozwoju.

1. Wzorzec Strategii: Pozbywanie się nawarstwionych warunków

Problem

Każdy raz, gdy logika musi się dzielić w zależności od kategorii użytkownika, metody płatności lub trybu przetwarzania, wielu programistów instynktownie używa ciągów instrukcji if/else lub rozbudowanych bloków switch.

// The Spaghetti Way
function calculateShipping(order) {
  if (order.type === 'standard') {
    return order.weight * 1.5;
  } else if (order.type === 'express') {
    return order.weight * 3.0 + 10;
  } else if (order.type === 'overnight') {
    return order.weight * 5.0 + 25;
  } else if (order.type === 'international') {
    return order.weight * 8.0 + 50;
  } else {
    throw new Error('Unknown shipping method');
  }
}

Za każdym razem, gdy wasz zespół wprowadza nowy poziom dostawy, jesteście zmuszeni modyfikować tę samą centralną funkcję. Jeden błąd lub nieprawidłowy operator tutaj powoduje awarię obliczeń kosztów dostawy dla wszystkich typów zamówień jednocześnie.

Rozwiązanie

Wzorzec Strategii przenosi każdy algorytm do odrębnej, samodzielnej funkcji i przechowuje je w wspólnym obiekcie referencyjnym.

// The Scalable Way
const shippingStrategies = {
  standard: (order) => order.weight * 1.5,
  express: (order) => order.weight * 3.0 + 10,
  overnight: (order) => order.weight * 5.0 + 25,
  international: (order) => order.weight * 8.0 + 50,
};

function calculateShipping(order) {
  const strategy = shippingStrategies[order.type];

  if (!strategy) {
    throw new Error(`Unsupported shipping method: ${order.type}`);
  }

  return strategy(order);
}

Dlaczego to działa w skali

  • Zasada otwartości/zamknięcia: dodanie kilkunastu nowych opcji dostawy oznacza jedynie dodanie nowych wpisów do shippingStrategies, bez konieczności zmian w samej funkcji calculateShipping.
  • Latwiejsze testowanie: każdą funkcję strategii można wyeksportować, przeanalizować pod kątem wydajności i przetestować całkowicie osobno.

2. Wzory modułowe i fabryczne: utrzymywanie stanu w kontrolowanej formie

Problem

Zmiennych globalnych o luźnym zasięgu oraz współdzielonych obiektów zmienialnych sprzyjają trudnym do wykrycia błędom. Gdy kilka komponentów interfejsu może swobodnie czytać i modyfikować ten sam stan, ustalenie, który z nich uszkodził wartość, przekształca się w zadanie detektywistyczne.

// The Spaghetti Way
let cart = [];
let total = 0;

function addItem(item) {
  cart.push(item);
  total += item.price;
}

function resetCart() {
  cart = [];
  total = 0;
}

Nic nie powstrzymuje innego, niespowiązanego skryptu na stronie przed ustawieniem cart na null lub aktualizacją total, nie modyfikując jednocześnie cart.

Rozwiązanie

Zamknięcia wewnątrz fungcji fabrycznych umożliwiają utrzymanie stanu w formie prywatnej, udostępniając jedynie te konkretne operacje, których faktycznie potrzebuje inny kod, przy jednoczesnym całkowitym ukryciu surowych zmiennych.

// The Scalable Way
function createCart() {
  // Private variables protected inside the closure
  let items = [];

  return {
    addItem(product) {
      if (!product || typeof product.price !== 'number') {
        throw new Error('Invalid product payload');
      }
      items.push({ ...product, id: crypto.randomUUID() });
    },
    removeItem(productId) {
      items = items.filter((item) => item.id !== productId);
    },
    getItems() {
      // Return a shallow copy so external mutations don't alter state
      return [...items];
    },
    getTotal() {
      return items.reduce((sum, item) => sum + item.price, 0);
    },
    clear() {
      items = [];
    }
  };
}

const userCart = createCart();
userCart.addItem({ name: 'Mechanical Keyboard', price: 120 });
console.log(userCart.getTotal()); // 120

Dlaczego to działa w skali

  • Brak wycieków zmiennych: kod z zewnątrz nie może bezpośrednio nadpisać items — musi korzystać z zweryfikowanych metod publicznych.
  • Bезpieczne wielokrotne instancje: każde wezwanie createCart() zwraca własny, niezależny stan, bez ryzyka, że jedna instancja zatrze inną.

3. Wzorzec obserwatora (Pub/Sub): Luźnienie ścisło powiązanego kodu

Problem

Gdy klient kliknie „Złożyć zamówienie”, musi jednocześnie nastąpić kilka rzeczy: koszyk zostaje opróżniony, pojawia się komunikat potwierdzenia, uruchamiany jest piksel śledzący, a serwer tła otrzymuje powiadomienie. Umieszczanie całej tej logiki w jednej funkcji sprawia, że staje się ona nie do zarządzania.

// The Spaghetti Way
async function handleCheckout(order) {
  await api.submitOrder(order);

  // UI logic mixed directly with tracking and data operations
  document.querySelector('#cart-count').textContent = '0';
  document.querySelector('#modal').classList.add('active');
  analytics.trackPurchase(order);
  notificationSystem.sendPush('Order confirmed');
}

Jeśli skrypt śledzenia wywoła błąd lub element DOM zostanie przemianowany, cały proces płatności może nie powieść się.

Rozwiązanie

// The Scalable Way
class EventEmitter {
  constructor() {
    this.events = new Map();
  }

  subscribe(eventName, listener) {
    if (!this.events.has(eventName)) {
      this.events.set(eventName, new Set());
    }
    this.events.get(eventName).add(listener);

    // Return an easy unsubscribe function
    return () => this.events.get(eventName).delete(listener);
  }

  publish(eventName, data) {
    const listeners = this.events.get(eventName);
    if (listeners) {
      listeners.forEach((listener) => {
        try {
          listener(data);
        } catch (err) {
          console.error(`Error executing listener for ${eventName}:`, err);
        }
      });
    }
  }
}

const appBus = new EventEmitter();

// Feature modules register their own behavior
appBus.subscribe('order:placed', (order) => {
  analytics.trackPurchase(order);
});

appBus.subscribe('order:placed', () => {
  document.querySelector('#cart-count').textContent = '0';
});

// The emitter stays minimal and decoupled
async function handleCheckout(order) {
  await api.submitOrder(order);
  appBus.publish('order:placed', order);
}

Dlaczego to działa w skali

  • Brak zależności między komponentami: funkcja płatności nie wie, kto jest od niej zależny. Można dodać nowe funkcje analityczne, powiadomienia e-mail lub efekty w interfejsie bez żadnych zmian w handleCheckout.
  • Izolacja błędów: awaria w jednym słuchaczu nie wpływa na funkcję, która wywołała zdarzenie.

4. Wzorzec adaptera: izolacja kodu od niestabilnych zależności

Problem

Sługi zewnętrzne, pakiety npm oraz wewnętrzne punkty końcowe mają tendencję do zmiany swoich interfejsów bez uprzedzenia. Jeśli piętnaście oddzielnych komponentów pobiera dane użytkownika, a każdy z nich odczytuje bezpośrednio surowe pola odpowiedzi, zmiana nazwy pojedynczej właściwości – na przykład user_id na id – powoduje konieczność refaktoryzacji, która dotyka całej bazy kodu.

// The Spaghetti Way: scattered across multiple UI components
function renderProfile(rawApiResponse) {
  // Directly tied to backend-specific naming conventions
  const name = `${rawApiResponse.first_name} ${rawApiResponse.last_name}`;
  const address = rawApiResponse.shipping_address_line_1;
  const avatar = rawApiResponse.meta_info.profile_image_url;
}

Rozwiązanie

Należy dodać warstwę adaptera pomiędzy zewnętrznym źródłem danych a wewnętrzną logiką aplikacji. Przekształcić dowolną formę danych przychodzących z zewnątrz w stabilną, przewidywalną strukturę, zanim cokolwiek poniżej jej dotknie.

// The Scalable Way
function userAdapter(externalUser) {
  return {
    id: externalUser.user_id || externalUser.id,
    fullName: `${externalUser.first_name || ''} ${externalUser.last_name || ''}`.trim(),
    address: externalUser.shipping_address_line_1 || externalUser.street || 'N/A',
    avatar: externalUser.meta_info?.profile_image_url || '/assets/default-avatar.png',
  };
}

// Your components only ever consume normalized models
async function getUserProfile(userId) {
  const response = await fetch(`/api/v1/users/${userId}`);
  const rawData = await response.json();
  return userAdapter(rawData);
}

Dlaczego to działa w skali

  • Jedno miejsce do dostosowania: jeśli backend zmieni swoją strukturę odpowiedzi w przyszłym tygodniu, wystarczy edytować userAdapter raz zamiast naprawiać czterdzieści uszkodzonych komponentów.
  • Prostsze testowe zamienniki: testy interfejsu użytkownika muszą sprawdzać jedynie znormalizowaną strukturę, a nie ciągle zmieniający się format zewnętrzny.

5. Preferowanie kompozycji nad dziedziczeniem: budowanie funkcjonalności jak elementów budulcowych

Problem

Głębokie hierarchie klas mają tendencję do załamywania się pod wpływem własnej złożoności. Załóżmy, że zaczynamy od ogólnej klasy User i tworzymy od niej warianty AdminUser, ModeratorUser oraz GuestUser. Wszystko się psuje w momencie, gdy potrzebujemy GuestModerator – osoby, która ma niektóre uprawnienia moderatora, ale nie cały zestaw.

// The Spaghetti Way: Deep Inheritance
class BaseUser {
  login() { /* ... */ }
}

class Admin extends BaseUser {
  deleteContent() { /* ... */ }
  manageBilling() { /* ... */ }
}

// What happens when you need a "BillingAgent" who cannot delete content?

Długie łańcuchy dziedziczenia łączą różne funkcje w sposób, który nie odpowiada rzeczywistości, a podklasy odziedziczają metody, które nie mają dla nich sensu.

Rozwiązanie

Zamiast tego polegaj na kompozycji obiektów: małe, wielokrotnie używalne funkcje zachowań (mixiny), które dodaje się do obiektu w razie potrzeby. Twórz zestawy funkcji w oparciu o to, co dany obiekt robi, zamiast przymuszać go do sztywnej hierarchii typu is-a.

// The Scalable Way: Composable Behaviors
const canAuthenticate = (state) => ({
  login: () => console.log(`${state.email} logged in`),
  logout: () => console.log(`${state.email} logged out`),
});

const canModerateContent = () => ({
  deletePost: (postId) => console.log(`Post ${postId} deleted`),
  banUser: (userId) => console.log(`User ${userId} banned`),
});

const canManageBilling = () => ({
  processInvoice: (amount) => console.log(`Invoice processed: ${amount}`),
});

// Build specialized actors on demand
function createSupportStaff(email) {
  const state = { email };
  return {
    email,
    ...canAuthenticate(state),
    ...canModerateContent(),
  };
}

function createSuperAdmin(email) {
  const state = { email };
  return {
    email,
    ...canAuthenticate(state),
    ...canModerateContent(),
    ...canManageBilling(),
  };
}

const moderator = createSupportStaff('support@example.com');
moderator.login();
moderator.deletePost(404);
// moderator.processInvoice is undefined - zero privilege leakage

Dlaczego to działa w dużych skali

  • Brak hierarchii do zarządzania: zachowania łączy się w czasie rzeczywistym, bez konieczności uprzedniego planowania drzewa klas.
  • Zgodność w różnych kontekstach: zachowanie takie jak canAuthenticate działa równie dobrze zarówno w kontach klientów, kontach pracowników, jak i w kontach automatycznych botów.
  • 6. Wzorzec Pipeline: sekwencyjne kroki asynchroniczne

    Problem

    Łączenie kilku transformacji asynchronicznych często skutkuje głęboko zagnieżdżonym kodem trudnym do zrozumienia, w którym mieszają się ze sobą niespowiązane kwestie.

    // The Spaghetti Way
    async function handleImageUpload(file) {
      if (file.size > 5000000) {
        throw new Error('Too large');
      }
      const compressed = await compressImage(file);
      const metadata = await extractExif(compressed);
      const tagged = await tagCategories(compressed, metadata);
      const uploadResult = await uploadToS3(tagged);
      return uploadResult;
    }
    

    To da się zarządzać przy użyciu trzech kroków, ale gdy zaczyna się dodawać logowanie, telemetrykę, próby ponowne oraz weryfikację, cała struktura staje się trudna do prześledzenia.

    Rozwiązanie

    Zastosuj wzorzec pipeline: traktuj każdą transformację jako małą, jednoznaczną funkcję i łącz je ze sobą w taki sposób, aby cały proces był czytelny od początku do końca, od góry na dół lub z lewej na prawą.

    // The Scalable Way
    const pipeAsync = (...functions) => (initialValue) =>
      functions.reduce(
        (currentPromise, currentFunction) => currentPromise.then(currentFunction),
        Promise.resolve(initialValue)
      );
    
    // Each step is an isolated, testable transformation
    const validateSize = async (file) => {
      if (file.size > 5 * 1024 * 1024) throw new Error('File exceeds 5MB limit');
      return file;
    };
    
    const compress = async (file) => compressImage(file);
    const attachWatermark = async (image) => applyWatermark(image);
    const upload = async (finalImage) => uploadToCloud(finalImage);
    
    // Create the pipeline
    const processUserImage = pipeAsync(
      validateSize,
      compress,
      attachWatermark,
      upload
    );
    
    // Usage
    processUserImage(rawFileInput)
      .then((res) => console.log('Upload complete:', res))
      .catch((err) => console.error('Pipeline failed:', err.message));
    

    Dlaczego to działa skutecznie w dużych skalach

    • Latwe przestawianie kolejności: dodawanie, usuwanie lub zmiana sekwencji kroków — np. wstawienie etapu generowania miniatur — nie wymaga prawie żadnego wysiłku.
    • Detekcja błędów krok po kroku: można dodać prostą funkcję logowania w dowolnym miejscu pipeline, aby sprawdzić, co wpływa do systemu i co z niego wychodzi.

    Jak poradzić sobie z zagmatwanym kodem

    Kod nie staje się chaotyczny z powodu braku umiejętności autorów. Staje się takim, ponieważ systemy rozwijają się pod presją czasu, a programiści korzystają z pierwszej napotkanej linii kodu, która najszybciej rozwiązuje bieżący problem.

    Prawdziwym kluczem do czystej architektury nie jest dodawanie jakiegoś ciężkiego frameworka korporacyjnego. Chodzi o stosowanie małych, dobrze przemyślanych struktur tam, gdzie to pasuje:

    1. Dopasuj wzorzec do rzeczywistego problemu: użyj Strategy, gdy warunki wymykają się spod kontroli, zastosuj pub/sub, gdy moduły zaczynają nawzajem się wywoływać w nieskończonym cyklu, a adapter, gdy umowa z dostawcą zewnętrznym grozi destabilizacją interfejsu użytkownika.
    2. Wolimy prostotę od pomysłowości: nie potrzebujesz wszystkich wzorców od samego początku. Pozwól, by fragment logiki powtórzył się dwa razy, zanim zaczniesz go abstrahować przy trzecim wystąpieniu.
    3. Zachowuj czystość funkcji i dobrze zdefiniowane interfejsy: przewidywalne dane wejściowe i wyjściowe znacznie ułatwiają przyszłe modyfikacje.

    Czysty kod to nie coś, co pisze się idealnie raz na zawsze — to kod, który nadal łatwo jest modyfikować po sześciu miesiącach.

    Literatura pokrewna

  • Cursor, Claude Code i Codex: Wybór narzędzia do programowania z użyciem SZI dla JS — To porównanie wyjaśnia, w jaki sposób Cursor, Claude Code i Codex pasują do różnych sposobów pracy z JavaScriptem, od programowania w edytorze po zadania wykonywane przez autonomiczne agenty.
  • Dziesięć powszechnych wzorców JavaScript i koszty związane z każdym z nich — Klauzule ochronne, tablice wyszukiwania, obiekty wynikowe, AbortController, allSettled, fabryki i małe bufory: kiedy każdy wzorzec się sprawdza, a kiedy przynosi negatywne skutki.