JavaScript jest językiem jednowątkowym, ale dzięki asynchroniczności może wykonywać operacje nieblokujące. W tym artykule poznamy ewolucję obsługi asynchroniczności w JavaScript - od callbacków, przez Promise, po nowoczesną składnię async/await.
Dlaczego asynchroniczność jest ważna?
W aplikacjach webowych często potrzebujemy wykonywać operacje, które mogą zająć dużo czasu:
- Pobieranie danych z API
- Czytanie plików
- Operacje na bazie danych
- Wywołania sieciowe
Bez asynchroniczności, aplikacja byłaby "zamrożona" podczas takich operacji, co znacznie pogorszyłoby doświadczenie użytkownika.
Era callbacków
Pierwszy sposób obsługi asynchroniczności w JavaScript to callbacks - funkcje przekazywane jako argumenty, które są wywoływane po zakończeniu operacji.
// Przykład prostego callbacka
function fetchUserData(userId, callback) {
setTimeout(() => {
const userData = {
id: userId,
name: 'Jan Kowalski',
email: '
[email protected]'
};
callback(null, userData);
}, 1000);
}
// Użycie
fetchUserData(123, (error, user) => {
if (error) {
console.error('Błąd:', error);
return;
}
console.log('Użytkownik:', user);
});
Problem: Callback Hell
Gdy musimy wykonać kilka operacji asynchronicznych jedna po drugiej, callbacks prowadzą do trudno czytelnego kodu zwanego "Callback Hell" lub "Pyramid of Doom":
fetchUser(userId, (userError, user) => {
if (userError) {
console.error(userError);
return;
}
fetchUserPosts(user.id, (postsError, posts) => {
if (postsError) {
console.error(postsError);
return;
}
fetchPostComments(posts[0].id, (commentsError, comments) => {
if (commentsError) {
console.error(commentsError);
return;
}
// Wreszcie mamy wszystkie dane...
console.log({ user, posts, comments });
});
});
});
Promise - rozwiązanie problemów callbacków
Promise to obiekt reprezentujący ewentualne zakończenie (lub niepowodzenie) operacji asynchronicznej. Promise może być w jednym z trzech stanów:
pending - oczekujące
fulfilled - spełnione (zakończone sukcesem)
rejected - odrzucone (zakończone błędem)
// Tworzenie Promise
function fetchUserData(userId) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (userId < 0) {
reject(new Error('Nieprawidłowe ID użytkownika'));
return;
}
const userData = {
id: userId,
name: 'Jan Kowalski',
email: '
[email protected]'
};
resolve(userData);
}, 1000);
});
}
// Użycie Promise z .then() i .catch()
fetchUserData(123)
.then(user => {
console.log('Użytkownik:', user);
return fetchUserPosts(user.id);
})
.then(posts => {
console.log('Posty:', posts);
return fetchPostComments(posts[0].id);
})
.then(comments => {
console.log('Komentarze:', comments);
})
.catch(error => {
console.error('Wystąpił błąd:', error);
});
Promise.all() i Promise.race()
JavaScript oferuje pomocnicze metody do pracy z wieloma Promise jednocześnie:
// Promise.all() - czeka na wszystkie Promise
const userPromise = fetchUserData(123);
const postsPromise = fetchUserPosts(123);
const friendsPromise = fetchUserFriends(123);
Promise.all([userPromise, postsPromise, friendsPromise])
.then(([user, posts, friends]) => {
console.log('Wszystkie dane pobrane:', { user, posts, friends });
})
.catch(error => {
console.error('Jeden z zapytań się nie powiódł:', error);
});
// Promise.race() - zwraca pierwszy zakończony Promise
Promise.race([
fetchFromServerA(),
fetchFromServerB(),
fetchFromServerC()
])
.then(result => {
console.log('Najszybszy serwer odpowiedział:', result);
})
.catch(error => {
console.error('Błąd:', error);
});
Async/Await - nowoczesna składnia
ES2017 wprowadził składnię async/await, która sprawia, że kod asynchroniczny wygląda i zachowuje się bardziej jak kod synchroniczny.
Kluczowe zasady async/await
async - funkcja zawsze zwraca Promise
await - można używać tylko wewnątrz funkcji async
await zatrzymuje wykonanie funkcji do rozwiązania Promise
// Przepisanie poprzedniego przykładu z async/await
async function getUserData(userId) {
try {
const user = await fetchUserData(userId);
console.log('Użytkownik:', user);
const posts = await fetchUserPosts(user.id);
console.log('Posty:', posts);
const comments = await fetchPostComments(posts[0].id);
console.log('Komentarze:', comments);
return { user, posts, comments };
} catch (error) {
console.error('Wystąpił błąd:', error);
throw error; // Przekazujemy błąd dalej
}
}
// Wywołanie
getUserData(123)
.then(data => console.log('Wszystkie dane:', data))
.catch(error => console.error('Główny błąd:', error));
Równoległe wykonywanie z async/await
Można łączyć async/await z Promise.all() dla równoległego wykonywania:
async function getUserFullData(userId) {
try {
// Te operacje wykonają się równolegle
const [user, posts, friends] = await Promise.all([
fetchUserData(userId),
fetchUserPosts(userId),
fetchUserFriends(userId)
]);
return { user, posts, friends };
} catch (error) {
console.error('Błąd podczas pobierania danych:', error);
throw error;
}
}
Obsługa błędów
Prawidłowa obsługa błędów w kodzie asynchronicznym jest kluczowa dla stabilności aplikacji.
// Szczegółowa obsługa błędów
async function fetchWithRetry(url, maxRetries = 3) {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
} catch (error) {
console.warn(`Próba ${attempt} nieudana:`, error.message);
if (attempt === maxRetries) {
throw new Error(`Nie udało się pobrać danych po ${maxRetries} próbach`);
}
// Czekamy przed następną próbą (exponential backoff)
await new Promise(resolve => setTimeout(resolve, Math.pow(2, attempt) * 1000));
}
}
}
// Użycie
async function loadUserData() {
try {
const userData = await fetchWithRetry('/api/user/123');
console.log('Dane użytkownika:', userData);
} catch (error) {
console.error('Nie udało się załadować danych użytkownika:', error);
// Pokazujemy użytkownikowi przyjazny komunikat
showErrorMessage('Nie udało się załadować danych. Spróbuj ponownie później.');
}
}
Praktyczne przykłady
Pobieranie danych z API
class ApiService {
constructor(baseURL) {
this.baseURL = baseURL;
}
async get(endpoint) {
try {
const response = await fetch(`${this.baseURL}${endpoint}`);
if (!response.ok) {
throw new Error(`API Error: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('GET request failed:', error);
throw error;
}
}
async post(endpoint, data) {
try {
const response = await fetch(`${this.baseURL}${endpoint}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data)
});
if (!response.ok) {
throw new Error(`API Error: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('POST request failed:', error);
throw error;
}
}
}
// Użycie
const api = new ApiService('https://api.example.com');
async function createUser(userData) {
try {
const newUser = await api.post('/users', userData);
console.log('Utworzono użytkownika:', newUser);
return newUser;
} catch (error) {
console.error('Nie udało się utworzyć użytkownika:', error);
throw error;
}
}
Oczekiwanie na wiele operacji
// Funkcja ładująca dashboard użytkownika
async function loadUserDashboard(userId) {
const loadingIndicator = showLoadingIndicator();
try {
// Pobieramy wszystkie potrzebne dane równolegle
const [
user,
notifications,
recentActivity,
stats
] = await Promise.allSettled([
api.get(`/users/${userId}`),
api.get(`/users/${userId}/notifications`),
api.get(`/users/${userId}/activity`),
api.get(`/users/${userId}/stats`)
]);
// Sprawdzamy wyniki i obsługujemy częściowe błędy
const dashboard = {
user: user.status === 'fulfilled' ? user.value : null,
notifications: notifications.status === 'fulfilled' ? notifications.value : [],
recentActivity: recentActivity.status === 'fulfilled' ? recentActivity.value : [],
stats: stats.status === 'fulfilled' ? stats.value : {}
};
if (dashboard.user) {
renderDashboard(dashboard);
} else {
throw new Error('Nie udało się załadować podstawowych danych użytkownika');
}
} catch (error) {
console.error('Błąd ładowania dashboardu:', error);
showErrorMessage('Nie udało się załadować dashboardu');
} finally {
hideLoadingIndicator(loadingIndicator);
}
}
Najlepsze praktyki
- Używaj async/await dla lepszej czytelności kodu
- Zawsze obsługuj błędy z try/catch
- Wykorzystuj Promise.all() dla równoległych operacji
- Unikaj mieszania Promise.then() z async/await
- Używaj Promise.allSettled() gdy chcesz wykonać wszystkie operacje niezależnie od błędów
Asynchroniczność w JavaScript ewoluowała znacznie od czasów callbacków. Dzisiejsze narzędzia jak async/await sprawiają, że pisanie i utrzymywanie kodu asynchronicznego jest znacznie prostsze i przyjemniejsze. Opanowanie tych koncepcji jest kluczowe dla każdego współczesnego programisty JavaScript.