SvelteKit 2.27 加了个叫 Remote Functions 的东西,还标着"实验性"。我这两天把它过了一遍,把用法记下来。
它解决什么
在 SvelteKit 里,读数据通常写 load,提交表单写 actions,剩下那些不挂表单上的读写,就开一个 +server.ts,前端再用 fetch 去调。三套写法,规矩还不一样。最烦的是类型对不上------服务端接口返回什么,前端 TypeScript 完全不知道,得自己写一堆 as any 或者手动维护类型。
Remote Functions 想把这些并成一件:你在组件里 import 一个函数,然后 await 它,完事。
ts
// data.remote.ts ------ 注意文件名里要有 remote 这个词
import { query } from '$app/server';
import * as db from '$lib/server/database';
export const getPosts = query(async () => {
return await db.sql`SELECT * FROM post ORDER BY published_at DESC`;
});
svelte
<script>
import { getPosts } from './data.remote';
</script>
{#each await getPosts() as post}
<p>{post.title}</p>
{/each}
函数体永远在服务器上跑,浏览器只拿结果。所以里面可以放心用数据库密码、环境变量这类不能漏到前端的东西。
开它要两个开关
现在是实验性的,默认关着,vite.config.js 里得开两处:
js
sveltekit({
experimental: { remoteFunctions: true },
compilerOptions: { experimental: { async: true } }
})
remoteFunctions 是开这个功能,async 是让组件能在渲染到一半时停下来等一个 Promise。第二个其实挺关键,因为 await getPosts() 这种写法依赖它。
四种函数
从 $app/server 导入的一共有四种:query 读数据,form 处理表单,command 做不挂在表单上的写操作,prerender 是构建时就算好、之后基本不变的数据。挨个说。
query:读
不带参数的最简单:
ts
export const getPosts = query(async () => {
return await db.sql`SELECT title, slug FROM post ORDER BY published_at DESC`;
});
要带参数,比如按 slug 查单篇,就写在 $derived 里:
svelte
<script>
import { getPost } from '../data.remote';
let { params } = $props();
const post = $derived(await getPost(params.slug));
</script>
<h1>{post.title}</h1>
这里有个必须提醒的事:带参数之后,这个函数就变成了一个公开的 HTTP 端点,任何人都能往上面灌数据。所以参数一定得校验。把 schema 作为第一个参数传进去就行,Zod、Valibot 这些标准 schema 库都行:
ts
import { z } from 'zod';
import { error } from '@sveltejs/kit';
import { query } from '$app/server';
import * as db from '$lib/server/database';
export const getPost = query(z.string(), async (slug) => {
const [post] = await db.sql`SELECT * FROM post WHERE slug = ${slug}`;
if (!post) error(404, 'Not found');
return post;
});
参数和返回值用 devalue 序列化,Date、Map、Set 都能传,不止 JSON。
两个附带的好处。一是去重:同样的 query、同样的参数,只执行一次,getPosts() === getPosts(),你在组件里 await 过一次,按钮里再 await 一次也不会发第二个请求。二是能手动刷新:
svelte
<button onclick={() => getPosts().refresh()}>刷新</button>
不用先存引用,getPosts() 每次拿到的是同一个实例,直接 .refresh() 就行。
还有两个进阶版。query.batch 解决列表场景的 n+1:比如一页渲染十个城市,每个城市要单独查一次天气,batch 能把同一批请求合成一次数据库查询。query.live 是实时数据,回调写成一个 async generator,靠长连接推流,返回的东西带 connected 属性和 reconnect() 方法。注意 batch 和 live 是 2.35 之后才加的,更早的版本没有。这两个用得上再查文档,先知道有这回事。
form:写
四种里最省事的是 form,因为表单的提交、校验、错误提示、回填,它几乎全包了。
ts
import { z } from 'zod';
import { redirect } from '@sveltejs/kit';
import { form } from '$app/server';
import * as db from '$lib/server/database';
export const createPost = form(
z.object({
title: z.string().min(1, '标题不能为空'),
content: z.string().min(1, '内容不能为空')
}),
async ({ title, content }) => {
await db.insert({ title, content });
redirect(303, `/blog/${title}`);
}
);
svelte
<form {...createPost}>
<label>
标题
<input {...createPost.fields.title.as('text')} />
</label>
<label>
内容
<textarea {...createPost.fields.content.as('text')}></textarea>
</label>
<button>发布</button>
</form>
{...createPost} 这个展开做了不少事:没有 JavaScript 的时候它退化成普通表单提交加整页刷新,有 JS 的时候走渐进增强、不刷新页面。校验失败,输入框会自动回填你填过的值,还自动加 aria-invalid。
字段的输入类型由 .as() 决定,'text'、'number'、'checkbox'、'radio'、'file' 这些。radio 和 checkbox 记得传第二个参数(值)。
错误信息这么读:
svelte
{#each createPost.fields.title.issues() ?? [] as issue}
<p style="color:red">{issue.message}</p>
{/each}
issues() 在校验没通过时返回 [{ message }],通过或者还没校验时是 undefined。想一次性拿所有字段的错误,用 createPost.fields.allIssues()。
读值、写值也有现成接口:createPost.fields.title.value() 实时跟随用户输入,createPost.fields.set({ ... }) 用来程序化填值(比如编辑已有数据)。如果想让用户打字时就校验,给 <form> 加个 oninput={() => createPost.validate()}。
两个可能用到的进阶点:preflight(schema) 可以在客户端先校验一遍、不合法就直接不发请求;enhance(fn) 用来接管提交流程,比如提交成功后弹个 toast、手动 reset 表单。列表里要渲染多个同款表单时,用 modifyTodo.for(id) 给每个隔离实例。
command:不挂表单的写
form 好是好,但它绑在 <form> 元素上。点赞、删除这种按钮操作,用 command:
ts
import { z } from 'zod';
import { command } from '$app/server';
import * as db from '$lib/server/database';
export const addLike = command(z.string(), async (id) => {
await db.sql`UPDATE item SET likes = likes + 1 WHERE id = ${id}`;
});
svelte
<button onclick={async () => {
await addLike(item.id);
}}>
点赞
</button>
两个限制:渲染期间不能调 command(只能放事件里),command 里也不能用 redirect------需要的话用 error(401),或者返回 { redirect: location } 自己在客户端处理。
prerender:构建期写死
跟 query 差不多,但数据在构建时就定好了,之后不变:
ts
import { prerender } from '$app/server';
import * as db from '$lib/server/database';
export const getPosts = prerender(async () => {
return await db.sql`SELECT title, slug FROM post`;
});
结果会进 CDN,导航飞快。代价是不能放私有数据,因为构建期运行、结果公开成静态文件。带参数时用 inputs 指定要预渲染哪些值。
静态站只能用 prerender
如果你的站点用 adapter-static 构建成纯静态文件(没有运行时服务器),那四种函数里只有 prerender 能用。
原因很简单:query、command、form 编译之后,都是向"SvelteKit 自己的服务器"发请求。静态站部署到 CDN 之后没有这个服务器,请求根本没地方去。而且 query 在整页预渲染的页面上是被明确禁用的。
prerender 能用是因为它反着来------不靠运行时服务器,而是在构建阶段就把结果算好,嵌进静态文件里。代价是数据"定死"在构建那一刻,像"用户点一下、值变一下"这种交互,纯静态站做不到,除非另接一个外部后端。
所以判断很简单:部署后有个服务器在跑(adapter-node、adapter-vercel 这些)→ 四种都能用;部署成纯静态(adapter-static)→ 只有 prerender,交互写操作另想办法。
写完之后顺手刷新读
写数据和读数据常常成对出现:创建了一条 post,列表就得刷新。老办法是两次请求,先写、再查。Remote Functions 支持单次往返,在 form 或 command 的回调里直接刷新对应的 query:
ts
export const createPost = form(z.object({/* ... */}), async (data) => {
await db.insert(data);
void getPosts().refresh(); // 结果随本次响应一起回来
redirect(303, '/blog/new');
});
服务端能做的刷新有三种:getPosts().refresh() 重新取,getPost(id).set(newPost) 直接覆盖,getNotifications(userId).reconnect() 重连 live query。
如果服务端不知道客户端渲染了哪些参数组合(比如列表带了 { filter: 'xxx' }),可以反过来让客户端点名要刷新哪些,服务端用 requested() 接。这个偏进阶,用到再说。
认证得写进函数里
远程函数能被任意页面调用,请求的 URL 也是客户端能伪造的,所以认证的检查 必须写在函数内部;可信上下文(比如 locals.user)可以照常在 handle hook 里从 cookie 派生注入,不必挪进函数。
ts
// $lib/server/auth.ts
import { redirect } from '@sveltejs/kit';
import { getRequestEvent } from '$app/server';
export function requireLogin() {
const { locals } = getRequestEvent();
if (!locals.user) redirect(303, '/login');
return locals.user;
}
ts
export const getMyProjects = query(async () => {
const user = requireLogin();
return await getProjects(user.id);
});
getRequestEvent() 拿到的对象里,route(当前路由)、params(URL 里的动态段,比如 [slug])、url(完整地址)这三个字段要特别注意。
先看 query。它会把结果缓存,参数相同就不重新执行。假如你在 query 里读了 url,用户从 /a 跳到 /b,因为参数没变,函数不会重跑,返回的还是 /a 时的旧结果,数据就过时了。所以这三个字段在 query 里直接被禁用,一读就报错。真想依赖它们,就把需要的值当参数传进来。
再看 form 和 command。这里虽然能读这三个字段,但它们描述的是"调用这个函数的那个页面"------这是客户端发来的信息,可以伪造。所以别拿它们判断用户有没有权限,权限检查要写在函数内部,像前面那个 requireLogin。
最后
这东西值不值得现在就用,看情况。好处是类型安全、少写一堆 fetch 和 res.json(),交互后刷新局部数据也干净。但它是实验性的,升级可能踩坑;而且只在页面渲染时取一次数据的场景,load 更直接;接口要给别人用、或者需要 PUT/DELETE/PATCH 这种动词,还是得写 +server.ts。
一句话:它适合"自己页面的门",不适合"对外的门"。
下面这个例子是我测试用的,复制过去就能跑。
vite.config.js:
js
sveltekit({
experimental: { remoteFunctions: true },
compilerOptions: { experimental: { async: true } }
})
src/routes/counter/data.remote.ts:
ts
import { z } from 'zod';
import { command, query } from '$app/server';
// 演示用:count 存在内存里,serverless 环境会重置、多用户共享,真实项目请存数据库
let count = 0;
export const getCount = query(async () => count);
export const increment = command(z.number().default(1), async (n) => {
count += n;
});
src/routes/counter/+page.svelte:
svelte
<script>
import { getCount, increment } from './data.remote';
</script>
<p>当前值:{await getCount()}</p>
<button onclick={async () => {
await increment(1);
await getCount().refresh();
}}>
+1
</button>