Moduł 5 - Zajęcia 10 - Nawigacja

Nawigacja po programie

React Router pozwala wykonać nawigację nie tylko po kliknięciu w Link, ale również po określonym działaniu użytkownika, zdarzeniu lub efekcie, na przykład po kliknięciu na przycisk, po wysłaniu formularza, po wyniku zapytania HTTP i tym podobne. Dla przykładu wykorzystujemy proces logowania użytkownika w aplikacji. Po wysłaniu formularza na stronie logowania wykonujemy nawigację na stronie profilu użytkownika, ale tylko jeśli zapytanie HTTP zakończyło się sukcesem.

Pierwszy sposób to hook useNavigate. Dostarcza nam funkcję navigate, do której przy wywołaniu przekazujemy ścieżkę określającą dokąd należy wykonać nawigację. Jest to imperatywny sposób, ale bardziej elastyczny i wymaga mniejszej ilości kodu.

            src/pages/Login.jsx

            import { useNavigate } from "react-router-dom";

            export const Login = () => {
              const navigate = useNavigate();

              const handleSubmit = async values => {
                const response = await FakeAPI.login(values);
                if (response.success) {
                  navigate("/profile", { replace: true });
                }
              };

              return (
                <div>
                  <h1>Login page</h1>
                  <LoginForm onSubmit={handleSubmit} />
                </div>
              );
            };
          

Zwróć uwagę na drugi, nieobowiązkowy argument funkcji navigate - to obiekt parametrów. Właściwość replace, domyślnie false, kontroluje, w jaki sposób zostanie dodany nowy zapis do stosu historii. Wróćmy do analogii ze stertą dokumentów. Domyślnie nowa kartka zostanie dodana na górze sterty, co w żaden sposób nie wpłynie na pozostałe karty. Jeżeli wskazać wartość true, to nowa karta podmieni tę, która jest na samym wierzchu. Wykorzystuje się to dość rzadko, na przykład przy logowaniu, aby użytkownik nie mógł powrócić przyciskiem "wróć" na stronę logowania po wejściu, ponieważ jest już w systemie i nie ma tam nic więcej do zrobienia.

Drugi sposób to komponent Navigate - owinięcie hooka useNavigate. Wykonuje nawigację w momencie renderowania. Ścieżka dla nawigacji i nieobowiązkowe parametry przekazywane są oddzielnymi propsami. Taki sposób jest bardziej deklaratywny, ale mniej elastyczny i wymaga więcej kodu.

            src/pages/Login.jsx

            import { Navigate, useState } from "react-router-dom";

            export const Login = () => {
              const [isLoginSuccess, setIsLoginSuccess] = useState(false);

              const handleSubmit = async values => {
                const response = await FakeAPI.login(values);
                setIsLoginSuccess(response.success);
              };

              if (isLoginSuccess) {
                return <Navigate to="/profile" replace />;
              }

              return (
                <div>
                  <h1>Login page</h1>
                  <LoginForm onSubmit={handleSubmit} />
                </div>
              );
            };
          

Trzecim sposobem jest wykorzystanie funkcji redirect. Jest ona istotnie rzadziej stosowana, ale w konkretnych przypadkach jest najlepszym wyborem.

            import { redirect } from "react-router-dom";

            const loader = async () => {
              const user = await getUser();
              if (!user) {
                return redirect("/login");
              }
              return null;
            };
          

CO JEST LEPSZE? To, jaki sposób wykorzystać, zależy tylko od twoich preferencji i wymagań zadania. W jednym przypadku będzie ci wygodnie wykorzystać deklaratywny Navigate, w drugim - imperatywny useNavigate. Funkcji redirect używamy za to, gdy chcemy zrobić przekierowanie bez interakcji użytkownika. Dobrym przykładem jest sprawdzenie czy użytkownik jest zalogowany - jeśli nie jest to używamy funkcji redirect i przenosimy go na stronę logowania

Łańcuch zapytania

Łańcuch zapytania i jego parametry to fundamentalny aspekt webu, ponieważ pozwala przekazywać stan aplikacji przez adres URL. Łańcuch zapytania dodaje się do podstawowego URL, zaczyna się od symbolu ? i zawiera jeden lub więcej parametrów w formacie "klucz-wartość" rozdzielone symbolem &.

            https://gomerch.it/products?name=hoodie&color=orange&maxPrice=500
          

Taki łańcuch zapytania zawiera trzy parametry i ich wartości: nazwa produktu, kolor i maksymalna cena. Przy przechodzeniu po tym URL, użytkownik zobaczy pasującą, przefiltrowaną listę produktów.

Przeanalizujmy pracę z łańcuchem zapytania i jego parametrami na przykładzie katalogu produktów, w którym użytkownik może wykonać wyszukiwanie po nazwie i zobaczyć przefiltrowaną listę.

Wykorzystanie lokalnego stanu przez hook useState jest dobre dla jednego użytkownika, ale złe dla wspólnej pracy z innymi użytkownikami. Na przykład kiedy użytkownik szuka produktów, wartość wyszukiwania dodawana jest do URL jako parametr łańcucha zapytania (/products?name=hoodie). Inny użytkownik, otrzymawszy ten odnośnik, zobaczy tę samą przefiltrowaną listę produktów na swojej stronie, dlatego że wszystkie dane niezbędne aplikacji do prawidłowego wyświetlenia interfejsu znajdują się bezpośrednio w URL.

Pobieranie parametrów

Dla odczytania i wprowadzania zmian w łańcuchach zapytania w React Router istnieje hook useSearchParams, który reprezentuje nieduże owinięcie wbudowanej w przeglądarkę klasy [URLSearchParams](https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams/URLSearchParams).

            const [searchParams, setSearchParams] = useSearchParams();
          

Zwraca tablicę składającą się z dwóch wartości: obiekt parametrów łańcucha zapytania (egzemplarz URLSearchParams) dla bieżącego URL i funkcję aktualizacji łańcucha zapytania. Dla otrzymania wartości parametrów istnieje metoda URLSearchParams.get(key), która oczekuje nazwy parametru i zwraca jego wartość lub null, jeśli w łańcuchu zapytania nie ma takiego parametru.

           src/pages/Products.jsx

            import { useSearchParams } from "react-router-dom";

            const Products = () => {
              const [searchParams] = useSearchParams();
              const name = searchParams.get("name");
              const color = searchParams.get("color");
              const maxPrice = searchParams.get("maxPrice");

              return (
                <div>
                  <p>Name: {name}</p>
                  <p>Color: {color}</p>
                  <p>Maximum price: {maxPrice}</p>
                </div>
              );
            };
          

Typ wartości

Metoda get() zawsze zwróci łańcuch niezależnie od wartości parametru, który został wskazany w łańcuchu zapytania. Na przykład dla takiego łańcucha zapytania ?name=hoodie&maxPrice=500&inStock=true otrzymamy następujące wartości parametrów.

            const [searchParams] = useSearchParams();

            const name = searchParams.get("name");
            console.log(name, typeof name);// "hoodie", string

            const maxPrice = searchParams.get("maxPrice");
            console.log(maxPrice, typeof maxPrice);// "500", string

            const inStock = searchParams.get("inStock");
            console.log(inStock, typeof inStock);// "true", string
          

Jeśli parametry to liczby lub wartości boolowskie, to dla otrzymania wartości prawidłowego typu należy wykonać przytoczenie typów. Można to zrobić wbudowaną klasą Number(value) i Boolean(value).

Parametry jako obiekt

Jeżeli łańcuch zapytania zawiera kilka parametrów, to ciągłe wykorzystywanie metody get() może być niewygodne. Oto prosty sposób przekształcenia egzemplarza klasy URLSearchParams w zwykły obiekt z właściwościami.

            const [searchParams] = useSearchParams();
            const params = useMemo(
              () => Object.fromEntries([...searchParams]),
              [searchParams]
            );
            const { name, maxPrice, inStock } = params;
          

info MEMOIZACJA: Memoizujemy operację przekształcenia obiektu parametrów, aby otrzymać odnośnik do nowego obiektu, tylko jeśli zmienią się parametry łańcucha zapytania, a nie przy każdym renderowaniu komponentu.

Zmiana łańcucha zapytania

Dla zmiany parametrów wykorzystujemy funkcję, którą useSearchParams zwraca jago drugi element tablicy. Należy jej przekazać obiekt nowych parametrów, który w pełni zamieni bieżący łańcuch zapytania.

            import { useSearchParams } from "react-router-dom";

            export const Products = () => {
              const [searchParams, setSearchParams] = useSearchParams();
              const name = searchParams.get("name");

              return (
                <div>
                  <h1>Products</h1>
                  <input
                    type="text"
                    value={name}
                    onChange={e => setSearchParams({ name: e.target.value })}
                  />
                </div>
              );
            };
          

Przeanalizuj pełny kod przykładu strony wszystkich produktów ([Products](https://codesandbox.io/s/goit-textbook-lesson-10-query-string-3msb5f?from-embed=&file=/src/pages/Products.jsx)), w którym realizowano zmianę łańcucha zapytania i filtrowanie listy. Zwróć uwagę na to, jak zrobiono usunięcie parametru name, jeśli wartość pola wprowadzenia to pusty łańcuch.

Śledzenie zmian

Jeśli zmienia się łańcuch zapytania, hook useSearchParams zwraca nową wartość parametrów i komponent aktualizuje się, dlatego można zareagować na to i uruchomić efekt.

            const App = () => {
              const [user, setUser] = useState(null);
              const [searchParams, setSearchParams] = useSearchParams();
              const username = searchParams.get("username");

              useEffect(() => {
                // Tu wykonujemy asynchroniczną operację
                // na przykład zapytanie HTTP za informacją o użytkowniku
                if (username === "") return;

                async function fetchUser() {
                  const user = await FakeAPI.getUser(username);
                  setUser(user);
                }

                fetchUser();
              }, [username]);

              const handleSubmit = e => {
                e.preventDefault();
                const form = e.currentTarget;
                setSearchParams({ username: form.elements.username.value });
                form.reset();
              };

              return (
                <>
                  <form onSubmit={handleSubmit}>
                    <input type="text" name="username" />
                    <button type="submit">Search</button>
                  </form>
                  {user && <UserInfo user={user} />}
                </>
              );
            };
          

Obiekt lokalizacji

Każdy zapis w stosie historii nawigacji jest opisany obiektem lokalizacji (location) ze standardowym zestawem właściwości, które przechowują pełną informację o URL. Gdy użytkownik klika na odnośnik i przemieszcza się w aplikacji, bieżąca lokalizacja zmienia się i dodawany jest nowy zapis do historii.

            {
              pathname: string;
              search: string;
              hash: string;
              state: any;
              key: string;
            }
          
  • pathname - łańcuch zawierający część URL od początkowego / do symbolu ?.
  • search - zawiera cały łańcuch zapytania. Jeśli parametrów brak, wartością będzie pusty łańcuch.
  • hash - łańcuch zawierający część URL od końca łańcucha zapytania i symbolu #, za którym następuje identyfikator fragmentu adresu URL. Jeżeli brak identyfikatora fragmentu, wartością będzie pusty łańcuch.
  • state - dowolna wartość, która zawiera dodatkową informację związaną z lokalizacją, ale nie wyświetla się w adresie URL. Określana jest przez programistę. Wykorzystywana jest dla przekazywania danych między ścieżkami.
  • key - unikalny łańcuch identyfikator związany z tą lokalizacją. Właściwość pomocnicza, której wartość określana jest automatycznie dla każdego nowego zapisu w historii nawigacji.

Na przykład dla takiego URL obiekt lokalizacji będzie wyglądał następująco.

            // https://gomerch.it/products?name=hoodie&color=orange&maxPrice=500#agreement

              {
                "pathname": "/products",
                "search": "?name=hoodie&color=orange&maxPrice=500",
                "hash": "#agreement",
                "state": null,
                "key": "random-browser-generated-id"
              }
          

Hook useLocation

Zwraca obiekt lokalizacji przedstawiający bieżący URL za każdym razem, gdy przechodzimy po nowej ścieżce w Routingu lub aktualizujemy część bieżącego URL. Jednym z zastosowań może być zadanie, gdzie należy wykonać jakiś efekt przy zmianie bieżącej lokalizacji. Na przykład wysłanie danych na serwer analityki.

            src/component/App.jsx

            import { useEffect } from "react";
            import { useLocation } from "react-router-dom";
            import Analytics from "path/to/analytics-service";

            const App = () => {
              const location = useLocation();

              useEffect(() => {
                Analytics.send(location);
              }, [location]);

              return <div>...</div>;
            };
          

Właściwość location.state

Wyobraź sobie następujący scenariusz w naszej aplikacji sklepu. Użytkownik znajduje się na stronie listy produktów i wykonał wyszukiwanie po nazwie, niech bieżącym URL będzie /products?name=hoodie. Następnie klika w obrazek produktu i przechodzi na stronę rozszerzonej informacji o produkcie, niech bieżącym URL będzie /products/h-1.

Mamy za zadanie dodanie na stronie produktu przycisku "Cofnij", po kliknięciu na który wykonuje się nawigacja na stronie wszystkich produktów, przy tym powinien zapisać się stan łańcucha zapytania. To oznacza, że po kliknięciu należy skierować użytkownika nie na /products, a w naszym przypadku na /products?name=hoodie - ten URL, z którego była wykonana nawigacja na stronie produktu,

            {
              pathname: string;
              search: string;
              hash: string;
              state: any;
              key: string;
            }
          

Właściwość state obiektu lokalizacji pozwala przekazywać dowolne dane przy nawigacji od jednej ścieżki do drugiej. W tym celu wykorzystujemy props state komponentu Link i przekazujemy obiekt z właściwością from - skąd przyszedł użytkownik. Wartość propsu state nie ma wcześniej zdefiniowanej struktury i może być dowolna, wedle uznania dewelopera.

            src/pages/Products.jsx

            const Products = () => {
              return (
                <Link to="/products/h-1" state={{ from: "/dashboard?name=hoodie" }}>
                  Navigate to product h-1
                </Link>
              );
            };
          

Wartość propsu state będzie dostępna na obiekcie lokalizacji ścieżki, dokąd była wykonana nawigacja. Wszystko, co należy zrobić, to wykorzystać hook useLocation, otrzymać obiekt location i zwrócić się do jego właściwości state.

            src/pages/ProductDetails.jsx

            const ProductDetails = () => {
              const location = useLocation();
              console.log(location.state);// { from: "/dashboard?name=hoodie" }

              return <Link to={location.state.from}>Back to products</Link>;
            };
          

W zasadzie nie trzeba obliczać bieżącego URL dla formowania wartości właściwości from. Obiekt location opisuje wszystkie części URL bieżącej Routy, dlatego można przekazać go w props state.

            src/pages/Products.jsx

            const Products = () => {
              const location = useLocation();

              return (
                <Link to="/product/h-1" state={{ from: location }}>
                  Navigate to product h-1
                </Link>
              );
            };
          

We właściwości location.state będzie odnośnik do obiektu location ścieżki, z której zaszła nawigacja. Propsowi to komponentu Link można przekazać nie tylko łańcuch opisujący href przyszłego odnośnika, ale i cały obiekt location.

            src/pages/ProductDetails.jsx

            const ProductDetails = () => {
              const location = useLocation();
              console.log(location.state);

            // /products -> products/h-1
            // { from: { pathname: "/products", search: "" } }

            // /products?name=hoodie -> products/h-1
            // { from: { pathname: "/products", search: "?name=hoodie" } }

              return <Link to={location.state.from}>Back to products</Link>;
            };
          

Ostatnie, co należy wziąć pod uwagę, to sytuacja, w której użytkownik przeszedł po zapisanym wcześniej odnośniku jednego produktu w nowej zakładce przeglądarki, a nie ze strony wszystkich produktów. W takim wypadku wartość location.state będzie null i przy zwróceniu się do właściwości location.state.from aplikacja pokaże błąd. Z tego względu należy zadbać o wartość domyślną dla propsu to.

            src/pages/ProductDetails.jsx

            const ProductDetails = () => {
              const location = useLocation();
              const backLinkHref = location.state?.from ?? "/products";

              return <Link to={backLinkHref}>Back to products</Link>;
            };
          

Dzielenie kodu

Domyślnie wszystkie zależności projektu łączą się w jednym pliku. Im więcej kodu, tym wolniej będzie się ładował, parsował i wykonywał w przeglądarce. Na słabszych komputerach lub telefonach, z kiepskim łączem internetowym mogą to być dziesiątki sekund.

Przy programowaniu na lokalnym serwerze (localhost) wszystkie pliki są dystrybuowane z naszego komputera. W tym przypadku prędkość podłączenia do internetu nie ma znaczenia i dlatego pliki projektu ładują się bardzo szybko. Jednak na produkcji ładowanie dużych plików może okazać się problematyczne, ponieważ nie wszędzie dostępny jest szybki internet i dobre komputery.

Rozwiązanie problemu jest proste - należy rozbić go na mniejsze pliki i ładować je tylko, jeśli są potrzebne. Na tym polega koncepcja dzielenia kodu. Jeżeli użytkownik wchodzi na stronę logowania, nie trzeba ładować całego kodu aplikacji. Wystarczy część odpowiadająca za renderowanie komponentów tylko tej strony.

CREATE REACT APP Podział kodu na kilka plików to zadanie modułu budującego projekt, na przykład Webpack, a nie frameworku frontend. Create React App wewnętrznie wykorzystuje Webpack jak moduł budujący i wspiera dzielenie kodu bez dodatkowej konfiguracji.

Kod aplikacji należy podzielić wedle Routingu aplikacji i ładować jeśli jest taka potrzeba. To wystarczy dla większości aplikacji. Przechodzimy na nową stronę - ładuje się niezbędny kod dla wyświetlenia jej komponentów. Takie podejście nazywa się dzieleniem kodu na podstawie ścieżek (route-centric).

Interfejsy mogą być bardzo obszerne. Idąc dalej, można optymalizować ładowanie oddzielnych, bardzo dużych komponentów strony, które niepotrzebne są do określonego działania użytkownika. Na przykład komponent okna modalnego, w którym wykorzystywana jest duża bibliotek edytora tekstowego. Takie podejście nazywane jest dzieleniem kodu na podstawie komponentów (component-based / component-centric).

CO WYKORZYSTAĆ? Programista podejmuje decyzję jak, co i gdzie podzielić. Poniżej przedstawiliśmy kilka dobrych praktyk:

  • Dzielenie kodu na podstawie ścieżek jest obowiązkowe w dowolnej aplikacji.
  • Dzielenie kodu na podstawie komponentów warto robić tylko w dużych, skomplikowanych interfejsach z setkami komponentów i dużymi bibliotekami.
  • Zbyt duży podział kodu również nie jest najlepszym pomysłem. Zapytanie HTTP o plik może trwać dłużej niż dodanie całego do pierwszego ładowania.
Dzielenie codu w React

React.lazy() i React.Suspense

Wiesz już, że moduły ES są statyczne, to znaczy import i eksport wykonuje się w czasie kompilacji, a nie w czasie wykonania kodu. Import powinien być zadeklarowany w górnej części pliku, w przeciwnym razie wystąpi błąd kompilacji. Oznacza to, że nie będziesz mógł importować zależności dynamicznie na podstawie jakiegoś warunku.

Bez dzielenia kodu

            import MyComponent from "path/to/MyComponent";

            const App = () => {
              return (
                <Routes>
                  <Route path="/some-path" element={<MyComponent />} />
                  {/* Reszta routingu */}
                </Routes>
              );
            };
          

W specyfikacji ES2020 pojawiła się możliwość dynamicznego importowania modułu. Różnica polega na tym, że zamiast zwyczajnego, statycznego import wykorzystywana jest funkcja import() zwracająca promise, którego wartością będzie plik modułu.

            import("path/to/MyComponent").then(module => console.log(module));
          

React dostarcza API do tego, aby wskazać, jaki kod należy wydzielić do oddzielnego pliku, a później ładować i renderować tylko w wypadku takiej potrzeby. Funkcja React.lazy() odpowiada za asynchroniczne ładowanie komponentu, a Suspense zatrzymuje jego wyświetlanie do zakończenia ładowania.

Z dzieleniem kodu

            import { lazy, Suspense } from "react";

            const MyComponent = lazy(() => import("path/to/MyComponent"));

            const App = () => {
              return (
                <Suspense fallback={<div>Loading...</div>}>
                  <Routes>
                    <Route path="/some-path" element={<MyComponent />} />
                    {/* Reszta routingu */}
                  </Routes>
                </Suspense>
              );
            };
          

Metoda lazy() oczekuje funkcji-ładowarki, która zwraca wynik dynamicznego importu - promise, którego wartością będzie defaultowy eksport modułu (komponent). Jeżeli w czasie renderowania komponent MyComponent jeszcze nie jest załadowany, należy pokazać zaślepkę. W tym celu wykorzystuje się komponent Suspense. Props fallback przyjmuje dowolny element React lub komponent. Suspense można umieścić w dowolnym miejscu nad asynchronicznym komponentem lub grupą komponentów.

DYNAMICZNY IMPORT Zwróć uwagę na brak statycznego importu MyComponent w ostatnim przykładzie. Zamiast tego wykorzystywana jest funkcja import(). Jeżeli zostawimy statyczny import, to Webpack nie wykona dzielenia kodu i doda cały kod MyComponent w podstawowy plik JavaScript projektu.

Suspense i podejście «shared layout»

Jeśli wykorzystujesz przyjęcie «shared layout», to należy umieścić Suspense bezpośrednio w środku komponentu SharedLayout. W przeciwnym razie przy ładowaniu każdej strony będą gubić się i ponownie renderować komponenty wspólnej części strony, na przykład header i nawigacja.

            // src/components/App.jsx
            import { lazy } from "react";

            const MyComponent = lazy(() => import("path/to/MyComponent"));

            const App = () => {
              return (
                <Routes>
                  <Route path="/" element={<SharedLayout />}>
                    <Route path="some-path" element={<MyComponent />} />
                    {/* Reszta routingu */}
                  </Route>
                </Routes>
              );
            };

            // src/components/SharedLayout.jsx
            import { Suspense } from "react";
            import { Outlet } from "react-router-dom";

            const SharedLayout = () => {
              return (
                <Container>
                  <AppBar>
                    <Navigation />
                    <UserMenu />
                  </AppBar>

                  <Suspense fallback={<div>Loading...</div>}>
                    <Outlet />
                  </Suspense>
                </Container>
              );
            };
          

Przeanalizuj kod aplikacji sklepu z dzieleniem kodu na podstawie ścieżek. Zmienił się kod komponentów [App](https://codesandbox.io/s/goit-textbook-lesson-10-code-splitting-7wjxvr?file=/src/components/App.jsx), [SharedLayout](https://codesandbox.io/s/goit-textbook-lesson-10-code-splitting-7wjxvr?file=/src/components/SharedLayout.jsx) i [About](https://codesandbox.io/s/goit-textbook-lesson-10-code-splitting-7wjxvr?file=/src/pages/About.jsx), a wszystkie komponenty stron zmieniły się w defaultowe eksporty.

Zwróć uwagę na wykorzystanie komponentu Suspense w kodzie komponentu strony About. W ten sposób przy ładowaniu podstron, nie będzie ponownie renderowała się cała strona, a tylko jej dolna część z układem podstron. Komponenty Suspense w SharedLayout i About nie przeszkadzają sobie nawzajem, zamiast tego React wykorzystuje najlepiej pasujący, ten, który jest najbliżej ładowanego komponentu.

React.Suspense

React.Lazy

Przykład "z" oraz "bez" Suspense