框架
版本

性能与请求瀑布

应用程序性能是一个广泛而复杂的话题,虽然 Solid Query 无法让您的 API 变得更快,但在使用 Solid Query 时仍需注意一些事项,以确保最佳性能。

在使用 Solid Query 或任何允许您在组件内获取数据的库时,最大的性能陷阱是请求瀑布流。本文档的其余部分将解释什么是请求瀑布流,如何识别它们,以及如何重构您的应用程序或 API 来避免它们。

预取与路由集成 指南在此基础上进行了扩展,并教您如何在无法重构应用程序或 API 的情况下提前预取数据。

服务器渲染与水合 指南教您如何在服务器上预取数据,并将该数据传递给客户端,从而无需再次获取。

高级服务器渲染 指南进一步讲解了如何将这些模式应用于服务器组件和流式服务器渲染。

什么是请求瀑布流?

请求瀑布流是指在请求一个资源(代码、CSS、图片、数据)之前,必须先完成另一个资源请求的情况。

考虑一个网页。在加载 CSS、JS 等内容之前,浏览器必须先加载标记。这就是一个请求瀑布流。

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

如果您在 JS 文件中获取 CSS,那么您就有了双重瀑布流。

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

如果该 CSS 使用了背景图片,那就是三重瀑布流。

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

识别和分析请求瀑布流的最佳方法通常是打开浏览器的开发者工具的“网络”选项卡。

每个瀑布流至少代表一次往返服务器,除非资源被本地缓存(实际上,这些瀑布流可能代表多次往返,因为浏览器需要建立连接,这需要一些来回通信,但在这里我们忽略这一点)。因此,请求瀑布流的负面影响高度依赖于用户的延迟。考虑三重瀑布流的例子,它实际上代表了 4 次服务器往返。在 250 毫秒的延迟下(在 3G 网络或糟糕的网络条件下并不少见),仅计算延迟,总时间就达到 4 * 250 = 1000 毫秒。如果我们能够将其展平到第一个例子,只进行 2 次往返,则得到 500 毫秒,可能只需一半的时间就能加载背景图片!

请求瀑布流与 Solid Query

现在让我们考虑 Solid Query。我们首先关注没有服务器渲染的情况。在我们开始进行任何查询之前,我们需要加载 JS,因此在将数据显示在屏幕上之前,我们就有一个双重瀑布流。

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

在此基础上,让我们看一些可能导致 Solid Query 中出现请求瀑布流的不同模式,以及如何避免它们。

  • 单个组件瀑布流 / 串行查询
  • 嵌套组件瀑布流
  • 代码分割

单个组件瀑布流 / 串行查询

当单个组件先获取一个查询,然后又获取另一个查询时,这就形成了请求瀑布流。这可能发生在第二个查询是依赖查询时,也就是说,它在获取数据时依赖于第一个查询的数据。

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,
})

虽然不总是可行,但为了获得最佳性能,最好重构您的 API,以便通过一个查询获取这两个数据。在上例中,与其先获取 getUserByEmail 以便能够获取 getProjectsByUser,不如引入一个新的 getProjectsByUserEmail 查询来展平瀑布流。

在不重构 API 的情况下缓解依赖查询的另一种方法是将瀑布流转移到延迟较低的服务器。这就是服务器组件背后的理念,服务器组件在高级服务器渲染指南中有介绍。

串行查询的另一个例子是当您将 Solid Query 与 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
  ...
}

请注意,使用普通的 useQuery 时,这些查询会并行发生。

幸运的是,这很容易修复,通过在组件中有多个 suspenseful 查询时,始终使用 useSuspenseQueries hook。

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 },
  ],
})

嵌套组件瀑布流

嵌套组件瀑布流是指父组件和子组件都包含查询,并且父组件在子组件的查询完成之前不渲染子组件。这可能发生在 useQueryuseSuspenseQuery 中。

如果子组件根据父组件的数据进行条件渲染,或者子组件依赖于传递给它的作为 prop 的父组件的某个部分来执行其查询,那么我们就遇到了一个*依赖性*嵌套组件瀑布流。

让我们先来看一个子组件*不*依赖于父组件的例子。

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,
  })

  ...
}

请注意,虽然 <Comments> 接收父组件的 id prop,但该 id 在 <Article> 渲染时就已经可用,因此没有理由不能同时获取评论。在实际应用中,子组件可能嵌套在父组件的下方很远的地方,这类瀑布流往往更难发现和修复,但对于我们的例子来说,展平瀑布流的一种方法是将评论查询提升到父组件中。

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} />
      )}
    </>
  )
}

这两个查询现在将并行获取。请注意,如果您正在使用 suspense,您应该将这两个查询合并到一个 useSuspenseQueries 中。

展平此瀑布流的另一种方法是在 <Article> 组件中预取评论,或在页面加载或导航时在路由器级别预取这两个查询,有关更多信息,请参阅预取与路由集成指南。

接下来,让我们看看*依赖性嵌套组件瀑布流*。

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,
  })

  ...
}

第二个查询 getGraphDataById 在两个方面依赖于其父组件。首先,除非 feedItem 是一个图表,否则它永远不会发生;其次,它需要一个来自父组件的 id

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

在这个例子中,我们无法通过简单地将查询提升到父组件,甚至添加预取来轻松展平瀑布流。就像本指南开头处的依赖查询示例一样,一种选择是重构我们的 API,将图表数据包含在 getFeed 查询中。另一种更高级的解决方案是利用服务器组件将瀑布流转移到延迟较低的服务器(有关更多信息,请参阅高级服务器渲染指南),但请注意,这可能是一个非常大的架构性更改。

即使存在一些零散的查询瀑布流,您仍然可以获得良好的性能,只需知道它们是常见的性能问题,并注意它们。一个特别棘手的版本是当涉及到代码拆分时,我们接下来看看。

代码分割

将应用程序的 JS 代码拆分成更小的块并只加载必要的部分,通常是实现良好性能的关键步骤。然而,它也有一个缺点,那就是它经常会引入请求瀑布流。当代码拆分的代码中也包含查询时,这个问题会进一步加剧。

将 Feed 示例稍作修改,就得到了这个。

tsx
// This lazy loads the GraphFeedItem component, meaning
// it wont start loading until something renders it
const GraphFeedItem = Solid.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 = Solid.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,
  })

  ...
}

这个例子有一个双重瀑布流,看起来像这样。

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

但这只是从示例代码来看,如果我们考虑此页面首次加载的样子,我们实际上需要完成 5 次往返服务器才能渲染图表!

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()

请注意,当服务器渲染时,这看起来有些不同,我们将在服务器渲染与水合指南中进一步探讨。另外请注意,包含 <Feed> 的路由也可能被代码拆分,这可能会增加另一个跳。

在代码拆分的情况下,将 getGraphDataById 查询提升到 <Feed> 组件并使其条件化,或者添加条件预取,可能会有所帮助。然后,该查询可以与代码并行获取,从而将示例部分变为这样。

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

但这在很大程度上是一种权衡。您现在将 getGraphDataById 的数据获取代码包含在与 <Feed> 相同的包中,因此请评估哪种最适合您的情况。有关如何执行此操作的更多信息,请参阅预取与路由集成指南。

在以下两者之间进行权衡:

  • 将所有数据获取代码包含在主包中,即使我们很少使用它。
  • 将数据获取代码放入代码拆分包中,但会产生请求瀑布流。

这种权衡并不理想,并且是服务器组件出现的原因之一。使用服务器组件,可以避免这两种情况,有关此内容如何应用于 Solid Query 的更多信息,请参阅高级服务器渲染指南。

总结与要点

请求瀑布流是一个非常普遍且复杂的性能问题,涉及许多权衡。您的应用程序中有许多意外引入它们的方式。

  • 向子组件添加查询,但未意识到父组件已有一个查询。
  • 向父组件添加查询,但未意识到子组件已有一个查询。
  • 将一个包含查询的后代组件移动到另一个具有查询祖先的新父组件。
  • 等等……

由于这种意外的复杂性,留意瀑布流并定期检查您的应用程序寻找它们(一个好方法是时不时地检查网络选项卡!)是值得的。您不必全部展平就能获得良好的性能,但要留意那些影响重大的瀑布流。

在下一个指南中,我们将通过利用预取与路由集成来探讨更多展平瀑布流的方法。