Warum
In diesem Artikel fokussieren wir uns darauf, wie der initiale JavaScript-Payload, der beim Laden einer Webseite anfällt, reduziert werden kann und Skripte erst dann geladen werden, wenn sie wirklich benötigt werden, um so die Time to Interactive
zu veringern. Dieser Artikel richtet sich an React-Einsteiger mit grundlegendem Verständnis in React und TypeScript.
Der durch mobile Endgeräte verbrauchte Traffic nimmt zu. So wurde in den USA im Jahr 2017 knapp 63 % des Traffics durch mobile Endgeräte verursacht. Oft sind Nutzer frustriert durch lange Ladezeiten oder schlechte Verbindungen. Das langsame Laden und Anzeigen einer Webseite hat oft viele Ursachen. Laut dem Cost of JavaScript 2019 Report von Addy Osmani ist eine dieser Ursachen inzwischen, dass der initiale JavaScript-Payload, der zum Anzeigen der Landing Page nötig ist, groß ist. Dies ist in doppelter Hinsicht schlecht. Denn je größer die initialen Skripte sind, desto länger dauert es, diese herunterzuladen und infolgedessen zu parsen und auszuführen. Leistungsschwächere Geräte kommen hier schnell an ihre Grenzen; oft weil die effektive Verbindung schlechter ist als die dem Benutzer angezeigte. Im Vergleich zu ihren nicht mobilen Artgenossen, haben die mobilen Geräte meist langsamere CPUs und GPUs. Damit wir die immer leistungsfähiger werdenden Geräte weiterhin in die Hosentasche stecken können, werden einige von ihnen, aufgrund ihrer immer kompakter werdenden Bauform und der damit verbundenen Überhitzungsgefahr, gedrosselt. Die Time to Interactive
, also die Zeit bis eine Webseite komplett funktionsfähig geladen ist, unterscheidet sich gravierend, je nach dem wie leistungsfähig das Endgerät ist; laut Addy Osmani für news.google.com
zwischen einem Moto G4 (weltweit gesehen ein Durchschnittstelefon) und einem Pixel 2 um den Faktor drei. Selbst auf leistungsfähigen Endgeräten wie einem MacBook ist die TTI
oft lang.
Seit Jahren ist ein Zuwachs an JavaScript-Skripten zu beobachten, die auf Webseiten benötigt werden. Auch die Anzahl an externen JavaScript-Quellen nimmt langsam aber stetig zu. Warum es sich lohnt, die TTI
zu optimieren, zeigen Fallstudien von BBC oder auch Pinterest . So konnte die BBC zeigen, dass sie für jede Sekunde längeren Ladens der Webseite 10 % der Benutzer verlieren. Auch bei Pinterest wird der Einfluss deutlich. Sie zeigten, dass die Menge an Sign-Ups und Suchmaschinen-Traffic um 15 % zugenommen hat, nachdem sie ihre (vom Benutzer) empfundenen Wartezeiten um 40 % verringert haben.
Voraussetzungen
Single-Page-Applications werden auf dem Client ausgeführt, das heisst in der Regel wird eine JavaScript-Datei (das Bundle
) heruntergeladen, geparst und ausgeführt. Je größer dieses Bundle beim initialen Laden ist, desto größer wird die Time to Interactive
. Es bietet sich also an, via Code-Splitting nicht initial benötigte Teile des Bundles später zu laden. React bietet hierfür ab Version 16.0 zwei sinnvolle APIs an:
- Suspense — um auf Komponenten zu warten und einen Fallback anzuzeigen bis diese gemountet werden und
- Lazy — um Komponenten dynamisch nachzuladen um so die Bundle-Größe zu verringern.
Eine kleine Demo-Application soll uns als Beispiel dienen. Die App wird einen Knopf besitzen. Wird er gedrückt, werden (lazy) Katzenbilder geladen, um sie dann anzuzeigen.
Zuerst werden wir eine TypeScript-basierte React App anlegen. Dazu verwenden wir npx
und create-react-app
. Im Terminal legen wir mit dem Befehl
1npx create-react-app lazy-suspense-cats --typescript 2cd lazy-suspense-cats
unsere App an und wechseln in den neu erstellen Ordner. Außerdem installieren wir noch einige Abhängigkeiten, die wir später benötigen werden:
1yarn add @testing-library/react @types/react-dom react-dom axios
Nun sollte die Datei package.json
so aussehen:
1{ 2 "name": "lazy-suspense-cats", 3 "version": "0.1.0", 4 "private": true, 5 "dependencies": { 6 "@testing-library/react": "^8.0.4", 7 "@types/jest": "24.0.15", 8 "@types/node": "12.0.10", 9 "@types/react": "16.8.22", 10 "@types/react-dom": "^16.8.4", 11 "axios": "^0.19.0", 12 "react": "^16.8.6", 13 "react-dom": "^16.8.6", 14 "react-scripts": "3.0.1", 15 "typescript": "3.5.2" 16 }, 17 "scripts": { 18 "start": "react-scripts start", 19 "build": "react-scripts build", 20 "test": "react-scripts test", 21 "eject": "react-scripts eject" 22 }, 23 "eslintConfig": { 24 "extends": "react-app" 25 }, 26 "browserslist": { 27 "production": [ 28 ">0.2%", 29 "not dead", 30 "not op_mini all" 31 ], 32 "development": [ 33 "last 1 chrome version", 34 "last 1 firefox version", 35 "last 1 safari version" 36 ] 37 } 38}
Cat App mit React Lazy und React Suspense
Damit können wir loslegen. Wir schreiben als erstes einen Test für eine Komponente, die ein Bild anzeigt. Das Bild bekommt die Komponente von außen hereingereicht, ebenso wie den alt
-Text. Im src
-Ordner legen wir die Dateien Cat.tsx
und Cat.spec.tsx
an. Für unsere Tests verwenden wir @testing-library/react
. Sie motiviert uns, Tests zu schreiben, die widerspiegeln, wie Benutzer*innen unsere App verwenden werden. Nun fügen wir in der Datei Cat.spec.tsx
den Test hinzu.
1import * as React from "react";
2import Cat from "./Cat";
3import { render, cleanup } from "@testing-library/react";
4
5describe("Cat", () => {
6 afterEach(cleanup);
7
8 it("should render an image", () => {
9 const imageUrl = "/image/url/image.jpg";
10 const altText = "alt text";
11 const { getByAltText } = render(
12
13 );
14
15 getByAltText(altText);
16 });
17});
Die Funktion cleanup()
sorgt dafür, dass nach jedem Test Überbleibsel von render
aufgeräumt werden. Im Test selbst erzeugen wir eine url
und einen altText
, welche die Komponente als props
überreicht bekommt. Dann rendern wir die Komponente mit den entsprechenden props
. Einen expect
-Block brauchen wir nicht, da getByAltText
im Fehlerfall bereits eine Exception wirft. Unsere Tests können wir mit yarn test
im Projektverzeichnis ausführen. Unser erster Test sollte fehlschlagen:
FAIL src/Cat.spec.tsx
Cat
✕ should render an image (24ms)
...
Nebenbei macht es Sinn, via yarn start
den Entwicklungsserver zu starten, der bei Änderungen neu geladen wird. Unter localhost:3000
können wir unsere Frontend-App im Browser aufrufen.
Cat-Komponente
Jetzt machen wir uns an die Implementierung unserer Cat.tsx
-Komponente. Sie bekommt als props
eine imageUrl
, einen altText
und optional einen CSS style
:
1import * as React from "react";
2
3const Cat = (props: {
4 imageUrl: string;
5 altText: string;
6 style?: React.CSSProperties;
7}) => <img src="{props.imageUrl}" alt="{props.altText}" />;
8
9export default Cat;
Führen wir nun unseren Test wieder aus, sollte dieser durchlaufen.
PASS src/Cat.spec.tsx
Lazy ist hier natürlich noch nichts. Daher bauen wir als nächstes eine Komponente, die uns einen Button anzeigt. Wird dieser geklickt, soll via axios der REST-Endpunkt https://api.thecatapi.com/v1/images/search?&limit=80"
gerufen werden. Wir bekommen ein JSON zurück, welches 80 Katzenobjekte enthält. Darunter ist auch die URL, die wir für die Cat
Komponente brauchen. Dazu mappen wir jeweils die URL einer Katze auf eine Cat
-Komponente.
ToggleCat-Komponente
Zuerst legen wir eine ToggleCat.spec.tsx
– und ToggleCat.tsx
-Datei an. Außerdem passen wir die Datei index.tsx
so an, dass sie unsere neue Komponente ToggleCat
direkt lädt.
1import * as React from "react";
2import { render } from "react-dom";
3import { ToggleCat } from "./ToggleCat";
4
5export const App = () => {
6 return (
Als nächstes schreiben wir einen Test, der:
- ein
cats
JSON erzeugt, das für unseren Test verwendet wird, - axios mockt und beim Aufruf von
axios.get
dieses JSON zurückliefert, - prüft, ob nichts angezeigt wird, bevor der Knopf gedrückt ist,
- den Knopf drückt,
- prüft, ob bis zum ersten Mount eine Fallback-Komponente angezeigt wird,
- prüft, ob nach einer gewissen Zeit zwei
Cat
Komponenten angezeigt werden.
1import {
2 cleanup,
3 fireEvent,
4 render,
5 waitForElement
6} from "@testing-library/react";
7import axios from "axios";
8import * as React from "react";
9import { ToggleCat } from "./ToggleCat";
10jest.mock("axios");
11
12describe("Toggle Cat", () => {
13 afterEach(cleanup);
14
15 it("should load a bunch of cats when toggle is clicked", async () => {
16 const buttonText = "Show all the cats!";
17
18 const cats = [
19 { url: "./__mocks__/imaages/d7j.jpg", id: "d7j.jpg" },
20 { url: "url/2", id: "1" }
21 ];
22 axios.get.mockResolvedValue({ data: cats });
23
24 // should render a page with a tempting button
25 const {
26 findAllByAltText,
27 queryAllByText,
28 getByText,
29 queryByAltText
30 } = render();
31
32 // should not have mounted any image so far
33 expect(queryByAltText("... a cat ...")).toEqual(null);
34
35 // trigger mounting of cat components
36 fireEvent.click(getByText(buttonText));
37
38 const fallback = await waitForElement(() =>
39 queryAllByText("... Loading ...")
40 );
41
42 // should render two fallback spinners (as we pass in two cats in this test)
43 expect(
44 await waitForElement(() => queryAllByText("... Loading ...").length)
45 ).toEqual(2);
46
47 // after sleeping a bit, we should see two cats
48 const lazycat = await waitForElement(() =>
49 findAllByAltText("... a cat ...")
50 );
51
52 expect(lazycat.length).toEqual(2);
53 });
54});
Da wir später „lazy“ arbeiten wollen, arbeiten wir direkt mit waitForElement
, sodass unser Test ggf. warten kann, bis lazy importiert wurde.
Eventuell wird die Fehlermeldung: Warning: An update to ToggleCat inside a test was not wrapped in act(...).
angezeigt. Diese kann ignoriert werden (siehe Kommentar im folgenden Quellcode). Los bekommt man sie, indem man im src/
Verzeichnis die Datei setupTests.js
mit folgendem Inhalt hinzufügt:
1// this is just a little hack to silence a warning that we'll get until react
2// fixes this: https://github.com/facebook/react/pull/14853
3const originalError = console.error;
4beforeAll(() => {
5 console.error = (...args) => {
6 if (/Warning.*not wrapped in act/.test(args[0])) {
7 return;
8 }
9 originalError.call(console, ...args);
10 };
11});
12
13afterAll(() => {
14 console.error = originalError;
15});
In der Datei ToggleCat.tsx
fügen wir zuerst einen Knopf hinzu, der beim Anklicken nichts tut, und einen useEffect
Hook, der sich um das Laden der Katzenobjekte kümmert:
1import axios from "axios";
2import * as React from "react";
3
4export const ToggleCat = () => {
5 const [cats, setCats] = React.useState([]);
6
7 React.useEffect(() => {
8 axios
9 .get("https://api.thecatapi.com/v1/images/search?&limit=80")
10 .then(response => {
11 setCats(response.data);
12 });
13 }, []);
14
15 return (
Die Komponente lädt somit initial das JSON mit den 80 Katzen und speichert die Response-Daten im Komponenten-State cats
. Unser entsprechende Test sollte fehlschlagen.
Als nächstes wollen wir via map
die URLs auf Cat
Komponenten mappen:
1import axios from "axios";
2import * as React from "react";
3import Cat from "./Cat";
4
5interface Category {
6 id: number;
7 name: string;
8}
9
10interface Cat {
11 breeds: [];
12 categories: Record<number, Category>;
13 url: string;
14 width: number;
15 height: number;
16 id: string;
17}
18
19export const ToggleCat = () => {
20 const [cats, setCats] = React.useState([]);
21
22 React.useEffect(() => {
23 axios
24 .get("https://api.thecatapi.com/v1/images/search?&limit=80")
25 .then(response => {
26 setCats(response.data);
27 });
28 }, []);
29
30 return (
Wenn wir jetzt die App unter localhost:3000
(sofern yarn start
ausgeführt wird) aufrufen, sollten wir einen Button und nach kurzer Zeit einige Katzenbilder sehen. Damit die Bilder erst mit dem Klick auf den Knopf erscheinen, fügen wir der ToggleCat
-Komponente einen weiteren State isActive
hinzu:
1...
2export const ToggleCat = () => {
3 const [cats, setCats] = React.useState([]);
4 const [isActive, setIsActive] = React.useState(false);
5
6 React.useEffect(() => {
7 axios
8 .get("https://api.thecatapi.com/v1/images/search?&limit=80")
9 .then(response => {
10 setCats(response.data);
11 });
12 }, []);
13
14 const toggleIsActive = () => {
15 setIsActive(!isActive);
16 };
17
18 return (
Jetzt fügen wir noch einige styles
hinzu. Wir verwenden Grid
, um die Ausrichtung der Kacheln etwas einfacher zu gestalten:
1import axios from "axios";
2import * as React from "react";
3import Cat from "./Cat";
4
5const catsContainerStyle = {
6 gridArea: "cats",
7 display: "flex",
8 justifyContent: "center",
9 alignItems: "center",
10 flexWrap: "wrap" as "wrap",
11 fontSize: "2em"
12};
13
14const catStyle = {
15 width: "400px",
16 height: "400px",
17 objectFit: "contain" as "contain",
18 padding: "20px"
19};
20
21const layoutStyle = {
22 display: "grid",
23 gridTemplateAreas: `"button"
24 "cats"`,
25 gridGap: "10px"
26};
27
28interface Category {
29 id: number;
30 name: string;
31}
32
33interface Cat {
34 breeds: [];
35 categories: Record<number, Category>;
36 url: string;
37 width: number;
38 height: number;
39 id: string;
40}
41
42export const ToggleCat = () => {
43 const [cats, setCats] = React.useState([]);
44 const [isActive, setIsActive] = React.useState(false);
45
46 React.useEffect(() => {
47 axios
48 .get("https://api.thecatapi.com/v1/images/search?&limit=80")
49 .then(response => {
50 setCats(response.data);
51 });
52 }, []);
53
54 const toggleIsActive = () => {
55 setIsActive(!isActive);
56 };
57
58 return (
Inzwischen sollte unsere App (nach einem Klick auf den Knopf) so aussehen wie auf dem folgenden Screenshot:
Lazy nachgeladen wird bisher aber noch nichts und unser Test schlägt deshalb noch fehl. Dies ändern wir jetzt und fügen dafür zuerst eine Spinner
-Komponente in ToggleCats.tsx
hinzu, die uns für die Zeit zwischen Laden und erstem Mount als Fallback dient:
1const Spinner = () => (
Außerdem definieren wir eine eigene sleep
-Funktion, um für dieses Tutorial die Fallback-Komponente länger anzeigen zu können:
1export const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));
Damit können wir die zwei letzten wichtigen Änderungen durchführen:
- Hinzufügen des
React.Lazy
Imports und - Hinzufügen der
React.Suspense
Komponente, um einen Fallback anzuzeigen.
React.Lazy
nimmt eine Funktion entgegen, die den dynamischen import()
aufruft. Das Ganze soll als Promise zurückgegeben werden. Der Promise löst sich dann zu einem Modul mit default
-Export auf. Dieses Modul enthält die Komponente, welche lazy geladen werden soll. Damit wir für die Demo den Fallback länger anzeigen lassen können, erzeugen wir nicht nur den dynamischen import()
, sondern schlafen ein bisschen mit unserer sleep
-Funktion:
1// when button is pressed, sleep and import run concurrently 2const Cat = React.lazy(async () => { 3 const [moduleExports] = await Promise.all([ 4 import("./Cat"), 5 sleep(4000) // simulate that react needs 4s to import and first render 6 ]); 7 return moduleExports; // return module with default export containing react component 8});
Wollen wir nicht schlafen, bevor der lazy import passiert, dann reicht es via:
1const Cat = React.lazy(() => import('./Cat'));
zu importieren.
Als letzten Schritt fügen wir die React.Suspense
-Komponente hinzu, die es uns ermöglicht, die Spinner
-Komponente als Fallback für den Lazy-Import-Zeitraum zu rendern (also für die Zeit zwischen dem Klick und dem ersten Rendering der Cat
-Komponenten durch React):
1import axios from "axios";
2import * as React from "react";
3
4const Spinner = () => (
Zusammengefasst sieht unsere ToggleCats.tsx
dann so aus:
1import axios from "axios";
2import * as React from "react";
3
4const Spinner = () => (
5 <div
6 style={{
7 ...catStyle,
8 textAlign: "left",
9 fontSize: "1em"
10 }}
11 >
12 ... Loading ...
13 </div>
14);
15
16export const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));
17
18// when button is pressed, sleep and import run concurrently
19const Cat = React.lazy(async () => {
20 const [moduleExports] = await Promise.all([
21 import("./Cat"),
22 sleep(4000) // simulate that react needs 4s to import and first render
23 ]);
24 return moduleExports;
25});
26
27const catsContainerStyle = {
28 gridArea: "cats",
29 display: "flex",
30 justifyContent: "center",
31 alignItems: "center",
32 flexWrap: "wrap" as "wrap",
33 fontSize: "2em"
34};
35
36const catStyle = {
37 width: "400px",
38 height: "400px",
39 objectFit: "contain" as "contain",
40 padding: "20px"
41};
42
43const layoutStyle = {
44 display: "grid",
45 gridTemplateAreas: `"button"
46 "cats"`,
47 gridGap: "10px"
48};
49
50interface Category {
51 id: number;
52 name: string;
53}
54
55interface Cat {
56 breeds: [];
57 categories: Record<number, Category>;
58 url: string;
59 width: number;
60 height: number;
61 id: string;
62}
63
64export const ToggleCat = () => {
65 const [cats, setCats] = React.useState([]);
66 const [isActive, setIsActive] = React.useState(false);
67
68 React.useEffect(() => {
69 axios
70 .get("https://api.thecatapi.com/v1/images/search?&limit=80")
71 .then(response => {
72 setCats(response.data);
73 });
74 }, []);
75
76 const toggleIsActive = () => {
77 setIsActive(!isActive);
78 };
79
80 return (
81 <div style={layoutStyle}>
82 <button
83 style={{ gridArea: "button", fontSize: "4em" }}
84 onClick={toggleIsActive}
85 >
86 Show all the cats!
87 </button>
88 <div style={catsContainerStyle}>
89 {isActive &&
90 cats.map((cat: Cat) => (
91 <React.Suspense key={cat.id} fallback={<Spinner />}>
92 <Cat
93 style={catStyle}
94 imageUrl={cat.url}
95 altText={"... a cat ..."}
96 />
97 </React.Suspense>
98 ))}
99 </div>
100 </div>
101 );
102};
Auch unser Test sollte nun durchlaufen. Durch die sleep
-Funktion sollte der ToggleCat
-Test
zwischen vier und fünf Sekunden dauern:
PASS src/Cat.spec.tsx
PASS src/ToggleCat.spec.tsx (5.135s)
Wenn wir nun im Browser unsere App neu laden und ausprobieren, dann sollte:
- initial nur einen Knopf zu sehen sein,
- beim Klick auf den Knopf für jedes Katzenbild eine Fallback-Komponente erscheinen (
... Loading ...
) - und, sobald die
Cat
-Komponente das erste Mal gemountet wird, den Alt-Text des Bildes und dann das Bild selbst zu sehen sein.
Außerdem können wir in der Developer-Console im Network-Tab sehen, dass der zweite Teil des Bundles erst geladen wird, wenn der Knopf gedrückt wird.
Zusammenfassung
Warum die Time to Interactive
, nicht nur im Bezug zu mobilen Endgeräten, wichtig ist und warum es gilt, sie so klein wie möglich zu halten, haben wir im ersten Abschnitt hinterleuchtet. Außerdem haben wir uns angeschaut, warum die TTI
immer größer wird. Wir haben gesehen, dass sich die TTI
mit einfachen Mitteln verkürzen lässt. Eines dieser Mittel ist Code-Splitting
, welches es uns ermöglicht, Teile des Bundles erst dann zu laden, wenn sie wirklich benötigt werden. React bietet hierzu zwei APIs an. React.Lazy
und React.Suspense
, welche es uns ermöglichen, das Bundle zu teilen und Fallback Komponenten anzuzeigen.
Wir haben eine kleine Demo-App implementiert, die Katzenbilder anzeigt und dabei Code-Splitting implementiert. Für eine kleine App wie diese ist Code-Spitting in der Praxis nicht nötig. Werden die Apps aber größer und komplexer, lohnt sich der Mehraufwand schnell.
Der komplette Quellcode findet sich auch im lazy-suspense-cats-demo
Repository auf Github. Kommentare sind wie immer herzlich willkommen.
Dein Job bei codecentric?
Jobs
Agile Developer und Consultant (w/d/m)
Alle Standorte
Weitere Artikel in diesem Themenbereich
Entdecke spannende weiterführende Themen und lass dich von der codecentric Welt inspirieren.
Gemeinsam bessere Projekte umsetzen.
Wir helfen deinem Unternehmen.
Du stehst vor einer großen IT-Herausforderung? Wir sorgen für eine maßgeschneiderte Unterstützung. Informiere dich jetzt.
Hilf uns, noch besser zu werden.
Wir sind immer auf der Suche nach neuen Talenten. Auch für dich ist die passende Stelle dabei.
Blog-Autor*in
Maik Figura
IT Consultant
Du hast noch Fragen zu diesem Thema? Dann sprich mich einfach an.
Du hast noch Fragen zu diesem Thema? Dann sprich mich einfach an.