Chapter 07

SvelteKit 路由与文件约定

掌握 SvelteKit 的文件系统路由,构建结构清晰、类型安全的全栈应用

文件系统路由

路由文件约定

SvelteKit 使用 src/routes/ 目录下的文件结构来定义路由。以 + 开头的文件名是路由相关的特殊文件,普通文件(组件、工具函数等)不会成为路由:

src/routes/ ├── +page.svelte → / ├── +layout.svelte → 全局布局(包裹所有页面) ├── +error.svelte → 全局错误页 ├── about/ │ └── +page.svelte → /about ├── blog/ │ ├── +page.svelte → /blog(博客列表) │ ├── +layout.svelte → /blog/* 共享布局 │ └── [slug]/ │ └── +page.svelte → /blog/:slug(博客详情) ├── (auth)/ → 路由组(不影响 URL) │ ├── login/ │ │ └── +page.svelte → /login │ └── register/ │ └── +page.svelte → /register └── api/ └── users/ └── +server.ts → /api/users(API 端点)

+page.svelte — 页面组件

<!-- src/routes/+page.svelte -->
<script lang="ts">
  import type { PageData } from './$types';

  // data 来自 +page.server.ts 的 load 函数
  let { data } = $props<{ data: PageData }>();
</script>

<svelte:head>
  <title>首页 - 我的网站</title>
  <meta name="description" content="网站首页" />
</svelte:head>

<h1>欢迎来到 {data.siteName}</h1>

+layout.svelte — 共享布局

<!-- src/routes/+layout.svelte(全局布局)-->
<script lang="ts">
  import type { Snippet } from 'svelte';
  import Nav from '$lib/Nav.svelte';
  import Footer from '$lib/Footer.svelte';

  let { children } = $props<{ children: Snippet }>();
</script>

<Nav />
<main>
  {@render children()}   <!-- 渲染当前页面 -->
</main>
<Footer />

+error.svelte — 错误页面

<!-- src/routes/+error.svelte -->
<script lang="ts">
  import { page } from '$app/stores';
</script>

<h1>{$page.status}: {$page.error?.message}</h1>

{#if $page.status === 404}
  <p>页面不存在,<a href="/">返回首页</a></p>
{:else}
  <p>发生了一个错误,请稍后重试。</p>
{/if}

动态路由

[slug] 动态参数

<!-- src/routes/blog/[slug]/+page.svelte -->
<script lang="ts">
  import type { PageData } from './$types';
  let { data } = $props<{ data: PageData }>();
</script>

<article>
  <h1>{data.post.title}</h1>
  {@html data.post.content}
</article>

<!-- src/routes/blog/[slug]/+page.server.ts -->
import type { PageServerLoad } from './$types';
import { error } from '@sveltejs/kit';

export const load: PageServerLoad = async ({ params }) => {
  // params.slug 是 URL 中 [slug] 的实际值
  const post = await getPost(params.slug);

  if (!post) {
    error(404, '文章不存在');
  }

  return { post };
};

可选参数与 Rest 参数

# 可选参数:[[lang]] 使参数变为可选
src/routes/[[lang]]/about/+page.svelte
# 匹配 /about 和 /en/about 和 /zh/about

# Rest 参数:[...path] 匹配多层路径
src/routes/docs/[...slug]/+page.svelte
# 匹配 /docs/intro 和 /docs/api/rest/get

编程式导航

<script lang="ts">
  import { goto, invalidate, invalidateAll } from '$app/navigation';
  import { page } from '$app/stores';

  async function handleLogin() {
    await loginUser();

    // 导航到指定路径
    await goto('/dashboard');

    // 替换历史记录而不是推入(类似 replace state)
    await goto('/dashboard', { replaceState: true });

    // 保留当前滚动位置
    await goto('/dashboard', { noScroll: true });
  }

  // 读取当前路由信息
  $effect(() => {
    console.log('当前路径:', $page.url.pathname);
    console.log('路由参数:', $page.params);
    console.log('路由数据:', $page.data);
  });
</script>

<!-- 声明式导航 -->
<a href="/about">关于</a>
<a href="/blog/{post.slug}">{post.title}</a>

<!-- 程序控制 -->
<button onclick={handleLogin}>登录</button>
路由组 (group) 的用途

用圆括号包裹的目录名(如 (auth)(marketing))会创建路由组:目录名不出现在 URL 中,但可以为该组内的路由应用不同的 +layout.svelte,非常适合区分需要认证和不需要认证的页面布局。

hooks.server.ts:服务端中间件

SvelteKit 的 src/hooks.server.ts 文件提供服务端请求生命周期钩子,用于实现认证、日志、性能监控等横切关注点。

handle 钩子
拦截所有服务端请求,可以在 resolve 前后添加逻辑。通过向 event.locals 写入数据,在 load 函数和 Action 中读取认证信息。类似于 Express 的 middleware 或 Next.js 的 middleware.ts。
event.locals
本次请求上下文中的自定义数据存储。由 handle 钩子填充(如解析 JWT 获得的用户对象),在该请求的所有 load 函数和 Action 中通过 locals 参数读取。
sequence()
将多个 handle 函数串联,类似于多个 Express 中间件按顺序执行。
// src/hooks.server.ts
import { sequence } from '@sveltejs/kit/hooks';
import type { Handle } from '@sveltejs/kit';
import jwt from 'jsonwebtoken';

// handle1:认证中间件——解析 JWT 并设置 locals.user
const auth: Handle = async ({ event, resolve }) => {
  const token = event.cookies.get('session_token');

  if (token) {
    try {
      const payload = jwt.verify(token, process.env.JWT_SECRET!) as any;
      // 将用户信息存入 locals,load 函数通过 locals.user 访问
      event.locals.user = {
        id: payload.userId,
        email: payload.email,
        role: payload.role
      };
    } catch {
      // token 无效或过期,清除 cookie
      event.cookies.delete('session_token', { path: '/' });
    }
  }

  return resolve(event);
};

// handle2:性能日志中间件
const logger: Handle = async ({ event, resolve }) => {
  const start = Date.now();
  const response = await resolve(event);
  const duration = Date.now() - start;

  if (duration > 500) {
    console.warn(`[SLOW] ${event.request.method} ${event.url.pathname} - ${duration}ms`);
  }

  response.headers.set('X-Response-Time', `${duration}ms`);
  return response;
};

// handle3:内容安全策略(CSP)
const security: Handle = async ({ event, resolve }) => {
  const response = await resolve(event);
  response.headers.set(
    'Content-Security-Policy',
    "default-src 'self'; script-src 'self' 'unsafe-inline'"
  );
  return response;
};

// 串联多个中间件(按顺序执行:auth → logger → security)
export const handle = sequence(auth, logger, security);
// src/app.d.ts:扩展 locals 类型定义
declare global {
  namespace App {
    // 定义 event.locals 的类型,使 TypeScript 能推断其结构
    interface Locals {
      user: {
        id: string;
        email: string;
        role: 'admin' | 'user';
      } | null;
    }
  }
}

export {};
// +page.server.ts:使用 locals 中的认证信息
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';

export const load: PageServerLoad = async ({ locals }) => {
  // locals.user 由 handle 钩子填充,类型安全
  if (!locals.user) {
    throw redirect(303, '/login');
  }

  if (locals.user.role !== 'admin') {
    error(403, '需要管理员权限');
  }

  return { user: locals.user };
};

API 路由 (+server.ts)

// src/routes/api/users/+server.ts
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';

export const GET: RequestHandler = async ({ url }) => {
  const page = Number(url.searchParams.get('page')) || 1;
  const users = await getUsers({ page });
  return json(users);
};

export const POST: RequestHandler = async ({ request }) => {
  const body = await request.json();
  const user = await createUser(body);
  return json(user, { status: 201 });
};

SvelteKit 路由的工作原理

文件系统路由如何映射到 HTTP 请求

理解 SvelteKit 内部如何将文件路径转化为路由处理器,有助于正确设计项目结构和排查路由问题:

路由识别顺序(Route Resolution Order)
当一个 URL 请求到来时,SvelteKit 按以下优先级匹配路由:精确静态路径(/blog/about)> 带参数路径(/blog/[slug])> 通配符路径(/blog/[...rest])。这意味着静态路由永远比动态路由优先,你不需要担心 /blog/about/blog/[slug] 错误匹配。
+layout.svelte 的嵌套机制
所有 +layout.svelte 文件形成嵌套结构。每个 +page.svelte 会被其所有上级目录的 layout 包裹,最外层是根 src/routes/+layout.svelte。这不只是"视觉嵌套",数据也会从父 layout 向下流动(父 layout 的 load 函数返回的 data 子页面可以访问)。
+page.server.ts vs +page.ts 的执行位置
+page.server.ts 只在服务器端执行(包含 SSR 时的服务端和 API 路由),可以安全访问数据库、读取环境变量、使用 Node.js API。+page.ts 在服务端和客户端都执行(SSR 时在服务端,CSR 导航时在客户端),不能访问数据库,适合获取公开 API 数据。
路由组(Route Groups)的用途
(auth)/login(public)/about:圆括号内的名字不出现在 URL 中(/login/about),但允许为不同组的路由使用不同的 +layout.svelte。这是实现"需要认证的页面共享一个带导航栏的布局,无需认证的页面使用干净布局"的标准做法。

动态路由参数的类型安全

// src/routes/blog/[slug]/+page.server.ts
import type { PageServerLoad } from './$types';
// $types 是 SvelteKit 自动生成的类型文件,保证 params.slug 的类型安全

export const load: PageServerLoad = async ({ params }) => {
  // params.slug 的类型是 string(由路由名决定)
  // 如果路由是 [id=integer](带参数验证器),类型是 string 但已通过验证
  const post = await db.post.findUnique({
    where: { slug: params.slug }
  });

  // 如果文章不存在,抛出 404 错误
  if (!post) {
    throw error(404, { message: '文章不存在' });
  }

  return { post };  // 返回的类型会传递给 +page.svelte 的 data prop
};

+server.ts(API 路由)vs +page.server.ts(页面数据)的选择

使用 +server.ts 的场景(REST API 端点):
  ✓ 需要被外部客户端(移动 App、第三方)调用
  ✓ 返回 JSON 以外的格式(二进制、流、CSV)
  ✓ 需要细粒度控制 HTTP 状态码和头部
  ✓ WebSocket 升级处理

使用 +page.server.ts 的场景(页面数据加载):
  ✓ 为当前页面提供数据(不需要被外部调用)
  ✓ 表单 Actions(POST 处理)
  ✓ 处理认证/授权(可以访问 cookies、session)

选择原则:
  如果数据只为这个页面服务,用 +page.server.ts;
  如果数据需要被多个地方复用或外部调用,用 +server.ts
常见错误:在 +page.ts 中访问数据库

+page.ts 在客户端导航时也会执行,而浏览器中没有数据库连接。如果你需要访问数据库,始终使用 +page.server.ts(后缀 .server.ts 表示只在服务端执行)。SvelteKit 会在构建时检测并报错,防止意外将数据库凭证暴露到客户端。

第7章小结