Kurs JavaScript i TypeScript · Moduł 5: Zaawansowany JavaScript
Wzorzec Builder w JavaScript
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:"
- Builder - buduje złożone obiekty krok po kroku zamiast jednego ogromnego konstruktora
- Fluent API / Method chaining - każda metoda zwraca
this, umożliwiając łańcuchowe wywołania - Walidacja -
build()sprawdza poprawność przed utworzeniem obiektu - 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: