Archiwum kategorii: Javascript

kurs programowania w jezyku javascript

Obiekt DOM

Przedostatnim elementem naszej podróży jest DOM (Document Object Model), czyli obiektowy model dokumentu HTML. Gdy przeglądarka ładuje stronę internetową, tworzy z reprezentacji HTML drzewo obiektów zwanych węzłami (nodes). Każdy element HTML staje się węzłem typu element, teksty stają się węzłami tekstowymi, itd. Obiekt document w JavaScript daje nam dostęp do tego drzewa DOM, dzięki czemu możemy dynamicznie odczytywać i modyfikować zawartość strony, strukturę elementów oraz reagować na zdarzenia (np. kliknięcia, ruch myszką, wpisywanie tekstu).

Innymi słowy, DOM jest interfejsem między JavaScriptem a HTML. Pozwala on traktować zawartość strony jako strukturę danych, którą możemy zmieniać „w locie” po załadowaniu strony, tworząc interaktywne aplikacje. Ta sekcja wprowadzi metody manipulacji DOM: jak wybierać elementy, zmieniać ich treść i styl, dodawać nowe elementy lub usuwać istniejące, a także jak obsługiwać zdarzenia użytkownika.

Wybieranie elementów DOM

Aby cokolwiek zrobić z DOM, najpierw musimy znaleźć (wybrać) elementy HTML, które nas interesują. JavaScript oferuje kilka metod do wyszukiwania elementów w dokumencie:

  • document.getElementById("id") – zwraca element o danym id. Identyfikator powinien być unikalny w ramach strony. Jeśli element o takim id nie istnieje, zwróci null.
    Przykład: const header = document.getElementById("main-header"); pobierze element o identyfikatorze "main-header".
  • document.querySelector("selector") – zwraca pierwszy element pasujący do danego selektora CSS. Selektorem może być nazwa tagu (np. "p"), klasa (".classname"), id ("#id") lub bardziej złożony selektor jak w CSS. Jeśli żaden element nie pasuje, zwraca null.
    Przykład: const pierwszyLink = document.querySelector("a"); zwróci pierwszy napotkany element <a> na stronie. const specjalny = document.querySelector(".active") zwróci pierwszy element z klasą „active”.
  • document.querySelectorAll("selector") – zwraca wszystkie elementy pasujące do selektora, w postaci kolekcji (NodeList). NodeList można iterować (np. pętlą for…of) lub przekształcić na prawdziwą tablicę. Jeśli żaden element nie pasuje, zwraca pustą kolekcję.
    Przykład: const wszystkieLinki = document.querySelectorAll("a"); pobierze listę wszystkich linków <a> w dokumencie.
  • Inne metody: Starsze, wyspecjalizowane metody to m.in. getElementsByClassName("nazwaKlasy"), getElementsByTagName("nazwaTagu"), które zwracają kolekcje elementów danej klasy lub tagu. Działają poprawnie, ale obecnie querySelectorAll jest często preferowane ze względu na elastyczność (można nim zastąpić tamte metody jednym uniwersalnym wywołaniem z odpowiednim selektorem CSS, np. document.querySelectorAll("div.note") zamiast getElementsByClassName("note")).

Przykład wykorzystujący różne metody selekcji:

 // HTML przykładowo zawiera <ul id="lista"><li class="item">A</li><li class="item">B</li></ul>
 const lista = document.getElementById("lista");          // <ul> z id "lista"
 const pierwszyItem = document.querySelector("#lista .item");  // pierwszy element z klasą "item" wewnątrz #lista
 const wszystkieItemy = document.querySelectorAll(".item");    // wszystkie elementy z klasą "item"
 console.log(lista.tagName);         // "UL"
 console.log(wszystkieItemy.length); // np. 2 (jeśli są dwa <li class="item">)

Kiedy mamy już referencję do elementu/elementów DOM, możemy przejść do manipulowania nimi.

Manipulacja treścią i stylami

Najprostszą formą manipulacji jest zmiana tekstowej treści lub HTML wewnątrz elementu, a także modyfikacja stylów CSS elementu.

  • Zmiana zawartości tekstowej: Właściwość textContent pozwala odczytać lub ustawić tekst wewnątrz elementu (bez interpretacji HTML). Podobnie działa innerText (który dodatkowo uwzględnia pewne aspekty widoczności CSS), ale zazwyczaj textContent jest preferowane do czystego tekstu.
    Jeśli chcemy wstawić kawałek HTML (np. tag <strong>), można użyć innerHTML, który traktuje przypisany string jako kod HTML do wyrenderowania wewnątrz elementu.
  • Zmiana stylów (CSS): Każdy element posiada właściwość style, która jest obiektem reprezentującym style inline (wbudowane w elemencie). Możemy ustawiać poszczególne właściwości CSS jako atrybuty tego obiektu. Np. element.style.color = "red"; ustawi kolor tekstu na czerwony, a element.style.backgroundColor = "yellow"; tło na żółte. Właściwości w stylu camelCase odpowiadają tym w CSS (np. background-color w CSS to backgroundColor w JavaScript).
    Innym podejściem jest manipulacja klasami CSS elementu, co jest często bardziej praktyczne – zamiast bezpośrednio zmieniać wiele stylów w skrypcie, dodajemy lub usuwamy klasy, które mają zdefiniowane style w arkuszu CSS. Do tego służy właściwość classList, która udostępnia metody takie jak add, remove, toggle do zarządzania listą klas elementu.

Przykłady:

 const naglowek = document.getElementById("main-header");
 naglowek.textContent = "Witaj w JavaScript!";  // zmiana tekstu nagłówka
 naglowek.style.color = "blue";                // zmiana koloru tekstu naglowek.style.fontSize = "24px";             // zmiana rozmiaru czcionki (np. na 24px)

 naglowek.classList.add("highlight");          // dodanie klasy CSS do elementu
 // (zakładamy, że w CSS mamy np. .highlight { background-color: yellow; } )

Powyższy kod zmienia tekst nagłówka (np. <h1 id="main-header">) na „Witaj w JavaScript!”, ustawia jego tekst na niebieski, rozmiar czcionki na 24px, a następnie dodaje mu klasę "highlight", która może np. podświetlać tło. Manipulacja textContent usuwa ewentualną poprzednią zawartość i wstawia nowy tekst. Gdybyśmy zamiast tego użyli innerHTML = "<span>Witaj</span>", to nagłówek zawierałby nowy element <span> wewnątrz. Trzeba być ostrożnym z innerHTML – jeśli wstawiamy w ten sposób dane pochodzące od użytkownika, może to prowadzić do problemów z bezpieczeństwem (wstrzyknięcie niechcianego kodu HTML/JS).

  • Zmiana atrybutów: Poza tekstem i stylem, elementy HTML mają różne atrybuty (np. src dla <img>, href dla <a>, value dla <input> itd.). Możemy je zmieniać poprzez bezpośrednie właściwości (np. image.src = "nowy.png") lub metody setAttribute/getAttribute. Np. link.setAttribute("target", "_blank") doda atrybut target do linku.

Tworzenie i usuwanie elementów w DOM

JavaScript może nie tylko zmieniać istniejące elementy, ale też tworzyć nowe elementy od zera i dodawać je do DOM, lub usuwać elementy.

  • Tworzenie elementu: Służy do tego metoda document.createElement("nazwaTagu"). Tworzy ona nowy węzeł elementu w oderwaniu od dokumentu (na razie nie jest widoczny na stronie, dopiero po dołączeniu). Po utworzeniu możemy ustawić jego zawartość lub atrybuty:
 const newParagraph = document.createElement("p"); // utworzenie elementu <p>    newParagraph.textContent = "To jest nowy akapit."; 
 newParagraph.style.fontWeight = "bold";

Powyżej utworzyliśmy nowy paragraf z tekstem i ustawiliśmy pogrubienie tekstu stylem. Nadal nie jest on dodany do strony.

  • Dodawanie do DOM: Aby element pojawił się na stronie, musimy go wstawić do drzewa DOM, np. jako dziecko jakiegoś innego elementu. Najczęściej używamy parentElement.appendChild(nowyElement) lub nowszej metody parentElement.append(nowyElement):
 const sekcja = document.getElementById("section1"); sekcja.appendChild(newParagraph);

Załóżmy, że w HTML mamy element o id section1 (np. <div id="section1"></div>). Powyższy kod dołącza nasz nowy paragraf jako dziecko tego elementu (na koniec zawartości sekcji). Metoda appendChild umieszcza element zawsze jako ostatni element wewnątrz rodzica. Jeśli chcemy wstawić w konkretne miejsce, można użyć parentElement.insertBefore(newElem, referencyjnyElem) lub metod typu after, before (np. element.before(newElem) wstawia nowyElem przed danym elementem w DOM).

  • Usuwanie elementu: Możemy usunąć element z DOM na kilka sposobów. Mając referencję do elementu, możemy wywołać element.remove(), co spowoduje jego usunięcie z drzewa DOM. Ta metoda jest prostsza, wspierana w większości nowoczesnych przeglądarek. Alternatywnie, możemy wywołać parent.removeChild(element), co zadziała nawet w starszych środowiskach: javascriptKopiujEdytujnewParagraph.remove(); // usuwa newParagraph z DOM (jego rodzicem był sekcja) Po tej operacji newParagraph istnieje dalej jako obiekt w pamięci, ale nie jest już częścią dokumentu widocznego dla użytkownika. Jeśli nie będziemy już go używać, po pewnym czasie zostanie oczyszczony z pamięci przez GC.

Podsumowując, dzięki createElement, appendChild/append i removeChild/remove możemy dowolnie manipulować strukturą strony: dynamicznie dodawać nowe sekcje, listy, elementy interfejsu lub usuwać te, które są niepotrzebne.

Obsługa zdarzeń

Strona internetowa staje się interaktywna głównie dzięki zdarzeniom (events) i ich obsłudze w JavaScript. Zdarzenie to np. kliknięcie w przycisk, najechanie myszą na element, naciśnięcie klawisza, załadowanie strony itp. W JavaScript możemy podpiąć funkcje (zwane obsługami zdarzeń lub handlerami) pod konkretne zdarzenia na konkretnych elementach. Gdy zdarzenie wystąpi, przeglądarka wywoła naszą funkcję, która zareaguje w określony sposób.

Najlepszym sposobem obsługi zdarzeń jest użycie metody addEventListener. Składnia: element.addEventListener(typZdarzenia, funkcjaDoWywołania).

Przykład: obsługa kliknięcia (event "click") na przycisku:

 const button = document.querySelector("button#clickMe");
 button.addEventListener("click", () => {
      alert("Kliknięto przycisk!");
 });

W powyższym kodzie zakładamy, że w HTML istnieje <button id="clickMe">Kliknij mnie</button>. Skrypt pobiera ten przycisk i rejestruje nasłuch na zdarzenie click. Gdy użytkownik kliknie ten przycisk, wykona się funkcja strzałkowa, która wywoła alert z komunikatem. Zamiast funkcji strzałkowej moglibyśmy przekazać nazwę zdefiniowanej gdzieś indziej funkcji, np. button.addEventListener("click", pokazKomunikat), jeśli funkcja pokazKomunikat była zdefiniowana.

Obsługa zdarzeń może również modyfikować DOM dynamicznie. Na przykład, możemy sprawić, że kliknięcie przycisku doda nowy element do strony lub zmieni styl istniejącego elementu:

 const dodajBtn = document.getElementById("dodajAkapit");  // np. <button id="dodajAkapit">Dodaj akapit</button>
 dodajBtn.addEventListener("click", () => {
      const p = document.createElement("p");
      p.textContent = "Nowy akapit dodany przez JavaScript.";
      document.body.appendChild(p);
 });

W tym przykładzie po kliknięciu przycisku o id "dodajAkapit" tworzymy nowy paragraf i dodajemy go na sam dół strony (document.body). Za każdym kliknięciem będzie dodany kolejny nowy akapit. Widać tu połączenie kilku poznanych wcześniej rzeczy: selekcja elementu, obsługa zdarzenia, tworzenie elementu i modyfikacja DOM.

Kilka wskazówek dot. obsługi zdarzeń:

  • Istnieje wiele typów zdarzeń: click, dblclick, mouseover, mouseout, mousedown, mouseup, keydown, keyup, submit, change, input i wiele innych. Każde zdarzenie przekazuje do funkcji obsługującej obiekt zdarzenia (event object) zawierający szczegółowe informacje (np. który klawisz został naciśnięty, jaka pozycja kursora, itp.). W naszym arrow function nie użyliśmy parametru, ale moglibyśmy go dodać, np. (event) => { ... } i korzystać z event.
  • Można podpiąć wiele niezależnych „nasłuchiwaczy” do tego samego zdarzenia na tym samym elemencie za pomocą addEventListener (każdy wykona się w kolejności dodania). To przewaga nad starszą metodą ustawiania element.onclick = ..., gdzie przypisanie nowej funkcji nadpisuje poprzednią.
  • Aby usunąć obsługę zdarzenia, istnieje odpowiednia para removeEventListener, ale wymaga to referencji do tej samej funkcji, którą dodano (przydatne np. przy czyszczeniu nasłuchów, gdy element jest usuwany).

Dzięki zdarzeniom i manipulacji DOM możemy tworzyć bogate interakcje: walidować formularze na bieżąco, tworzyć galerie obrazów, reagować na ruch myszki, budować gry przeglądarkowe, i wiele więcej.

Klasy i obiekty. Programowanie obiektowe.

Obiekty w JavaScript to podstawowe struktury danych służące do przechowywania kolekcji właściwości powiązanych z wartościami. Właściwość (property) to para klucz-wartość. Kluczem jest zazwyczaj string (lub symbol), a wartością dowolny typ danych (liczba, tekst, funkcja, inny obiekt, itd.). Obiekty pozwalają modelować bardziej złożone byty, jak np. użytkownik, samochód, ustawienia aplikacji itp., grupując powiązane informacje pod jedną nazwą. W tej sekcji nauczymy się tworzyć obiekty, odczytywać i zmieniać ich właściwości, definiować metody (funkcje będące właściwościami obiektu) i zrozumiemy, jak działa słowo kluczowe this. Omówimy też zagnieżdżanie obiektów oraz niektóre operacje na obiektach (iteracja, kopiowanie, łączenie).

Tworzenie i właściwości obiektu

Najprostszym sposobem utworzenia obiektu jest użycie literału obiektu, czyli par klucz:wartość ujętych w nawiasy klamrowe { }. Przykładowo:

 const person = {
   name: "Jan",
   age: 30
 };

Powyżej zdefiniowaliśmy obiekt person z dwoma właściwościami:

  • klucz "name" z wartością "Jan" (łańcuch znaków),
  • klucz "age" z wartością 30 (liczba).

Klucze są tutaj napisami (JS automatycznie traktuje identyfikatory właściwości jako stringi, o ile są poprawnymi identyfikatorami). Można też ująć klucz w cudzysłów explicite, zwłaszcza gdy zawiera spacje lub znaki specjalne, np. {"full name": "Jan Kowalski"} byłoby poprawne (choć do takiej właściwości trudniej się odwołać przez kropkę, o czym za moment). Wartości mogą być różnych typów – w tym inne obiekty lub funkcje.

Obiekt możemy też stworzyć za pomocą konstruktora new Object() i potem dynamicznie dodawać właściwości, ale literał { } jest krótszy i częściej używany.

Dostęp do właściwości obiektu

Do właściwości obiektu w JavaScript odwołujemy się na dwa sposoby:

  1. Notacja kropkowaobiekt.klucz
  2. Notacja nawiasowaobiekt["klucz"]

Kontynuując przykład obiektu person:

 console.log(person.name);    // "Jan"
 console.log(person.age);     // 30

 // To samo za pomocą notacji nawiasowej:
 console.log(person["name"]); // "Jan"

Notacja kropkowa jest wygodna i często używana, ale ma ograniczenie: działa tylko z takimi nazwami właściwości, które są poprawnymi identyfikatorami w JavaScript (czyli nie zawierają spacji, nie zaczynają się od cyfry itp.). Gdy nazwa właściwości jest nietypowa, albo jest w zmiennej, należy użyć notacji nawiasowej. Np.:

 person["full name"] = "Jan Kowalski";     // dodajemy nową właściwość ze spacją w nazwie
 console.log(person["full name"]);        // "Jan Kowalski"

 const prop = "age";
 console.log(person[prop]);              // 30 (odwołanie poprzez nazwę w zmiennej)

W powyższym kodzie pokazaliśmy dwa przypadki, gdzie notacja [] jest niezbędna: gdy klucz zawiera spację oraz gdy klucz jest przechowywany w zmiennej (prop). Notacja nawiasowa przyjmuje ciąg znaków (string) lub symbol jako klucz i zwraca odpowiadającą wartość (lub pozwala ustawić wartość).

Jeśli spróbujemy odczytać właściwość, która nie istnieje, otrzymamy undefined. Można to sprawdzić operatorem in (np. "age" in person zwróci true, "salary" in person zwróci false) lub porównując do undefined.

Metody obiektu i słowo kluczowe this

Jeśli jako wartość właściwości obiektu przypiszemy funkcję, nazywamy taką właściwość metodą obiektu. Metody pozwalają obiektom na posiadanie „zachowań”, operacji związanych z nimi. Np. obiekt person może mieć metodę greet wypisującą powitanie.

Przykład dodania metody do obiektu:

 person.greet = function() {
   console.log("Cześć, jestem " + this.name + "!");
 };

 person.greet(); // Cześć, jestem Jan!

Tutaj dodaliśmy właściwość greet, która jest funkcją. Wywołujemy ją poprzez person.greet(). Zauważ użycie słowa kluczowego this wewnątrz metody. this odnosi się do bieżącego obiektu, na którym metoda została wywołana. W naszym przypadku wywołanie person.greet() sprawia, że wewnątrz funkcji greet słowo this wskazuje na obiekt person. Dlatego this.name w środku funkcji zwróci person.name („Jan”). W efekcie metoda wypisuje komunikat ze wskazaniem imienia obiektu.

Kilka uwag dotyczących metod i this:

  • Możemy definiować metody od razu przy tworzeniu obiektu literatem:
 const calculator = {      x: 5, y: 3, sum: function() { 
    return this.x + this.y; 
    } 
 }; 
 console.log(calculator.sum()); // 8

W powyższym przykładzie obiekt calculator ma właściwości liczbowe x i y oraz metodę sum zwracającą ich sumę. this.x odnosi się do calculator.x.

  • Istnieje skrócona składnia zapisu metod w literałach obiektów (ES6): zamiast sum: function() { ... } można napisać sum() { ... } – efekt jest ten sam, krótszy zapis.
  • this zawsze zależy od kontekstu wywołania funkcji. Jeżeli oddzielimy funkcję od obiektu, np. const tylkoFunkcja = person.greet; i wywołamy tylkoFunkcja(), to w środku tej funkcji this nie będzie już wskazywał na person (w trybie strict będzie undefined, a w trybie niestrict wskazywałby na obiekt globalny). Dlatego ważne jest, jak wywoływana jest metoda – czy jako obiekt.metoda(), czy samodzielnie.
  • Funkcje strzałkowe, jak wspomniano wcześniej, nie mają własnego this. Gdybyśmy zdefiniowali greet jako funkcję strzałkową: greet: () => { console.log(this.name) } wewnątrz person, to this w niej byłby rozwiązywany leksykalnie – zapewne wskazywałby na window lub undefined (jeśli używamy modułów/strict mode), a nie na obiekt person. Dlatego do metod obiektu najlepiej używać zwykłych funkcji, aby this wskazywało na obiekt.

Modyfikacja i zagnieżdżone obiekty

Obiekty w JavaScript są mutowalne, co oznacza, że po utworzeniu obiektu możemy dodawać, usuwać czy zmieniać jego właściwości w trakcie działania programu.

  • Dodawanie/edytowanie właściwości: Odbywa się to poprzez proste przypisanie wartości do klucza, używając notacji kropkowej lub nawiasowej.
 person.job = "Programista"; // dodajemy nową właściwość job  person.age = 31; // edytujemy istniejącą właściwość age

Po tych operacjach obiekt person ma dodatkowo właściwość job oraz zmienioną wartość age.

  • Usuwanie właściwości: Służy do tego operator delete.
 delete person["full name"]; // usuwa właściwość 'full name' z obiektu  delete person.job; // usuwa właściwość 'job'

Po użyciu delete danej właściwości już nie będzie w obiekcie (uwaga: operator ten działa tylko na własne właściwości obiektu; nie usuwa ewentualnych właściwości dziedziczonych po prototypie).

Obiekty mogą być zagnieżdżone, tzn. właściwości obiektu mogą same być obiektami (lub tablicami). Pozwala to tworzyć struktury drzewiaste reprezentujące bardziej złożone dane. Na przykład:

 const company = {
   name: "ABC Corp",
   address: {
     city: "Warszawa",
     zip: "00-001",
   },
   employees: [
     { name: "Jan", position: "CEO" },
     { name: "Anna", position: "Developer" }
   ]
 };

 console.log(company.address.city);         // "Warszawa"
 console.log(company.employees[1].name);    // "Anna"

W powyższym obiekcie company właściwość address jest obiektem z kolejnymi własnymi właściwościami city i zip. Z kolei employees jest tablicą zawierającą obiekty reprezentujące pracowników. Dostęp do zagnieżdżonych danych uzyskujemy przez łańcuch notacji kropkowej/nawiasowej: np. company.employees[1].name najpierw pobiera drugi element tablicy employees (o indeksie 1), a następnie jego pole name.

Zagnieżdżone obiekty modyfikujemy podobnie jak płaskie:

 company.address.street = "Ul. Długa 5";       // dodajemy ulicę do adresu
 company.employees[0].position = "CTO";       // zmieniamy stanowisko pierwszego pracownika

Trzeba tylko uważać, by odwołując się do głębokich właściwości, mieć pewność że po drodze żaden „poziom” nie jest undefined (w przeciwnym razie dostaniemy błąd typu Cannot read property ... of undefined). W nowszych wersjach JS istnieje opcja bezpiecznego dostępu łańcuchowego ?. (optional chaining), ale to temat na inny raz.

Operacje na obiektach

Poza bezpośrednim odczytem i zapisem właściwości, często potrzebujemy wykonać różne operacje ogólne na obiekcie:

  • Iteracja po właściwościach: Do przechodzenia po wszystkich własnych właściwościach obiektu można użyć pętli for…in.
 for (let key in person) {      if (person.hasOwnProperty(key)) {
     console.log(key, ":", person[key]); 
     } 
 }

Pętla for...in iteruje po kluczach (nazwach właściwości) obiektu person. Użycie hasOwnProperty zapewnia, że pomijamy ewentualne właściwości dziedziczone z prototypu. Wewnątrz pętli możemy użyć person[key], by uzyskać wartość danej właściwości.
Uwaga: Kolejność iteracji for...in nie jest gwarantowana (choć w praktyce przeglądarki zwykle zwracają w kolejności dodawania dla zwykłych właściwości liczbowych i potem nieliczbowych).

  • Pobranie listy kluczy, wartości: Obiekt Object dostarcza metody statyczne do tego celu:
    Object.keys(person) zwróci tablicę wszystkich kluczy (np. ["name","age","greet"]),
    Object.values(person) zwróci tablicę wszystkich wartości (np. ["Jan", 31, function]),
    Object.entries(person) zwróci tablicę par [klucz, wartość] (np. [ ["name","Jan"], ["age",31], ["greet", ƒ] ]).
    Te metody są wygodne, gdy chcemy np. policzyć właściwości lub przekształcić obiekt w inną strukturę.
  • Kopiowanie obiektów: Ponieważ obiekty są przekazywane przez referencję, proste przypisanie const obj2 = obj1;nie tworzy kopii, a jedynie nowy odnośnik do tego samego obiektu. Aby skopiować obiekt (płytka kopia, tzw. shallow copy), można użyć:
    • operatora spread: const kopia = {...person}; – tworzy nowy obiekt, rozpakowując wszystkie własności person do niego,
    • lub Object.assign: const kopia = Object.assign({}, person); – efekt ten sam co spread.
      Obie te metody skopiują wartości właściwości, ale jeśli któraś z nich jest obiektem zagnieżdżonym, to skopiuje się tylko referencja (czyli kopia będzie współdzielić z oryginałem zagnieżdżony obiekt). To właśnie oznacza kopia płytka. Głębokie kopiowanie wymaga dodatkowych technik (np. rekurencji, funkcji z biblioteki, czy pośrednio JSON.stringify/parse, z pewnymi ograniczeniami).
  • Łączenie/rozszerzanie obiektów: Operator spread {...obj1, ...obj2} można też użyć do połączenia własności dwóch obiektów w jeden. Jeśli występują te same klucze, własność z obiektu po prawej stronie nadpisze tę z lewej. Przykład:
 const objA = { x: 1, y: 2 };    const objB = { y: 3, z: 4 };
 const objC = { ...objA, ...objB };
 console.log(objC); // { x: 1, y: 3, z: 4 }

W wyniku objC zawiera kombinację objA i objB. Własność y została nadpisana wartością z objB.
Alternatywnie można użyć Object.assign(objA, objB), co dołączy objB do objA (modyfikując objA).

  • Inne operacje: Sprawdzanie istnienia właściwości ("key" in obj lub obj.hasOwnProperty("key")), usuwanie właściwości (delete obj.key – już omówione), blokowanie zmian obiektu (Object.freeze(obj) aby uczynić obiekt niezmiennym), itp. Te zaawansowane operacje są przydatne w określonych sytuacjach, ale wykraczają poza podstawy.

Podsumowując, obiekty w JavaScript są wszechstronne i można je dynamicznie tworzyć oraz modyfikować. Ponieważ są przekazywane przez referencję, należy być ostrożnym przy ich kopiowaniu i porównywaniu (porównanie dwóch różnych obiektów nawet o identycznej zawartości zwróci false, bo porównują się referencje, nie wartości).

Tablice

Tablice (arrays) służą do przechowywania uporządkowanych kolekcji danych. W JavaScript tablica jest obiektem specjalnego rodzaju, który przechowuje wartości w indeksowanych (ponumerowanych) pozycjach. Indeksy w tablicy zaczynają się od 0 (pierwszy element ma indeks 0, drugi indeks 1, itd.). Tablice w JavaScript mogą zawierać elementy różnych typów (np. liczby, stringi, a nawet inne tablice czy obiekty), choć często dla przejrzystości przechowuje się w jednej tablicy dane jednego rodzaju. W tej sekcji omówimy tworzenie tablic, dostęp do ich elementów, iterację oraz najważniejsze metody do operacji na tablicach. Pokażemy też, na czym polega destrukturyzacja tablic.

Tworzenie tablic i dostęp do elementów

Tablicę najłatwiej utworzyć za pomocą literału tablicy, czyli elementów ujętych w nawiasy kwadratowe []. Przykład deklaracji tablicy i odczytu jej elementów:

 const liczby = [10, 20, 30, 40];    // tablica liczb
 console.log(liczby[0]);            // 10 (pierwszy element, indeks 0)
 console.log(liczby[3]);            // 40 (czwarty element, indeks 3)
 console.log(liczby.length);        // 4  (właściwość length oznacza liczbę elementów tablicy)

W powyższym przykładzie liczby to tablica zawierająca cztery liczby. Używając nazwaTablicy[indeks] dostajemy się do konkretnego elementu. Należy pamiętać, że:

  • Jeśli spróbujemy odczytać element pod nieistniejącym indeksem (np. liczby[10] w powyższej tablicy), otrzymamy undefined, ponieważ taki element nie istnieje.
  • Właściwość length zawsze zwraca nominalną długość tablicy (ostatni indeks + 1). Można ją też ustawiać (przycięcie lub rozszerzenie tablicy), ale zazwyczaj odczytujemy length, by iterować po tablicy.

Możliwe jest również utworzenie tablicy za pomocą konstruktora Array (np. const arr = new Array(5) tworzy tablicę o długości 5 z pustymi miejscami), ale literał [] jest bardziej powszechny i zalecany.

Elementy tablicy mogą być dowolnego typu, np.:

 const rozne = [42, "tekst", true, null, {x:1, y:2}];
 console.log(rozne[1]);    // "tekst"
 console.log(rozne[4].x);  // 1 (dostęp do pola obiektu wewnątrz tablicy)

Powyższa tablica rozne zawiera kolejno: liczbę, napis, wartość boolean, null oraz obiekt. Widać, że do obiektu wewnątrz tablicy można się dostać najpierw przez indeks tablicy, a następnie przez właściwość obiektu (rozne[4].x).

Iteracja po tablicach

Bardzo często potrzebujemy przejść przez wszystkie elementy tablicy i coś z nimi zrobić (np. wypisać, przetworzyć). Istnieje kilka sposobów iterowania po tablicach:

 const dane = [5, 10, 15];

 // 1. Klasyczna pętla for z indeksem
 for (let i = 0; i < dane.length; i++) {
      console.log("Indeks", i, "wartość", dane[i]);
 }

 // 2. Pętla for...of (ES6) – iteruje bezpośrednio po wartościach
 for (const element of dane) {
      console.log("Wartość:", element);
 }

 // 3. Metoda forEach – wywołuje funkcję dla każdego elementu
 dane.forEach((element, index) => {
      console.log("Index:", index, "wartość:", element);
 });

Wynik działania każdego z powyższych sposobów będzie podobny – w konsoli pojawią się wartości 5, 10, 15 (wraz z indeksami w przykładach 1 i 3). Omówienie sposobów:

  • Pętla for z licznikiem – tradycyjny sposób znany z wielu języków. Dajemy pełną kontrolę nad indeksem, można iterację rozpoczynać/kończyć w dowolnym miejscu, przeskakiwać co kilka itp. Trzeba jednak uważać, by poprawnie zainicjować i aktualizować licznik oraz by warunek i < dane.length był prawidłowy (łatwo o błąd „out of range”).
  • Pętla for…of – nowszy, bardziej zwięzły sposób iteracji. Przy każdej iteracji zmienna (element w powyższym przykładzie) przyjmuje kolejną wartość z tablicy. Nie mamy bezpośrednio dostępu do indeksu (choć można go śledzić osobno lub użyć metody entries()), ale kod jest czytelniejszy, gdy indeks nie jest potrzebny.
  • forEach – metoda tablicowa przyjmująca funkcję zwrotną (callback). Dla każdego elementu tablicy wywoła tę funkcję, przekazując element, indeks i całą tablicę jako argumenty. W przykładzie użyliśmy funkcji strzałkowej, która wypisuje indeks i wartość. forEach jest wygodny, gdy chcemy wykonać operację dla każdego elementu, nie interesuje nas przerwanie iteracji (nie można użyć break wewnątrz forEach tak jak w pętli for) i nie potrzebujemy wyniku (forEach zawsze zwraca undefined).

Istnieje także pętla for…in, jednak w przypadku tablic nie jest zalecana – iteruje ona po właściwościach obiektu, a tablica to obiekt, którego „właściwościami” są indeksy i ewentualnie inne dodane właściwości. Może to dać nieoczekiwane wyniki lub złą kolejność dla tablic, dlatego do tablic preferujemy for, for…of lub metody wbudowane.

Popularne metody tablicowe

JavaScript dostarcza bogaty zestaw metod ułatwiających pracę z tablicami. Poniżej omówimy kilka często używanych metod:

  • push – dodaje element na koniec tablicy. Zwraca nową długość tablicy.
    Przykład: const arr = [1,2]; arr.push(3); // arr teraz [1,2,3].
  • pop – usuwa ostatni element tablicy i zwraca go.
    Przykład: arr.pop(); // zwraca 3, arr staje się [1,2].
  • unshift – dodaje element na początek tablicy (przesuwając istniejące elementy w prawo). Zwraca nową długość.
    Przykład: arr.unshift(0); // arr teraz [0,1,2].
  • shift – usuwa pierwszy element tablicy i zwraca go (przesuwa pozostałe w lewo).
    Przykład: arr.shift(); // zwraca 0, arr staje się [1,2].
  • indexOf – wyszukuje podany element i zwraca jego indeks lub -1 jeśli nie znajdzie.
    Przykład: [10,20,30].indexOf(20); // 1.
  • includes – zwraca true/false czy dany element znajduje się w tablicy.
    Przykład: [10,20,30].includes(25); // false.
  • slice – zwraca nową tablicę będącą wycinkiem oryginalnej (nie zmienia oryginału). Przyjmuje indeks początkowy i końcowy (nie włącznie) wycinka.
    Przykład: const a = [1,2,3,4]; const b = a.slice(1,3); // b = [2,3].
  • splice – uniwersalna metoda do modyfikowania tablicy: może usunąć, dodać lub zastąpić elementy w środku tablicy. Zwraca tablicę usuniętych elementów.
    Przykład: const a = [1,2,3,4]; a.splice(1,2,9,8); // a = [1,9,8,4] (pod indeksem 1 usuwa 2 elementy i wstawia 9,8).
  • forEach – omówiony wyżej; wykonuje przekazaną funkcję dla każdego elementu (nie zwraca nowej tablicy).
  • map – tworzy nową tablicę, w której każdy element jest wynikiem wywołania przekazanej funkcji na odpowiadającym elemencie wejściowym.
    Przykład: [1,2,3].map(x => x * 2); // wynik: [2,4,6] (oryginalna tablica nie zmieniona).
  • filter – tworzy nową tablicę z tymi elementami, dla których przekazana funkcja zwróciła wartość prawdziwą (true).
    Przykład: [1,2,3,4].filter(x => x % 2 === 0); // [2,4] (wyfiltrowano liczby parzyste).
  • reduce – przetwarza tablicę do pojedynczej wartości poprzez wykonywanie funkcji akumulującej na kolejnych elementach. Funkcja ta otrzymuje akumulator i bieżący element, a zwraca nowy akumulator. reduce przyjmuje także wartość początkową akumulatora.
    Przykład (sumowanie): [1,2,3].reduce((acc, x) => acc + x, 0); // 6.
    reduce bywa trudniejszy do zrozumienia na początku, ale jest potężny – pozwala np. sumować, mnożyć, łączyć obiekty, a nawet realizować logikę każdej innej metody (map, filter) w jednym.
  • find – zwraca pierwszy element, dla którego funkcja testująca (callback) zwróci true. Jeśli nie znajdzie, zwraca undefined.
    Przykład: [{x:1},{x:2}].find(obj => obj.x===2); // {x:2}.
  • sort – sortuje tablicę w miejscu (mutuje ją). Domyślnie sortuje elementy jak stringi Unicode, ale można przekazać własną funkcję porównującą do sortowania liczb lub obiektów według klucza.
  • join – łączy wszystkie elementy tablicy w jeden string, z podanym separatorem.
    Przykład: [\"A\",\"B\",\"C\"].join(\"-\"); // \"A-B-C\".

To tylko kilka metod – jest ich więcej, ale te należą do najczęściej używanych. Ważne jest rozróżnienie, które metody mutują oryginalną tablicę (np. push, pop, shift, unshift, splice, sort) a które zwracają nową tablicę pozostawiając oryginał bez zmian (map, filter, slice, etc.).

Przykładowo, jeśli chcemy zachować oryginalne dane, lepiej użyć slice niż splice albo filter zamiast usuwać elementy ręcznie. Natomiast gdy chcemy dokonać zmiany w miejscu (in-place), np. dodając element, użyjemy push/unshift lub usuwając pop/shift.

Destrukturyzacja tablic

Destrukturyzacja (destructuring) to wygodna składnia umożliwiająca wyciąganie wartości z tablic (lub obiektów) i przypisywanie ich do zmiennych, wszystko w jednej deklaracji. W przypadku tablic używamy do tego nawiasów kwadratowych po lewej stronie przypisania.

Przykład destrukturyzacji tablicy:

 const coords = [100, 200, 300];
 const [x, y, z] = coords;
 console.log(x); // 100
 console.log(y); // 200
 console.log(z); // 300

Tutaj tablica coords zawiera trzy liczby. Dzięki składni const [x, y, z] = coords; trzy nowe zmienne x, y, z otrzymują wartości kolejnych elementów tablicy coords. Jest to równoważne trzem osobnym przypisaniom z użyciem indeksów, ale zapisane zwięźle w jednej linii.

Kilka przydatnych możliwości destrukturyzacji:

  • Można zignorować niektóre elementy, wstawiając pusty przecinek. Np.
    const [pierwszy, , trzeci] = [10, 20, 30]; // pomijamy drugi
    Po tym przypisaniu pierwszy == 10, trzeci == 30. Drugi element tablicy został pominięty.
  • Można przypisać tylko pierwsze kilka elementów do zmiennych, a resztę zgrupować w tablicę za pomocą operatora rest ....
    Np.
 const [head, ...tail] = [1, 2, 3, 4, 5]; 
 console.log(head); // 1 console.log(tail); // [2, 3, 4, 5] Tutaj `head` to pierwszy element, a `tail` to tablica z pozostałymi.

Destrukturyzacja jest przydatna przy zamianie wartości dwóch zmiennych bez użycia zmiennej pomocniczej:

 let a = 1, b = 2;  [a, b] = [b, a];    console.log(a, b); // 2 1 Powyżej w jednej linii zamieniliśmy wartości zmiennych `a` i `b`.

Destrukturyzacja zwiększa czytelność, gdy wyciągamy wiele wartości z tablicy (np. wynik funkcji zwracającej tablicę) – zamiast pisać const val0 = arr[0]; const val1 = arr[1]; ..., możemy to zrobić zwięźle.

Przykładowe operacje na tablicach

Aby zademonstrować praktyczną pracę z tablicami, rozważmy następujący przykład: Mamy tablicę liczb i chcemy z tej tablicy wybrać liczby parzyste, następnie podwoić te wartości i na końcu zsumować wszystkie otrzymane wyniki. Możemy to osiągnąć łańcuchowo za pomocą metod filter, map i reduce:

 const liczby = [1, 2, 3, 4, 5];
 const parzyste = liczby.filter(num => num % 2 === 0);      // wybieramy parzyste -> [2, 4]
 const podwojone = parzyste.map(num => num * 2);            // podwajamy każdy -> [4, 8]
 const suma = podwojone.reduce((acc, num) => acc + num, 0); // sumujemy -> 12
 console.log("Suma podwojonych parzystych:", suma);

Omówienie krok po kroku:

  1. Filter: parzyste zawiera wynik liczby.filter(...). Funkcja strzałkowa num => num % 2 === 0 pozostawia w tablicy tylko te elementy, dla których warunek „czy liczba jest parzysta” jest spełniony. Oryginalna tablica liczby pozostaje bez zmian.
  2. Map: Na wynikowej tablicy parzystych liczb wywołujemy map, przekazując funkcję mnożącą każdy element przez 2. Otrzymujemy nową tablicę podwojone z przekształconymi wartościami.
  3. Reduce: Następnie redukujemy tablicę podwojone do pojedynczej wartości sumy. Funkcja akumulatora ((acc, num) => acc + num) dodaje kolejne liczby do akumulatora acc, zaczynając od zera (drugi argument reduce to 0 – wartość początkowa akumulatora). Wynik trafia do zmiennej suma.

Na końcu w zmiennej suma otrzymujemy wartość 12, która jest sumą podwojonych liczb parzystych z oryginalnej tablicy. Taki styl programowania (łączenie operacji na tablicach) jest czytelny i często spotykany, zwłaszcza w przetwarzaniu danych. Pozwala opisać co chcemy zrobić z kolekcją danych w kolejnych krokach, bez pisania ręcznych pętli i liczników.

Funkcje w JavaScript

Funkcja to podstawowy blok kodu w JavaScript, który można zdefiniować raz i wykonywać wielokrotnie. Funkcje pozwalają organizować program w mniejsze, logiczne fragmenty, co ułatwia ponowne użycie kodu oraz jego czytelność. W tej sekcji omówimy sposoby definiowania funkcji, różne rodzaje funkcji (deklaracje, wyrażenia, funkcje strzałkowe), a także pojęcia zasięgu zmiennych i domknięć. Na koniec wyjaśnimy, co oznacza, że funkcje w JavaScript są „obiektami pierwszej klasy”.

Definiowanie funkcji: deklaracje vs wyrażenia

W JavaScript funkcję można zdefiniować na kilka sposobów. Najczęściej używane to deklaracja funkcji oraz wyrażenie funkcyjne.

  • Deklaracja funkcji: Używa słowa kluczowego function na początku. Taka funkcja ma nazwę i można ją wywołać w kodzie. Przykład deklaracji funkcji:
 function greet() {          console.log("Witaj!"); 
 } 
 greet(); // wywołanie funkcji, wypisze: Witaj! 

Funkcję greet zdefiniowaliśmy przez deklarację. Takie funkcje podlegają mechanizmowi hoistingu – co oznacza, że są „wynoszone” na górę zakresu podczas wykonywania skryptu. W praktyce można wywołać funkcję deklarowaną nawet przed jej definicją w kodzie (choć dla czytelności kodu lepiej tego unikać).

  • Wyrażenie funkcyjne: Funkcja może być również utworzona jako wyrażenie przypisane do zmiennej. Nie ma wtedy osobnej nazwy (lub jest to funkcja anonimowa), a dostęp do niej uzyskujemy poprzez zmienną. Przykład wyrażenia funkcyjnego:
 const greet2 = function() {     console.log("Cześć!"); 
 } 
 greet2(); // wypisze: Cześć! 

Tutaj utworzyliśmy funkcję anonimową i przypisaliśmy ją do stałej greet2. Takiej funkcji nie można wywołać przed linią, w której została zdefiniowana, ponieważ nie jest hoistowana w ten sam sposób co deklaracja funkcji. Wyrażenia funkcyjne są użyteczne, gdy chcemy przekazać funkcję jako wartość (np. do innej funkcji) lub definiować je warunkowo.

Oba powyższe sposoby tworzą funkcje o identycznym zachowaniu w czasie wykonywania – różnią się głównie sposobem deklaracji i zachowaniem podczas ładowania skryptu.

Funkcje strzałkowe (arrow functions)

Funkcje strzałkowe zostały wprowadzone w ES6 (ECMAScript 2015) jako krótsza składnia definiowania funkcji. Zamiast słowa kluczowego function, używamy symbolu => (tzw. „strzałki”). Funkcje strzałkowe często upraszczają kod, zwłaszcza gdy definiujemy proste funkcje zwrotne (callbacki).

Przykład zwykłej funkcji vs funkcji strzałkowej robiącej to samo:

 // Zwykła funkcja mnożąca liczbę przez 2
 function multiplyByTwo(x) {
      return x * 2;
 }

 // Funkcja strzałkowa mnożąca liczbę przez 2
 const multiplyByTwoArrow = (x) => {
      return x * 2;
 };

 // Można jeszcze prościej: jeśli ciało funkcji strzałkowej to jedna instrukcja, można pominąć  nawiasy klamrowe i słowo return
 const multiplyByTwoArrowShort = x => x * 2;

 console.log(multiplyByTwo(5));         // 10
 console.log(multiplyByTwoArrow(5));    // 10
 console.log(multiplyByTwoArrowShort(5)); // 10

W powyższym kodzie multiplyByTwo, multiplyByTwoArrow i multiplyByTwoArrowShort dają ten sam rezultat. Widać, że funkcja strzałkowa pozwala zredukować ilość kodu, zwłaszcza w przypadku funkcji jedno-liniowych. Kilka cech funkcji strzałkowych:

  • Krótka składnia: brak słowa function, a gdy jest tylko jeden parametr można pominąć nawiasy (), zaś gdy funkcja od razu zwraca wynik pojedynczego wyrażenia, można pominąć klamry {} i słowo return.
  • Brak własnego this: Funkcje strzałkowe nie mają własnego kontekstu this ani obiektu arguments. this wewnątrz funkcji strzałkowej odnosi się do this z kontekstu otaczającego (czyli jest leksykalnie wiązane). Dzięki temu funkcje strzałkowe są wygodne np. w metodach tablicowych czy obsłudze zdarzeń, bo nie zmieniają kontekstu. Jednak z tego powodu nie nadają się do definiowania metod obiektu, gdzie zazwyczaj chcemy, by this wskazywało na ten obiekt (o czym więcej później).
  • Nie mogą być konstruktorami: Nie można użyć funkcji strzałkowej z operatorem new do tworzenia obiektów. Są przeznaczone głównie do krótkich funkcji wykonywanych „w locie”.

Zasięg zmiennych a zakres leksykalny

Zasięg (scope) zmiennej to obszar kodu, w którym dana zmienna jest dostępna. JavaScript ma zasięg funkcyjny dla deklaracji var oraz zasięg blokowy dla deklaracji let i const (wprowadzonych w ES6). Oznacza to, że:

  • Zmienna zadeklarowana słowem kluczowym var wewnątrz funkcji jest dostępna tylko w tej funkcji (oraz w ewentualnych funkcjach zagnieżdżonych). Jeśli zadeklarujemy var poza funkcją, będzie globalna (dostępna w całym skrypcie).
  • Zmienne zadeklarowane przez let lub const są dostępne tylko wewnątrz bloku, w którym zostały zadeklarowane (np. między { } pętli, instrukcji warunkowej, funkcji itp.). Nie „wynoszą” się poza ten blok.

Przykładowo:

 function testScope() {
   var a = 1;
   let b = 2;
   if (true) {
     var c = 3;
     let d = 4;
     console.log(a, b); // 1 2 (wewnątrz funkcji widzimy a i b)
   }
   console.log(c); // 3 (c jest dostępne, bo var ma zasięg funkcji)
   console.log(d); // BŁĄD: d is not defined (d ma zasięg bloku if i poza nim nie istnieje)
 }
 testScope();
 console.log(typeof a); // undefined (a nie istnieje poza funkcją)

W powyższym kodzie zmienna a jest lokalna dla funkcji testScope, podobnie b. Zmienna c zadeklarowana przez var wewnątrz bloku if jest wciąż dostępna w całej funkcji (zasięg funkcji), natomiast d zadeklarowane przez let istnieje tylko w bloku if. Poza funkcją testScope żadne z a, b, c, d nie jest widoczne.

W JavaScript obowiązuje zasięg leksykalny (lexical scope), co oznacza, że zasięg zmiennej zależy od tego, gdzie w kodzie została zdefiniowana, a nie od tego, skąd jest wywoływana. Funkcje zagnieżdżone mają dostęp do zmiennych zdefiniowanych w otaczających je funkcjach (czyli w ich zewnętrznym leksykalnym kontekście). Ten mechanizm prowadzi nas do koncepcji domknięć.

Domknięcia (Closures)

Domknięcie to mechanizm, dzięki któremu funkcja wewnętrzna pamięta zmienne ze swojego otaczającego kontekstu, nawet jeśli ten kontekst przestanie istnieć (funkcja zewnętrzna zakończyła wykonanie). Innymi słowy, domknięcie pozwala funkcji zachować dostęp do zasięgu leksykalnego, w którym została utworzona.

Przeanalizujmy przykład domknięcia:

 function utworzLicznik() {
      let licznik = 0;               // zmienna lokalna funkcji utworzLicznik
      return function() {            // zwracamy nową funkcję (anonimową)
        licznik++;                   // korzysta ze zmiennej spoza swojego ciała
        console.log("Stan licznika: " + licznik);
      };
 }

 const licz = utworzLicznik();    // wywołujemy utworzLicznik, zwraca funkcję, którą  przypisujemy do zmiennej
 licz(); // Stan licznika: 1
 licz(); // Stan licznika: 2
 licz(); // Stan licznika: 3

W funkcji utworzLicznik deklarujemy zmienną licznik i zwracamy funkcję wewnętrzną, która inkrementuje licznik i wypisuje jego wartość. Zmienna licz staje się tą wewnętrzną funkcją. Mimo że utworzLicznik() zakończyło działanie, jego zmienna lokalna licznik nie zniknęła – jest przechowywana w domknięciu związanym z funkcją przypisaną do licz. Za każdym wywołaniem licz(), funkcja ma dostęp do swojej zamkniętej zmiennej licznik i może ją modyfikować. To pokazuje istotę domknięć: funkcja zachowuje dostęp do zmiennych obecnych w momencie jej definicji.

Domknięcia są powszechnie wykorzystywane w JavaScript. Przykładowe zastosowania:

  • Funkcje fabrykujące (factory functions): tak jak utworzLicznik powyżej, możemy tworzyć funkcje generujące inne funkcje z pewnym stanem „prywatnym”.
  • Emulacja prywatnych zmiennych: używając funkcji i domknięć można ukryć pewne dane przed zewnętrznym dostępem (moduły, IIFE).
  • Callbacki z dostępem do kontekstu: np. w obsłudze zdarzeń czy timeoutów, często funkcja callback korzysta z zmiennych spoza siebie (z kontekstu, w którym została utworzona).

Zrozumienie domknięć bywa trudne dla początkujących, ale jest kluczowe dla pełnego opanowania JavaScript. W uproszczeniu: domknięcie to „pakiet” zawierający funkcję wraz z jej leksykalnym środowiskiem (zmiennymi), które są zachowane nawet po wykonaniu tej funkcji zewnętrznej.

Funkcje jako obiekty pierwszej klasy

W JavaScript funkcje są obiektami pierwszej klasy (first-class objects/citizens). Oznacza to, że traktujemy je jak każdą inną wartość w języku. Konsekwencje takiego podejścia:

  • Funkcję można przypisać do zmiennej lub stałej (jak pokazano wcześniej).
  • Funkcję można przekazać jako argument do innej funkcji. Np. wiele metod tablicowych czy API przeglądarki oczekuje funkcji-callbacku. Przykład: arr.forEach(function(x) { ... }) przekazuje funkcję anonimową do wykonania na każdym elemencie tablicy.
  • Funkcja może być zwrócona jako wynik z innej funkcji (co widzieliśmy w przykładzie z domknięciem).
  • Funkcje można przechowywać w strukturach danych (np. w tablicy: const operacje = [dodaj, odejmij, pomnóż] gdzie dodaj etc. są funkcjami).
  • Można tworzyć funkcje w locie, np. jako wyrażenia strzałkowe () => ....

Dzięki temu, że funkcje są wartościami, programowanie w JavaScript wspiera paradygmat funkcyjny. Przykładowo, możemy napisać funkcję wyższego rzędu (higher-order function), która przyjmuje inną funkcję jako parametr:

 function wykonajOperacje(a, b, operacja) {
      return operacja(a, b); // wywołujemy przekazaną funkcję operacja z argumentami a i b
 }

 function dodaj(x, y) {
      return x + y;
 }
 console.log(wykonajOperacje(3, 4, dodaj));            // 7, przekazujemy nazwę funkcji
 console.log(wykonajOperacje(3, 4, (x, y) => x * y));  // 12, przekazujemy funkcję strzałkową mnożącą

W powyższym przykładzie wykonajOperacje to funkcja, która deleguje konkretną logikę do przekazanej funkcji operacja. Raz użyliśmy gotowej funkcji dodaj, a raz przekazaliśmy funkcję strzałkową anonimowo przy wywołaniu. Tego typu konstrukcje są możliwe dzięki temu, że funkcje w JavaScript są obiektami pierwszej klasy i mogą być traktowane tak samo jak np. liczby czy stringi.

Pętle w Javascript

Pętle to konstrukcje programistyczne pozwalające powtarzać wykonanie pewnego fragmentu kodu wielokrotnie, dopóki spełniony jest określony warunek lub przez określoną liczbę razy. Zamiast pisać wiele razy to samo polecenie, możemy użyć pętli, aby automatycznie przechodzić przez kolejne iteracje. Pętle są niezastąpione, gdy mamy do przetworzenia sekwencję danych (np. elementy tablicy) albo gdy chcemy wykonać daną operację n razy. W JavaScript występuje kilka rodzajów pętli: for, while, do...while, a także specjalne konstrukcje do iteracji po kolekcjach, takie jak for...of i for...in. W tej części omówimy, jak działają poszczególne rodzaje pętli i jak ich używać.

Pętla for

Pętla for jest często używana, gdy z góry wiadomo, ile razy dany blok kodu ma zostać powtórzony, lub gdy chcemy iterować po kolejnych wartościach licznikowych (np. od 1 do 100). Charakteryzuje się ona umieszczeniem w nagłówku pętli wszystkich informacji sterujących: inicjalizacji, warunku kontynuacji oraz modyfikacji licznika po każdej iteracji. Składnia pętli for wygląda następująco:

 for (inicjalizacja; warunek; iteracja) {
     // kod, który będzie powtarzany dopóki warunek jest spełniony
 } 
  • Inicjalizacja: wykonywana jednorazowo na początku pętli. Często służy do utworzenia i zainicjalizowania zmiennej sterującej (licznika). Np. let i = 0.
  • Warunek (kontynuacji): jest sprawdzany przed każdą iteracją pętli. Dopóki ten warunek jest true, pętla będzie się wykonywać. Gdy tylko warunek stanie się false, pętla kończy działanie i program przechodzi do kolejnej części kodu za pętlą.
  • Iteracja (zmiana): to wyrażenie wykonywane po każdej iteracji (na końcu bloku pętli, tuż przed ponownym sprawdzeniem warunku). Służy najczęściej do zmiany wartości licznika pętli, np. i++ (zwiększenie licznika o 1).

Wszystkie trzy części są opcjonalne, ale średniki ; oddzielające je muszą pozostać. W praktyce najczęściej wypełniamy wszystkie te sekcje, a pominięcie którejś jest rzadziej spotykane i ma specyficzne zastosowania (np. nieskończone pętle). Standardowe użycie pętli for wygląda tak:

 for (let i = 1; i <= 5; i++) {
     console.log("Iteracja numer " + i);
 }
 console.log("Koniec pętli");

Przeanalizujmy powyższy przykład:

  • Inicjalizacja: let i = 1 – przed startem pętli tworzymy zmienną i i ustawiamy ją na 1. Zmienne sterujące pętli for bardzo często nazywa się i (od „index” lub „iterator”).
  • Warunek: i <= 5 – przed każdą iteracją sprawdzamy, czy i nie przekroczyło 5. Dopóki i wynosi 5 lub mniej, warunek jest prawdziwy i pętla się wykona. Gdy i stanie się 6, warunek i <= 5 będzie fałszywy i pętla zakończy działanie.
  • Iteracja: i++ – po wykonaniu bloku kodu za każdym razem zwiększamy i o 1.

Działanie krok po kroku:

  1. Ustaw i = 1 (inicjalizacja).
  2. Sprawdź warunek: czy i <= 5? (dla i=1 jest true, więc wchodzimy do pętli).
  3. Wewnątrz pętli wykonujemy console.log("Iteracja numer " + i). Dla i=1 wyświetli: „Iteracja numer 1”.
  4. Koniec bloku – wykonujemy krok iteracji i++ (teraz i staje się 2).
  5. Sprawdź warunek ponownie: i=2, czy 2 <= 5? true, więc kolejna iteracja.
  6. Wewnątrz pętli wypisze „Iteracja numer 2”.
  7. Koniec bloku – i++ (i = 3).
  8. Warunek: 3 <= 5? true, kontynuuj…
  9. … i tak dalej, aż do momentu gdy i zostanie zwiększone do 6.
  10. Gdy i = 6, sprawdzamy warunek: 6 <= 5? to już false – pętla się nie wykona dla i=6 i zostanie przerwana.
  11. Program przechodzi do instrukcji za pętlą, czyli wypisuje „Koniec pętli”.

W wyniku działania tego kodu zobaczymy kolejno w konsoli:

 Iteracja numer 1  
 Iteracja numer 2  
 Iteracja numer 3  
 Iteracja numer 4  
 Iteracja numer 5  
 Koniec pętli

Pętla for okazała się tu bardzo użyteczna – zamiast pisać pięć razy console.log z różnym numerem, napisaliśmy raz i kazaliśmy pętli powtórzyć to 5 razy, zmieniając za każdym razem wartość i. Jeśli chcielibyśmy wypisać od 1 do 100, wystarczyłoby zmienić warunek na i <= 100 i pętla automatycznie wykonałaby się 100 razy.

Zastosowania pętli for:

  • Iteracja po tablicach: Mając tablicę elementów, możemy użyć for z licznikiem jako indeksem, np.:
 let fruits = ["jabłko", "banan", "gruszka"]; 
 for (let index = 0; index < fruits.length; index++) { 
     console.log("Owoc nr " + index + ": " + fruits[index]); 
 }

Ten kod przejdzie przez wszystkie indeksy tablicy fruits (od 0 do fruits.length-1) i wypisze każdy owoc. Pętla for z licznikiem jest klasycznym sposobem przeglądania elementów tablicy.

  • Wykonywanie operacji określoną liczbę razy: np. obliczenie silni liczby, sumy ciągu liczb itp. Pętla for potrafi łatwo iterować zliczając coś.
  • Generowanie powtarzalnych wzorców wyjścia: np. narysowanie prostych figur złożonych z znaków (choć do tego często używa się pętli zagnieżdżonych).

Warto zauważyć, że wszystkie trzy części nagłówka pętli for są opcjonalne – możemy np. pominąć inicjalizację (jeśli zmienna była wcześniej zdefiniowana), pominąć iterację (jeśli zwiększamy licznik wewnątrz pętli inaczej) czy nawet pominąć warunek (co stworzy pętlę nieskończoną, wykonującą się wciąż, dopóki nie przerwiemy jej ręcznie, np. instrukcją break). Przykład pętli nieskończonej: for(;;) { ... } – wewnątrz musiałby być jakiś break żeby kiedykolwiek wyjść. Jednak w praktyce początkujący rzadko mają potrzebę pomijania tych sekcji – standardowa forma jest najbardziej czytelna.

Pętla while

Pętla while ma prostszą konstrukcję niż for, ale daje dużą elastyczność. Jej składnia to:

 while (warunek) {
     // kod powtarzany dopóki warunek jest true
 } 

Działanie: dopóki podany warunek jest spełniony (true), wykonuj w kółko blok kodu wewnątrz pętli. Sprawdzenie warunku następuje przed każdą iteracją (czyli jest to pętla z warunkiem na początku). Jeśli warunek jest fałszywy już na starcie, kod pętli nie wykona się ani razu.

Pętla while przypomina zatem nieco uproszczony for bez części inicjalizacji i iteracji w nagłówku – te elementy musimy zapewnić sobie sami w kodzie, jeśli są potrzebne. Zazwyczaj używamy while w sytuacjach, gdy nie wiemy z góry ile iteracji będzie potrzebnych, i pętla powinna trwać tak długo, aż coś się wydarzy (aż warunek przestanie być spełniony).

Przykład: Użyjemy pętli while, aby obliczyć najmniejszą potęgę liczby 2, która przekracza 100. Innymi słowy, będziemy mnożyć 2 * 2 * 2 * … aż wynik stanie się większy niż 100.

 let liczba = 1;
 let potega = 0;
 while (liczba <= 100) {
     liczba = liczba * 2;
     potega++;
 }
 console.log("2^" + potega + " = " + liczba + ", czyli to pierwsza potęga 2 > 100");

W tym kodzie:

  • Ustawiamy liczba = 1 (co odpowiada 2^0) i licznik potęgi potega = 0.
  • Warunek pętli to liczba <= 100. Dopóki wynik jest 100 lub mniej, musimy kontynuować mnożenie.
  • Wewnątrz pętli: mnożymy liczba przez 2 i zwiększamy potega o 1 (czyli liczymy następną potęgę).
  • Gdy warunek przestanie być spełniony (tzn. liczba przekroczy 100), wychodzimy z pętli i wypisujemy wynik.

Działanie krokowe:

  • Początek: liczba = 1, potega = 0. Sprawdzenie warunku: 1 <= 100 (true) -> wejdź do pętli.
  • W pętli: liczba = 1*2 = 2, potega = 1. Koniec iteracji.
  • Sprawdzenie warunku: 2 <= 100 (true) -> kolejna iteracja.
  • W pętli: liczba = 2*2 = 4, potega = 2.
  • Warunek: 4 <= 100 (true) -> dalej.
  • … (pętla będzie kolejno dawać liczba: 8,16,32,64,128; potega: 3,4,5,6,7)
  • Gdy liczba stanie się 128 i potega 7, sprawdzenie warunku: 128 <= 100 jest false, więc pętla kończy się.
  • Po pętli wypisujemy: „2^7 = 128, czyli to pierwsza potęga 2 > 100”.

Wynik pokazuje, że 27=1282^7 = 12827=128 jest pierwszą potęgą dwójki większą niż 100, co zgadza się z oczekiwaniami (bo 26=642^6 = 6426=64 jeszcze było poniżej 100). Widzimy tutaj typowy przypadek pętli while, gdzie nie było z góry ustalone ile razy pętla się wykona – kręciła się tak długo, aż osiągnięto pewien stan (liczba przekroczyła 100).

W pętli while bardzo ważne jest, by kod wewnątrz zmierzał do spełnienia warunku kończącego pętlę. Jeśli zapomnimy zmienić wartości, która jest sprawdzana, lub warunek nigdy nie stanie się fałszywy, dostaniemy pętlę nieskończoną. Np. while(true) { ... } będzie się wykonywać w nieskończoność (chyba że w środku użyjemy break – o tym za chwilę). W naszym przykładzie elementem zmierzającym do zakończenia pętli jest mnożenie liczba = liczba * 2 – dzięki temu liczba rośnie i w końcu przekroczy 100, przerywając pętlę. Gdybyśmy tego nie zrobili, utknęlibyśmy w nieskończonym loopie.

Pętle while często stosuje się również do czekania na jakiś warunek lub przetwarzania danych wejściowych, np. czytania kolejnych wartości aż do napotkania jakiejś specjalnej (choć w przeglądarkowym JavaScript typowe wejście/wyjście przebiega inaczej, więc to raczej koncepcyjny przykład).

Pętla do...while

Pętla do...while jest bardzo podobna do while, z tą różnicą, że warunek sprawdzany jest na końcu iteracji, a nie na początku. To powoduje, że pętla do...while zawsze wykona się przynajmniej raz, nawet jeśli warunek od początku jest fałszywy. Składnia wygląda tak:

 do {
     // kod do wykonania
 } while (warunek);

Zasada: wykonaj blok do { ... }, a następnie sprawdź while(warunek). Jeśli warunek jest true – wróć do początku pętli (czyli wykonaj blok ponownie), jeśli false – zakończ pętlę.

Przykład użycia do...while: Wyobraźmy sobie, że chcemy symulować rzut kostką do gry tak długo, aż wypadnie nam szóstka. Pętla powinna wykonać się co najmniej raz (bo musimy chociaż raz rzucić), a potem powtarzać, jeśli rezultat nie był 6.

 function rzutKostka() {
     // funkcja pomocnicza zwracająca losową liczbę od 1 do 6
     return Math.floor(Math.random() * 6) + 1;
 }

 let wynik;
 do {
     wynik = rzutKostka();
     console.log("Wynik rzutu: " + wynik);
 } while (wynik !== 6);
 console.log("Wypadła 6, koniec pętli.");

W tym kodzie:

  • Używamy funkcji Math.random() do wygenerowania pseudolosowej liczby i symulacji rzutu sześciościenną kostką.
  • Pętla do...while wykonuje blok co najmniej raz. W bloku do generujemy wynik rzutu i wypisujemy go.
  • Warunek pętli to wynik !== 6 (czyli dopóki wynik jest różny od 6, kontynuuj).
  • Jeśli wylosowana liczba jest różna od 6, pętla powtarza się i kostka rzucana jest ponownie. Gdy w końcu wynik stanie się równy 6, warunek wynik !== 6 zwróci false i pętla się zakończy.
  • Po pętli wypisujemy informację o zakończeniu.

Wykonanie takiego programu może skutkować np. następującym ciągiem w konsoli:

Wynik rzutu: 4  
Wynik rzutu: 2
Wynik rzutu: 5
Wynik rzutu: 6
Wypadła 6, koniec pętli.

Tutaj pętla wykonała się 4 razy, ostatni rzut dał wynik 6 i spowodował zakończenie iteracji. Gdyby pierwszy rzut od razu dał 6, pętla i tak wykonałaby się ten jeden raz (wypisując wynik) i zakończyła, co pokazuje przewagę do...while w sytuacjach, gdzie zawsze chcemy wykonać ciało pętli przynajmniej raz, niezależnie od warunku.

Pętle for...in i for...of

Poza omówionymi wyżej podstawowymi konstrukcjami, JavaScript posiada również dwie specjalne pętle skracające iterowanie po obiektach lub kolekcjach danych:

  • for...in: służy do iteracji po właściwościach obiektu (kluczach) lub indeksach tablicy. Jego składnia to for (zmienna in obiekt) { ... }. Przy każdej iteracji zmienna przyjmuje kolejną nazwę własności (klucza) obiektu. Na tablicach zwróci indeksy (które w JS są właściwie także kluczami, tylko numerycznymi). Przykład:
 let person = { name: "Jan", age: 30, city: "Kraków" };  for (let key in person) { 
     console.log(key + ": " + person[key]); 
 }

Ten kod przejdzie po wszystkich kluczach obiektu person (czyli name, age, city) i wypisze nazwy pól oraz ich wartości. Rezultat: name: Jan age: 30 city: Kraków Pętla for...in jest więc wygodna do przeglądania właściwości obiektu. Można jej użyć też do iteracji po tablicy, ale zazwyczaj do tablic preferuje się pętlę for lub nowocześniejszą for...of, ponieważ for...in przy tablicach może iterować również ewentualne dodatkowe właściwości tablicy poza indeksami, jeśli takie istnieją (rzadki przypadek, ale bywa).

  • for...of: wprowadzona w nowszej wersji JavaScript (ES6) pętla służąca do iteracji bezpośrednio po elementach iterowalnych kolekcji, takich jak tablice, ciągi znaków (stringi), mapy, zbiory itp. Jej składnia to for (zmienna of iterowalnaStruktura) { ... }. W odróżnieniu od for...in, tutaj zmienna przyjmuje wartości elementów (a nie ich klucze/indeksy). Przykład użycia z tablicą:
 let fruits = ["Jabłko", "Banan", "Gruszka"]; 
 for (let fruit of fruits) { 
     console.log("Owoc: " + fruit); 
 }

Wynik działania:

Owoc: Jabłko Owoc: Banan Owoc: Gruszka

Jak widać, pętla for...of uprościła kod – nie trzeba odwoływać się do tablicy przez indeks, jak to było w tradycyjnej pętli for. Podobnie możemy iterować po znakach w stringu:

 let word = "HELLO"; 
 for (let ch of word) { 
     console.log(ch); 
 }

To wypisze każdy znak z osobna: H E L L O Pętla for...of jest bardzo czytelna i zalecana, gdy chcemy po kolei przetworzyć elementy jakiejś kolekcji (i nie potrzebujemy indeksu elementu). Trzeba jednak pamiętać, że działa na tzw. obiektach iterowalnych – w praktyce najczęściej na tablicach i zbiorach danych. Nie można nią iterować po zwykłym obiekcie (nieiterowalnym) – do obiektów służy wspomniana for...in lub metody obiektu.

Sterowanie przebiegiem pętli: break i continue

Podczas używania pętli czasem potrzebujemy wpłynąć na jej działanie z wnętrza bloku, np. przerwać pętlę wcześniej lub pominąć bieżącą iterację. Służą do tego dwie specjalne instrukcje:

  • break: natychmiast przerywa wykonywanie całej pętli i powoduje wyjście z niej. Program kontynuuje wykonywanie kodu od pierwszej instrukcji za pętlą. break zazwyczaj używa się, gdy znaleźliśmy to, czego szukaliśmy i dalsze kręcenie pętli jest zbędne, lub w sytuacji jakiegoś błędu/wyjątku, gdy chcemy porzucić dalsze powtarzanie.
  • continue: przerywa bieżącą iterację pętli i przechodzi do kolejnej iteracji (o ile warunek pętli nadal jest spełniony). Kod znajdujący się poniżej continue w ciele pętli zostanie pominięty w danej iteracji, ale pętla nie kończy się całkowicie – zaczyna się następny obrót. continue jest użyteczne, gdy chcemy pominąć niektóre elementy i nie wykonywać dla nich całego bloku pętli.

Przykład użycia break: załóżmy, że mamy tablicę liczb i chcemy znaleźć pierwszą liczbę ujemną w tej tablicy. Gdy ją znajdziemy, nie ma sensu przeglądać dalszych elementów – możemy przerwać pętlę.

 let numbers = [3, 7, 2, -5, 8, -1, 4];
 let firstNegative = null;
 for (let num of numbers) {
     if (num < 0) {
         firstNegative = num;
         break;  // znaleziono pierwszą ujemną liczbę, przerywamy pętlę
     }
 }
 if (firstNegative !== null) {
     console.log("Pierwsza liczba ujemna to: " + firstNegative);
 } else {
     console.log("W tablicy nie było liczb ujemnych.");
 } 

Analiza: Tablica numbers zawiera kilka liczb, w tym ujemne. Pętla for...of przechodzi po kolei przez liczby: 3, 7, 2, -5, … Kiedy natrafi na -5, warunek num < 0 staje się prawdą, więc ustawiamy zmienną firstNegative na tę wartość i wykonujemy break. break natychmiast kończy pętlę – w tym momencie przestajemy iterować, choć w tablicy były jeszcze dalsze elementy (-1, 4), to już nas nie obchodzi, bo szukaliśmy tylko pierwszej ujemnej. Po pętli sprawdzamy, czy znaleziono liczbę ujemną (zmienna firstNegative różna od null) i wypisujemy wynik. W tym przykładzie wypisze: „Pierwsza liczba ujemna to: -5”.

Przykład użycia continue: zademonstrujmy pominiecie pewnych iteracji. Np. chcemy wypisać liczby od 1 do 10, ale z adnotacją, które z nich są parzyste, a które nie. Można to zrobić na różne sposoby, w tym z continue:

 for (let i = 1; i <= 10; i++) {
     if (i % 2 == 0) {
         console.log(i + " jest parzyste");
         continue; // przejdź do kolejnej iteracji, nie wykonując dalszych instrukcji dla parzystych
     }
     console.log(i + " jest nieparzyste");
 }

Tutaj w każdej iteracji pętli for sprawdzamy, czy bieżąca liczba i jest parzysta (warunek i % 2 == 0). Jeśli tak, wypisujemy informację że jest parzysta i używamy continue. Instrukcja continue powoduje, że pętla od razu przechodzi do kolejnego obrotu, pomijając resztę kodu wewnątrz pętli dla aktualnej wartości i. W efekcie dla liczb parzystych nie wykona się linia console.log(i + " jest nieparzyste"), bo została pominięta. Dla nieparzystych warunek if będzie fałszywy, więc continue nie zadziała i program dojdzie do drugiej linijki, wypisując informację o nieparzystości.

Rezultat powyższego kodu:

1 jest nieparzyste  
2 jest parzyste
3 jest nieparzyste
4 jest parzyste
5 jest nieparzyste
6 jest parzyste
7 jest nieparzyste
8 jest parzyste
9 jest nieparzyste
10 jest parzyste

Jak widać, continue pozwoliło nam rozdzielić logikę dla parzystych i nieparzystych w jednej pętli, pomijając wykonanie niepotrzebnej części kodu w danej iteracji.

Podsumowanie pętli: Pętle for, while, do...while oraz konstrukcje for...in i for...of to potężne narzędzia pozwalające efektywnie powtarzać operacje. Należy zawsze dbać o to, by pętla miała odpowiedni warunek kończący i nie stała się nieskończona (chyba że celowo uruchamiamy nieskończoną pętlę w specyficznych scenariuszach, co jednak w codziennym programowaniu zdarza się rzadko i wymaga ostrożności). Użycie break i continue daje dodatkową kontrolę nad przebiegiem pętli – można przerwać całość lub tylko pominąć pojedynczą iterację według potrzeby.