Kolejny wpis po monitorowaniu pracy dotyczący poprawy wydajności aplikacji pisanych w React. Tym razem skupimy się na jednym specyficznym zagadnieniu jakim jest Code Splitting.
Większość z nas podczas tworzenia aplikacji w React korzysta z jakiegoś bundlera, który łączy wszystkie pliki naszego projektu w jeden zminimalizowany i zoptmalizowany produkcyjnie plik, tzw. bundle. Osoby korzystające z create-react-app, Next.js czy GatsbyJS będą miały z automatu dołączonego do nowo tworzonych projektów Webpacka. Część osób zaczynających z aplikacją Reactową od zera być może skusi się na Parcel.js. W każdym z tych przypadków domyślne ustawienia bundlera zazwyczaj będzie nam budowało jeden wynikowy plik .js z naszą całą aplikacją.
Code Splitting
Tworzenie jednego pliku jest w zupełności wystarczające gdy nasza aplikacja jest dość mała i nie zawiera dużych zewnętrzynych bibliotek. W przypadku bardziej złożonych aplikacji takie podejście może powodować problemy wydajnościowe. Załóżmy sytuację gdy w naszym programie mamy kilka ekranów zawierających duże wykresy bądź ciężkie edytory tekstowe. W podejściu z jednym plikiem wszystkie te komponenty zostaną załadowane do pamięci przeglądarki naszego użytkownika mimo, iż być może nigdy on nie skorzysta z w/w ekranów.
Najlepszym rozwiązaniem w tej sytuacji byłoby wydzielenie niektórych elementów z naszego zbiorczego pliku. Następnie możemy ładować je tylko wtedy gdy rzeczywiście będą potrzebne. Zredukujemy w ten sposób również rozmiar pliku wykonywanego podczas pierwszego ładowania aplikacji. Finalnie więc dostarczymy użytkownikowi pierwsze elementy na ekranie znacznie szybciej.
W React mamy kilka sposób na to, aby zaimplementować Code Splitting i przyjrzymy się dzisiaj najpopularniejszym z nich.
Dynamic imports
W obecnej wersji specyfikacji ECMAScript komponenty eksportowane jako ES modules są kompletnie statyczne. Muszą być eksportowane i importowane jedynie podczas kompilowania, nie podczas wykonywania programu. Dlatego też importy w naszych komponentach są zawsze umieszczane na samym początku pliku. Nie ma możliwości importowania ich dynamicznie, np. w wyrażeniu switch {}
Na nasze szczęście taka funkcjonalność jest już dostępna jako eksperymentalny feature i znajduje się w fazie Stage 3 w procesie dodawania nowych funkcjonalności do standardu ECMAScript. Prawdopodobnie już niedługo będzie powszechnie dostępna. W chwili obecnej musimy jeszcze wspierać się Babelem oraz Webpackiem aby móc skorzystać z dynamicznych importów.
Wszystkie nowe aplikacje korzystające z create-react-app bądź Next.js mają tą funkcjonalnść skonfigurowaną domyślnie. Wszyscy inni powinni skorzystać z instrukcji dla Webpacka oraz skorzystać z odpowiedniego pluginu dla Babela.
Dynamiczny import możemy użyć w każdym miejscu naszej aplikacji. Różni się on od tradycyjnego importu tym, iż import wywołany w ten sposób zwraca nam Promise:
if (newMail === true) { import('./BigTextEditor') .then(module => module.showEditor()) .catch(e => throw new Error(e) ) } // or async/await let module = await import('./BigTextEditor');
Myślę, że użycie dynamicznego importu jest dość proste i nie wymaga większych wyjaśnień. Należy jednak pamiętać, aby zadbać tutaj o takie kwestie jak być może pokazanie użytkownikowi jakiegoś spinnera na czas ładowania komponentu. W takiej sytuacji możemy wspomóc się dość popularną biblioteką – React Loadable
React Loadable
React Loadable jest to HOC który umożliwia nam ładowanie komponentów przy użyciu właśnie co omówionego dynamicznego importu. API tej biblioteki umożliwia nam w łatwy sposób dodać komponent wyświetlany podczas ładowania zasobów. Oprócz tego możemy obsłużyć błędy, zaimplementować preloading, importować dynamicznie moduły w trybie server-side rendering oraz jeszcze wiele innych użytecznych funkcjonalności. Zainteresowanych odsyłam do dokumentacji..
Jeszcze inną biblioteką wspomagającą nas w Code Splitting w React (również obsługującą server-side rendering) jest loadable components.
React.lazy
Począwszy od wersji 16.6 React posiada już wbudowaną funkcjonalność dynamicznego importowania komponentów. Przykład użycia:
const BigTextEditor = React.lazy(() => import('./BigTextEditor')); function MyComponent() { return ( <div> <OtherComponent /> </div> ); }
W powyższym przypadku BigTextEditor zostanie dynamicznie zaimportowany tylko wtedy gdy zostanie wywołany przez metodę render()
. W przeciwnym razie import()
nigdy się nie wykona. Jak widać na powyższym przykładzie metoda .lazy()
przyjmuje jako argument funkcję wywołującą dynamiczny import. Dynamiczny import w tym przypadku musi zwrócić nam Promise którego wynikiem będzie moduł zawierający domyślnie eksportowany (export default) komponent Reactowy. Z racji tego, iż ładowanie komponentów zazwyczaj trwa chwilę i chcielibyśmy w tym czasie pokazać użytkownikowi jakiś wskaźnik ładowania – React.lazy praktycznie zawsze będziemy używać razem z kolejną funkcjonalnością Reacta, czyli Suspense.
Suspense
Suspense jest komponentem którego zadaniem jest wyświetlenie czegoś w miejsce dynamicznie ładujących się komponentów. Może to być np. wspomniany już wcześniej wskaźnik ładowania.
Info
W dużym skrócie Suspense pozwala nam odłożyć „na później” renderowanie części naszej aplikacji do momentu, gdy nie zostaną spełnione pewne warunki (np. do czasu gdy nie pobierzemy danych z API bądź gdy jesteśmy w trakcie dynamicznego importu komponentu).
Suspense akceptuje props o nazwie fallback, który może być dowolnym komponentem Reactowym. Ten właśnie komponent będzie widoczny na ekranie w trakcie wykonywania dynamicznego importu. Wewnątrz jednego komponentu Suspense możemy mieć kilka dynamicznie importowanych modułów:
const BigTextEditor = React.lazy(() => import('./BigTextEditor')); const MultistepForm = React.lazy(() => import('./MultistepForm')); function MyComponent() { return ( <div> <Suspense fallback={<div>Loading...</div>}> <section> <h2>Fill the form<h2> <MultistepForm /> </section> <section> <h2>Send us your feedback<h2> <BigTextEditor /> </section> </Suspense> </div> ); }
Obsługa błędów
Nie możemy zapominać również o sytuacji w której z jakiś powodów nie uda nam się załadować dynamicznie naszych komponentów. Możemy obsłużyć taki przypadek wykorzystując kolejny komponent dostarczony wraz z Reactem – Error Boundaries. Komponent tego typu umieszczamy „nad” komponentami dynamicznymi i za jego pomocą przekazujemy użytkownikowi stosowne komunikaty o błędzie na ekranie:
import FormErrorBoundary from './FormErrorBoundary'; const BigTextEditor = React.lazy(() => import('./BigTextEditor')); const MultistepForm = React.lazy(() => import('./MultistepForm')); function MyComponent() { return ( <div> <FormErrorBoundary> <Suspense fallback={<div>Loading...</div>}> <section> <h2>Fill the form<h2> <MultistepForm /> </section> <section> <h2>Send us your feedback<h2> <BigTextEditor /> </section> </Suspense> </FormErrorBoundary> </div> ); }
W którym miejscu dzielić kod?
Myślę że niemal każdy zgodzi się ze stwierdzeniem iż stosowanie code splitting w większych aplikacjach przyniesie nam wiele dobrego. Zostaje jeszcze jedna kwestia – w którym miejscu dzielić nasz kod?
Istnieją dwa najpopularniejsze podejścia:
- route-based, czyli dzielenie na poziomie pojedynczych stron (ekranów),
- component-based, czyli dzielenie na poziomie komponentów.
Dzielenie na poziomie stron może wydawać się dość intuicyjne, gdyż ładujemy zawartość strony tylko wtedy, gdy użytkownik na nią przejdzie. W dodatku nasi użytkownicy korzystając z innych stron w Internecie pewnie są przyzwyczajeni do tego, iż przejście między podstronami może powodować lekkie opóźnienie.
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom'; import React, { Suspense, lazy } from 'react'; const Home = lazy(() => import('./pages/Home')); const PageWithTabs = lazy(() => import('./pages/PageWithTabs')); const About = lazy(() => import('./pages/About')); const App = () => ( <Router> <Suspense fallback={<div>Loading...</div>}> <Switch> <Route exact path="/" component={Home}/> <Route path="/about" component={About}/> <Route path="/pagewithtabs" component={PageWithTabs}/> </Switch> </Suspense> </
Jednak co w przypadku gdy na naszej stronie mamy zakładki i w jednej z tych zakładek znajduje się ciężki edytor tekstowy? Załaduje się razem z całą stroną mimo, iż być może nie będzie w ogóle użyty.
Między innymi z tego powodu osobiście uważam iż lepszym rozwiązaniem będzie stosowanie drugiego podejścia – dzielenie na poziomie komponentów. Nawiasem mówiąc w większości routerów stworzonych dla React – ścieżka (route) jest również komponentem. Pokazanie użytkownikowi jak najszybciej nowej strony na której będzie trochę tekstu i kręcące się wskaźniki ładowania w miejsce importowanych właśnie komponentów moim zdaniem również wygląda lepiej niż lag związany z ładowanie całej nowej strony.
webpack-bundle-analyzer
Na koniec wspomnę jeszcze o bardzo fajnym narzędziu, które pozwala nam analizować pliki generowane przez webpacka – webpack-bundle-analyzer. Może to być dobry punkt startowy, aby zobaczyć które z komponentów badź zewnętrznych bibliotek zajmują najwięcej miejsca w naszym wyjściowym pliku bundle i może od wycięcia ich z głównej aplikacji zacząć cały proces optymalizacyjny. Webpack-bundle-analyzer będzie również przydatny, aby stwierdzić, czy faktycznie udało nam się zacząć dzielić jeden plik na kilka mniejszych.
Znamy już w takim razie podstawowe metody optymalizacji oraz wiemy jak działa i jak stosować code splitting. Zostały już tylko dwa ostatnie tematy które chciałbym podciągnać pod tematykę przyśpieszania Reacta a mianowicie debounce/throttling oraz windowing. Stosowny artykuł pojawi się już niedługo.