Framework
Version

Leistung & Request Waterfalls

Anwendungsperformance ist ein breites und komplexes Thema, und obwohl React Query Ihre APIs nicht schneller machen kann, gibt es dennoch Dinge, auf die Sie bei der Verwendung von React Query achten sollten, um die beste Performance zu gewährleisten.

Die größte Performance-Falle bei der Verwendung von React Query oder jeder anderen Datenabrufbibliothek, die es Ihnen ermöglicht, Daten innerhalb von Komponenten abzurufen, sind Request Waterfalls. Der Rest dieser Seite erklärt, was sie sind, wie Sie sie erkennen und wie Sie Ihre Anwendung oder APIs umstrukturieren können, um sie zu vermeiden.

Der Leitfaden zu Prefetching & Router-Integration baut darauf auf und lehrt Sie, wie Sie Daten im Voraus abrufen können, wenn es nicht möglich oder machbar ist, Ihre Anwendung oder APIs umzustrukturieren.

Der Leitfaden zu Server Rendering & Hydration lehrt Sie, wie Sie Daten auf dem Server im Voraus abrufen und diese Daten an den Client weitergeben, sodass Sie sie nicht erneut abrufen müssen.

Der Leitfaden zu Advanced Server Rendering lehrt Sie weiter, wie Sie diese Muster auf Serverkomponenten und Streaming Server Rendering anwenden.

Was ist ein Request Waterfall?

Ein Request Waterfall entsteht, wenn die Anforderung einer Ressource (Code, CSS, Bilder, Daten) nicht beginnt, bevor eine andere Anforderung einer Ressource abgeschlossen ist.

Betrachten Sie eine Webseite. Bevor Sie Dinge wie CSS, JS usw. laden können, muss der Browser zuerst das Markup laden. Dies ist ein Request Waterfall.

1. |-> Markup
2.   |-> CSS
2.   |-> JS
2.   |-> Image
1. |-> Markup
2.   |-> CSS
2.   |-> JS
2.   |-> Image

Wenn Sie Ihr CSS innerhalb einer JS-Datei abrufen, haben Sie jetzt einen doppelten Waterfall

1. |-> Markup
2.   |-> JS
3.     |-> CSS
1. |-> Markup
2.   |-> JS
3.     |-> CSS

Wenn dieses CSS ein Hintergrundbild verwendet, ist es ein dreifacher Waterfall

1. |-> Markup
2.   |-> JS
3.     |-> CSS
4.       |-> Image
1. |-> Markup
2.   |-> JS
3.     |-> CSS
4.       |-> Image

Der beste Weg, Ihre Request Waterfalls zu erkennen und zu analysieren, ist normalerweise das Öffnen des "Netzwerk"-Tabs in den Entwicklertools Ihres Browsers.

Jeder Waterfall repräsentiert mindestens eine Roundtrip zum Server, es sei denn, die Ressource ist lokal zwischengespeichert (in der Praxis können einige dieser Waterfalls mehr als eine Roundtrip darstellen, da der Browser eine Verbindung herstellen muss, was einige Hin- und Her-Kommunikation erfordert, aber das ignorieren wir hier). Aus diesem Grund sind die negativen Auswirkungen von Request Waterfalls stark von der Latenz des Benutzers abhängig. Betrachten Sie das Beispiel des dreifachen Waterfalls, das tatsächlich 4 Server-Roundtrips darstellt. Bei 250 ms Latenz, was auf 3G-Netzwerken oder bei schlechten Netzwerkbedingungen nicht unüblich ist, ergeben sich insgesamt 4 * 250 = 1000 ms nur unter Berücksichtigung der Latenz. Wenn wir das auf das erste Beispiel mit nur 2 Roundtrips abflachen könnten, erhalten wir stattdessen 500 ms, möglicherweise das Laden dieses Hintergrundbilds in der halben Zeit!

Request Waterfalls & React Query

Betrachten wir nun React Query. Wir konzentrieren uns zunächst auf den Fall ohne Server Rendering. Bevor wir überhaupt eine Abfrage starten können, müssen wir das JS laden, also bevor wir diese Daten auf dem Bildschirm anzeigen können, haben wir einen doppelten Waterfall

1. |-> Markup
2.   |-> JS
3.     |-> Query
1. |-> Markup
2.   |-> JS
3.     |-> Query

Mit dieser Grundlage werden wir uns nun einige verschiedene Muster ansehen, die zu Request Waterfalls in React Query führen können, und wie man sie vermeidet.

  • Single Component Waterfalls / Serielle Abfragen
  • Verschachtelte Komponenten-Waterfalls
  • Code-Splitting

Single Component Waterfalls / Serielle Abfragen

Wenn eine einzelne Komponente zuerst eine Abfrage und dann eine weitere abruft, ist das ein Request Waterfall. Dies kann passieren, wenn die zweite Abfrage eine abhängige Abfrage ist, d.h. sie hängt bei der Abfrage von Daten aus der ersten Abfrage ab.

tsx
// Get the user
const { data: user } = useQuery({
  queryKey: ['user', email],
  queryFn: getUserByEmail,
})

const userId = user?.id

// Then get the user's projects
const {
  status,
  fetchStatus,
  data: projects,
} = useQuery({
  queryKey: ['projects', userId],
  queryFn: getProjectsByUser,
  // The query will not execute until the userId exists
  enabled: !!userId,
})
// Get the user
const { data: user } = useQuery({
  queryKey: ['user', email],
  queryFn: getUserByEmail,
})

const userId = user?.id

// Then get the user's projects
const {
  status,
  fetchStatus,
  data: projects,
} = useQuery({
  queryKey: ['projects', userId],
  queryFn: getProjectsByUser,
  // The query will not execute until the userId exists
  enabled: !!userId,
})

Auch wenn dies nicht immer möglich ist, ist es für optimale Performance besser, Ihre API so umzustrukturieren, dass Sie beide in einer einzigen Abfrage abrufen können. Im obigen Beispiel würden Sie anstatt zuerst getUserByEmail abzurufen, um getProjectsByUser abrufen zu können, durch die Einführung einer neuen getProjectsByUserEmail Abfrage den Waterfall abflachen.

Eine weitere Möglichkeit, abhängige Abfragen zu mildern, ohne Ihre API umzustrukturieren, ist die Verlagerung des Waterfalls auf den Server, wo die Latenz geringer ist. Dies ist die Idee hinter Server Components, die im Leitfaden zu Advanced Server Rendering behandelt werden.

Ein weiteres Beispiel für serielle Abfragen ist die Verwendung von React Query mit Suspense

tsx
function App () {
  // The following queries will execute in serial, causing separate roundtrips to the server:
  const usersQuery = useSuspenseQuery({ queryKey: ['users'], queryFn: fetchUsers })
  const teamsQuery = useSuspenseQuery({ queryKey: ['teams'], queryFn: fetchTeams })
  const projectsQuery = useSuspenseQuery({ queryKey: ['projects'], queryFn: fetchProjects })

  // Note that since the queries above suspend rendering, no data
  // gets rendered until all of the queries finished
  ...
}
function App () {
  // The following queries will execute in serial, causing separate roundtrips to the server:
  const usersQuery = useSuspenseQuery({ queryKey: ['users'], queryFn: fetchUsers })
  const teamsQuery = useSuspenseQuery({ queryKey: ['teams'], queryFn: fetchTeams })
  const projectsQuery = useSuspenseQuery({ queryKey: ['projects'], queryFn: fetchProjects })

  // Note that since the queries above suspend rendering, no data
  // gets rendered until all of the queries finished
  ...
}

Beachten Sie, dass diese mit regulärem useQuery parallel ablaufen würden.

Glücklicherweise ist dies leicht zu beheben, indem Sie immer den Hook useSuspenseQueries verwenden, wenn Sie mehrere Suspense-Abfragen in einer Komponente haben.

tsx
const [usersQuery, teamsQuery, projectsQuery] = useSuspenseQueries({
  queries: [
    { queryKey: ['users'], queryFn: fetchUsers },
    { queryKey: ['teams'], queryFn: fetchTeams },
    { queryKey: ['projects'], queryFn: fetchProjects },
  ],
})
const [usersQuery, teamsQuery, projectsQuery] = useSuspenseQueries({
  queries: [
    { queryKey: ['users'], queryFn: fetchUsers },
    { queryKey: ['teams'], queryFn: fetchTeams },
    { queryKey: ['projects'], queryFn: fetchProjects },
  ],
})

Verschachtelte Komponenten-Waterfalls

Verschachtelte Komponenten-Waterfalls entstehen, wenn sowohl eine Eltern- als auch eine Kindkomponente Abfragen enthalten und die Elternkomponente die Kindkomponente erst rendert, wenn ihre Abfrage abgeschlossen ist. Dies kann sowohl mit useQuery als auch mit useSuspenseQuery geschehen.

Wenn die Kindkomponente bedingt basierend auf den Daten der Elternkomponente gerendert wird oder wenn die Kindkomponente auf einen Teil des Ergebnisses angewiesen ist, das als Prop von der Elternkomponente übergeben wird, um ihre Abfrage zu machen, haben wir einen abhängigen verschachtelten Komponenten-Waterfall.

Betrachten wir zunächst ein Beispiel, bei dem das Kind nicht von der Elternkomponente abhängt.

tsx
function Article({ id }) {
  const { data: articleData, isPending } = useQuery({
    queryKey: ['article', id],
    queryFn: getArticleById,
  })

  if (isPending) {
    return 'Loading article...'
  }

  return (
    <>
      <ArticleHeader articleData={articleData} />
      <ArticleBody articleData={articleData} />
      <Comments id={id} />
    </>
  )

}

function Comments({ id }) {
  const { data, isPending } = useQuery({
    queryKey: ['article-comments', id],
    queryFn: getArticleCommentsById,
  })

  ...
}
function Article({ id }) {
  const { data: articleData, isPending } = useQuery({
    queryKey: ['article', id],
    queryFn: getArticleById,
  })

  if (isPending) {
    return 'Loading article...'
  }

  return (
    <>
      <ArticleHeader articleData={articleData} />
      <ArticleBody articleData={articleData} />
      <Comments id={id} />
    </>
  )

}

function Comments({ id }) {
  const { data, isPending } = useQuery({
    queryKey: ['article-comments', id],
    queryFn: getArticleCommentsById,
  })

  ...
}

Beachten Sie, dass <Comments> zwar eine Prop id von der Elternkomponente nimmt, diese ID aber bereits verfügbar ist, wenn <Article> gerendert wird, so dass es keinen Grund gibt, die Kommentare nicht gleichzeitig mit dem Artikel abzurufen. In realen Anwendungen kann das Kind weit unter der Elternkomponente verschachtelt sein, und diese Art von Waterfalls ist oft schwieriger zu erkennen und zu beheben. Für unser Beispiel wäre eine Möglichkeit, den Waterfall abzuflachen, die Kommentar-Abfrage in die Elternkomponente zu verschieben.

tsx
function Article({ id }) {
  const { data: articleData, isPending: articlePending } = useQuery({
    queryKey: ['article', id],
    queryFn: getArticleById,
  })

  const { data: commentsData, isPending: commentsPending } = useQuery({
    queryKey: ['article-comments', id],
    queryFn: getArticleCommentsById,
  })

  if (articlePending) {
    return 'Loading article...'
  }

  return (
    <>
      <ArticleHeader articleData={articleData} />
      <ArticleBody articleData={articleData} />
      {commentsPending ? (
        'Loading comments...'
      ) : (
        <Comments commentsData={commentsData} />
      )}
    </>
  )
}
function Article({ id }) {
  const { data: articleData, isPending: articlePending } = useQuery({
    queryKey: ['article', id],
    queryFn: getArticleById,
  })

  const { data: commentsData, isPending: commentsPending } = useQuery({
    queryKey: ['article-comments', id],
    queryFn: getArticleCommentsById,
  })

  if (articlePending) {
    return 'Loading article...'
  }

  return (
    <>
      <ArticleHeader articleData={articleData} />
      <ArticleBody articleData={articleData} />
      {commentsPending ? (
        'Loading comments...'
      ) : (
        <Comments commentsData={commentsData} />
      )}
    </>
  )
}

Die beiden Abfragen werden nun parallel abgerufen. Beachten Sie, dass Sie bei Verwendung von Suspense diese beiden Abfragen zu einer einzigen useSuspenseQueries zusammenfassen möchten.

Eine weitere Möglichkeit, diesen Waterfall abzuflachen, wäre das Vorabrufen der Kommentare in der <Article> Komponente oder das Vorabrufen beider dieser Abfragen auf Router-Ebene beim Laden oder Navigieren einer Seite. Lesen Sie mehr dazu im Leitfaden zu Prefetching & Router-Integration.

Als Nächstes betrachten wir einen abhängigen verschachtelten Komponenten-Waterfall.

tsx
function Feed() {
  const { data, isPending } = useQuery({
    queryKey: ['feed'],
    queryFn: getFeed,
  })

  if (isPending) {
    return 'Loading feed...'
  }

  return (
    <>
      {data.map((feedItem) => {
        if (feedItem.type === 'GRAPH') {
          return <GraphFeedItem key={feedItem.id} feedItem={feedItem} />
        }

        return <StandardFeedItem key={feedItem.id} feedItem={feedItem} />
      })}
    </>
  )
}

function GraphFeedItem({ feedItem }) {
  const { data, isPending } = useQuery({
    queryKey: ['graph', feedItem.id],
    queryFn: getGraphDataById,
  })

  ...
}
function Feed() {
  const { data, isPending } = useQuery({
    queryKey: ['feed'],
    queryFn: getFeed,
  })

  if (isPending) {
    return 'Loading feed...'
  }

  return (
    <>
      {data.map((feedItem) => {
        if (feedItem.type === 'GRAPH') {
          return <GraphFeedItem key={feedItem.id} feedItem={feedItem} />
        }

        return <StandardFeedItem key={feedItem.id} feedItem={feedItem} />
      })}
    </>
  )
}

function GraphFeedItem({ feedItem }) {
  const { data, isPending } = useQuery({
    queryKey: ['graph', feedItem.id],
    queryFn: getGraphDataById,
  })

  ...
}

Die zweite Abfrage getGraphDataById hängt auf zwei verschiedene Arten von ihrer Elternkomponente ab. Erstens findet sie nur statt, wenn feedItem ein Graph ist, und zweitens benötigt sie eine id von der Elternkomponente.

1. |> getFeed()
2.   |> getGraphDataById()
1. |> getFeed()
2.   |> getGraphDataById()

In diesem Beispiel können wir den Waterfall nicht einfach durch Verschieben der Abfrage in die Elternkomponente oder sogar durch Hinzufügen von Prefetching abflachen. Ähnlich wie beim Beispiel für abhängige Abfragen zu Beginn dieser Anleitung besteht eine Option darin, unsere API zu refaktorieren, um die Graphdaten in die getFeed Abfrage aufzunehmen. Eine weitere fortgeschrittenere Lösung ist die Nutzung von Server Components, um den Waterfall auf den Server zu verlagern, wo die Latenz geringer ist (lesen Sie mehr dazu im Leitfaden zu Advanced Server Rendering), aber beachten Sie, dass dies eine sehr große architektonische Änderung sein kann.

Sie können auch mit einigen wenigen Abfrage-Waterfalls hier und da eine gute Performance erzielen, wissen Sie einfach, dass sie ein häufiges Performance-Problem darstellen und seien Sie sich dessen bewusst. Eine besonders heimtückische Version tritt auf, wenn Code Splitting beteiligt ist, sehen wir uns das als Nächstes an.

Code-Splitting

Das Aufteilen des JS-Codes einer Anwendung in kleinere Blöcke und das Laden nur der notwendigen Teile ist in der Regel ein entscheidender Schritt zur Erzielung guter Performance. Es hat jedoch einen Nachteil: Es führt oft zu Request Waterfalls. Wenn dieser Code-Split-Code auch eine Abfrage enthält, verschlimmert sich dieses Problem noch weiter.

Betrachten Sie dies als eine leicht modifizierte Version des Feed-Beispiels.

tsx
// This lazy loads the GraphFeedItem component, meaning
// it wont start loading until something renders it
const GraphFeedItem = React.lazy(() => import('./GraphFeedItem'))

function Feed() {
  const { data, isPending } = useQuery({
    queryKey: ['feed'],
    queryFn: getFeed,
  })

  if (isPending) {
    return 'Loading feed...'
  }

  return (
    <>
      {data.map((feedItem) => {
        if (feedItem.type === 'GRAPH') {
          return <GraphFeedItem key={feedItem.id} feedItem={feedItem} />
        }

        return <StandardFeedItem key={feedItem.id} feedItem={feedItem} />
      })}
    </>
  )
}

// GraphFeedItem.tsx
function GraphFeedItem({ feedItem }) {
  const { data, isPending } = useQuery({
    queryKey: ['graph', feedItem.id],
    queryFn: getGraphDataById,
  })

  ...
}
// This lazy loads the GraphFeedItem component, meaning
// it wont start loading until something renders it
const GraphFeedItem = React.lazy(() => import('./GraphFeedItem'))

function Feed() {
  const { data, isPending } = useQuery({
    queryKey: ['feed'],
    queryFn: getFeed,
  })

  if (isPending) {
    return 'Loading feed...'
  }

  return (
    <>
      {data.map((feedItem) => {
        if (feedItem.type === 'GRAPH') {
          return <GraphFeedItem key={feedItem.id} feedItem={feedItem} />
        }

        return <StandardFeedItem key={feedItem.id} feedItem={feedItem} />
      })}
    </>
  )
}

// GraphFeedItem.tsx
function GraphFeedItem({ feedItem }) {
  const { data, isPending } = useQuery({
    queryKey: ['graph', feedItem.id],
    queryFn: getGraphDataById,
  })

  ...
}

Dieses Beispiel hat einen doppelten Waterfall, der so aussieht

1. |> getFeed()
2.   |> JS for <GraphFeedItem>
3.     |> getGraphDataById()
1. |> getFeed()
2.   |> JS for <GraphFeedItem>
3.     |> getGraphDataById()

Aber das ist nur die Betrachtung des Codes aus dem Beispiel. Wenn wir bedenken, wie der erste Seitenaufruf dieser Seite aussieht, müssen wir tatsächlich 5 Roundtrips zum Server abschließen, bevor wir den Graphen rendern können!

1. |> Markup
2.   |> JS for <Feed>
3.     |> getFeed()
4.       |> JS for <GraphFeedItem>
5.         |> getGraphDataById()
1. |> Markup
2.   |> JS for <Feed>
3.     |> getFeed()
4.       |> JS for <GraphFeedItem>
5.         |> getGraphDataById()

Beachten Sie, dass dies beim Server Rendering etwas anders aussieht, das werden wir im Leitfaden zu Server Rendering & Hydration weiter untersuchen. Beachten Sie auch, dass es nicht ungewöhnlich ist, dass die Route, die <Feed> enthält, ebenfalls Code-Split ist, was noch einen weiteren Sprung hinzufügen könnte.

Im Code-Split-Fall könnte es tatsächlich hilfreich sein, die getGraphDataById Abfrage in die <Feed> Komponente zu verschieben und sie bedingt zu machen oder ein bedingtes Prefetching hinzuzufügen. Diese Abfrage könnte dann parallel zum Code abgerufen werden, was das Beispiel auf diese Weise verändert

1. |> getFeed()
2.   |> getGraphDataById()
2.   |> JS for <GraphFeedItem>
1. |> getFeed()
2.   |> getGraphDataById()
2.   |> JS for <GraphFeedItem>

Dies ist jedoch ein deutlicher Kompromiss. Sie binden nun den Datenabrurcode für getGraphDataById in dasselbe Bundle wie <Feed> ein, bewerten Sie also, was für Ihren Fall am besten ist. Lesen Sie mehr darüber, wie Sie dies tun können, im Leitfaden zu Prefetching & Router-Integration.

Der Kompromiss zwischen

  • Alle Datenabruf-Codes in das Hauptpaket aufnehmen, auch wenn wir sie selten verwenden
  • Den Datenabruf-Code in das Code-Split-Paket legen, aber mit einem Request Waterfall

ist nicht gut und war eine der Motivationen für Server Components. Mit Server Components ist es möglich, beides zu vermeiden, lesen Sie mehr darüber, wie sich dies auf React Query auswirkt, im Leitfaden zu Advanced Server Rendering.

Zusammenfassung und Fazit

Request Waterfalls sind ein sehr häufiges und komplexes Performance-Problem mit vielen Kompromissen. Es gibt viele Möglichkeiten, sie versehentlich in Ihre Anwendung einzuführen

  • Hinzufügen einer Abfrage zu einer Kindkomponente, ohne zu erkennen, dass eine Elternkomponente bereits eine Abfrage hat
  • Hinzufügen einer Abfrage zu einer Elternkomponente, ohne zu erkennen, dass eine Kindkomponente bereits eine Abfrage hat
  • Verschieben einer Komponente mit Nachkommen, die eine Abfrage hat, zu einer neuen Elternkomponente mit einem Vorfahren, der eine Abfrage hat
  • Usw..

Aufgrund dieser unbeabsichtigten Komplexität ist es ratsam, auf Waterfalls zu achten und Ihre Anwendung regelmäßig auf sie zu überprüfen (eine gute Methode ist, von Zeit zu Zeit den Netzwerk-Tab zu überprüfen!). Sie müssen sie nicht unbedingt alle abflachen, um eine gute Performance zu erzielen, aber behalten Sie die Auswirkungen der wichtigsten im Auge.

Im nächsten Leitfaden werden wir weitere Möglichkeiten zur Abflachung von Waterfalls untersuchen, indem wir Prefetching & Router-Integration nutzen.