Usamos cookies para mejorar tu experiencia en el sitio
CodeWorlds

Synchronizacja z zewnętrznymi systemami

W poprzednim module poznaliśmy podstawy efektów ubocznych z hookiem useEffect. Teraz zagłębimy się w jeden z najważniejszych przypadków użycia efektów: synchronizację komponentów React z zewnętrznymi systemami i bibliotekami.

Dlaczego synchronizacja jest ważna?

Komponenty React istnieją w swoim reaktywnym środowisku, ale często muszą współpracować z niezależnymi systemami, które nie są świadome cyklu życia React ani jego modelu renderowania. Możesz wyobrazić sobie to jako mostek między statkiem kosmicznym React a innymi systemami - komunikacja musi być dwustronna i niezawodna.

Przykładami takich zewnętrznych systemów są:

  • Biblioteki map (np. Google Maps, Leaflet)
  • Biblioteki wykresów (np. D3.js, Chart.js)
  • Odtwarzacze mediów (np. HTML5 Video, Web Audio API)
  • SDK do websocketów i komunikacji czasu rzeczywistego
  • Systemy zarządzania formularzami (np. Formik, React Hook Form)
  • Biblioteki animacji (np. GSAP, Anime.js)
  • Interfejsy przeglądarki (geolokalizacja, Web Bluetooth)

Podstawowy wzorzec synchronizacji

Podstawowy wzorzec synchronizacji z zewnętrznym systemem wygląda następująco:

  1. Inicjalizuj zewnętrzną bibliotekę/system po pierwszym renderowaniu
  2. Aktualizuj zewnętrzny system, gdy zmieniają się odpowiednie props lub stan
  3. Sprzątaj po sobie (zatrzymaj, odłącz), gdy komponent jest odmontowywany
1function ExternalSystemComponent({ config }) {
2  useEffect(() => {
3    // 1. Inicjalizacja
4    const externalInstance = new ExternalSystem(config);
5    externalInstance.initialize();
6    
7    // 2. Aktualizacja (opcjonalnie)
8    externalInstance.update(config);
9    
10    // 3. Sprzątanie
11    return () => {
12      externalInstance.cleanup();
13    };
14  }, [config]); // Ponowna synchronizacja, gdy zmienia się konfiguracja
15  
16  return <div className="external-system-container" />;
17}

Praktyczne przykłady synchronizacji

1. Integracja z biblioteką map (Leaflet)

Leaflet to popularna biblioteka do interaktywnych map, która działa niezależnie od React. Oto przykład integracji:

1import React, { useEffect, useRef } from 'react';
2import L from 'leaflet';
3import 'leaflet/dist/leaflet.css';
4
5function MarsExplorationMap({ missionCoordinates, roverPosition, zoom = 5 }) {
6  // Ref do kontenera mapy
7  const mapContainerRef = useRef(null);
8  // Ref do instancji mapy (aby uniknąć ponownego tworzenia)
9  const mapInstanceRef = useRef(null);
10  // Ref do markera łazika (aby aktualizować tylko jego pozycję)
11  const roverMarkerRef = useRef(null);
12  
13  // Inicjalizacja mapy
14  useEffect(() => {
15    if (!mapInstanceRef.current && mapContainerRef.current) {
16      // Tworzenie instancji mapy tylko raz
17      const mapInstance = L.map(mapContainerRef.current).setView(
18        [missionCoordinates.lat, missionCoordinates.lng],
19        zoom
20      );
21      
22      // Dodanie warstwy mapy
23      L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
24        attribution: '© OpenStreetMap contributors'
25      }).addTo(mapInstance);
26      
27      // Zapisanie instancji mapy w referencji
28      mapInstanceRef.current = mapInstance;
29      
30      // Dodanie markera łazika
31      roverMarkerRef.current = L.marker([roverPosition.lat, roverPosition.lng])
32        .addTo(mapInstance)
33        .bindPopup('Łazik marsjański');
34    }
35    
36    // Funkcja czyszcząca
37    return () => {
38      if (mapInstanceRef.current) {
39        // Usunięcie mapy przy odmontowaniu komponentu
40        mapInstanceRef.current.remove();
41        mapInstanceRef.current = null;
42        roverMarkerRef.current = null;
43      }
44    };
45  }, []); // Pusta tablica zależności - inicjalizacja tylko raz
46  
47  // Aktualizacja pozycji łazika
48  useEffect(() => {
49    if (roverMarkerRef.current && roverPosition) {
50      // Aktualizacja pozycji istniejącego markera
51      roverMarkerRef.current.setLatLng([roverPosition.lat, roverPosition.lng]);
52    }
53  }, [roverPosition]); // Aktualizuj tylko gdy zmienia się pozycja łazika
54  
55  // Aktualizacja poziomu przybliżenia
56  useEffect(() => {
57    if (mapInstanceRef.current) {
58      mapInstanceRef.current.setZoom(zoom);
59    }
60  }, [zoom]); // Aktualizuj tylko gdy zmienia się zoom
61  
62  return <div ref={mapContainerRef} style={{ height: '500px', width: '100%' }} />;
63}

W powyższym przykładzie:

  1. Używamy referencji (useRef) do przechowywania elementów DOM i instancji biblioteki zewnętrznej
  2. Inicjalizujemy mapę tylko raz i zapisujemy jej instancję
  3. Używamy oddzielnych efektów do obsługi różnych aspektów synchronizacji
  4. Czyścimy zasoby, gdy komponent jest odmontowywany

2. Integracja z biblioteką wykresów (Chart.js)

Chart.js to popularna biblioteka do tworzenia interaktywnych wykresów:

1import React, { useEffect, useRef } from 'react';
2import Chart from 'chart.js/auto';
3
4function MissionResourcesChart({ resources, title }) {
5  const chartRef = useRef(null);
6  const chartInstanceRef = useRef(null);
7  
8  useEffect(() => {
9    // Upewniamy się, że mamy dostęp do elementu canvas
10    if (!chartRef.current) return;
11    
12    // Pobierz kontekst canvas
13    const ctx = chartRef.current.getContext('2d');
14    
15    // Zniszcz poprzednią instancję wykresu, jeśli istnieje
16    if (chartInstanceRef.current) {
17      chartInstanceRef.current.destroy();
18    }
19    
20    // Utwórz nową instancję wykresu
21    chartInstanceRef.current = new Chart(ctx, {
22      type: 'bar',
23      data: {
24        labels: resources.map(resource => resource.name),
25        datasets: [{
26          label: title || 'Zasoby misji',
27          data: resources.map(resource => resource.level),
28          backgroundColor: resources.map(resource => {
29            // Kolorowanie słupków w zależności od poziomu zasobów
30            if (resource.level < 20) return 'rgba(255, 0, 0, 0.7)'; // Krytyczny
31            if (resource.level < 50) return 'rgba(255, 165, 0, 0.7)'; // Ostrzeżenie
32            return 'rgba(0, 128, 0, 0.7)'; // OK
33          }),
34          borderColor: 'rgba(0, 0, 0, 0.1)',
35          borderWidth: 1
36        }]
37      },
38      options: {
39        responsive: true,
40        scales: {
41          y: {
42            beginAtZero: true,
43            max: 100
44          }
45        }
46      }
47    });
48    
49    // Sprzątanie
50    return () => {
51      if (chartInstanceRef.current) {
52        chartInstanceRef.current.destroy();
53        chartInstanceRef.current = null;
54      }
55    };
56  }, [resources, title]); // Ponowne tworzenie wykresu przy zmianie danych lub tytułu
57  
58  return <canvas ref={chartRef} />;
59}

3. Obsługa Web Sockets

Integracja z Web Sockets umożliwia komunikację czasu rzeczywistego:

1import React, { useEffect, useState } from 'react';
2
3function MissionControlSocket({ missionId, onMessage }) {
4  const [status, setStatus] = useState('disconnected');
5  const [error, setError] = useState(null);
6  
7  useEffect(() => {
8    // Inicjalizacja socketu
9    const socket = new WebSocket(`wss://mission-control.example.com/ws/${missionId}`);
10    
11    // Konfiguracja zdarzeń
12    socket.onopen = () => {
13      setStatus('connected');
14      console.log(`Połączono z misją ${missionId}`);
15    };
16    
17    socket.onmessage = (event) => {
18      const data = JSON.parse(event.data);
19      onMessage(data);
20    };
21    
22    socket.onerror = (err) => {
23      setStatus('error');
24      setError('Błąd połączenia z centrum kontroli misji');
25      console.error('WebSocket error:', err);
26    };
27    
28    socket.onclose = () => {
29      setStatus('disconnected');
30      console.log('Połączenie zamknięte');
31    };
32    
33    // Funkcja czyszcząca
34    return () => {
35      // Zamknięcie połączenia przy odmontowaniu
36      if (socket.readyState === WebSocket.OPEN) {
37        socket.close();
38      }
39    };
40  }, [missionId, onMessage]); // Ponowne połączenie przy zmianie missionId
41  
42  return (
43    <div className="mission-socket">
44      <div className={`connection-status ${status}`}>
45        Status połączenia: {status}
46      </div>
47      {error && <div className="connection-error">{error}</div>}
48    </div>
49  );
50}

Strategie efektywnej synchronizacji

1. Minimalizacja ponownych inicjalizacji

Unikaj niepotrzebnego inicjalizowania zewnętrznych systemów, gdy tylko część konfiguracji się zmienia. Zamiast tego:

  1. Używaj osobnych efektów dla różnych aspektów synchronizacji
  2. Wykorzystuj referencje do przechowywania instancji zewnętrznych bibliotek
  3. Aktualizuj istniejące instancje zamiast tworzyć nowe
1// ŹLE - pojedynczy efekt reinikcjalizuje całość
2useEffect(() => {
3  const chart = new ChartLibrary(config);
4  return () => chart.destroy();
5}, [config]); // Każda zmiana w config spowoduje reinicjalizację
6
7// DOBRZE - oddzielna inicjalizacja i aktualizacja
8// Ref do przechowywania instancji
9const chartRef = useRef(null);
10
11// Inicjalizacja tylko raz
12useEffect(() => {
13  chartRef.current = new ChartLibrary(containerRef.current);
14  return () => chartRef.current.destroy();
15}, []);
16
17// Aktualizacja konfiguracji bez reinicjalizacji
18useEffect(() => {
19  if (chartRef.current) {
20    chartRef.current.updateConfig(config);
21  }
22}, [config]);

2. Zarządzanie subskrypcjami

Przy pracy z zewnętrznymi źródłami danych, które używają subskrypcji:

  1. Zawsze anuluj subskrypcje w funkcji czyszczącej
  2. Aktualizuj subskrypcje, gdy zmieniają się parametry
1function TelemetryMonitor({ satelliteId, dataType }) {
2  const [data, setData] = useState(null);
3  
4  useEffect(() => {
5    // Utworzenie subskrypcji
6    const subscription = telemetryAPI.subscribe(
7      satelliteId,
8      dataType,
9      (newData) => setData(newData)
10    );
11    
12    // Czyszczenie
13    return () => subscription.unsubscribe();
14  }, [satelliteId, dataType]); // Ponowna subskrypcja gdy zmieni się ID lub typ danych
15  
16  return <div>{/* Renderowanie danych */}</div>;
17}

3. Opóźnione inicjalizowanie zasobożernych bibliotek

Dla ciężkich bibliotek zewnętrznych, możesz opóźnić ich inicjalizację do momentu, gdy są faktycznie potrzebne:

1function HeavyChart({ data, shouldRender }) {
2  const [chartInstance, setChartInstance] = useState(null);
3  const containerRef = useRef(null);
4  
5  // Leniwa inicjalizacja
6  useEffect(() => {
7    if (!shouldRender) {
8      // Nie inicjalizuj jeśli nie powinniśmy jeszcze renderować
9      return;
10    }
11    
12    // Dynamiczny import ciężkiej biblioteki
13    import('heavy-chart-library').then(ChartLib => {
14      const instance = new ChartLib.Chart(containerRef.current, data);
15      setChartInstance(instance);
16      
17      return () => {
18        instance.destroy();
19      };
20    });
21  }, [shouldRender]); // Uruchom tylko gdy shouldRender zmienia się na true
22  
23  // Aktualizacja danych
24  useEffect(() => {
25    if (chartInstance && data) {
26      chartInstance.updateData(data);
27    }
28  }, [chartInstance, data]);
29  
30  return <div ref={containerRef} />;
31}

Tworzenie własnych hooków do synchronizacji

Aby poprawić ponowne użycie kodu i enkapsulację logiki synchronizacji, warto tworzyć własne hooki.

Przykład: Custom hook do integracji z mapą

1function useLeafletMap(containerRef, initialConfig) {
2  const mapRef = useRef(null);
3  const [isReady, setIsReady] = useState(false);
4  
5  // Inicjalizacja
6  useEffect(() => {
7    if (!containerRef.current || mapRef.current) return;
8    
9    const map = L.map(containerRef.current, initialConfig);
10    L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png').addTo(map);
11    
12    mapRef.current = map;
13    setIsReady(true);
14    
15    return () => {
16      map.remove();
17      mapRef.current = null;
18      setIsReady(false);
19    };
20  }, [containerRef, initialConfig]);
21  
22  // Metody do manipulacji mapą
23  const setView = useCallback((center, zoom) => {
24    if (mapRef.current) {
25      mapRef.current.setView(center, zoom);
26    }
27  }, []);
28  
29  const addMarker = useCallback((position, options = {}) => {
30    if (!mapRef.current) return null;
31    
32    const marker = L.marker(position, options).addTo(mapRef.current);
33    return marker;
34  }, []);
35  
36  return {
37    map: mapRef.current,
38    isReady,
39    setView,
40    addMarker
41  };
42}
43
44// Użycie custom hooka
45function MarsMap() {
46  const containerRef = useRef(null);
47  const { map, isReady, setView, addMarker } = useLeafletMap(
48    containerRef, 
49    { center: [24.7136, 46.6753], zoom: 5 }
50  );
51  
52  // Dodanie markera po załadowaniu mapy
53  useEffect(() => {
54    if (isReady) {
55      const marker = addMarker([24.7136, 46.6753], { title: 'Baza Alfa' });
56      marker.bindPopup('Baza Alfa - Główna kolonia na Marsie').openPopup();
57    }
58  }, [isReady, addMarker]);
59  
60  return (
61    <div>
62      <h2>Mapa kolonii marsjańskiej</h2>
63      <div ref={containerRef} style={{ height: '500px' }} />
64      {!isReady && <div>Ładowanie mapy...</div>}
65    </div>
66  );
67}

Przykład: Custom hook do lokalnego składowania

1function useLocalStorage(key, initialValue) {
2  // Funkcja inicjalizująca stan - uruchamiana tylko raz
3  const [storedValue, setStoredValue] = useState(() => {
4    try {
5      // Sprawdzenie czy localStorage jest dostępny
6      if (typeof window === 'undefined') {
7        return initialValue;
8      }
9      
10      // Próba pobrania wartości z localStorage
11      const item = window.localStorage.getItem(key);
12      return item ? JSON.parse(item) : initialValue;
13    } catch (error) {
14      console.error('Błąd odczytu z localStorage:', error);
15      return initialValue;
16    }
17  });
18  
19  // Funkcja aktualizująca wartość w localStorage
20  const setValue = useCallback((value) => {
21    try {
22      // Obsługa funkcji jako wartości (jak w setState)
23      const valueToStore = value instanceof Function ? value(storedValue) : value;
24      
25      // Aktualizacja stanu React
26      setStoredValue(valueToStore);
27      
28      // Aktualizacja localStorage
29      if (typeof window !== 'undefined') {
30        window.localStorage.setItem(key, JSON.stringify(valueToStore));
31      }
32    } catch (error) {
33      console.error('Błąd zapisu do localStorage:', error);
34    }
35  }, [key, storedValue]);
36  
37  // Synchronizacja z localStorage przy zmianie klucza
38  useEffect(() => {
39    // Pobranie aktualnej wartości dla nowego klucza
40    try {
41      if (typeof window === 'undefined') return;
42      
43      const item = window.localStorage.getItem(key);
44      setStoredValue(item ? JSON.parse(item) : initialValue);
45    } catch (error) {
46      console.error('Błąd synchronizacji z localStorage:', error);
47    }
48  }, [key, initialValue]);
49  
50  return [storedValue, setValue];
51}
52
53// Użycie custom hooka
54function AstronautSettings() {
55  const [settings, setSettings] = useLocalStorage('astronaut-settings', {
56    oxygenAlert: 20,
57    darkMode: false,
58    notifications: true
59  });
60  
61  const toggleDarkMode = () => {
62    setSettings(prev => ({
63      ...prev,
64      darkMode: !prev.darkMode
65    }));
66  };
67  
68  return (
69    <div className={settings.darkMode ? 'dark-theme' : 'light-theme'}>
70      <h2>Ustawienia astronauty</h2>
71      <button onClick={toggleDarkMode}>
72        Przełącz na tryb {settings.darkMode ? 'jasny' : 'ciemny'}
73      </button>
74      <div>
75        Powiadomienie o poziomie tlenu: {settings.oxygenAlert}%
76        <input
77          type="range"
78          min="5"
79          max="50"
80          value={settings.oxygenAlert}
81          onChange={e => setSettings({
82            ...settings,
83            oxygenAlert: Number(e.target.value)
84          })}
85        />
86      </div>
87      <label>
88        <input
89          type="checkbox"
90          checked={settings.notifications}
91          onChange={() => setSettings(prev => ({
92            ...prev,
93            notifications: !prev.notifications
94          }))}
95        />
96        Włącz powiadomienia
97      </label>
98    </div>
99  );
100}

Obsługa efektów w testach

Testowanie komponentów, które synchronizują się z zewnętrznymi systemami, wymaga specjalnego podejścia:

  1. Mockowanie zewnętrznych bibliotek
  2. Testowanie efektów czyszczących
  3. Sprawdzanie, czy synchronizacja działa poprawnie

Przykład testu dla komponentu z WebSocket:

1import { render, act, screen, waitFor } from '@testing-library/react';
2import MissionControlSocket from './MissionControlSocket';
3
4// Mock dla WebSocket
5class MockWebSocket {
6  constructor(url) {
7    this.url = url;
8    this.readyState = WebSocket.CONNECTING;
9    
10    // Automatyczne "połączenie" po utworzeniu
11    setTimeout(() => {
12      this.readyState = WebSocket.OPEN;
13      this.onopen && this.onopen();
14    }, 0);
15  }
16  
17  // Metody do symulowania zdarzeń z zewnątrz
18  simulateMessage(data) {
19    this.onmessage && this.onmessage({ data: JSON.stringify(data) });
20  }
21  
22  simulateError() {
23    this.onerror && this.onerror(new Error('Test error'));
24  }
25  
26  simulateClose() {
27    this.readyState = WebSocket.CLOSED;
28    this.onclose && this.onclose();
29  }
30  
31  close() {
32    this.simulateClose();
33  }
34}
35
36// Podmiana globalnej klasy WebSocket
37global.WebSocket = MockWebSocket;
38
39describe('MissionControlSocket', () => {
40  test('should connect to WebSocket and handle messages', async () => {
41    const mockOnMessage = jest.fn();
42    
43    render(
44      <MissionControlSocket 
45        missionId="apollo11" 
46        onMessage={mockOnMessage} 
47      />
48    );
49    
50    // Czekaj na połączenie
51    await waitFor(() => {
52      expect(screen.getByText(/status połączenia: connected/i)).toBeInTheDocument();
53    });
54    
55    // Symulacja otrzymania wiadomości
56    const testData = { type: 'telemetry', oxygen: 95, fuel: 76 };
57    act(() => {
58      // Dostęp do instancji WebSocket przez monkeypatching
59      const socket = global.WebSocket.instances[0];
60      socket.simulateMessage(testData);
61    });
62    
63    // Sprawdzenie czy callback został wywołany z odpowiednimi danymi
64    expect(mockOnMessage).toHaveBeenCalledWith(testData);
65    
66    // Symulacja błędu
67    act(() => {
68      const socket = global.WebSocket.instances[0];
69      socket.simulateError();
70    });
71    
72    // Sprawdzenie czy status błędu jest widoczny
73    expect(screen.getByText(/status połączenia: error/i)).toBeInTheDocument();
74    expect(screen.getByText(/błąd połączenia/i)).toBeInTheDocument();
75  });
76  
77  test('should clean up WebSocket connection on unmount', () => {
78    const { unmount } = render(
79      <MissionControlSocket missionId="apollo11" onMessage={() => {}} />
80    );
81    
82    // Pobierz instancję WebSocket
83    const socket = global.WebSocket.instances[0];
84    const closeSpy = jest.spyOn(socket, 'close');
85    
86    // Odmontowanie komponentu
87    unmount();
88    
89    // Sprawdzenie czy socket został zamknięty
90    expect(closeSpy).toHaveBeenCalled();
91  });
92});

Podsumowanie

Synchronizacja z zewnętrznymi systemami to jedna z najważniejszych funkcji hooka useEffect. Umożliwia wykorzystanie istniejących bibliotek i API w ekosystemie React, zachowując reaktywny model aktualizacji.

Najważniejsze zasady:

  1. Inicjalizuj zewnętrzne systemy po pierwszym renderowaniu
  2. Aktualizuj je, gdy zmieniają się odpowiednie dane
  3. Sprzątaj zasoby, gdy komponent jest odmontowywany
  4. Dziel efekty według ich odpowiedzialności
  5. Enkapsuluj logikę synchronizacji w dedykowanych hookach
  6. Minimalizuj zbędne reinicjalizacje
  7. Testuj poprawność synchronizacji i czyszczenia

Pamiętaj, że głównym celem synchronizacji jest utrzymanie spójności między reaktywnym światem React a zewnętrznymi systemami, które nie wiedzą nic o cyklu życia komponentów. Dzięki prawidłowej synchronizacji, Twoja aplikacja będzie działać płynnie i efektywnie, łącząc różne technologie w jedną, spójną całość.

Ir a CodeWorlds