JavaScript: zaawansowana semantyka i nowoczesne API (ES2023/ES2024)
Dogłębny kurs semantyki JavaScriptu: koercja typów, hoisting, domknięcia, this, event loop, async/await, prototypy, klasy oraz nowoczesne API ES2023/2024 dla praktykujących inżynierów.
- == vs === i mechanizm koercji typów
- Hoisting oraz Temporal Dead Zone (let/const/var)
- Dokładna kolejność microtasków i makrotasków w event loop
- `this` w function declarations, arrow functions, metodach i klasach
- Promise.all/allSettled/race/any oraz obsługa błędów w async/await
- Domknięcia jako źródło wycieków pamięci w długożyjących aplikacjach
1 · Równość, koercja typów i podstawy wartości
★ egzaminOperator == wykonuje niejawną konwersję typów przed porównaniem (abstract equality comparison), podczas gdy === porównuje wartość i typ bez konwersji (strict equality comparison). W kodzie produkcyjnym === jest domyślnym wyborem; == rezerwuje się dla świadomych, udokumentowanych przypadków.
| Porównanie | == | === | Komentarz |
|---|---|---|---|
| 0 == '' | true | false | '' konwertowane do 0 |
| null == undefined | true | false | specjalny przypadek w spec |
| NaN == NaN | false | false | NaN nigdy nie jest równe samemu sobie |
| [] == false | true | false | [] → '' → 0, false → 0 |
| '0' == false | true | false | oba konwertowane do 0 |
console.log(0 == ''); // true
console.log(null == undefined); // true
console.log(NaN == NaN); // false
console.log([] == false); // true
console.log('0' == false); // true
console.log(0 === ''); // false Object.is(NaN, NaN); // true
NaN === NaN; // false
Object.is(0, -0); // false
0 === -0; // true - ToPrimitive: obiekt konwertowany jest do prymitywu (najpierw valueOf, potem toString, chyba że kontekst preferuje string).
- ToNumber: string, boolean i null/undefined konwertowane są do liczby wg ściśle zdefiniowanych reguł.
- Jeśli oba operandy == są tego samego typu, zachowuje się identycznie jak ===.
- null == undefined jest jedynym przypadkiem, w którym null porównuje się jako równe czemukolwiek innemu niż samo siebie.
2 · Hoisting i Temporal Dead Zone
★ egzaminHoisting to mechanizm, w którym deklaracje zmiennych i funkcji są rejestrowane na górze swojego zakresu podczas fazy tworzenia środowiska wykonania, przed wykonaniem kodu. var, let, const i function zachowują się przy tym zupełnie inaczej, co jest częstym źródłem błędów w code review.
| Deklaracja | Hoistowana? | Wartość przed deklaracją | Błąd przy dostępie |
|---|---|---|---|
| var x | tak | undefined | brak |
| let x / const x | tak (do TDZ) | brak dostępu | ReferenceError |
| function f(){} | tak, w całości | cała funkcja dostępna | brak |
| class C {} | tak (do TDZ) | brak dostępu | ReferenceError |
console.log(typeof aVar); // 'undefined' - var jest hoistowane
console.log(typeof aLet); // ReferenceError: Cannot access 'aLet' before initialization
var aVar = 1;
let aLet = 2; console.log(declared()); // działa: 'hoisted'
console.log(expr()); // TypeError: expr is not a function
function declared() { return 'hoisted'; }
var expr = function() { return 'not hoisted'; }; - 1 Silnik skanuje zakres i rejestruje wszystkie deklaracje function, var, let, const, class.
- 2 Function declarations otrzymują od razu pełną wartość (referencję do funkcji).
- 3 var otrzymuje wartość undefined.
- 4 let, const i class trafiają do TDZ, bez przypisanej wartości.
- 5 Wykonanie kodu liniowego rozpoczyna się, TDZ kończy się w linii z deklaracją.
3 · Domknięcia (closures) i zakres leksykalny
★ egzaminDomknięcie (closure) powstaje, gdy funkcja 'zapamiętuje' zmienne ze swojego zakresu leksykalnego (miejsca zdefiniowania), nawet po tym, jak zakres zewnętrzny zakończył wykonanie. To fundament modułów, memoizacji, prywatnego stanu i większości wzorców funkcyjnych w JS.
function createCounter() {
let count = 0;
return {
increment: () => ++count,
value: () => count,
};
}
const counter = createCounter();
counter.increment();
counter.increment();
console.log(counter.value()); // 2 for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}
// wypisuje: 3, 3, 3 for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}
// wypisuje: 0, 1, 2 - Enkapsulacja prywatnego stanu (module revealing pattern) bez klas.
- Memoizacja: cache wyników funkcji trzymany w domknięciu między wywołaniami.
- Fabryki funkcji (curry, partial application) z zamrożonymi argumentami.
- Debounce/throttle: domknięcie przechowuje id timera i timestamp między wywołaniami.
function memoize(fn) {
const cache = new Map();
return (arg) => {
if (cache.has(arg)) return cache.get(arg);
const result = fn(arg);
cache.set(arg, result);
return result;
};
} Pętla z var (błąd)
- Jedna wspólna zmienna i dla wszystkich iteracji
- setTimeout loguje wartość po zakończeniu pętli
- Typowy wynik: 3, 3, 3
Pętla z let (poprawnie)
- Nowe wiązanie i tworzone przy każdej iteracji
- Każde domknięcie łapie własną kopię i
- Wynik: 0, 1, 2
4 · `this`, prototypy i klasy pod maską
★ egzaminWartość this nie jest ustalana w miejscu zdefiniowania funkcji (poza arrow functions), lecz w miejscu jej wywołania (call site). To najczęstsze źródło bugów przy przekazywaniu metod jako callbacki.
| Forma wywołania | Wartość this |
|---|---|
| obj.method() | obj (odbiornik wywołania) |
| function() w strict mode (samodzielnie) | undefined |
| function() w sloppy mode (samodzielnie) | globalThis (np. window) |
| Arrow function | this z otaczającego zakresu leksykalnego (nie zmienia się nigdy) |
| new Ctor() | nowo utworzony obiekt |
| fn.call(ctx) / fn.apply(ctx) / fn.bind(ctx) | jawnie podany ctx |
| Metoda w klasie | instancja, na której wywołano metodę (strict mode zawsze) |
const obj = {
value: 42,
regular: function () { return this.value; },
arrow: () => this?.value,
};
console.log(obj.regular()); // 42
console.log(obj.arrow()); // undefined - this to zakres modułu, nie obj class Button {
label = 'OK';
handleClick = () => console.log(this.label); // this związane raz, przy tworzeniu instancji
}
const b = new Button();
const el = document.querySelector('button');
el.addEventListener('click', b.handleClick); // działa poprawnie class Animal {
constructor(name) { this.name = name; }
speak() { return `${this.name} robi dźwięk`; }
}
// odpowiednik pod maską:
function AnimalFn(name) { this.name = name; }
AnimalFn.prototype.speak = function () {
return `${this.name} robi dźwięk`;
}; - extends ustawia [[Prototype]] klasy potomnej na klasę bazową przez Object.setPrototypeOf.
- super() musi zostać wywołane przed użyciem this w konstruktorze klasy potomnej.
- Klasy są hoistowane do TDZ (jak let/const), function constructors są hoistowane w pełni.
- Pola instancji (class fields) są przypisywane na początku konstruktora, przed jego resztą ciała.
5 · Event loop: microtaski vs makrotaski
★ egzaminJavaScript jest single-threaded, ale event loop pozwala obsługiwać operacje asynchroniczne bez blokowania wątku głównego. Kluczowe jest rozróżnienie microtask queue i macrotask (task) queue oraz zasada: po każdym pojedynczym makrotasku silnik opróżnia CAŁĄ kolejkę microtasków, zanim przejdzie dalej.
console.log('start');
setTimeout(() => console.log('timeout'), 0);
Promise.resolve().then(() => console.log('promise 1'));
queueMicrotask(() => console.log('queueMicrotask'));
Promise.resolve().then(() => console.log('promise 2'));
console.log('end');
// kolejność: start, end, promise 1, queueMicrotask, promise 2, timeout - 1 1. Kod synchroniczny 'start' i 'end' logują się od razu, call stack musi się opróżnić zanim ruszy event loop.
- 2 2. Opróżnienie microtask queue 'promise 1', 'queueMicrotask', 'promise 2' wykonują się w kolejności FIFO dodania do kolejki, całość przed jakimkolwiek makrotaskiem.
- 3 3. Pierwszy makrotask Dopiero teraz silnik bierze callback z setTimeout i loguje 'timeout', mimo delay 0.
| Źródło | Typ kolejki |
|---|---|
| Promise.then/catch/finally | microtask |
| queueMicrotask() | microtask |
| await (wznowienie po nim) | microtask |
| setTimeout / setInterval | macrotask |
| setImmediate (Node.js) | macrotask (po fazie I/O) |
| Zdarzenia DOM (click, input) | macrotask |
| MutationObserver | microtask |
async function example() {
console.log('A');
await null; // wymusza wznowienie jako microtask
console.log('B');
}
example();
console.log('C');
// kolejność: A, C, B 6 · async/await i kombinatory Promise
★ egzaminasync/await to składniowy cukier nad Promise, ułatwiający czytanie kodu asynchronicznego jako sekwencji kroków, przy zachowaniu w pełni asynchronicznej, nieblokującej semantyki pod spodem.
async function fetchUser(id) {
try {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} catch (err) {
console.error('Nie udało się pobrać użytkownika', err);
throw err; // propaguj dalej, nie połykaj cicho
}
} // źle: sumaryczny czas obu operacji, sekwencyjnie
const a = await fetchA();
const b = await fetchB();
// dobrze: obie operacje startują równolegle
const [a2, b2] = await Promise.all([fetchA(), fetchB()]); | Metoda | Kiedy się rozstrzyga | Wynik przy sukcesie | Wynik przy porażce |
|---|---|---|---|
| Promise.all | gdy wszystkie fulfilled, lub od razu przy pierwszym rejected | tablica wartości w oryginalnej kolejności | odrzucenie z powodem pierwszego rejected |
| Promise.allSettled | gdy wszystkie się rozstrzygną (fulfilled lub rejected) | tablica {status, value} lub {status, reason} | nigdy się nie odrzuca |
| Promise.race | gdy pierwsza się rozstrzygnie (fulfilled lub rejected) | wartość pierwszej rozstrzygniętej | powód pierwszej odrzuconej, jeśli to ona wygra wyścig |
| Promise.any | gdy pierwsza zostanie fulfilled | wartość pierwszej fulfilled | AggregateError, gdy wszystkie rejected |
try {
const first = await Promise.any([
fetch('/mirror-a'),
fetch('/mirror-b'),
]);
} catch (err) {
console.error(err instanceof AggregateError); // true, gdy oba mirrory padły
console.error(err.errors); // tablica wszystkich powodów odrzucenia
} const results = await Promise.allSettled([fetchA(), fetchB()]);
// [{status:'fulfilled', value: ...}, {status:'rejected', reason: ...}]
const ok = results.filter(r => r.status === 'fulfilled').map(r => r.value); - try/catch wokół await łapie zarówno błędy synchroniczne, jak i odrzucenia promisów.
- Niezłapane odrzucenie promisa emituje event unhandledrejection (przeglądarka) lub 'unhandledRejection' (Node) - zawsze dodawaj listener w aplikacjach produkcyjnych.
- Promise.all fail-fast: jedno odrzucenie przerywa oczekiwanie na resztę, nawet jeśli reszta i tak wykona się w tle.
- Do równoległych operacji, gdzie porażka części jest akceptowalna, używaj allSettled, nie all.
7 · Nowoczesne API: optional chaining, nullish coalescing, nowe metody Array/Object
ES2020-ES2024 wprowadziły grupę drobnych, ale codziennie używanych usprawnień: bezpieczny dostęp do zagnieżdżonych właściwości, precyzyjniejszą obsługę wartości domyślnych, niemutujące warianty klasycznych metod tablicowych oraz naturalne grupowanie danych bez bibliotek typu lodash.
const city = user?.address?.city ?? 'nieznane';
const total = config?.limits?.max ?? 100;
user?.notify?.(); // wywołanie opcjonalnej metody, bez rzucania jeśli notify nie istnieje const count = 0;
console.log(count || 10); // 10 - błąd logiczny, 0 jest falsy
console.log(count ?? 10); // 0 - poprawne, 0 nie jest null/undefined const arr = [10, 20, 30];
console.log(arr.at(-1)); // 30
console.log(arr[arr.length - 1]); // 30, ten sam wynik, mniej czytelnie const nums = [3, 1, 2];
const sorted = nums.toSorted(); // [1, 2, 3], nums bez zmian
const reversed = nums.toReversed(); // [2, 1, 3], nums bez zmian
const replaced = nums.with(0, 99); // [99, 1, 2], nums bez zmian
console.log(nums); // [3, 1, 2] - oryginał nietknięty const inventory = [
{ name: 'apple', type: 'fruit' },
{ name: 'carrot', type: 'vegetable' },
{ name: 'banana', type: 'fruit' },
];
const byType = Object.groupBy(inventory, item => item.type);
// { fruit: [apple, banana], vegetable: [carrot] } - obiekt bez prototypu const original = { date: new Date(), items: new Map([['a', 1]]) };
const copy = structuredClone(original);
copy.items.set('b', 2);
console.log(original.items.has('b')); // false - niezależna kopia | Mutująca (oryginał) | Niemutująca (ES2023) |
|---|---|
| sort() | toSorted() |
| reverse() | toReversed() |
| splice() | toSpliced() |
| arr[i] = x | with(i, x) |
8 · Moduły ESM vs CommonJS oraz wycieki pamięci
★ egzaminECMAScript Modules (ESM, import/export) i CommonJS (require/module.exports) różnią się nie tylko składnią, ale fundamentalną semantyką ładowania: statyczną i asynchroniczną analizę w ESM kontra dynamiczne, synchroniczne wykonanie w CommonJS. Interop między nimi to częste źródło błędów w Node.js.
ESM (import/export)
- Statyczna struktura, analizowana przed wykonaniem (umożliwia tree-shaking)
- Bindingi żywe: zaimportowana wartość aktualizuje się przy zmianie w module źródłowym
- Domyślnie strict mode, wspiera top-level await
- Ładowanie może być asynchroniczne (fetch modułów w przeglądarce)
CommonJS (require/module.exports)
- Dynamiczne wywołanie require() w dowolnym miejscu kodu, w tym warunkowo
- Eksport to kopia wartości w momencie require (dla prymitywów)
- Synchroniczne ładowanie, blokujące I/O
- Brak top-level await, brak natywnego tree-shakingu
// utils.mjs
export const PI = 3.14159;
export function double(x) { return x * 2; }
export default class Calculator {}
// main.mjs
import Calculator, { PI, double } from './utils.mjs'; // utils.cjs
const PI = 3.14159;
function double(x) { return x * 2; }
module.exports = { PI, double };
// main.cjs
const { PI, double } = require('./utils.cjs'); - Zapomniane event listenery: addEventListener bez odpowiadającego removeEventListener trzyma referencję do całego zamknięcia i elementu.
- Odłączone (detached) węzły DOM: element usunięty z drzewa DOM, ale wciąż referencjonowany przez zmienną JS lub domknięcie, nie może zostać zwolniony przez garbage collector.
- Timery bez czyszczenia: setInterval/setTimeout przechowujące w domknięciu referencje do dużych obiektów, nigdy niewyczyszczone przez clearInterval/clearTimeout.
- Rosnące globalne cache/Map bez limitu rozmiaru lub strategii eksmisji (np. LRU), akumulujące dane przez cały czas życia aplikacji.
let cachedRows = [];
function renderTable(rows) {
document.querySelector('#table').replaceChildren(...rows);
cachedRows = rows; // wyciek: stare rows nadal referencjonowane po usunięciu z DOM
}
// naprawa: nie trzymaj referencji dłużej niż potrzeba
function renderTableFixed(rows) {
document.querySelector('#table').replaceChildren(...rows);
// brak długożyjącej referencji poza zakresem funkcji
} - 1 1. Zrób heap snapshot Chrome DevTools → Memory → Heap snapshot, przed i po scenariuszu podejrzanym o wyciek.
- 2 2. Wymuś GC i porównaj Kliknij 'Collect garbage', zrób drugi snapshot, użyj widoku 'Comparison' do zobaczenia obiektów, które przybyły i nie zostały zwolnione.
- 3 3. Szukaj Detached Filtruj po 'Detached' w widoku Containment - to węzły DOM usunięte z drzewa, ale nadal w pamięci.
- 4 4. Prześledź retainers Panel 'Retainers' pokazuje dokładny łańcuch referencji (closure → zmienna → obiekt) trzymający obiekt przy życiu - tam jest źródło wycieku.
Sprawdź się - testowanie to nauka
24 pytań w losowej kolejności. Twoje wyniki zapisują się lokalnie.