要点
这是 Svelte 系列的第二篇。开山篇《Svelte 入门》讲清了编译时框架与 runes 响应式,本篇往下走一层:SvelteKit 2 如何用文件树表达路由,以及全栈框架最容易踩坑的部分——load 函数的数据加载。看完这篇你应该能回答:路由优先级怎么定、universal load 与 server load 怎么分工、什么会触发 load 重跑、为什么必须用 event.fetch 而不是全局 fetch。版本锚点:截至本文(2025 年 10 月底),稳定线为 Svelte 5.43.x 与 SvelteKit 2.48.x,全文均以此为准。
路由是文件树的投影
SvelteKit 没有路由配置文件,src/routes 目录本身就是路由表。目录是路径段,特殊的 + 前缀文件承担不同职责:
src/routes/
├── +layout.svelte # 根布局:每个页面都会套上它
├── +page.svelte # / 首页
├── about/
│ └── +page.svelte # /about
├── blog/
│ ├── +page.svelte # /blog 列表页
│ └── [slug]/
│ └── +page.svelte # /blog/hello 动态详情页
只有带 + 前缀的文件参与路由,其余文件(工具函数、组件)放在同级目录里就是普通私有模块——惯用做法是放进 src/lib(有内置别名 $lib)。各文件分工如下表:
| 文件 | 职责 | 运行位置 |
|---|---|---|
| +page.svelte | 页面 UI | 服务端首渲 + 客户端 |
| +page.js | universal load,取数后交给页面 | 服务端与客户端都跑 |
| +page.server.js | server load 与表单 action,可碰密钥 | 仅服务端 |
| +layout.svelte | 嵌套布局,包裹子路由 | 服务端首渲 + 客户端 |
| +layout.server.js | 整棵子树共享的 server 数据 | 仅服务端 |
| +server.js | 纯 API 端点(GET/POST 等处理器) | 仅服务端 |
| +error.svelte | 错误兜底页,可放在任意层级 | 服务端首渲 + 客户端 |
动态段与匹配优先级
方括号目录是动态段,五种写法覆盖绝大多数场景:
| 写法 | 匹配 | 示例 |
|---|---|---|
| [slug] | 一段任意内容 | /blog/hello |
| [[optional]] | 可有可无的一段 | /shop 与 /shop/electronics 同一组件 |
| […rest] | 剩余全部路径(含多段) | 兜底 404 页、深层 catch-all |
| [id=integer] | 带匹配器的段,只匹配数字 | /post/42 命中、/post/abc 不命中 |
| (marketing) | 分组目录,不进入 URL | 同一 URL 换一套布局 |
匹配器是 src/params/ 下的一个模块,适合在路由层就把非法参数挡掉,而不是进页面再校验:
// src/params/integer.js —— 拒绝非数字,路由直接 404
/** @type {import('@sveltejs/kit').ParamMatcher} */
export function match(param) {
return /^\d+$/.test(param);
}
同一位置多个路由都命中时,优先级从高到低是:静态段、带匹配器的动态段、普通动态段、rest 段。这个排序直觉上就是”越具体越优先”,边界情况以官方文档 routing 章节为准。分组布局 (marketing) 解决的是经典问题:/about 要营销页头,/dashboard 要侧边栏,两者互斥——把它们放进不同组,各自写 +layout.svelte,URL 不受影响。
布局的嵌套与”不重置”
布局沿目录层层嵌套,Svelte 5 用 snippet 接收子内容——子页面渲染在 children 里:
<!-- src/routes/+layout.svelte -->
<script>
let { children } = $props(); // Svelte 5 写法;旧 slot 语法已让位
</script>
<nav><a href="/">首页</a></nav>
{@render children()}
一个值得记住的性质:导航只在同层级换页时替换 page 部分,layout 组件实例是保留的——里面的 $state 不会因为跳转子路由而被重置。侧边栏的展开状态、滚动位置因此天然存活;反过来,想在每次导航都重算的数据不要放布局里,要放 load。出错时的兜底也按层级生效:/blog/[slug]/+error.svelte 只接管该子树的异常,根部的 +error.svelte 接管其余。
两个 load:universal 与 server
数据加载的核心是 load 函数,它有两种载体,分工是整个 SvelteKit 数据流的第一决策点:
| 关注点 | +page.js(universal load) | +page.server.js(server load) |
|---|---|---|
| 运行环境 | 服务端首渲时跑一次,之后客户端导航时在浏览器跑 | 永远只在服务端跑 |
| 能碰什么 | 公开数据、CMS API | 数据库、 cookies、私有环境变量 |
| 返回值 | 可序列化(经 devalue 编码传给客户端) | 必须可序列化,不能含函数或类实例 |
| 典型用途 | 调公共第三方 API、想省一次服务端往返 | 拉需要鉴权的数据、渲染前取私密内容 |
只有 +page.server.js 时数据在服务端取好随 HTML 到达;只有 +page.js 时服务端也执行它(保证首屏有数据),后续导航在浏览器执行。两者并存时 SvelteKit 会等 server load 完成后,把其结果通过 parent() 供给 universal load。load 收到的 event 参数常用成员如下:
| 成员 | 用途 | 备注 |
|---|---|---|
| event.params | 路径动态段,如 { slug: ‘hello’ } | 匹配器已保证格式 |
| event.url | 当前 URL 对象 | 读 searchParams 会影响重跑规则,见下节 |
| event.fetch | 带凭证与去重的 fetch,见下节 | 不要换成全局 fetch |
| event.cookies | 读写 Cookie(server load 专属) | 写即向响应附加 Set-Cookie |
| event.locals | hooks 里挂载的请求级数据(如登录用户) | server load 专属 |
| event.parent() | 取上级 load 的数据 | 注意别造成请求瀑布 |
| event.depends() | 声明自定义依赖,配合 invalidate 重跑 | 轮询、实时数据用 |
server load 的典型样子:
// src/routes/blog/[slug]/+page.server.js
import { error } from '@sveltejs/kit';
export async function load({ params, locals }) {
const post = await locals.db.getPost(params.slug); // 数据库只在服务端碰
if (!post) error(404, '文章不存在'); // SvelteKit 2 中 error 是抛出式函数
return { post }; // 经序列化传给 +page.svelte,组件用 let { data } = $props() 接
}
什么会触发 load 重跑
SvelteKit 会追踪每个 load 的依赖,导航时只重跑”依赖变了”的 load。规则里最容易踩的是查询串(query string):
- universal load:只有当它实际读过
url.searchParams(get/getAll/has),对应参数变化才会触发重跑——读没读,SvelteKit 是靠追踪知道的。 - server load:客户端导航时不会因为查询串变化而重跑,重跑条件是 params、
depends()声明的依赖或父级数据变化。
反模式点名:在 server load 里读查询串做筛选,然后期待用户切换筛选条件时数据自动刷新。客户端侧导航不会重跑它,页面看起来”没反应”。两条正路:筛选条件放进 universal load 读(读 url.searchParams 即建立依赖),或在交互后调用 invalidate(url) / invalidateAll() 手动触发。同理,路径参数变化一定会重跑 load,所以 /blog/a 跳 /blog/b 不需要任何手动刷新代码——有人在组件里用 $effect 监听参数再发请求,把 SSR 白做掉,还多一次客户端往返,这是第二个反模式。
event.fetch 而不是 fetch
load 里的 HTTP 请求必须用 event.fetch,不用全局 fetch,换来的三件事都是实打实的:相对 URL 可用(全局 fetch 在服务端没有 origin,相对路径直接抛错);Cookie 与凭证自动透传(用户身份跟着请求走);对站内 +server.js 端点的请求在 SSR 时直接调用处理函数,不经过真实网络往返。第三点经常被低估——你的页面 load 与数据端点各自独立开发,SSR 时却能合并成一次进程内调用。
流式:慢数据不必阻塞首屏
server load 的返回值里可以放未完成的 Promise,SvelteKit 会先把页面骨架随流式 HTML 发出,慢数据就绪后再补上:
export async function load({ locals }) {
return {
user: await locals.getUser(), // 快,阻塞渲染
stats: locals.db.slowAggregate() // 慢,作为 Promise 直接种进 data
};
}
<!-- +page.svelte:流式数据用 await 语法等它 -->
{#await data.stats}
<p>统计生成中…</p>
{:then s}
<p>共 {s.total} 条</p>
{/await}
边界要清楚:流开始后无法再改响应头和状态码,流中才发生的错误只能在 +error.svelte 边界里呈现,SEO 上流式内容对爬虫可见性也不如静态内容——所以只把”可迟到的数据”做成流式。
下一篇与延伸
路由与 load 之外,SvelteKit 还有第三块拼图:表单 action 与渐进增强(无 JavaScript 也能提交),那部分在本系列下一篇实战里落地——《用 SvelteKit 做一个 Markdown 片段收藏夹》(2025-11-06 发布)会把本篇的 load 知识用起来。另外,SvelteKit 2.27 起提供了实验性的 remote functions(.remote.js 文件里的函数永远在服务端执行),目标是替代手写端点的样板代码,截至本文仍是实验特性,生产项目以官方文档为准。常用 API 的速查版,见《Svelte / SvelteKit 基础知识参考字典》。
总结:本篇给了你 routes 文件树、动态段与优先级、两种 load 的分工、重跑规则与 event.fetch 五块认知。如果项目是纯静态内容站,server load 与 cookies 用不上,考虑直接 prerender;如果异步基础还不牢,先补Node.js 异步与 Promise——load 的心智全建立在 async/await 上。
版本核实(以官方文档为准):截至本文(2025-10-30)Svelte 稳定线为 5.43.x、SvelteKit 稳定线为 2.48.x;$app/state 自 SvelteKit 2.12(2024-12)取代 $app/stores;remote functions 自 SvelteKit 2.27(2025-07)起为实验特性;error()/redirect()/fail() 为 SvelteKit 2 抛出式 API。安装命令不钉版本号。