首页 / 文章

使用 Parallel load 函数在 SvelteKit 中终止请求瀑布流

了解为什么顺序的 await 会导致页面加载缓慢,以及 SvelteKit 如何通过加载函数、Promise.all、谨慎的 parent() 调用和流式承诺来消除这种加载顺序问题。

发布者:REACTAPP.TOP阅读约 11 分钟2629 词

来源: Killing Request Waterfalls: Server-Side Data Fetching in SvelteKit

当页面的请求是依次执行而非同时进行时,即便网络连接快速且后端处理速度也很快,页面仍可能显得反应迟缓。每次往返请求都需要等待前一次完成,因此用户看到首个有效内容的时间实际上就是所有请求耗时之和,而非最慢那个请求的耗时。本指南将解释这种“瀑布式”请求现象的成因,并展示如何设计 SvelteKit 的 load 函数,使得关键数据能够并行获取,布局与页面加载互不干扰,而那些缓慢且非必要的数据则可在首次渲染之后再加载。

请求瀑布式的表现形式

在典型的客户端渲染的 React 或 Vue 控制台中,打开浏览器开发者工具中的网络面板,加载一个需要多组数据的页面。这些数据请求的进度条通常呈阶梯状:第一个请求完成后,第二个请求才会开始,第三个请求则要等待第二个请求完成。每一步都意味着一次完整的网络往返。

造成这种阶梯状请求模式的代码看起来往往很正常。组件在浏览器中加载后,才会发现自身需要哪些数据。在某个效应函数或挂载钩子中,代码会执行类似 const user = await getUser()、const posts = await getPosts()、const comments = await getComments() 的操作,仅三行代码、三次异步等待,看不出有什么问题。

问题在于await的含义。它会暂停函数直到承诺状态确定,因此只有在user的响应完全到达后才能发送posts请求,而comments则需依次等待posts的响应。假设每次调用在考虑DNS解析、TLS加密、服务器处理及数据传输后大约需要300毫秒。这样一来,页面在所有这些操作完成之前都无法显示任何有用的内容,而非仅等待最耗时的那一步。

没有异常抛出,测试也不会失败,每个组件都按指令正常工作。用户只需等待大约三倍于实际所需时间的加载指示器。正是这种不可见性使得瀑布式架构如此普遍。

为何瀑布式架构仍屡见不鲜

大多数瀑布式架构源于三种常见的习惯:

  • 每个组件都会获取自己的数据。在客户端渲染的应用中,子组件通常必须等待父组件的加载完成才能初始化,而子组件在收到父组件传来的属性之前也无法发起自己的请求。这样一来,组件树就变成了一条请求链。
  • 出于习惯而逐行等待。顺序排列的await语句看起来很清晰,但这恰恰掩盖了它们实际上将本不应相互依赖的请求序列化了这一事实。
  • 无需对页面数据有一个统一的视图。当获取数据的代码分散在三个文件中时,就很难意识到这三个请求其实可以同时开始。

编写更快的代码并无帮助。真正有效的方法是将数据获取操作移到不同的时间和地点:在任何组件出现之前,在一个统一的位置进行。在 SvelteKit 中,这个位置就是 load 函数。若想以与框架无关的方式了解相同的并发处理方案,可参阅博客中关于 Promise.all、Promise.race 与顺序式 awaits 的对比文章。

SvelteKit 的 load 函数如何改变默认行为

任何 SvelteKit 路由都可以导出一个 load 函数,该函数会在页面组件渲染之前执行。这样一来,组件在挂载后就不再需要自行获取数据;路由会先收集所有数据,然后以单个 data 属性的形式传递给页面。

这种函数有两种变体,选择哪种很重要:

  • +page.js 中的通用加载逻辑在服务器端渲染时在服务器上运行,而在客户端导航时在浏览器中运行。它适用于调用公共 API 以及返回不可序列化的值。
  • +page.server.js 中的服务器端加载逻辑仅在服务器上运行。其代码永远不会被发送到浏览器,因此可以安全地使用数据库客户端、私有环境变量和认证令牌。

+layout.js 和 +layout.server.js 也遵循相同的模式。

在大多数实际应用中,任何涉及数据库、内部服务或机密信息的操作都应放在 +page.server.js 中。其最简形式如下:

// src/routes/dashboard/+page.server.js

export async function load({ fetch }) {
  const res = await fetch('/api/user');
  const user = await res.json();
  return {
    user
  };
}

关于这段代码有三点值得尽早掌握。

加载函数在页面渲染完成前就已执行

当 +page.svelte 开始渲染时,user 已经是一个普通对象。不存在挂载钩子,没有加载提示,其值也绝不会是 undefined。

使用 SvelteKit 提供的 fetch 函数

fetch 参数并非全局版本。SvelteKit 提供了一个增强版,它可以接受诸如 /api/user 这样的相对 URL,在服务器端渲染时会转发请求中的 Cookie 和头部信息;而当目标为同一应用内的其他路由时,则直接调用对应的处理函数,而不会发起真正的 HTTP 请求。如果导入全局的 fetch,就会失去这些功能。

返回值将成为页面的 data 属性

函数返回的任何值都会以 data 的形式暴露给页面组件。在 Svelte 5 组件中,可通过 let { data } = $props(); 读取该值(较旧的 Svelte 4 代码则使用 export let data;),然后在标记中渲染 data.user.name。

其思维模式很简单:先获取数据,再进行渲染,这样就能得到已解析的值,而无需在组件内部管理承诺对象。

不过,将数据获取操作放入 load 函数中本身并不能消除瀑布流式执行的问题。在 load 函数内连续使用三次 await 仍会在服务器端生成同样的层级结构。真正的解决方案在后面。

同时启动所有独立请求

解决办法是立即发起所有独立请求,然后一起等待它们的完成:

// src/routes/dashboard/+page.server.js

export async function load({ fetch }) {
  // Kick off all three requests immediately - none of them
  // are awaited yet, so none of them block each other.
  const userPromise = fetch('/api/user').then(r => r.json());
  const postsPromise = fetch('/api/posts').then(r => r.json());
  const commentsPromise = fetch('/api/comments').then(r => r.json());
  // NOW wait for all of them to finish, in parallel.
  const [user, posts, comments] = await Promise.all([
    userPromise,
    postsPromise,
    commentsPromise
  ]);
  return { user, posts, comments };
}

为何这样更快

决定性因素在于发送请求与等待其结果之间的时间差。fetch(...)会立即发送请求,而不会等待await。添加.then()只是用来规定当响应最终到达时该如何处理它。因此在上述代码中:

  • 第一行发送/api/user请求;
  • 第二行在第一个请求仍在传输时发送/api/posts请求;
  • 第三行在前两个请求仍在传输时发送/api/comments请求;
  • 只有Promise.all会真正暂停执行,且会在三个请求中最慢的那个完成后立即继续运行。

总耗时从三个请求的时间之和降至最慢那个请求的耗时左右。请求、服务器和数据均保持不变,仅调整了await语句的位置。以每个请求约300毫秒加上一些开销为例,这样处理后页面的可用时间从大约960毫秒缩短到约360毫秒;在网络速度较慢或API负载较高的情况下,这一差距还会进一步增大。

对于一个数据量较大的页面而言,如此小的改动就能带来如此大的提升。无需添加任何库,也无需重新设计架构,只需调整几行代码的顺序即可。

使用Promise.all时需要注意的事项

Promise.all 会在其任何一个承诺被拒绝时立即抛出错误。如果某个失败的 comments 请求不应导致整个页面崩溃,可以使用 Promise.allSettled,或者为每个承诺设置独立的 .catch() 方法来返回备用值。还需注意,r.json() 并不会检查 r.ok;带有 JSON 错误内容的 404 或 500 响应仍会被解析并当作数据返回,因此在需要确保数据正确性时务必检查状态码。

布局与页面之间的隐性流水线

那些掌握了 Promise.all 技巧的开发者,往往仍会引入另一种跨越多个文件的流水线:即布局的数据加载如何与其下方页面的数据加载相互影响。

默认情况下,SvelteKit会像上面的并行示例一样,同时启动+layout.server.js和+page.server.js中的load函数。解决此问题的方法是使用parent(),它能让页面的load函数获取上层布局返回的数据。有时候这正是你所需要的,比如当页面的查询需要只有布局才能获取的用户ID时。不过如果过早调用它,就会使之后的所有操作都变为串行执行:

// src/routes/dashboard/+page.server.js

// BAD: this creates a waterfall between the layout and the page,
// even if `posts` doesn't actually need anything from `parent()`.
export async function load({ parent, fetch }) {
  const { user } = await parent(); // blocks here until layout's load finishes
  const posts = await fetch(`/api/posts?userId=${user.id}`).then(r => r.json());
  return { posts };
}

如果文章请求确实需要user.id,那么这种执行顺序是不可避免的,也是完全可以接受的,因为这属于真正的依赖关系。问题出现在页面还需要与布局无关的数据时。第一行中对parent()的等待会阻塞后续的所有语句,包括那些独立的请求,直到布局处理完成。

解决办法是调整顺序:先执行独立的工作,只有在需要parent()的值时再等待它。

// src/routes/dashboard/+page.server.js

// GOOD: independent work starts immediately; parent() is only
// awaited once we actually need the merged result.
export async function load({ parent, fetch }) {
  const commentsPromise = fetch('/api/comments').then(r => r.json());
  const { user } = await parent(); // runs concurrently with the fetch above
  const posts = await fetch(`/api/posts?userId=${user.id}`).then(r => r.json());
  const comments = await commentsPromise;
  return { user, posts, comments };
}

在这里,评论请求在页面等待布局之前就被发送了,因此两者会重叠。文章请求仍然需要等待user.id,这是正确的;而评论相关的承诺通常在最后被等待时已经处理完毕。

经验法则:将 await parent() 视为与其他 await 语句相同。应将其放在确实需要该值的 위치,绝不能机械地放在函数顶部,并且应在调用它之前发起所有不依赖父级数据的请求。

非关键数据的流式传输

Promise.all 并不总是最佳解决方案。如果某个请求速度较慢,且其返回的数据并非用户首先需要看到的内容,等待该请求会导致整个页面的加载速度降至最慢的那个部分的水平。在产品页面上,名称、价格和图片是关键信息;而几页之后的评论部分则不属于关键内容。

针对这种情况,SvelteKit 支持流式传输。可以在不等待其结果的情况下从服务器的 load 函数返回一个 promise,SvelteKit 会立即发送已渲染的页面,等到该 promise 解析后再将结果传递给浏览器。

// src/routes/product/[id]/+page.server.js

export async function load({ fetch, params }) {
  // Critical: awaited, blocks the initial render - but it's fast.
  const product = await fetch(`/api/product/${params.id}`).then(r => r.json());
  // Non-critical: NOT awaited. This is handed to the page as a
  // pending Promise, and SvelteKit streams it in once it resolves.
  const reviewsPromise = fetch(`/api/product/${params.id}/reviews`).then(r => r.json());
  return {
    product,          // resolved value
    reviews: reviewsPromise  // still-pending promise
  };
}

由于初始渲染需要该数据且请求速度很快,因此会立即等待产品相关数据的返回。而评论相关的请求虽然已被发起,但并未被等待,所以页面的data.reviews字段中会包含一个待处理的承诺对象。

在页面上,Svelte的{#await}块负责处理这两种状态。在{#await data.reviews}块中,会渲染一个简单的占位符,比如“正在加载评论...”这样的文字;而在{:then reviews}分支中,则会遍历返回的结果。另外还有一个可选的{:catch error}分支,用于在请求失败时显示相应提示。

用户几乎可以立即看到产品详情,评论区域会显示短暂的加载状态,而真实的评论数据一旦到达就会立刻替换掉该加载状态。整个实现无需额外的客户端请求代码,也没有使用任何挂载钩子。

流式处理的注意事项

请牢记以下限制条件:

  • 流式传输依赖于服务器端的加载功能。承诺对象在服务器端创建,然后从 +page.server.js 或 +layout.server.js 中进行流式传输。通用的 +page.js 加载功能也可以返回承诺对象,但不会以相同方式从服务器端流式传输。
  • 您的适配器和托管服务必须支持流式响应。Node 和 Vercel 适配器以及大多数现代平台都支持此功能,但对于不太常见的目标环境则需确认其兼容性。会对响应进行缓冲的代理服务器也可能会悄悄抵消这一优势。
  • 处理拒绝情况。如果没有 {:catch} 分支或 .catch() 处理程序,流式承诺对象在拒绝时可能会在服务器端表现为未处理的拒绝错误。应为非关键性的承诺对象提供备用方案。
  • 一旦开始流式传输,标题和Cookie就会固定不变。任何需要设置Cookie或状态码的操作都必须在响应开始前完成。
  • 流式内容依赖于浏览器中的JavaScript。如果没有它,用户只能看到加载状态,因此不要传输那些搜索引擎或不支持JavaScript的用户所必需的内容。
  • 完整的请求生命周期

    通过并行执行关键数据获取操作、有意识地使用parent()函数以及对非必要数据进行流式传输,一个包含大量数据的路由请求会经历以下步骤:

    1. 浏览器发起对该路由的请求。
    2. SvelteKit在服务器端运行该路由的布局及页面load函数。
    3. 所有独立数据,如用户信息、帖子及评论等,会同时被获取。
  • 服务器会直接生成已嵌入该数据的完整HTML,因此初始页面无需显示加载状态。
  • 浏览器收到的是一个完整的响应,而非需要依次发起的多次网络请求。
  • Svelte会对页面进行水合处理,为已渲染的标记添加交互功能,同时任何流式承诺会在解析时逐步填充对应内容。
  • 这与从一开始就采用客户端处理的方案相比,页面加载后浏览器无需进行三次连续的往返请求,而服务器会在任何数据发送之前就并行完成这些请求,且通常比用户浏览器连接到数据源的延迟要低得多。

    数据量大的路由发布前的检查清单

    在发布需要多份数据的路由之前,请先思考以下问题:

    • 组件挂载时是否立即获取了初始渲染所需的数据?应将其移至load函数中,通常该函数位于服务器端。
    • load函数内部是否连续存在多个独立的await操作?应先发起所有请求,再使用Promise.all或Promise.allSettled统一等待它们的结果。
    • await parent()是否是页面load函数的第一行代码?应将其移至实际使用父数据的位置,并在其之前发起无关的请求。
    • 是否有任何数据与首次渲染无关?应将其作为未等待处理的promise返回,并通过{#await}进行渲染,同时添加错误处理分支。
  • fetch 是从 load 参数中获取的吗?只有这种方式才能在服务器渲染时正确解析相对URL并传递cookie。
  • 总结

    数据流式加载并非代码疏忽的体现,而是当数据获取分散在组件树中时自然会出现的现象。SvelteKit的load函数的重要性不在于它们在服务器上运行,而在于它们将页面所需的每个请求集中到一处,让你能够决定哪些请求应同时执行、哪些确实依赖于其他请求、以及哪些可以稍后处理。培养这个习惯很简单:不再机械地等待,而是有意识地安排等待时机。将这一方法应用到每个路由中,就能在各种慢速连接和繁忙的后端环境下为用户节省时间。