Code Splitting mit React Lazy und React Suspense

Keine Kommentare

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.

Lighthouse Performance of CNN on a MacBook via LTE
CNN Performance auf einem Macbook via LTE



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

npx create-react-app lazy-suspense-cats --typescript
cd 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:

yarn add @testing-library/react @types/react-dom react-dom axios

Nun sollte die Datei package.jsonso aussehen:

{
  "name": "lazy-suspense-cats",
  "version": "0.1.0",
  "private": true,
  "dependencies": {
    "@testing-library/react": "^8.0.4",
    "@types/jest": "24.0.15",
    "@types/node": "12.0.10",
    "@types/react": "16.8.22",
    "@types/react-dom": "^16.8.4",
    "axios": "^0.19.0",
    "react": "^16.8.6",
    "react-dom": "^16.8.6",
    "react-scripts": "3.0.1",
    "typescript": "3.5.2"
  },
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject"
  },
  "eslintConfig": {
    "extends": "react-app"
  },
  "browserslist": {
    "production": [
      ">0.2%",
      "not dead",
      "not op_mini all"
    ],
    "development": [
      "last 1 chrome version",
      "last 1 firefox version",
      "last 1 safari version"
    ]
  }
}

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.

import * as React from "react";
import Cat from "./Cat";
import { render, cleanup } from "@testing-library/react";
 
describe("Cat", () => {
  afterEach(cleanup);
 
  it("should render an image", () => {
    const imageUrl = "/image/url/image.jpg";
    const altText = "alt text";
    const { getByAltText } = render(
 
    );
 
    getByAltText(altText);
  });
});

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:

import * as React from "react";
 
const Cat = (props: {
  imageUrl: string;
  altText: string;
  style?: React.CSSProperties;
}) =&gt; <img src="{props.imageUrl}" alt="{props.altText}" />;
 
export 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.

import * as React from "react";
import { render } from "react-dom";
import { ToggleCat } from "./ToggleCat";
 
export const App = () =&gt; {
  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.

import {
  cleanup,
  fireEvent,
  render,
  waitForElement
} from "@testing-library/react";
import axios from "axios";
import * as React from "react";
import { ToggleCat } from "./ToggleCat";
jest.mock("axios");
 
describe("Toggle Cat", () =&gt; {
  afterEach(cleanup);
 
  it("should load a bunch of cats when toggle is clicked", async () =&gt; {
    const buttonText = "Show all the cats!";
 
    const cats = [
      { url: "./__mocks__/imaages/d7j.jpg", id: "d7j.jpg" },
      { url: "url/2", id: "1" }
    ];
    axios.get.mockResolvedValue({ data: cats });
 
    // should render a page with a tempting button
    const {
      findAllByAltText,
      queryAllByText,
      getByText,
      queryByAltText
    } = render();
 
    // should not have mounted any image so far
    expect(queryByAltText("... a cat ...")).toEqual(null);
 
    // trigger mounting of cat components
    fireEvent.click(getByText(buttonText));
 
    const fallback = await waitForElement(() =&gt;
      queryAllByText("... Loading ...")
    );
 
    // should render two fallback spinners (as we pass in two cats in this test)
    expect(
      await waitForElement(() =&gt; queryAllByText("... Loading ...").length)
    ).toEqual(2);
 
    // after sleeping a bit, we should see two cats
    const lazycat = await waitForElement(() =&gt;
      findAllByAltText("... a cat ...")
    );
 
    expect(lazycat.length).toEqual(2);
  });
});


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:

// this is just a little hack to silence a warning that we'll get until react
// fixes this: https://github.com/facebook/react/pull/14853
const originalError = console.error;
beforeAll(() =&gt; {
  console.error = (...args) =&gt; {
    if (/Warning.*not wrapped in act/.test(args[0])) {
      return;
    }
    originalError.call(console, ...args);
  };
});
 
afterAll(() =&gt; {
  console.error = originalError;
});

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:

import axios from "axios";
import * as React from "react";
 
export const ToggleCat = () =&gt; {
  const [cats, setCats] = React.useState([]);
 
  React.useEffect(() =&gt; {
    axios
      .get("https://api.thecatapi.com/v1/images/search?&amp;limit=80")
      .then(response =&gt; {
        setCats(response.data);
      });
  }, []);
 
  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:

import axios from "axios";
import * as React from "react";
import Cat from "./Cat";
 
interface Category {
  id: number;
  name: string;
}
 
interface Cat {
  breeds: [];
  categories: Record&lt;number, Category&gt;;
  url: string;
  width: number;
  height: number;
  id: string;
}
 
export const ToggleCat = () =&gt; {
  const [cats, setCats] = React.useState([]);
 
  React.useEffect(() =&gt; {
    axios
      .get("https://api.thecatapi.com/v1/images/search?&amp;limit=80")
      .then(response =&gt; {
        setCats(response.data);
      });
  }, []);
 
  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:

...
export const ToggleCat = () =&gt; {
  const [cats, setCats] = React.useState([]);
  const [isActive, setIsActive] = React.useState(false);
 
  React.useEffect(() =&gt; {
    axios
      .get("https://api.thecatapi.com/v1/images/search?&amp;limit=80")
      .then(response =&gt; {
        setCats(response.data);
      });
  }, []);
 
  const toggleIsActive = () =&gt; {
    setIsActive(!isActive);
  };
 
  return (

Jetzt fügen wir noch einige styles hinzu. Wir verwenden Grid, um die Ausrichtung der Kacheln etwas einfacher zu gestalten:

import axios from "axios";
import * as React from "react";
import Cat from "./Cat";
 
const catsContainerStyle = {
  gridArea: "cats",
  display: "flex",
  justifyContent: "center",
  alignItems: "center",
  flexWrap: "wrap" as "wrap",
  fontSize: "2em"
};
 
const catStyle = {
  width: "400px",
  height: "400px",
  objectFit: "contain" as "contain",
  padding: "20px"
};
 
const layoutStyle = {
  display: "grid",
  gridTemplateAreas: `"button"
      "cats"`,
  gridGap: "10px"
};
 
interface Category {
  id: number;
  name: string;
}
 
interface Cat {
  breeds: [];
  categories: Record&lt;number, Category&gt;;
  url: string;
  width: number;
  height: number;
  id: string;
}
 
export const ToggleCat = () =&gt; {
  const [cats, setCats] = React.useState([]);
  const [isActive, setIsActive] = React.useState(false);
 
  React.useEffect(() =&gt; {
    axios
      .get("https://api.thecatapi.com/v1/images/search?&amp;limit=80")
      .then(response =&gt; {
        setCats(response.data);
      });
  }, []);
 
  const toggleIsActive = () =&gt; {
    setIsActive(!isActive);
  };
 
  return (


Inzwischen sollte unsere App (nach einem Klick auf den Knopf) so aussehen wie auf dem folgenden Screenshot:

Gallerie der Katzen noch ohne React Lazy und React Suspense
App mit Styles aber nicht lazy



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:

const Spinner = () =&gt; (

Außerdem definieren wir eine eigene sleep-Funktion, um für dieses Tutorial die Fallback-Komponente länger anzeigen zu können:

export const sleep = (ms: number) =&gt; new Promise(r =&gt; 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:

// when button is pressed, sleep and import run concurrently
const Cat = React.lazy(async () =&gt; {
  const [moduleExports] = await Promise.all([
    import("./Cat"),
    sleep(4000) // simulate that react needs 4s to import and first render
  ]);
  return moduleExports; // return module with default export containing react component
});

Wollen wir nicht schlafen, bevor der lazy import passiert, dann reicht es via:

const Cat = React.lazy(() =&gt; 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):

import axios from "axios";
import * as React from "react";
 
const Spinner = () =&gt; (

Zusammengefasst sieht unsere ToggleCats.tsx dann so aus:

import axios from "axios";
import * as React from "react";
 
const Spinner = () => (
  <div
    style={{
      ...catStyle,
      textAlign: "left",
      fontSize: "1em"
    }}
  >
    ... Loading ...
  </div>
);
 
export const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));
 
// when button is pressed, sleep and import run concurrently
const Cat = React.lazy(async () => {
  const [moduleExports] = await Promise.all([
    import("./Cat"),
    sleep(4000) // simulate that react needs 4s to import and first render
  ]);
  return moduleExports;
});
 
const catsContainerStyle = {
  gridArea: "cats",
  display: "flex",
  justifyContent: "center",
  alignItems: "center",
  flexWrap: "wrap" as "wrap",
  fontSize: "2em"
};
 
const catStyle = {
  width: "400px",
  height: "400px",
  objectFit: "contain" as "contain",
  padding: "20px"
};
 
const layoutStyle = {
  display: "grid",
  gridTemplateAreas: `"button"
    "cats"`,
  gridGap: "10px"
};
 
interface Category {
  id: number;
  name: string;
}
 
interface Cat {
  breeds: [];
  categories: Record<number, Category>;
  url: string;
  width: number;
  height: number;
  id: string;
}
 
export const ToggleCat = () => {
  const [cats, setCats] = React.useState([]);
  const [isActive, setIsActive] = React.useState(false);
 
  React.useEffect(() => {
    axios
      .get("https://api.thecatapi.com/v1/images/search?&limit=80")
      .then(response => {
        setCats(response.data);
      });
  }, []);
 
  const toggleIsActive = () => {
    setIsActive(!isActive);
  };
 
  return (
    <div style={layoutStyle}>
      <button
        style={{ gridArea: "button", fontSize: "4em" }}
        onClick={toggleIsActive}
      >
        Show all the cats!
      </button>
      <div style={catsContainerStyle}>
        {isActive &&
          cats.map((cat: Cat) => (
            <React.Suspense key={cat.id} fallback={<Spinner />}>
              <Cat
                style={catStyle}
                imageUrl={cat.url}
                altText={"... a cat ..."}
              />
            </React.Suspense>
          ))}
      </div>
    </div>
  );
};

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.

maik.figura

Maik absolvierte zunächst eine Ausbildung zum IT-Systemelektroniker. Danach studierte er im Bachelorstudiengang Audiovisuelle Medien an der Hochschule der Medien Stuttgart mit Schwerpunkt Computer Grafik. Seinen Master in Electronic Media absolvierte Maik ebenfalls an der Hochschule der Medien.

Danach arbeitete Maik an Projekten im Cloud-Umfeld.

Bei der codecentric AG macht er das was nötig ist, um Projekte voran zu bringen.

Seine Interessenschwerpunkte liegen inzwischen bei React, Chaos Engineering und Softwareentwicklungsprozessen, wie Mob Programming oder „no estimates“.

Weitere Inhalte zu Frontend

Kommentieren

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.