在 SvelteKit 中缓存数据

Avatar of Adam Rackis
Adam Rackis

DigitalOcean 为您旅程的每个阶段提供云产品。 立即开始使用 200 美元的免费信用额度!

我的 上一篇文章 是对 SvelteKit 的广泛概述,我们看到了它在 Web 开发中的强大功能。 本文将从我们之前做的事情出发,深入研究每个开发人员最喜欢的主题:缓存。 因此,如果您还没有阅读我的上一篇文章,请务必阅读。 本文中的代码 可在 GitHub 上获取,以及 一个实时演示

本文将重点介绍数据处理。 我们将添加一些基本搜索功能,这些功能将修改页面的查询字符串(使用内置的 SvelteKit 功能),并重新触发页面的加载器。 但是,我们不会仅仅重新查询我们的(假想)数据库,而是添加一些缓存,以便重新搜索之前的搜索(或使用后退按钮)将快速从缓存中显示以前检索到的数据。 我们将研究如何控制缓存数据保持有效的时间长度,更重要的是,如何手动使所有缓存值失效。 作为锦上添花,我们将研究如何在客户端手动更新当前屏幕上的数据,并在进行变异后仍然清除缓存。

与我通常编写的大多数内容相比,这将是一篇更长、更困难的文章,因为我们涵盖了更难的主题。 本文将向您展示如何实现像 react-query 这样的流行数据实用程序的常见功能; 但是,我们不会引入外部库,我们只会使用 Web 平台和 SvelteKit 功能。

不幸的是,Web 平台的功能有点低级,因此我们将比您习惯的做更多工作。 好处是,我们不需要任何外部库,这将有助于保持捆绑包大小非常小。 请不要使用我将要展示的方法,除非你有充分的理由。 缓存很容易出错,正如您将看到的,这会带来一些复杂性,从而导致您的应用程序代码。 希望您的数据存储速度很快,并且您的 UI 很好地允许 SvelteKit 始终请求任何给定页面所需的数据。 如果是,那就保持原样。 享受简洁。 但是,本文将向您展示一些在这种情况不再适用时的技巧。

说到 react-query,它 刚刚发布 用于 Svelte! 因此,如果您发现自己经常依赖手动缓存技术,请务必查看该项目,看看它是否有帮助。

设置

在我们开始之前,让我们对 我们之前使用的代码 进行一些小的更改。 这将为我们提供一个机会来查看其他 SvelteKit 功能,更重要的是,为我们成功做好准备。

首先,让我们将数据加载从 +page.server.js 中的加载器移动到 API 路由。 我们将在 routes/api/todos 中创建一个 +server.js 文件,然后添加一个 GET 函数。 这意味着我们现在可以使用默认的 GET 方法来获取 /api/todos 路径。 我们将添加与以前相同的加载数据代码。

import { json } from "@sveltejs/kit";
import { getTodos } from "$lib/data/todoData";

export async function GET({ url, setHeaders, request }) {
  const search = url.searchParams.get("search") || "";

  const todos = await getTodos(search);

  return json(todos);
}

接下来,让我们将之前使用的页面加载器从 +page.server.js 重命名为 +page.js(或 .ts,如果您已将项目构建为使用 TypeScript)。 这将我们的加载器更改为“通用”加载器,而不是服务器加载器。 SvelteKit 文档 解释了差异,但是通用加载器在服务器和客户端上运行。 对我们来说,一个优势是 fetch 调用到我们的新端点将直接从我们的浏览器运行(在初始加载之后),使用浏览器的原生 fetch 函数。 我们将在稍后添加标准 HTTP 缓存,但现在,我们所做的只是调用端点。

export async function load({ fetch, url, setHeaders }) {
  const search = url.searchParams.get("search") || "";

  const resp = await fetch(`/api/todos?search=${encodeURIComponent(search)}`);

  const todos = await resp.json();

  return {
    todos,
  };
}

现在,让我们在 /list 页面添加一个简单的表单

<div class="search-form">
  <form action="/list">
    <label>Search</label>
    <input autofocus name="search" />
  </form>
</div>

是的,表单可以直接指向我们的正常页面加载器。 现在,我们可以在搜索框中添加一个搜索词,按下 Enter,一个“搜索”词将附加到 URL 的查询字符串,这将重新运行我们的加载器并搜索我们的待办事项。

Search form

让我们还在 /lib/data 中的 todoData.js 文件中增加延迟。 这将使我们更容易看到在本文中处理时数据是否被缓存。

export const wait = async amount => new Promise(res => setTimeout(res, amount ?? 500));

请记住,本文的完整代码 都在 GitHub 上,如果您需要参考它。

基本缓存

让我们从向 /api/todos 端点添加一些缓存开始。 我们将返回 +server.js 文件,并添加我们的第一个缓存控制头。

setHeaders({
  "cache-control": "max-age=60",
});

…这将使整个函数看起来像这样

export async function GET({ url, setHeaders, request }) {
  const search = url.searchParams.get("search") || "";

  setHeaders({
    "cache-control": "max-age=60",
  });

  const todos = await getTodos(search);

  return json(todos);
}

我们将在稍后介绍手动失效,但这个函数只是说将这些 API 调用缓存 60 秒。 将其设置为任何您想要的值,并且根据您的用例,stale-while-revalidate 也可能值得研究。

就这样,我们的查询被缓存了。

Cache in DevTools.

注意 确保您取消选中开发工具中禁用缓存的复选框。

请记住,如果您的应用程序的初始导航是列表页,则这些搜索结果将在 SvelteKit 内部缓存,因此不要期望在返回该搜索时在 DevTools 中看到任何内容。

缓存的内容以及位置

我们应用程序的第一个服务器端渲染加载(假设我们从 /list 页面开始)将在服务器上获取。 SvelteKit 将序列化并将此数据发送到我们的客户端。 更重要的是,它将观察响应上的 Cache-Control 标头,并且知道在缓存窗口内(我们在 put 示例中设置为 60 秒)使用此缓存数据进行该端点调用。

在初始加载之后,当您开始在页面上搜索时,您应该会看到浏览器从 /api/todos 列表发送网络请求。 当您搜索您已经搜索过的事物(在过去 60 秒内)时,响应应该立即加载,因为它们被缓存了。

这种方法特别酷的是,由于这是通过浏览器的原生缓存进行的缓存,这些调用可以(取决于您如何管理我们将要介绍的缓存清除)即使您重新加载页面也会继续缓存(与最初的服务器端加载不同,后者总是从头开始调用端点,即使它在过去 60 秒内已经调用过)。

显然,数据随时都可能发生变化,因此我们需要一种方法来手动清除此缓存,我们将在接下来介绍。

缓存失效

现在,数据将被缓存 60 秒。 无论如何,一分钟后,将从我们的数据存储中提取新鲜数据。 您可能希望使用更短或更长的时间段,但是如果您对某些数据进行了变异,并且希望立即清除缓存,以便您的下一个查询是最新的,该怎么办? 我们将通过向发送到 /todos 端点的 URL 添加一个查询清除值来解决此问题。

让我们将此缓存清除值存储在 cookie 中。 该值可以在服务器上设置,但仍然可以在客户端读取。 让我们看一些示例代码。

我们可以在 routes 文件夹的根目录下创建一个 +layout.server.js 文件。 这将在应用程序启动时运行,是设置初始 cookie 值的理想位置。

export function load({ cookies, isDataRequest }) {
  const initialRequest = !isDataRequest;

  const cacheValue = initialRequest ? +new Date() : cookies.get("todos-cache");

  if (initialRequest) {
    cookies.set("todos-cache", cacheValue, { path: "/", httpOnly: false });
  }

  return {
    todosCacheBust: cacheValue,
  };
}

您可能已经注意到 isDataRequest 值。 请记住,布局将在客户端代码调用 invalidate() 时,或者我们在运行服务器操作时(假设我们没有关闭默认行为)重新运行。 isDataRequest 指示这些重新运行,因此我们只在该值为 false 时设置 cookie; 否则,我们将发送已经存在的值。

httpOnly: false 标志也很重要。 这允许我们的客户端代码在 document.cookie 中读取这些 cookie 值。 这通常是一个安全问题,但在我们的案例中,这些是毫无意义的数字,使我们能够缓存或清除缓存。

读取缓存值

我们的通用加载器调用我们的 /todos 端点。 这在服务器或客户端上运行,我们需要读取我们刚刚设置的缓存值,无论我们在哪里。 如果我们在服务器上,这相对容易:我们可以调用 await parent() 来从父布局获取数据。 但是在客户端,我们将需要使用一些很糟糕的代码来解析 document.cookie

export function getCookieLookup() {
  if (typeof document !== "object") {
    return {};
  }

  return document.cookie.split("; ").reduce((lookup, v) => {
    const parts = v.split("=");
    lookup[parts[0]] = parts[1];

    return lookup;
  }, {});
}

const getCurrentCookieValue = name => {
  const cookies = getCookieLookup();
  return cookies[name] ?? "";
};

幸运的是,我们只需要它一次。

发送缓存值

但是现在,我们需要将此值发送到我们的 /todos 端点。

import { getCurrentCookieValue } from "$lib/util/cookieUtils";

export async function load({ fetch, parent, url, setHeaders }) {
  const parentData = await parent();

  const cacheBust = getCurrentCookieValue("todos-cache") || parentData.todosCacheBust;
  const search = url.searchParams.get("search") || "";

  const resp = await fetch(`/api/todos?search=${encodeURIComponent(search)}&cache=${cacheBust}`);
  const todos = await resp.json();

  return {
    todos,
  };
}

getCurrentCookieValue('todos-cache') 中包含一个检查,用于确定我们是在客户端(通过检查文档类型),如果是在客户端,则返回空值,此时我们就知道是在服务器端。然后它使用我们布局中的值。

清除缓存

但是,我们如何在需要时实际更新缓存清除值?由于它存储在 cookie 中,我们可以从任何服务器操作中调用它,如下所示

cookies.set("todos-cache", cacheValue, { path: "/", httpOnly: false });

实现

从这里开始,一切都变得轻松;我们已经完成了最困难的工作。我们已经涵盖了我们需要的各种 Web 平台原语,以及它们的位置。现在让我们玩得开心,编写应用程序代码将它们全部连接起来。

出于稍后会变得清楚的原因,让我们从向我们的/list 页面添加编辑功能开始。我们将为每个待办事项添加此第二行表格

import { enhance } from "$app/forms";
<tr>
  <td colspan="4">
    <form use:enhance method="post" action="?/editTodo">
      <input name="id" value="{t.id}" type="hidden" />
      <input name="title" value="{t.title}" />
      <button>Save</button>
    </form>
  </td>
</tr>

当然,我们需要为我们的/list 页面添加一个表单操作。操作只能在.server 页面中,因此我们将在/list 文件夹中添加一个+page.server.js。(是的,+page.server.js 文件可以与+page.js 文件共存。)

import { getTodo, updateTodo, wait } from "$lib/data/todoData";

export const actions = {
  async editTodo({ request, cookies }) {
    const formData = await request.formData();

    const id = formData.get("id");
    const newTitle = formData.get("title");

    await wait(250);
    updateTodo(id, newTitle);

    cookies.set("todos-cache", +new Date(), { path: "/", httpOnly: false });
  },
};

我们正在获取表单数据,强制延迟,更新我们的待办事项,然后,最重要的是,清除我们的缓存清除 cookie。

让我们试试看。重新加载页面,然后编辑其中一个待办事项。你应该会在片刻后看到表格值更新。如果你在 DevToold 的“网络”选项卡中查看,你会看到一个指向/todos 端点的 fetch 请求,它会返回你的新数据。简单,默认情况下有效。

Saving data

立即更新

如果我们想避免在更新待办事项后发生的 fetch 请求,而是直接在屏幕上更新修改后的项目,该怎么办?

这不仅仅是性能问题。如果你搜索“post”,然后从列表中的任何待办事项中删除“post”一词,它们将在编辑后从列表中消失,因为它们不再在该页面的搜索结果中。你可以通过对消失的待办事项进行一些精致的动画来改善用户体验,但假设我们想重新运行该页面的加载函数,但仍然清除缓存并更新修改后的待办事项,以便用户可以看到编辑。SvelteKit 使这成为可能——让我们看看如何做到!

首先,让我们对我们的加载程序进行一些小的更改。与其返回我们的待办事项,不如返回一个可写存储,其中包含我们的待办事项。

return {
  todos: writable(todos),
};

之前,我们是在data 属性上访问我们的待办事项,但我们不拥有它也无法更新它。但是 Svelte 允许我们将数据返回到它们自己的存储中(假设我们使用的是通用加载程序,而我们正在使用)。我们只需要对我们的/list 页面进行最后一次调整。

与其这样

{#each todos as t}

……我们需要这样,因为todos 本身现在是一个存储。

{#each $todos as t}

现在我们的数据与以前一样加载。但是,由于todos 是一个可写存储,因此我们可以更新它。

首先,让我们为我们的use:enhance 属性提供一个函数

<form
  use:enhance={executeSave}
  on:submit={runInvalidate}
  method="post"
  action="?/editTodo"
>

这将在提交之前运行。让我们接下来编写它

function executeSave({ data }) {
  const id = data.get("id");
  const title = data.get("title");

  return async () => {
    todos.update(list =>
      list.map(todo => {
        if (todo.id == id) {
          return Object.assign({}, todo, { title });
        } else {
          return todo;
        }
      })
    );
  };
}

此函数提供了一个data 对象,其中包含我们的表单数据。我们返回一个异步函数,该函数将在编辑完成后运行。文档 解释了这一切,但通过这样做,我们关闭了 SvelteKit 的默认表单处理,该处理本来会重新运行我们的加载程序。这正是我们想要的!(正如文档所解释的,我们可以轻松地恢复默认行为。)

我们现在在我们的todos 数组上调用update,因为它是一个存储。就是这样。编辑待办事项后,我们的更改会立即显示,并且我们的缓存会被清除(与之前一样,因为我们在我们的editTodo 表单操作中设置了新的 cookie 值)。因此,如果我们搜索然后导航回此页面,我们将从我们的加载程序获取新鲜数据,该加载程序将正确排除所有已更新的待办事项。

立即更新的代码在 GitHub 上可用

深入挖掘

我们可以在任何服务器加载函数(或服务器操作)中设置 cookie,而不仅仅是在根布局中。因此,如果某些数据仅在单个布局下使用,或者甚至是在单个页面下使用,那么你可以在那里设置该 cookie 值。此外,如果你手动执行我刚才展示的直接更新屏幕上数据的技巧,而是希望加载程序在发生更改后重新运行,那么你始终可以在该加载函数中设置新的 cookie 值,而无需对isDataRequest 进行任何检查。它将首先设置,然后在运行任何服务器操作时,该页面布局将自动失效并重新调用加载程序,在调用通用加载程序之前重新设置缓存清除字符串。

编写重新加载函数

最后,让我们构建一个最后一个功能:重新加载按钮。让我们为用户提供一个按钮,该按钮将清除缓存,然后重新加载当前查询。

我们将添加一个非常简单的表单操作

async reloadTodos({ cookies }) {
  cookies.set('todos-cache', +new Date(), { path: '/', httpOnly: false });
},

在实际项目中,你可能不会在多个地方复制粘贴相同的代码来以相同的方式设置相同的 cookie,但为了这篇文章,我们将优化以实现简单性和可读性。

现在,让我们创建一个表单来将其发布到其中

<form method="POST" action="?/reloadTodos" use:enhance>
  <button>Reload todos</button>
</form>

这有效!

UI after reload.

我们可以称之为完成并继续,但让我们改进一下这个解决方案。具体来说,让我们在页面上提供反馈,告诉用户重新加载正在进行。此外,默认情况下,SvelteKit 操作会使所有内容失效。当前页面层次结构中的每个布局、页面等都会重新加载。可能有一些数据是在根布局中加载一次的,我们不需要使它失效或重新加载。

因此,让我们专注一点,只在我们调用此函数时重新加载我们的待办事项。

首先,让我们传递一个函数来增强

<form method="POST" action="?/reloadTodos" use:enhance={reloadTodos}>
import { enhance } from "$app/forms";
import { invalidate } from "$app/navigation";

let reloading = false;
const reloadTodos = () => {
  reloading = true;

  return async () => {
    invalidate("reload:todos").then(() => {
      reloading = false;
    });
  };
};

我们在该操作的开始将一个新的reloading 变量设置为true。然后,为了覆盖使所有内容失效的默认行为,我们返回一个async 函数。此函数将在我们的服务器操作完成后运行(它只是设置了一个新的 cookie)。

如果没有返回此async 函数,SvelteKit 将使所有内容失效。由于我们提供了此函数,它不会使任何东西失效,因此由我们来告诉它要重新加载什么。我们使用invalidate 函数来做到这一点。我们用reload:todos 的值调用它。此函数返回一个 Promise,该 Promise 在失效完成后解析,此时我们将reloading 设置回false

最后,我们需要使用此新的reload:todos 失效值来同步我们的加载程序。我们使用depends 函数在我们的加载程序中执行此操作

export async function load({ fetch, url, setHeaders, depends }) {
    depends('reload:todos');

  // rest is the same

就是这样。dependsinvalidate 是非常有用的函数。有趣的是,invalidate 并不仅仅接受我们提供的任意值,就像我们做的那样。我们还可以提供一个 URL,SvelteKit 会跟踪该 URL,并使依赖于该 URL 的任何加载程序失效。为此,如果你想知道我们是否可以跳过对depends 的调用并完全使我们的/api/todos 端点失效,你可以这样做,但你必须提供完全相同的 URL,包括search 项(以及我们的缓存值)。因此,你可以组合当前搜索的 URL,或者匹配路径名称,如下所示

invalidate(url => url.pathname == "/api/todos");

就我个人而言,我发现使用depends 的解决方案更加明确和简单。但是,请查看文档 获取更多信息,当然,并自行决定。

如果你想看到重新加载按钮的实际操作,它的代码在存储库的此分支中。

结语

这篇文章很长,但希望不会太难理解。我们深入探讨了在使用 SvelteKit 时缓存数据的各种方法。其中很大一部分只是使用 Web 平台原语来添加正确的缓存和 cookie 值,这方面的知识将在 Web 开发中普遍适用,而不仅仅局限于 SvelteKit。

此外,这绝对是你并不总是需要的东西。可以说,你只有在真正需要这些高级功能时才应该使用它们。如果你的数据存储能够快速有效地提供数据,并且你没有遇到任何可扩展性问题,那么没有必要使用这里讨论的功能来使你的应用程序代码变得复杂。

与往常一样,编写清晰、简洁、简单的代码,并在需要时进行优化。这篇文章的目的是为你提供这些优化工具,供你真正需要时使用。希望你喜欢!