SvelteKit里缓存咋整,手动失效和即时更新有法子吗?

4,898字
21–31 分钟
in

写代码的时候,数据缓存这事儿整不好就容易翻车。尤其用SvelteKit搞项目,请求后端数据一多,每次都重新拉取,页面卡顿不说,后端也得累够呛。但浏览器自带的缓存机制,配合点小技巧,就能让数据飞一样地展示出来。这篇就唠唠咋在SvelteKit里给数据加缓存,还能随时手动清掉,甚至编辑完不用重新请求页面直接刷新界面。

目录

缓存咋配

给接口加个头

后端接口返回数据的时候,顺手给响应头里塞个cache-control。这玩意儿告诉浏览器:“这堆数据你存60秒,60秒内别再来烦我了”。

// routes/api/todos/+server.js
import { json } from "@sveltejs/kit";
import { getTodos } from "$lib/data/todoData";

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

  // 加缓存头,60秒内直接读本地
  setHeaders({
    "cache-control": "max-age=60"
  });

  const todos = await getTodos(search);
  return json(todos);
}

这段代码干的事儿特简单:每次请求/api/todos,都告诉浏览器存60秒。同一个搜索词在这60秒内再访问,浏览器直接掏本地缓存,连网络请求都省了。调试的时候记得把开发者工具的“Disable cache”复选框取消掉,不然死活看不到效果。

缓存存哪了

头一回打开/list页面,SvelteKit会在服务端把数据请求好,然后序列化扔给客户端。服务端响应时带着cache-control头,SvelteKit自己也会认这个头,在60秒内重复调用同一个接口,直接复用之前的数据。等页面加载完,在搜索框里敲字,每次敲完回车,浏览器会发起新的fetch请求。这时候如果搜的是60秒内搜过的词,网络面板里能看到请求直接走(disk cache),速度嗖嗖的。

注意一点:服务端渲染那一下,每次刷新页面都会重新请求后端,不管缓存窗口过没过。这是SvelteKit的设计,不碍事,客户端后续的请求才享受缓存红利。

手动失效

缓存刷新的痛点

设了60秒自动过期,但万一改了数据想立刻让缓存失效咋办?比如编辑了一个待办事项,希望下次搜索立马拿到最新的。浏览器缓存又不会自己长眼睛,得手动给它一棍子。套路就是在请求URL后面挂一个会变的参数,参数一变,浏览器就觉得这是个新请求,乖乖去后端拿新数据。

用cookie存版本号

在根布局的+layout.server.js里塞一个cookie,存一个时间戳当版本号。

// routes/+layout.server.js
export function load({ cookies, isDataRequest }) {
  // 只有真正的初次请求才设置新cookie
  const initialRequest = !isDataRequest;
  const cacheValue = initialRequest ? +new Date() : cookies.get("todos-cache");

  if (initialRequest) {
    cookies.set("todos-cache", cacheValue, { 
      path: "/", 
      httpOnly: false   // 让客户端也能读到这个cookie
    });
  }

  return {
    todosCacheBust: cacheValue
  };
}

httpOnly: false平时不建议开,但这里存的只是一个数字,没啥敏感信息,开了方便客户端读取。isDataRequest用来区分是不是因为invalidate触发的重新加载,避免每次刷新页面都重置cookie。

请求时带上版本号

改一下页面加载器,从cookie里捞出版本号,拼到请求URL后面。

// routes/list/+page.js
import { getCurrentCookieValue } from "$lib/util/cookieUtils";

export async function load({ fetch, parent, url }) {
  const parentData = await parent();
  // 客户端读cookie,服务端用父布局传下来的值
  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 };
}

客户端读cookie那段工具代码,判断document存不存在,不存在就返回空,然后降级用服务端传下来的值。这样同构代码跑得顺溜。

编辑后刷掉缓存

在编辑待办的表单动作里,把cookie更新成新的时间戳。

// routes/list/+page.server.js
export const actions = {
  async editTodo({ request, cookies }) {
    const formData = await request.formData();
    const id = formData.get("id");
    const newTitle = formData.get("title");

    // 假装更新数据库
    updateTodo(id, newTitle);

    // 刷新缓存版本号
    cookies.set("todos-cache", +new Date(), { 
      path: "/", 
      httpOnly: false 
    });
  }
};

下次再请求/api/todos时,URL里的cache参数变了,浏览器就不会命中旧缓存,乖乖去后端拿最新数据。

即时更新

场景描述

编辑完一个待办,如果这个待办的关键词变了(比如原来叫“买苹果”,改成了“买香蕉”),当前页面的搜索结果里它可能就不该出现了。但默认流程是:提交表单 -> 后端改数据 -> 刷新cookie -> 页面加载器重新跑 -> 整个列表重新请求。网络好的时候还行,但总归有个延迟。想实现编辑完那一瞬间,界面直接变,连那个额外的网络请求都省掉,咋整?

把数据变成store

把加载器返回的数据包一层writable

// routes/list/+page.js
import { writable } from "svelte/store";

export async function load({ fetch, url }) {
  const resp = await fetch(`/api/todos?...`);
  const todos = await resp.json();

  return {
    todos: writable(todos)   // 变成可写store
  };
}

页面里取数据时,前面加个$符号。

<!-- routes/list/+page.svelte -->
<script>
  export let data;
  $: ({ todos } = data);
</script>

{#each $todos as t}
  <tr>
    <td>{t.title}</td>
    <!-- 其他列 -->
  </tr>
{/each}

用enhance劫持表单

给编辑表单加上use:enhance,传一个处理函数。

<form 
  use:enhance={executeSave}
  method="post" 
  action="?/editTodo"
>
  <input name="id" value="{t.id}" type="hidden" />
  <input name="title" value="{t.title}" />
  <button>保存</button>
</form>

executeSave函数长这样:

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

  // 返回一个异步函数,等后端保存完执行
  return async () => {
    todos.update(list =>
      list.map(todo => 
        todo.id == id ? { ...todo, title: newTitle } : todo
      )
    );
  };
}

这个返回的异步函数会在后端editTodo动作完成后调用。关键点在于:返回了这个函数,SvelteKit就不会自动重新运行页面的加载器了。但后端那边照样更新了数据库、刷新了cookie。所以界面上的store被手动更新,数据立刻变样,同时下次搜索或者切回来再加载,拿到的也是新数据(因为cookie版本号变了)。这一套组合拳打下来,既丝滑又准确。

重载耍法

加个手动刷新按钮

有时候用户想主动拉一下最新数据,不管缓存窗口过没过。加个按钮,触发一个表单动作。

<script>
  import { enhance } from "$app/forms";
  import { invalidate } from "$app/navigation";

  let reloading = false;

  const reloadTodos = () => {
    reloading = true;
    return async () => {
      await invalidate("reload:todos");
      reloading = false;
    };
  };
</script>

<form method="POST" action="?/reloadTodos" use:enhance={reloadTodos}>
  <button disabled={reloading}>
    {reloading ? "刷新中..." : "重载待办"}
  </button>
</form>

后端动作只需要刷新cookie:

// routes/list/+page.server.js
export const actions = {
  async reloadTodos({ cookies }) {
    cookies.set("todos-cache", +new Date(), { 
      path: "/", 
      httpOnly: false 
    });
  }
};

精准重载不连累别人

默认情况下,执行表单动作后SvelteKit会把整个页面的所有加载器都无效掉。但如果只想让待办列表重新请求,其他部分(比如用户头像、全局配置)不动,可以用invalidate配合depends

先在加载器里声明依赖:

// routes/list/+page.js
export async function load({ fetch, url, depends }) {
  depends("reload:todos");   // 声明这个加载器依赖一个自定义key
  // ... 其余请求代码不变
}

然后在按钮的处理函数里只无效这个key:

const reloadTodos = () => {
  reloading = true;
  return async () => {
    await invalidate("reload:todos");
    reloading = false;
  };
};

这样一点击按钮,只有依赖reload:todos的加载器会重新跑,别的纹丝不动。性能拿捏得死死的。

还有一个更野的路子:直接无效掉/api/todos路径。invalidate函数可以接受一个URL匹配函数:

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

但这需要拼出完整的URL(包括查询参数和缓存版本号),不如depends方式清爽。看个人喜好,哪个顺手用哪个。

代码仓库

这篇所有示例代码都扔网上了,包括即时更新分支和重载按钮分支。需要完整跑一遍的,直接去扒下来耍。记住一点:缓存这东西,不是每个项目都要搞。后端响应够快、并发不大,老老实实每次请求新数据最省心。等哪天页面卡得不行了,再掏出这些招儿来救场。