Kurs JavaScript i TypeScript · Moduł 5: Zaawansowany JavaScript

Wzorzec Builder w JavaScript

8 min czytania
W tej lekcji6

W Parku Jurajskim tworzenie nowego dinozaura to złożony proces. Dr. Henry Wu nie może po prostu wrzucić wszystkich parametrów do jednego konstruktora - sekwencja genetyczna, dieta, habitat, poziom zagrożenia, wymagania temperaturowe... To dziesiątki parametrów! Na szczęście istnieje wzorzec Builder - elegancki sposób na budowanie złożonych obiektów krok po kroku.

Problem: Zbyt wiele parametrów

Wyobraź sobie konstruktor dinozaura, który przyjmuje jedenaście argumentów w sztywnej kolejności i bez żadnych nazw:

1// Konstruktor z wieloma parametrami - nieczytelne i podatne na błędy
2const dino = new Dinosaur(
3  "Rexy",          // name
4  "Tyrannosaurus",  // species
5  "carnivore",      // diet
6  8,                // age
7  12000,            // weight
8  "Sector A",       // habitat
9  10,               // dangerLevel
10  true,             // isActive
11  38,               // optimalTemp
12  null,             // mate
13  ["hunt", "roar"]  // abilities
14);
15// Który parametr jest który? Łatwo pomylić kolejność!

Argumenty rozpoznajemy wyłącznie po pozycji. Zamiana wieku z poziomem zagrożenia przejdzie bez błędu, bo oba są liczbami, a pomyłka wyjdzie na jaw dopiero na wybiegu. Samo true czy null w środku listy nic nie mówi osobie czytającej kod.

Wzorzec Builder - rozwiązanie

Builder pozwala budować obiekt krok po kroku, z czytelnym API opartym na łańcuchach wywołań (method chaining). Wymagane dane, czyli imię i gatunek, trafiają do konstruktora, a każdą opcjonalną cechę ustawia osobna metoda o jasnej nazwie:

1class DinosaurBuilder {
2  constructor(name, species) {
3    // Wymagane parametry w konstruktorze
4    this.dinosaur = {
5      name,
6      species,
7      abilities: []
8    };
9  }
10
11  // Każda metoda ustawia właściwość i zwraca this (builder)
12  setDiet(diet) {
13    this.dinosaur.diet = diet;
14    return this; // Kluczowe! Zwraca builder, umożliwiając chaining
15  }
16
17  setAge(age) {
18    this.dinosaur.age = age;
19    return this;
20  }
21
22  setWeight(weight) {
23    this.dinosaur.weight = weight;
24    return this;
25  }
26
27  setHabitat(habitat) {
28    this.dinosaur.habitat = habitat;
29    return this;
30  }
31
32  setDangerLevel(level) {
33    this.dinosaur.dangerLevel = level;
34    return this;
35  }
36
37  setActive(isActive) {
38    this.dinosaur.isActive = isActive;
39    return this;
40  }
41
42  setOptimalTemperature(temp) {
43    this.dinosaur.optimalTemp = temp;
44    return this;
45  }
46
47  addAbility(ability) {
48    this.dinosaur.abilities.push(ability);
49    return this;
50  }
51
52  // Metoda build() tworzy finalny obiekt
53  build() {
54    // Walidacja przed budowaniem
55    if (!this.dinosaur.diet) {
56      throw new Error("Dieta jest wymagana!");
57    }
58    return { ...this.dinosaur };
59  }
60}

Sercem wzorca jest linia return this: każda metoda zmienia jedną właściwość i oddaje ten sam builder, więc na wyniku można od razu wywołać kolejną metodę. Metoda build() kończy budowę - najpierw sprawdza dane, potem zwraca kopię zebranych właściwości.

Tak wygląda użycie. Każde ogniwo łańcucha mówi, co ustawia, więc kod czyta się jak protokół laboratoryjny:

1// Użycie - czytelne i samodokumentujące się
2const rexy = new DinosaurBuilder("Rexy", "Tyrannosaurus")
3  .setDiet("carnivore")
4  .setAge(8)
5  .setWeight(12000)
6  .setHabitat("Sector A")
7  .setDangerLevel(10)
8  .setActive(true)
9  .setOptimalTemperature(38)
10  .addAbility("hunt")
11  .addAbility("roar")
12  .build();
13
14console.log(rexy);
15// { name: "Rexy", species: "Tyrannosaurus", abilities: ["hunt", "roar"],
16//   diet: "carnivore", age: 8, weight: 12000, habitat: "Sector A",
17//   dangerLevel: 10, isActive: true, optimalTemp: 38 }

Na początku zawsze stoi new DinosaurBuilder(...), potem metody ustawiające w dowolnej kolejności, a build() na samym końcu, bo zwraca gotowy obiekt, a nie builder. Uwaga: spread w build() robi płytką kopię, więc tablica abilities jest wspólna dla buildera i gotowego dinozaura.

Fluent API - method chaining w praktyce

Kluczem do wzorca Builder jest Fluent API - każda metoda zwraca this, co pozwala łączyć wywołania w łańcuch. Ten sam trik świetnie sprawdza się przy zapytaniach do bazy dinozaurów. Najpierw sam builder zapytań:

1// Fluent API do budowania zapytań o dinozaury
2class DinoQueryBuilder {
3  constructor() {
4    this.query = {
5      filters: {},
6      sort: null,
7      limit: 100,
8      offset: 0
9    };
10  }
11
12  whereSpecies(species) {
13    this.query.filters.species = species;
14    return this;
15  }
16
17  whereDiet(diet) {
18    this.query.filters.diet = diet;
19    return this;
20  }
21
22  whereDangerAbove(level) {
23    this.query.filters.minDanger = level;
24    return this;
25  }
26
27  whereActive(isActive = true) {
28    this.query.filters.isActive = isActive;
29    return this;
30  }
31
32  sortBy(field, direction = "asc") {
33    this.query.sort = { field, direction };
34    return this;
35  }
36
37  limitTo(count) {
38    this.query.limit = count;
39    return this;
40  }
41
42  skip(count) {
43    this.query.offset = count;
44    return this;
45  }
46
47  build() {
48    return { ...this.query };
49  }
50
51  // Symulacja wykonania zapytania
52  execute(database) {
53    const query = this.build();
54    console.log("Wykonuję zapytanie:", JSON.stringify(query, null, 2));
55
56    let results = [...database];
57
58    // Filtrowanie
59    if (query.filters.species) {
60      results = results.filter(d => d.species === query.filters.species);
61    }
62    if (query.filters.diet) {
63      results = results.filter(d => d.diet === query.filters.diet);
64    }
65    if (query.filters.minDanger) {
66      results = results.filter(d => d.dangerLevel >= query.filters.minDanger);
67    }
68    if (query.filters.isActive !== undefined) {
69      results = results.filter(d => d.isActive === query.filters.isActive);
70    }
71
72    // Sortowanie
73    if (query.sort) {
74      results.sort((a, b) => {
75        const dir = query.sort.direction === "asc" ? 1 : -1;
76        if (a[query.sort.field] === b[query.sort.field]) return 0; // równe wartości
77        return a[query.sort.field] > b[query.sort.field] ? dir : -dir;
78      });
79    }
80
81    // Paginacja
82    results = results.slice(query.offset, query.offset + query.limit);
83
84    return results;
85  }
86}

Metody where... tylko zapisują filtry w obiekcie query - nic nie jest jeszcze filtrowane. Dopiero execute() przepuszcza kopię bazy przez filter(), sortuje ją i przycina przez slice(), a oryginalna tablica zostaje nietknięta. Funkcja porównująca zwraca 0 dla równych wartości, bo sort() wymaga spójnego porównania.

Teraz przykładowa baza i zapytanie złożone z pięciu czytelnych kroków:

1// Przykładowa baza danych
2const dinoDatabase = [
3  { name: "Rexy", species: "Tyrannosaurus", diet: "carnivore", dangerLevel: 10, isActive: true },
4  { name: "Blue", species: "Velociraptor", diet: "carnivore", dangerLevel: 8, isActive: true },
5  { name: "Spike", species: "Triceratops", diet: "herbivore", dangerLevel: 4, isActive: true },
6  { name: "Echo", species: "Velociraptor", diet: "carnivore", dangerLevel: 7, isActive: false },
7  { name: "Trike", species: "Triceratops", diet: "herbivore", dangerLevel: 3, isActive: true }
8];
9
10// Budowanie zapytania z fluent API
11const dangerousCarnivores = new DinoQueryBuilder()
12  .whereDiet("carnivore")
13  .whereDangerAbove(7)
14  .whereActive()
15  .sortBy("dangerLevel", "desc")
16  .limitTo(5)
17  .execute(dinoDatabase);
18
19console.log("Niebezpieczne mięsożerne:", dangerousCarnivores);
20// [{ name: "Rexy", ... }, { name: "Blue", ... }]

Z pięciu dinozaurów zostają Rexy i Blue: roślinożercy odpadają na filtrze diety, a Echo na filtrze aktywności. Ten sam styl spotkasz w wielu bibliotekach budujących zapytania do baz danych.

Builder z walidacją i wartościami domyślnymi

Builder może też pilnować zasad bezpieczeństwa parku. Konstruktor ustawia rozsądne wartości domyślne, a metody sprawdzają dane i zapisują błędy, zamiast przerywać budowę w połowie:

1class EnclosureBuilder {
2  constructor(name) {
3    this.enclosure = {
4      name,
5      type: "standard",
6      fenceVoltage: 10000,
7      size: 1000,
8      features: [],
9      maxCapacity: 5,
10      currentDinosaurs: []
11    };
12    this.errors = [];
13  }
14
15  setType(type) {
16    const validTypes = ["standard", "aquatic", "aviary", "high-security"];
17    if (!validTypes.includes(type)) {
18      this.errors.push(`Nieprawidłowy typ wybiegu: ${type}`);
19    }
20    this.enclosure.type = type;
21    return this;
22  }
23
24  setFenceVoltage(voltage) {
25    if (voltage < 5000) {
26      this.errors.push("Napięcie ogrodzenia musi być >= 5000V");
27    }
28    this.enclosure.fenceVoltage = voltage;
29    return this;
30  }
31
32  setSize(squareMeters) {
33    if (squareMeters < 100) {
34      this.errors.push("Minimalna powierzchnia to 100 m²");
35    }
36    this.enclosure.size = squareMeters;
37    return this;
38  }
39
40  setMaxCapacity(capacity) {
41    this.enclosure.maxCapacity = capacity;
42    return this;
43  }
44
45  addFeature(feature) {
46    this.enclosure.features.push(feature);
47    return this;
48  }
49
50  addDinosaur(dinosaur) {
51    if (this.enclosure.currentDinosaurs.length >= this.enclosure.maxCapacity) {
52      this.errors.push(`Wybieg pełny! Maks: ${this.enclosure.maxCapacity}`);
53    }
54    this.enclosure.currentDinosaurs.push(dinosaur);
55    return this;
56  }
57
58  build() {
59    if (this.errors.length > 0) {
60      throw new Error(
61        `Błędy budowania wybiegu:\n${this.errors.join("\n")}`
62      );
63    }
64    return Object.freeze({ ...this.enclosure }); // Zamrożony obiekt
65  }
66}

Błędy zbierają się w tablicy errors, więc build() zgłasza wszystkie problemy naraz, a nie tylko pierwszy. Object.freeze() zamraża gotowy wybieg, ale tylko na pierwszym poziomie - do tablicy features wciąż da się coś dopisać.

Budowa wybiegu dla raptorów przechodzi walidację, bo napięcie, powierzchnia i liczba mieszkańców mieszczą się w limitach:

1// Budowanie wybiegu
2const raptorPaddock = new EnclosureBuilder("Raptor Paddock")
3  .setType("high-security")
4  .setFenceVoltage(25000)
5  .setSize(5000)
6  .setMaxCapacity(4)
7  .addFeature("electrified-fence")
8  .addFeature("motion-sensors")
9  .addFeature("reinforced-gates")
10  .addDinosaur("Blue")
11  .addDinosaur("Charlie")
12  .addDinosaur("Delta")
13  .build();
14
15console.log(raptorPaddock);
16// { name: "Raptor Paddock", type: "high-security", fenceVoltage: 25000,
17//   size: 5000, features: [...], maxCapacity: 4, currentDinosaurs: [...] }

Gdyby ktoś ustawił napięcie 3000 V, build() rzuciłby wyjątek z listą błędów i żaden wybieg by nie powstał. Pola, których nie ustawisz, zachowają wartości domyślne z konstruktora.

Builder z funkcjami (bez klas)

Builder nie wymaga klas - możemy użyć prostych funkcji. Stan raportu żyje w domknięciu, a metody zwracają obiekt builder zamiast this:

1function createDinoReport(name) {
2  const report = { name, sections: [] };
3
4  const builder = {
5    addVitals(heartRate, temperature) {
6      report.sections.push({
7        type: "vitals",
8        heartRate,
9        temperature
10      });
11      return builder;
12    },
13
14    addBehavior(description, threatLevel) {
15      report.sections.push({
16        type: "behavior",
17        description,
18        threatLevel
19      });
20      return builder;
21    },
22
23    addNote(text) {
24      report.sections.push({
25        type: "note",
26        text,
27        timestamp: new Date().toISOString()
28      });
29      return builder;
30    },
31
32    build() {
33      return {
34        ...report,
35        generatedAt: new Date().toISOString(),
36        totalSections: report.sections.length
37      };
38    }
39  };
40
41  return builder;
42}

Zmienna report jest niedostępna z zewnątrz, więc zmienisz ją wyłącznie metodami buildera. Zwracanie builder zamiast this sprawia, że metoda zadziała nawet wtedy, gdy ktoś wyjmie ją z obiektu i wywoła osobno.

Raport weterynaryjny składamy z pięciu wpisów i jednego wywołania build():

1// Budowanie raportu
2const report = createDinoReport("Rexy")
3  .addVitals(65, 38.2)
4  .addBehavior("Spokojny, je regularnie", "low")
5  .addNote("Zaobserwowano nowy wzorzec polowania")
6  .addVitals(120, 39.1)
7  .addBehavior("Pobudzony po burzy", "medium")
8  .build();
9
10console.log(JSON.stringify(report, null, 2));

Metoda build() dokłada datę wygenerowania i liczbę sekcji, tutaj 5, a JSON.stringify(report, null, 2) drukuje wynik z wcięciami.

Podsumowanie

Dr. Wu podsumowuje: "Wzorzec Builder to jak protokół tworzenia dinozaura - krok po kroku, z walidacją na każdym etapie:"

  1. Builder - buduje złożone obiekty krok po kroku zamiast jednego ogromnego konstruktora
  2. Fluent API / Method chaining - każda metoda zwraca this, umożliwiając łańcuchowe wywołania
  3. Walidacja - build() sprawdza poprawność przed utworzeniem obiektu
  4. Czytelność - kod jest samodokumentujący się, każdy parametr ma nazwaną metodę

Wzorzec Builder jest szczególnie przydatny, gdy:

  • Obiekt ma wiele opcjonalnych parametrów
  • Chcesz walidować dane przed utworzeniem obiektu
  • Budujesz zapytania, konfiguracje lub skomplikowane struktury danych

Moja rada: po wzorzec Builder sięgaj przy wielu opcjonalnych polach. Przy dwóch czy trzech wystarczy konstruktor przyjmujący jeden obiekt opcji, na przykład new Dinosaur({ name, diet }), bo nazwy pól same dokumentują kod. W następnej lekcji stworzysz własne klasy błędów, dzięki którym build() zgłosi problem precyzyjniej niż zwykłym Error. W laboratorium poniżej zbudujesz ReportBuilder oparty na łańcuchu metod.

Pamiętaj: Builder to protokół wylęgarni - dinozaur powstaje krok po kroku i wychodzi na świat dopiero po kontroli w build().

Kod do tej lekcji: index.js
1// Wzorzec Builder w JavaScript
2// Park Jurajski - Tworzenie zlozonych obiektow
3
4console.log("=== WZORZEC BUILDER ===");
5
6// Builder dla dinozaurow
7class DinosaurBuilder {
8  constructor(name, species) {
9    this.dinosaur = { name, species, abilities: [] };
10  }
11
12  setDiet(diet) {
13    this.dinosaur.diet = diet;
14    return this; // Zwraca this - umozliwia chaining
15  }
16
17  setAge(age) {
18    this.dinosaur.age = age;
19    return this;
20  }
21
22  setWeight(weight) {
23    this.dinosaur.weight = weight;
24    return this;
25  }
26
27  setHabitat(habitat) {
28    this.dinosaur.habitat = habitat;
29    return this;
30  }
31
32  setDangerLevel(level) {
33    this.dinosaur.dangerLevel = level;
34    return this;
35  }
36
37  addAbility(ability) {
38    this.dinosaur.abilities.push(ability);
39    return this;
40  }
41
42  build() {
43    if (!this.dinosaur.diet) {
44      throw new Error("Dieta jest wymagana!");
45    }
46    return { ...this.dinosaur };
47  }
48}
49
50// Tworzenie dinozaura z method chaining
51const rexy = new DinosaurBuilder("Rexy", "Tyrannosaurus")
52  .setDiet("carnivore")
53  .setAge(8)
54  .setWeight(12000)
55  .setHabitat("Sector A")
56  .setDangerLevel(10)
57  .addAbility("hunt")
58  .addAbility("roar")
59  .build();
60
61console.log("Rexy:", rexy);
62
63const blue = new DinosaurBuilder("Blue", "Velociraptor")
64  .setDiet("carnivore")
65  .setAge(4)
66  .setWeight(80)
67  .setDangerLevel(8)
68  .addAbility("hunt")
69  .addAbility("communicate")
70  .addAbility("coordinate-pack")
71  .build();
72
73console.log("Blue:", blue);
74
75// === FLUENT QUERY BUILDER ===
76console.log("\n=== QUERY BUILDER ===");
77
78class DinoQueryBuilder {
79  constructor() {
80    this.query = { filters: {}, sort: null, limit: 100 };
81  }
82
83  whereDiet(diet) {
84    this.query.filters.diet = diet;
85    return this;
86  }
87
88  whereDangerAbove(level) {
89    this.query.filters.minDanger = level;
90    return this;
91  }
92
93  sortBy(field, direction = "asc") {
94    this.query.sort = { field, direction };
95    return this;
96  }
97
98  limitTo(count) {
99    this.query.limit = count;
100    return this;
101  }
102
103  execute(database) {
104    let results = [...database];
105    const f = this.query.filters;
106    if (f.diet) results = results.filter(d => d.diet === f.diet);
107    if (f.minDanger) results = results.filter(d => d.dangerLevel >= f.minDanger);
108    if (this.query.sort) {
109      const dir = this.query.sort.direction === "asc" ? 1 : -1;
110      results.sort((a, b) =>
111        a[this.query.sort.field] > b[this.query.sort.field] ? dir : -dir
112      );
113    }
114    return results.slice(0, this.query.limit);
115  }
116}
117
118const db = [
119  { name: "Rexy", diet: "carnivore", dangerLevel: 10 },
120  { name: "Blue", diet: "carnivore", dangerLevel: 8 },
121  { name: "Spike", diet: "herbivore", dangerLevel: 4 },
122  { name: "Echo", diet: "carnivore", dangerLevel: 7 },
123  { name: "Trike", diet: "herbivore", dangerLevel: 3 },
124];
125
126const dangerous = new DinoQueryBuilder()
127  .whereDiet("carnivore")
128  .whereDangerAbove(7)
129  .sortBy("dangerLevel", "desc")
130  .limitTo(5)
131  .execute(db);
132
133console.log("Niebezpieczne miesozerne:", dangerous);
134
135// === BUILDER Z WALIDACJA ===
136console.log("\n=== BUILDER Z WALIDACJA ===");
137
138class EnclosureBuilder {
139  constructor(name) {
140    this.enclosure = { name, type: "standard", features: [], dinosaurs: [] };
141    this.errors = [];
142  }
143
144  setType(type) {
145    const valid = ["standard", "aquatic", "aviary", "high-security"];
146    if (!valid.includes(type)) this.errors.push("Nieprawidlowy typ: " + type);
147    this.enclosure.type = type;
148    return this;
149  }
150
151  addFeature(feature) {
152    this.enclosure.features.push(feature);
153    return this;
154  }
155
156  addDinosaur(name) {
157    this.enclosure.dinosaurs.push(name);
158    return this;
159  }
160
161  build() {
162    if (this.errors.length > 0) {
163      throw new Error("Bledy: " + this.errors.join(", "));
164    }
165    return Object.freeze({ ...this.enclosure });
166  }
167}
168
169const paddock = new EnclosureBuilder("Raptor Paddock")
170  .setType("high-security")
171  .addFeature("electrified-fence")
172  .addFeature("motion-sensors")
173  .addDinosaur("Blue")
174  .addDinosaur("Charlie")
175  .addDinosaur("Delta")
176  .build();
177
178console.log("Wybieg:", paddock);
179
180// TODO: Stworz ReportBuilder z method chaining
181// addVitals(), addBehavior(), addNote(), build()
182console.log("\n=== Cwiczenie ===");
183console.log("Stworz ReportBuilder - patrz TODO w kodzie");

Widzisz błąd w tej lekcji?

Zadania praktyczne w grze

  • Układanie w poziomie

    Ułóż elementy tworzenia obiektu wzorcem Builder:

  • Klikanie w kolejności

    Ułóż elementy metody Builder umożliwiającej chaining:

Przydatne artykuły