Utilizziamo i cookie per migliorare la tua esperienza sul sito
CodeWorlds

Czyszczenie efektów

Poprawne czyszczenie efektów ubocznych jest kluczowym aspektem tworzenia niezawodnych aplikacji w React. Podobnie jak astronauci muszą czyścić swoje systemy podtrzymywania życia, aby uniknąć awarii, tak samo my musimy dbać o czyszczenie naszych efektów, aby uniknąć wycieków pamięci, niepotrzebnych obliczeń i innych problemów.

Dlaczego czyszczenie efektów jest ważne?

Gdy komponent jest odmontowywany (usuwany z DOM) lub gdy zmieniają się zależności efektu, mogą wystąpić różne problemy, jeśli nie zadbamy o właściwe czyszczenie:

  1. Wycieki pamięci - nieużywane zasoby, które nie są zwalniane
  2. Aktualizacja stanu odmontowanych komponentów - prowadzi do ostrzeżeń i błędów
  3. Niepotrzebne obliczenia - zużywanie zasobów na operacje, które nie są już potrzebne
  4. Kolidujące aktualizacje - gdy stare i nowe efekty działają jednocześnie
  5. Subskrypcje "duchów" - stare subskrypcje nadal reagujące na zdarzenia

Podstawowa składnia funkcji czyszczącej

W useEffect, funkcja czyszcząca jest zwracana z funkcji efektu:

1useEffect(() => {
2  // Kod efektu - uruchamiany po renderowaniu
3  console.log('Efekt uruchomiony');
4  
5  // Funkcja czyszcząca - uruchamiana przed następnym efektem lub przy odmontowaniu
6  return () => {
7    console.log('Efekt wyczyszczony');
8  };
9}, [dependency]); // Tablica zależności

Kiedy funkcja czyszcząca jest wywoływana?

Funkcja czyszcząca jest uruchamiana w dwóch sytuacjach:

  1. Przed ponownym uruchomieniem efektu - gdy zmieniają się zależności
  2. Przed odmontowaniem komponentu - gdy komponent jest usuwany z DOM
1function SpaceModule({ moduleId }) {
2  useEffect(() => {
3    console.log(`Moduł ${moduleId} został zainicjalizowany`);
4    
5    // Ta funkcja zostanie wywołana:
6    // 1. Przed następną inicjalizacją, gdy moduleId się zmieni
7    // 2. Gdy komponent SpaceModule zostanie odmontowany
8    return () => {
9      console.log(`Moduł ${moduleId} został wyłączony`);
10    };
11  }, [moduleId]);
12  
13  return <div className="space-module">Moduł kosmiczny: {moduleId}</div>;
14}

Typowe przypadki wymagające czyszczenia

1. Interwały i timeouty

Niezwolnione interwały i timeouty mogą nadal działać w tle, zużywając zasoby i potencjalnie aktualizując stan nieistniejących już komponentów:

1function Countdown({ seconds, onComplete }) {
2  const [timeLeft, setTimeLeft] = useState(seconds);
3  
4  useEffect(() => {
5    // Nie rozpoczynaj odliczania, jeśli już zakończone
6    if (timeLeft <= 0) {
7      onComplete();
8      return;
9    }
10    
11    console.log(`Rozpoczynam odliczanie od ${timeLeft}`);
12    
13    // Ustawienie interwału
14    const intervalId = setInterval(() => {
15      setTimeLeft(prevTime => {
16        if (prevTime <= 1) {
17          onComplete();
18          return 0;
19        }
20        return prevTime - 1;
21      });
22    }, 1000);
23    
24    // Funkcja czyszcząca zatrzymuje interwał
25    return () => {
26      console.log(`Zatrzymuję odliczanie na ${timeLeft}`);
27      clearInterval(intervalId);
28    };
29  }, [timeLeft, onComplete]);
30  
31  return <div className="countdown">{timeLeft}</div>;
32}

2. Subskrypcje do zewnętrznych źródeł danych

Subskrypcje, które nie są anulowane, mogą nadal otrzymywać dane i powodować aktualizacje stanu:

1function TelemetryMonitor({ shipId }) {
2  const [data, setData] = useState(null);
3  
4  useEffect(() => {
5    let isMounted = true;
6    console.log(`Rozpoczynam monitorowanie statku ${shipId}`);
7    
8    // Utworzenie subskrypcji do telemetrii
9    const subscription = telemetryService.subscribe(shipId, telemetryData => {
10      // Aktualizuj stan tylko jeśli komponent jest nadal zamontowany
11      if (isMounted) {
12        setData(telemetryData);
13      }
14    });
15    
16    // Funkcja czyszcząca anuluje subskrypcję
17    return () => {
18      console.log(`Kończę monitorowanie statku ${shipId}`);
19      isMounted = false;
20      subscription.unsubscribe();
21    };
22  }, [shipId]);
23  
24  if (!data) return <div>Oczekiwanie na dane telemetryczne...</div>;
25  
26  return (
27    <div className="telemetry">
28      <h3>Telemetria statku: {shipId}</h3>
29      <p>Poziom tlenu: {data.oxygen}%</p>
30      <p>Temperatura: {data.temperature}°C</p>
31      <p>Prędkość: {data.velocity} km/h</p>
32    </div>
33  );
34}

3. Nasłuchiwanie zdarzeń DOM

Nasłuchiwacze zdarzeń, które nie są usuwane, mogą prowadzić do wycieków pamięci:

1function KeyboardShortcuts({ onEscape }) {
2  useEffect(() => {
3    console.log('Instaluję nasłuchiwacz klawiatury');
4    
5    // Funkcja obsługująca naciśnięcie klawisza
6    const handleKeyDown = (event) => {
7      if (event.key === 'Escape') {
8        onEscape();
9      }
10    };
11    
12    // Dodanie nasłuchiwacza do dokumentu
13    document.addEventListener('keydown', handleKeyDown);
14    
15    // Funkcja czyszcząca usuwa nasłuchiwacz
16    return () => {
17      console.log('Usuwam nasłuchiwacz klawiatury');
18      document.removeEventListener('keydown', handleKeyDown);
19    };
20  }, [onEscape]);
21  
22  return null; // Ten komponent nie renderuje niczego widocznego
23}

4. WebSockets i połączenia czasu rzeczywistego

Połączenia, które pozostają otwarte, mogą zużywać zasoby i powodować wycieki:

1function MissionControlSocket({ missionId, onMessage }) {
2  const [status, setStatus] = useState('disconnected');
3  
4  useEffect(() => {
5    let socket = null;
6    
7    // Funkcja inicjalizująca połączenie
8    const connect = () => {
9      console.log(`Łączenie z misją ${missionId}`);
10      setStatus('connecting');
11      
12      // Utworzenie WebSocketa
13      socket = new WebSocket(`wss://mission-control.space/mission/${missionId}`);
14      
15      socket.onopen = () => {
16        console.log(`Połączono z misją ${missionId}`);
17        setStatus('connected');
18      };
19      
20      socket.onmessage = (event) => {
21        const data = JSON.parse(event.data);
22        onMessage(data);
23      };
24      
25      socket.onerror = (error) => {
26        console.error(`Błąd połączenia WebSocket: ${error.message}`);
27        setStatus('error');
28      };
29      
30      socket.onclose = () => {
31        console.log(`Rozłączono z misją ${missionId}`);
32        setStatus('disconnected');
33      };
34    };
35    
36    // Inicjalizacja połączenia
37    connect();
38    
39    // Funkcja czyszcząca zamyka połączenie
40    return () => {
41      console.log(`Zamykanie połączenia z misją ${missionId}`);
42      if (socket) {
43        socket.close();
44      }
45    };
46  }, [missionId, onMessage]);
47  
48  return (
49    <div className="socket-status">
50      Status połączenia: {status}
51    </div>
52  );
53}

5. Anulowanie żądań fetch

Anulowanie trwających żądań fetch zapobiega aktualizacjom stanu po odmontowaniu:

1function PlanetInfo({ planetId }) {
2  const [planet, setPlanet] = useState(null);
3  const [loading, setLoading] = useState(true);
4  const [error, setError] = useState(null);
5  
6  useEffect(() => {
7    // Utworzenie kontrolera do anulowania żądania
8    const abortController = new AbortController();
9    const signal = abortController.signal;
10    
11    async function fetchPlanetData() {
12      try {
13        setLoading(true);
14        
15        console.log(`Pobieranie danych planety ${planetId}`);
16        const response = await fetch(`https://api.space.com/planets/${planetId}`, {
17          signal // przekazanie sygnału do fetch
18        });
19        
20        if (!response.ok) {
21          throw new Error(`HTTP Error: ${response.status}`);
22        }
23        
24        const data = await response.json();
25        setPlanet(data);
26        setError(null);
27      } catch (err) {
28        // Ignoruj błędy anulowania
29        if (err.name !== 'AbortError') {
30          console.error(`Błąd pobierania danych: ${err.message}`);
31          setError(`Nie udało się pobrać danych planety: ${err.message}`);
32        }
33      } finally {
34        setLoading(false);
35      }
36    }
37    
38    fetchPlanetData();
39    
40    // Funkcja czyszcząca anuluje trwające żądania
41    return () => {
42      console.log(`Anulowanie żądania dla planety ${planetId}`);
43      abortController.abort();
44    };
45  }, [planetId]);
46  
47  if (loading) return <div>Ładowanie danych planety...</div>;
48  if (error) return <div>Błąd: {error}</div>;
49  if (!planet) return <div>Brak danych planety</div>;
50  
51  return (
52    <div className="planet-card">
53      <h2>{planet.name}</h2>
54      <p>Typ: {planet.type}</p>
55      <p>Średnica: {planet.diameter} km</p>
56      <p>Odległość od Słońca: {planet.distanceFromSun} mln km</p>
57    </div>
58  );
59}

Zaawansowane wzorce czyszczenia efektów

1. Flaga isMounted

Chociaż użycie AbortController jest preferowanym sposobem anulowania żądań fetch, w przypadkach, gdy nie możemy łatwo anulować operacji asynchronicznej, możemy użyć flagi

isMounted
:

1function AsteroidTracker({ asteroidId }) {
2  const [data, setData] = useState(null);
3  
4  useEffect(() => {
5    let isMounted = true; // Flaga informująca, czy komponent jest zamontowany
6    
7    async function fetchAsteroidData() {
8      const response = await fetch(`https://api.nasa.gov/neo/rest/v1/neo/${asteroidId}`);
9      const asteroidData = await response.json();
10      
11      // Aktualizuj stan tylko jeśli komponent jest nadal zamontowany
12      if (isMounted) {
13        setData(asteroidData);
14      }
15    }
16    
17    fetchAsteroidData();
18    
19    // Funkcja czyszcząca zmienia flagę
20    return () => {
21      isMounted = false;
22    };
23  }, [asteroidId]);
24  
25  // Renderowanie...
26}

2. Synchronizacja z zewnętrznymi składowymi stanu

Gdy synchronizujemy stan React z zewnętrznym składowym stanu, ważne jest, aby przywrócić poprzedni stan podczas czyszczenia:

1function ThemeSwitcher({ theme }) {
2  useEffect(() => {
3    // Zapisanie aktualnego motywu
4    const previousTheme = document.body.dataset.theme;
5    
6    // Ustawienie nowego motywu
7    document.body.dataset.theme = theme;
8    console.log(`Zmieniam motyw na: ${theme}`);
9    
10    // Przywrócenie poprzedniego motywu podczas czyszczenia
11    return () => {
12      console.log(`Przywracam poprzedni motyw: ${previousTheme || 'default'}`);
13      document.body.dataset.theme = previousTheme || 'default';
14    };
15  }, [theme]);
16  
17  return (
18    <div className="theme-info">
19      Aktualny motyw: {theme}
20    </div>
21  );
22}

3. Obsługa zasobów współdzielonych

Gdy wiele komponentów współdzieli zasoby, należy użyć systemu zliczania referencji:

1// Singleton do zarządzania współdzielonymi zasobami
2class SharedResourceManager {
3  constructor() {
4    this.resources = new Map();
5    this.refCounts = new Map();
6  }
7  
8  // Pozyskanie zasobu (zwiększenie licznika referencji)
9  acquire(resourceId, creator) {
10    if (!this.resources.has(resourceId)) {
11      // Tworzenie zasobu, jeśli nie istnieje
12      this.resources.set(resourceId, creator());
13      this.refCounts.set(resourceId, 0);
14    }
15    
16    // Zwiększenie licznika referencji
17    this.refCounts.set(resourceId, this.refCounts.get(resourceId) + 1);
18    return this.resources.get(resourceId);
19  }
20  
21  // Zwolnienie zasobu (zmniejszenie licznika referencji)
22  release(resourceId) {
23    if (!this.resources.has(resourceId)) return;
24    
25    // Zmniejszenie licznika referencji
26    const newCount = this.refCounts.get(resourceId) - 1;
27    this.refCounts.set(resourceId, newCount);
28    
29    // Usunięcie zasobu, gdy nie ma już referencji
30    if (newCount <= 0) {
31      const resource = this.resources.get(resourceId);
32      if (resource.dispose && typeof resource.dispose === 'function') {
33        resource.dispose();
34      }
35      this.resources.delete(resourceId);
36      this.refCounts.delete(resourceId);
37    }
38  }
39}
40
41// Singleton do użycia w całej aplikacji
42export const sharedResources = new SharedResourceManager();
43
44// Hook do użycia współdzielonego zasobu
45function useSharedResource(resourceId, createResource) {
46  useEffect(() => {
47    // Pozyskanie zasobu
48    const resource = sharedResources.acquire(resourceId, createResource);
49    console.log(`Zasób ${resourceId} pozyskany, liczba użytkowników: ${sharedResources.refCounts.get(resourceId)}`);
50    
51    // Zwolnienie zasobu przy czyszczeniu
52    return () => {
53      sharedResources.release(resourceId);
54      console.log(`Zasób ${resourceId} zwolniony`);
55    };
56  }, [resourceId]);
57  
58  return sharedResources.resources.get(resourceId);
59}
60
61// Przykład użycia
62function SpaceshipScanner() {
63  // Ten zasób będzie współdzielony między wszystkimi instancjami komponentu
64  const scanner = useSharedResource('advanced-scanner', () => {
65    console.log('Inicjalizacja skanera (kosztowna operacja)');
66    return {
67      scan: (target) => console.log(`Skanowanie ${target}...`),
68      dispose: () => console.log('Wyłączanie skanera')
69    };
70  });
71  
72  return (
73    <button onClick={() => scanner.scan('Mars')}>
74      Skanuj Mars
75    </button>
76  );
77}

Typowe problemy i rozwiązania

1. Przepełnienie stosu wywołań przy czyszczeniu timera

Problem: Niepoprawne zatrzymanie timera może prowadzić do przepełnienia stosu:

1// ŹLE: Funkcja czyszcząca nie zatrzymuje timeoutu prawidłowo
2useEffect(() => {
3  function tick() {
4    setCount(count + 1);
5    setTimeout(tick, 1000);
6  }
7  
8  tick();
9  
10  return () => {
11    // Problem: nie mamy odniesienia do aktualnego timeout ID
12  };
13}, [count]); // 🚨 Zależność powoduje ponowne uruchomienie efektu

Rozwiązanie: Przechowuj aktualny identyfikator timeout:

1// DOBRZE: Zapisujemy ID timeoutu, aby móc go anulować
2useEffect(() => {
3  let timeoutId = null;
4  
5  function tick() {
6    setCount(prevCount => prevCount + 1);
7    timeoutId = setTimeout(tick, 1000);
8  }
9  
10  timeoutId = setTimeout(tick, 1000);
11  
12  return () => {
13    clearTimeout(timeoutId);
14  };
15}, []); // Brak zależności - uruchamiamy tylko raz

2. Problemy z kolejnością czyszczenia

Problem: Czasami kolejność czyszczenia ma znaczenie, szczególnie gdy mamy kilka zagnieżdżonych efektów:

1// Problematyczna kolejność czyszczenia
2useEffect(() => {
3  // Efekt zewnętrzny
4  const resource = initializeResource();
5  
6  useEffect(() => {
7    // Efekt wewnętrzny korzystający z zasobu
8    resource.use();
9    
10    return () => {
11      // To czyszczenie może być wywołane po zwolnieniu zasobu
12      resource.stopUsing();
13    };
14  }, [someCondition]);
15  
16  return () => {
17    // Ten zasób może być już używany przez wewnętrzny efekt
18    resource.dispose();
19  };
20}, []);

Rozwiązanie: Używaj oddzielnych, niezależnych efektów:

1// Lepsze podejście
2useEffect(() => {
3  // Efekt inicjalizujący zasób
4  const resource = initializeResource();
5  
6  return () => {
7    // Czyszczenie zasobu
8    resource.dispose();
9  };
10}, []);
11
12useEffect(() => {
13  // Efekt korzystający z zasobu (używaj useRef lub kontekstu)
14  if (resourceRef.current) {
15    resourceRef.current.use();
16    
17    return () => {
18      if (resourceRef.current) {
19        resourceRef.current.stopUsing();
20      }
21    };
22  }
23}, [someCondition]);

3. Niespójność stanu podczas czyszczenia

Problem: Podczas czyszczenia możemy uzyskać dostęp do nieaktualnego stanu:

1function SpaceshipControl({ shipId }) {
2  const [power, setPower] = useState(100);
3  
4  useEffect(() => {
5    // Inicjalizacja kontroli statku
6    const ship = spaceshipAPI.connect(shipId);
7    ship.setPower(power);
8    
9    return () => {
10      // 🚨 Problem: power może być nieaktualne podczas czyszczenia
11      console.log(`Odłączanie od statku przy mocy: ${power}`);
12      ship.disconnect();
13    };
14  }, [shipId]); // Brak zależności od power
15  
16  return (
17    <div>
18      <p>Moc silników: {power}%</p>
19      <button onClick={() => setPower(power - 10)}>Zmniejsz moc</button>
20    </div>
21  );
22}

Rozwiązanie: Używaj referencji do przechowywania aktualnych wartości stanu:

1function SpaceshipControl({ shipId }) {
2  const [power, setPower] = useState(100);
3  const powerRef = useRef(power);
4  
5  // Aktualizacja referencji przy każdej zmianie stanu
6  useEffect(() => {
7    powerRef.current = power;
8  }, [power]);
9  
10  useEffect(() => {
11    // Inicjalizacja kontroli statku
12    const ship = spaceshipAPI.connect(shipId);
13    ship.setPower(power);
14    
15    return () => {
16      // ✅ Używamy aktualnej wartości z referencji
17      console.log(`Odłączanie od statku przy mocy: ${powerRef.current}%`);
18      ship.disconnect();
19    };
20  }, [shipId, power]);
21  
22  return (
23    <div>
24      <p>Moc silników: {power}%</p>
25      <button onClick={() => setPower(power - 10)}>Zmniejsz moc</button>
26    </div>
27  );
28}

Testowanie czyszczenia efektów

Aby upewnić się, że efekty są poprawnie czyszczone, warto pisać testy sprawdzające to zachowanie:

1// Przykład testu dla komponentu z czyszczeniem efektu
2import { render, act, screen } from '@testing-library/react';
3import { useState } from 'react';
4
5// Prosty komponent z timerem do testowania
6function TimerComponent() {
7  const [count, setCount] = useState(0);
8  
9  useEffect(() => {
10    const interval = setInterval(() => {
11      setCount(c => c + 1);
12    }, 100);
13    
14    return () => clearInterval(interval);
15  }, []);
16  
17  return <div data-testid="count">{count}</div>;
18}
19
20// Test czyszczenia efektu
21test('effect cleanup stops the interval', async () => {
22  jest.useFakeTimers();
23  
24  const { unmount } = render(<TimerComponent />);
25  
26  // Pozwól na kilka tyknięć timera
27  act(() => {
28    jest.advanceTimersByTime(250);
29  });
30  
31  const countBefore = Number(screen.getByTestId('count').textContent);
32  
33  // Odmontuj komponent (powinno to wyczyścić efekt i zatrzymać timer)
34  unmount();
35  
36  // Przesuń zegar do przodu jeszcze raz
37  act(() => {
38    jest.advanceTimersByTime(250);
39  });
40  
41  // Ponieważ komponent jest odmontowany i timer zatrzymany,
42  // możemy tylko sprawdzić, czy licznik nie wzrósł za bardzo przed odmontowaniem
43  expect(countBefore).toBeGreaterThanOrEqual(2);
44  expect(countBefore).toBeLessThanOrEqual(3);
45  
46  jest.useRealTimers();
47});

Najlepsze praktyki czyszczenia efektów

  1. Zawsze czyść efekty, które tworzą subskrypcje - timery, nasłuchiwacze zdarzeń, połączenia WebSocket, itp.

  2. Używaj AbortController do anulowania żądań fetch - jest to standardowy mechanizm do anulowania operacji asynchronicznych.

  3. Utrzymuj flagę montowania - dla operacji asynchronicznych, których nie można anulować, używaj flagi

    isMounted
    i sprawdzaj ją przed aktualizacją stanu.

  4. Przechowuj referencje do zasobów - zmienne lokalne w efekcie przestają istnieć po jego wyczyszczeniu, więc używaj

    useRef
    do przechowywania odniesień do zasobów.

  5. Unikaj zbyt skomplikowanych efektów - efekt, który robi zbyt wiele rzeczy, będzie trudniejszy do poprawnego wyczyszczenia. Rozważ podzielenie go na mniejsze, bardziej skoncentrowane efekty.

  6. Unikaj asynchronicznych funkcji czyszczących - funkcje czyszczące powinny być synchroniczne, aby React mógł zagwarantować ich wykonanie we właściwej kolejności.

  7. Testuj czyszczenie efektów - szczególnie ważne jest testowanie odmontowywania komponentów, które tworzą zewnętrzne subskrypcje.

Czyszczenie efektów a wydajność

Odpowiednie czyszczenie efektów nie tylko zapobiega wyciekom pamięci, ale może również znacząco wpłynąć na wydajność aplikacji:

  1. Mniejsze zużycie pamięci - przez zwalnianie nieużywanych zasobów

  2. Mniej niepotrzebnych obliczeń - przez anulowanie operacji, które nie są już potrzebne

  3. Redukcja liczby ponownych renderowań - przez unikanie niepotrzebnych aktualizacji stanu

  4. Szybsze przeładowania strony - przez zapewnienie, że wszystkie zasoby są prawidłowo zwalniane

Podsumowanie

Czyszczenie efektów jest często pomijanym, ale kluczowym aspektem pracy z React. Podobnie jak astronauci muszą dbać o swoje systemy podtrzymywania życia, tak programiści muszą dbać o czystość swoich efektów ubocznych.

Dobrze napisane funkcje czyszczące:

  • Zapobiegają wyciekom pamięci
  • Unikają aktualizacji stanu odmontowanych komponentów
  • Zwalniają zasoby systemowe
  • Zapewniają spójność aplikacji

Pamiętaj, że każdy efekt, który tworzy połączenie, subskrypcję lub rezerwuje zasoby, powinien je również zwolnić podczas czyszczenia.

Vai a CodeWorlds