1061 lines
25 KiB
Markdown
1061 lines
25 KiB
Markdown
# Svelte / SvelteKit Code Review Guide
|
||
|
||
Svelte 5 / SvelteKit 审查重点:Runes 响应式系统、Server/Client 边界、Form Actions、Store 迁移、以及安全性。
|
||
|
||
## 目录
|
||
|
||
- [Runes: $state / $derived / $effect](#runes-state--derived--effect)
|
||
- [Load 函数(Server vs Client)](#load-函数server-vs-client)
|
||
- [Form Actions](#form-actions)
|
||
- [Store 迁移(→ $state)](#store-迁移)
|
||
- [SSR vs CSR 边界](#ssr-vs-csr-边界)
|
||
- [响应式语句迁移($: → Runes)](#响应式语句迁移)
|
||
- [性能优化](#性能优化)
|
||
- [安全审查](#安全审查)
|
||
- [Review Checklist](#review-checklist)
|
||
|
||
---
|
||
|
||
## Runes: $state / $derived / $effect
|
||
|
||
### $state 基础用法
|
||
|
||
```svelte
|
||
<!-- ❌ $state 用于永远不会变化的值 -->
|
||
<script lang="ts">
|
||
let config = $state({ timeout: 5000 }); // 不需要响应式
|
||
const API_URL = $state('/api'); // 常量不需要 $state
|
||
</script>
|
||
|
||
<!-- ✅ 常量直接声明 -->
|
||
<script lang="ts">
|
||
const config = { timeout: 5000 };
|
||
const API_URL = '/api';
|
||
|
||
// $state 只用于会变化的值
|
||
let count = $state(0);
|
||
let user = $state<User | null>(null);
|
||
</script>
|
||
```
|
||
|
||
### $state.raw 与大型对象
|
||
|
||
```svelte
|
||
<!-- ❌ 大型不可变数据使用深度响应式 -->
|
||
<script lang="ts">
|
||
// largeData 会被深度代理,性能开销大
|
||
let data = $state(hugeApiResponse);
|
||
</script>
|
||
|
||
<!-- ✅ $state.raw 避免深度代理 -->
|
||
<script lang="ts">
|
||
let data = $state.raw(hugeApiResponse);
|
||
|
||
// 整体替换时才触发更新
|
||
async function refresh() {
|
||
data = await fetchLatestData(); // ✅ triggers reactivity
|
||
}
|
||
|
||
// ❌ 修改嵌套属性不会触发更新
|
||
// data.items[0].name = 'new'; // will NOT re-render
|
||
</script>
|
||
```
|
||
|
||
### $state.snapshot 用于外部库
|
||
|
||
```svelte
|
||
<!-- ❌ 直接将 $state 对象传给外部库 -->
|
||
<script lang="ts">
|
||
let state = $state({ x: 0, y: 0 });
|
||
|
||
onMount(() => {
|
||
// 外部库可能无法正确处理 Proxy 对象
|
||
chartLibrary.update(state); // state is a Proxy!
|
||
});
|
||
</script>
|
||
|
||
<!-- ✅ $state.snapshot 获取普通对象副本 -->
|
||
<script lang="ts">
|
||
let state = $state({ x: 0, y: 0 });
|
||
|
||
onMount(() => {
|
||
// $state.snapshot produces a plain object (Svelte 5)
|
||
chartLibrary.update($state.snapshot(state));
|
||
});
|
||
</script>
|
||
```
|
||
|
||
### 解构 $state 丢失响应性
|
||
|
||
```svelte
|
||
<!-- ❌ 解构 $state 对象丢失响应性 -->
|
||
<script lang="ts">
|
||
let state = $state({ count: 0, name: 'Svelte' });
|
||
let { count, name } = state; // count and name are plain values!
|
||
</script>
|
||
<p>{count}</p> <!-- ❌ will NOT update when state.count changes -->
|
||
|
||
<!-- ✅ 直接访问 $state 属性 -->
|
||
<script lang="ts">
|
||
let state = $state({ count: 0, name: 'Svelte' });
|
||
</script>
|
||
<p>{state.count}</p> <!-- ✅ stays reactive -->
|
||
|
||
<!-- ✅ 或者单独声明每个 $state -->
|
||
<script lang="ts">
|
||
let count = $state(0);
|
||
let name = $state('Svelte');
|
||
</script>
|
||
```
|
||
|
||
---
|
||
|
||
### $derived 正确用法
|
||
|
||
```svelte
|
||
<!-- ❌ #1 反模式:用 $effect 做状态同步 -->
|
||
<script lang="ts">
|
||
let firstName = $state('John');
|
||
let lastName = $state('Doe');
|
||
let fullName = $state('');
|
||
|
||
// 不要用 $effect 来同步派生状态!
|
||
$effect(() => {
|
||
fullName = `${firstName} ${lastName}`; // unnecessary effect
|
||
});
|
||
</script>
|
||
|
||
<!-- ✅ 使用 $derived 计算派生值 -->
|
||
<script lang="ts">
|
||
let firstName = $state('John');
|
||
let lastName = $state('Doe');
|
||
let fullName = $derived(`${firstName} ${lastName}`);
|
||
</script>
|
||
```
|
||
|
||
### $derived 中不应有副作用
|
||
|
||
```svelte
|
||
<!-- ❌ $derived 中产生副作用 -->
|
||
<script lang="ts">
|
||
let items = $state<Item[]>([]);
|
||
let count = $derived(() => {
|
||
console.log('recalculating'); // side effect!
|
||
analytics.track('count', items.length); // side effect!
|
||
return items.length;
|
||
});
|
||
</script>
|
||
|
||
<!-- ✅ $derived 只用于纯计算 -->
|
||
<script lang="ts">
|
||
let items = $state<Item[]>([]);
|
||
let count = $derived(items.length);
|
||
|
||
// side effects go in $effect
|
||
$effect(() => {
|
||
analytics.track('count', count);
|
||
});
|
||
</script>
|
||
```
|
||
|
||
---
|
||
|
||
### $effect 正确用法
|
||
|
||
#### $effect vs $derived
|
||
|
||
```svelte
|
||
<!-- ❌ $effect 用于同步状态(第一大反模式) -->
|
||
<script lang="ts">
|
||
let searchQuery = $state('');
|
||
let results = $state([]);
|
||
|
||
$effect(() => {
|
||
results = searchQuery ? items.filter(i => i.name.includes(searchQuery)) : items;
|
||
});
|
||
</script>
|
||
|
||
<!-- ✅ 使用 $derived -->
|
||
<script lang="ts">
|
||
let searchQuery = $state('');
|
||
let results = $derived(
|
||
searchQuery ? items.filter(i => i.name.includes(searchQuery)) : items
|
||
);
|
||
</script>
|
||
```
|
||
|
||
#### 无限循环
|
||
|
||
```svelte
|
||
<!-- ❌ $effect 中更新自身依赖 → 无限循环 -->
|
||
<script lang="ts">
|
||
let count = $state(0);
|
||
|
||
$effect(() => {
|
||
console.log(count);
|
||
count++; // modifying dependency inside effect → infinite loop!
|
||
});
|
||
</script>
|
||
|
||
<!-- ✅ 避免在 $effect 中修改被追踪的状态 -->
|
||
<script lang="ts">
|
||
let count = $state(0);
|
||
let log = $state<string[]>([]);
|
||
|
||
$effect(() => {
|
||
// read count, write to a different state
|
||
log = [...log, `count is ${count}`];
|
||
});
|
||
</script>
|
||
```
|
||
|
||
#### 清理函数
|
||
|
||
```svelte
|
||
<!-- ❌ 缺少清理函数 → 内存泄漏 -->
|
||
<script lang="ts">
|
||
let roomId = $state('');
|
||
|
||
$effect(() => {
|
||
const ws = new WebSocket(`ws://example.com/${roomId}`);
|
||
ws.onmessage = (e) => {
|
||
messages = [...messages, JSON.parse(e.data)];
|
||
};
|
||
// no cleanup! WebSocket leaks when roomId changes
|
||
});
|
||
</script>
|
||
|
||
<!-- ✅ 返回清理函数 -->
|
||
<script lang="ts">
|
||
let roomId = $state('');
|
||
|
||
$effect(() => {
|
||
const ws = new WebSocket(`ws://example.com/${roomId}`);
|
||
ws.onmessage = (e) => {
|
||
messages = [...messages, JSON.parse(e.data)];
|
||
};
|
||
return () => ws.close(); // cleanup on re-run
|
||
});
|
||
</script>
|
||
|
||
<!-- ✅ 定时器清理 -->
|
||
<script lang="ts">
|
||
$effect(() => {
|
||
const id = setInterval(() => {
|
||
console.log('tick');
|
||
}, 1000);
|
||
return () => clearInterval(id);
|
||
});
|
||
</script>
|
||
```
|
||
|
||
#### async $effect 的追踪陷阱
|
||
|
||
```svelte
|
||
<!-- ❌ await 后读取的状态不会被追踪 -->
|
||
<script lang="ts">
|
||
let userId = $state('1');
|
||
let preference = $state('dark');
|
||
|
||
$effect(async () => {
|
||
const user = await fetchUser(userId); // userId IS tracked
|
||
const theme = preference; // NOT tracked (read after await)!
|
||
applyTheme(user, theme);
|
||
});
|
||
</script>
|
||
|
||
<!-- ✅ 在 await 前读取所有依赖 -->
|
||
<script lang="ts">
|
||
let userId = $state('1');
|
||
let preference = $state('dark');
|
||
|
||
$effect(async () => {
|
||
const currentPref = preference; // read before await
|
||
const user = await fetchUser(userId);
|
||
applyTheme(user, currentPref);
|
||
});
|
||
</script>
|
||
```
|
||
|
||
#### untrack 排除依赖
|
||
|
||
```svelte
|
||
<!-- ❌ 不小心追踪了不必要的依赖 -->
|
||
<script lang="ts">
|
||
let data = $state<Data | null>(null);
|
||
let debugMode = $state(false);
|
||
|
||
$effect(() => {
|
||
if (debugMode) { // debugMode becomes a dependency!
|
||
console.log('data changed', data);
|
||
}
|
||
});
|
||
</script>
|
||
|
||
<!-- ✅ untrack 排除不相关的依赖 -->
|
||
<script lang="ts">
|
||
import { untrack } from 'svelte';
|
||
|
||
let data = $state<Data | null>(null);
|
||
let debugMode = $state(false);
|
||
|
||
$effect(() => {
|
||
if (untrack(() => debugMode)) { // debugMode is NOT tracked
|
||
console.log('data changed', data);
|
||
}
|
||
});
|
||
</script>
|
||
```
|
||
|
||
---
|
||
|
||
## Load 函数(Server vs Client)
|
||
|
||
### +page.server.js vs +page.js
|
||
|
||
```typescript
|
||
// ❌ 在 +page.js 中访问数据库或 secrets
|
||
// src/routes/admin/+page.js
|
||
export async function load({ fetch }) {
|
||
// universal load runs on both server and client
|
||
const data = await db.query('SELECT * FROM users'); // db not available in browser!
|
||
return { users: data };
|
||
}
|
||
|
||
// ✅ 服务端逻辑放在 +page.server.js
|
||
// src/routes/admin/+page.server.js
|
||
import { db } from '$lib/server/db';
|
||
|
||
export async function load() {
|
||
const users = await db.query('SELECT * FROM users');
|
||
return { users };
|
||
}
|
||
```
|
||
|
||
```typescript
|
||
// ✅ +page.js 用于客户端也可用的数据(如 fetch 聚合)
|
||
// src/routes/dashboard/+page.js
|
||
export async function load({ fetch, parent }) {
|
||
const [analytics, notifications] = await Promise.all([
|
||
fetch('/api/analytics').then(r => r.json()),
|
||
fetch('/api/notifications').then(r => r.json())
|
||
]);
|
||
return { analytics, notifications };
|
||
}
|
||
```
|
||
|
||
### await parent() 瀑布流
|
||
|
||
```typescript
|
||
// ❌ 顺序 await parent → 瀑布流
|
||
// src/routes/blog/[slug]/+page.js
|
||
export async function load({ parent, fetch }) {
|
||
const parentData = await parent(); // wait for parent
|
||
const post = await fetch(`/api/posts/${parentData.blogId}`);
|
||
return { post };
|
||
}
|
||
|
||
// ✅ 尽可能并行,避免不必要的 parent await
|
||
// src/routes/blog/[slug]/+page.js
|
||
export async function load({ parent, fetch }) {
|
||
// only await parent if you truly need its data
|
||
const post = await fetch('/api/posts/slug');
|
||
return { post };
|
||
}
|
||
|
||
// ✅ 如果确实需要 parent 数据,无法避免瀑布流,但要明确注释
|
||
// src/routes/blog/[slug]/+page.js
|
||
export async function load({ parent, fetch }) {
|
||
const { blogId } = await parent(); // required: need blogId for post URL
|
||
const post = await fetch(`/api/posts/${blogId}`);
|
||
return { post };
|
||
}
|
||
```
|
||
|
||
### 不可序列化的返回值
|
||
|
||
```typescript
|
||
// ❌ 从 server load 返回不可序列化的值
|
||
// src/routes/api/+page.server.js
|
||
export async function load() {
|
||
return {
|
||
stream: fs.createReadStream('data.csv'), // not serializable!
|
||
callback: () => console.log('hi'), // functions not serializable!
|
||
date: new Date(), // OK — devalue serializes Date/Map/Set fine
|
||
};
|
||
}
|
||
|
||
// ✅ 只返回可序列化的数据
|
||
// src/routes/api/+page.server.js
|
||
export async function load() {
|
||
return {
|
||
data: await readFile('data.csv', 'utf-8'),
|
||
timestamp: Date.now(),
|
||
};
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Form Actions
|
||
|
||
### 使用 POST 处理副作用
|
||
|
||
```svelte
|
||
<!-- ❌ 用 GET/load 函数处理副作用 -->
|
||
<script lang="ts">
|
||
import { goto } from '$app/navigation';
|
||
|
||
async function deleteUser(id: string) {
|
||
await fetch(`/api/users/${id}`, { method: 'DELETE' });
|
||
goto('/users'); // side effect via client navigation
|
||
}
|
||
</script>
|
||
<button onclick={() => deleteUser(user.id)}>Delete</button>
|
||
|
||
<!-- ✅ 使用 form actions -->
|
||
```
|
||
|
||
```typescript
|
||
// src/routes/users/+page.server.js
|
||
import { fail, redirect } from '@sveltejs/kit';
|
||
|
||
export const actions = {
|
||
delete: async ({ request, locals }) => {
|
||
const formData = await request.formData();
|
||
const id = formData.get('id');
|
||
|
||
if (!id) return fail(400, { message: 'Missing id' });
|
||
|
||
await locals.db.users.delete(id);
|
||
throw redirect(303, '/users');
|
||
}
|
||
};
|
||
```
|
||
|
||
```svelte
|
||
<!-- form with progressive enhancement -->
|
||
<script lang="ts">
|
||
import { enhance } from '$app/forms';
|
||
</script>
|
||
|
||
<form method="POST" action="?/delete" use:enhance>
|
||
<input type="hidden" name="id" value={user.id} />
|
||
<button type="submit">Delete</button>
|
||
</form>
|
||
```
|
||
|
||
### fail() 中不暴露敏感信息
|
||
|
||
```typescript
|
||
// ❌ fail() 中返回敏感信息
|
||
// src/routes/login/+page.server.js
|
||
export const actions = {
|
||
default: async ({ request, locals }) => {
|
||
const formData = await request.formData();
|
||
const user = await locals.db.users.findByEmail(formData.get('email'));
|
||
|
||
return fail(401, {
|
||
password: formData.get('password'), // ❌ exposes password in page data!
|
||
hint: user.passwordHint, // ❌ leaks internal data!
|
||
});
|
||
}
|
||
};
|
||
|
||
// ✅ 只返回安全的错误信息
|
||
export const actions = {
|
||
default: async ({ request }) => {
|
||
const formData = await request.formData();
|
||
const email = formData.get('email');
|
||
|
||
return fail(401, {
|
||
email, // ✅ safe to echo back
|
||
incorrect: true, // ✅ generic error flag
|
||
});
|
||
}
|
||
};
|
||
```
|
||
|
||
### use:enhance 渐进增强
|
||
|
||
```svelte
|
||
<!-- ❌ 表单不使用 use:enhance → 没有 JS 时才用原生行为 -->
|
||
<form method="POST" action="?/create">
|
||
<input name="title" />
|
||
<button type="submit">Create</button>
|
||
</form>
|
||
|
||
<!-- ✅ use:enhance 提供 SPA 体验 + progressive enhancement -->
|
||
<script lang="ts">
|
||
import { enhance } from '$app/forms';
|
||
</script>
|
||
|
||
<form method="POST" action="?/create" use:enhance={() => {
|
||
return ({ update }) => {
|
||
update({ reset: false }); // customize behavior
|
||
};
|
||
}}>
|
||
<input name="title" />
|
||
<button type="submit">Create</button>
|
||
</form>
|
||
|
||
<!-- ✅ 带加载状态 -->
|
||
<form
|
||
method="POST"
|
||
action="?/create"
|
||
use:enhance={() => {
|
||
submitting = true;
|
||
return ({ update }) => {
|
||
update();
|
||
submitting = false;
|
||
};
|
||
}}
|
||
>
|
||
<button type="submit" disabled={submitting}>
|
||
{submitting ? 'Creating...' : 'Create'}
|
||
</button>
|
||
</form>
|
||
```
|
||
|
||
---
|
||
|
||
## Store 迁移(→ $state)
|
||
|
||
### writable/readable → $state
|
||
|
||
```typescript
|
||
// ❌ Legacy store pattern (Svelte 4)
|
||
// src/lib/stores/user.js
|
||
import { writable, derived } from 'svelte/store';
|
||
|
||
export const user = writable(null);
|
||
export const isLoggedIn = derived(user, $user => !!$user);
|
||
|
||
// usage with $ prefix
|
||
// $user = { name: 'John' };
|
||
|
||
// ✅ Svelte 5: shared state in .svelte.js files
|
||
// src/lib/stores/user.svelte.js
|
||
let currentUser = $state<User | null>(null);
|
||
|
||
export function getUser() {
|
||
return currentUser;
|
||
}
|
||
|
||
export function setUser(user: User | null) {
|
||
currentUser = user;
|
||
}
|
||
|
||
export function isLoggedIn() {
|
||
return currentUser !== null;
|
||
}
|
||
```
|
||
|
||
### $ 前缀 store 语法是遗留语法
|
||
|
||
```svelte
|
||
<!-- ❌ $ 前缀 store 自动订阅是遗留模式 -->
|
||
<script lang="ts">
|
||
import { count } from '$lib/stores/count';
|
||
// $count is legacy syntax in Svelte 5
|
||
</script>
|
||
<p>{$count}</p>
|
||
|
||
<!-- ✅ Svelte 5 runes 模式 -->
|
||
<script lang="ts">
|
||
import { getCount } from '$lib/stores/count.svelte';
|
||
|
||
let count = $derived(getCount());
|
||
</script>
|
||
<p>{count}</p>
|
||
|
||
<!-- ✅ 或者直接用 export 的 $state 响应式 getter -->
|
||
<script lang="ts">
|
||
// count.svelte.js exports a reactive reference
|
||
import { counter } from '$lib/stores/count.svelte';
|
||
</script>
|
||
<p>{counter.value}</p>
|
||
```
|
||
|
||
### .svelte.js / .svelte.ts 扩展名
|
||
|
||
```typescript
|
||
// ❌ 在普通 .js 文件中使用 runes → 编译错误
|
||
// src/lib/utils.js
|
||
let state = $state(0); // runes only work in .svelte.js files!
|
||
|
||
// ✅ 使用 .svelte.js 扩展名
|
||
// src/lib/utils.svelte.js
|
||
let state = $state(0);
|
||
|
||
export function getState() {
|
||
return state;
|
||
}
|
||
|
||
export function setState(val: number) {
|
||
state = val;
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## SSR vs CSR 边界
|
||
|
||
### ssr=false SPA 模式
|
||
|
||
```typescript
|
||
// ❌ 在根 layout 中禁用 SSR → 全部变成 CSR
|
||
// src/routes/+layout.js
|
||
export const ssr = false; // entire app becomes SPA
|
||
|
||
// ✅ 只在需要的页面禁用 SSR
|
||
// src/routes/admin/dashboard/+page.js
|
||
export const ssr = false; // only this page skips SSR
|
||
|
||
// ✅ 更好的做法:按路由配置
|
||
// src/routes/editor/+page.js
|
||
export const ssr = false; // editor needs browser APIs, skip SSR
|
||
```
|
||
|
||
### 浏览器全局变量在 SSR 中
|
||
|
||
```svelte
|
||
<!-- ❌ 在模块顶层访问浏览器 API -->
|
||
<script lang="ts">
|
||
const height = window.innerHeight; // ReferenceError during SSR!
|
||
const prefersDark = matchMedia('(prefers-color-scheme: dark)'); // crash!
|
||
</script>
|
||
|
||
<!-- ✅ 在 onMount 或 browser guard 中访问 -->
|
||
<script lang="ts">
|
||
import { onMount } from 'svelte';
|
||
import { browser } from '$app/environment';
|
||
|
||
let height = $state(0);
|
||
|
||
onMount(() => {
|
||
height = window.innerHeight;
|
||
});
|
||
|
||
// or conditional check
|
||
const prefersDark = browser
|
||
? matchMedia('(prefers-color-scheme: dark)').matches
|
||
: false;
|
||
</script>
|
||
```
|
||
|
||
### prerender 与 actions 冲突
|
||
|
||
```typescript
|
||
// ❌ prerender 页面中定义 actions → 编译错误
|
||
// src/routes/contact/+page.server.js
|
||
export const prerender = true;
|
||
|
||
export const actions = {
|
||
// Error: prerendered pages cannot have server-side form actions
|
||
default: async ({ request }) => { /* ... */ }
|
||
};
|
||
|
||
// ✅ prerender 页面不使用 server actions
|
||
// src/routes/about/+page.server.js
|
||
export const prerender = true;
|
||
// no actions — static page
|
||
|
||
// ✅ 需要 actions 的页面不 prerender
|
||
// src/routes/contact/+page.server.js
|
||
export const actions = {
|
||
default: async ({ request }) => {
|
||
// handle form submission
|
||
}
|
||
};
|
||
```
|
||
|
||
---
|
||
|
||
## 响应式语句迁移
|
||
|
||
### $: → $derived / $effect
|
||
|
||
```svelte
|
||
<!-- ❌ Svelte 4 响应式语句 -->
|
||
<script lang="ts">
|
||
let count = 0;
|
||
let doubled = 0;
|
||
|
||
$: doubled = count * 2; // reactive assignment
|
||
$: if (count > 10) console.log('big');
|
||
</script>
|
||
|
||
<!-- ✅ Svelte 5 runes -->
|
||
<script lang="ts">
|
||
let count = $state(0);
|
||
let doubled = $derived(count * 2); // derived value
|
||
|
||
$effect(() => {
|
||
if (count > 10) console.log('big');
|
||
});
|
||
</script>
|
||
```
|
||
|
||
### export let → $props()
|
||
|
||
```svelte
|
||
<!-- ❌ Svelte 4 props -->
|
||
<script lang="ts">
|
||
export let title: string;
|
||
export let count = 0;
|
||
</script>
|
||
|
||
<!-- ✅ Svelte 5 $props() -->
|
||
<script lang="ts">
|
||
let { title, count = 0 }: { title: string; count?: number } = $props();
|
||
</script>
|
||
```
|
||
|
||
### on:click → onclick
|
||
|
||
```svelte
|
||
<!-- ❌ Svelte 4 指令式事件 -->
|
||
<button on:click={handleClick}>Click</button>
|
||
<button on:click={() => count++}>Increment</button>
|
||
|
||
<!-- ✅ Svelte 5 HTML 属性式事件 -->
|
||
<button onclick={handleClick}>Click</button>
|
||
<button onclick={() => count++}>Increment</button>
|
||
```
|
||
|
||
### createEventDispatcher → 回调 props
|
||
|
||
```svelte
|
||
<!-- ❌ Svelte 4 事件 dispatch -->
|
||
<script lang="ts">
|
||
import { createEventDispatcher } from 'svelte';
|
||
const dispatch = createEventDispatcher();
|
||
|
||
function handleDelete() {
|
||
dispatch('delete', { id: 42 });
|
||
}
|
||
</script>
|
||
|
||
<!-- ✅ Svelte 5 回调 props -->
|
||
<script lang="ts">
|
||
let { ondelete }: { ondelete?: (e: { id: number }) => void } = $props();
|
||
|
||
function handleDelete() {
|
||
ondelete?.({ id: 42 });
|
||
}
|
||
</script>
|
||
|
||
<!-- parent usage -->
|
||
<Child ondelete={(e) => removeItem(e.id)} />
|
||
```
|
||
|
||
### slot → @render children()
|
||
|
||
```svelte
|
||
<!-- ❌ Svelte 4 slot -->
|
||
<!-- Card.svelte -->
|
||
<div class="card">
|
||
<slot />
|
||
</div>
|
||
|
||
<!-- ✅ Svelte 5 snippets -->
|
||
<!-- Card.svelte -->
|
||
<script lang="ts">
|
||
let { children } = $props();
|
||
</script>
|
||
<div class="card">
|
||
{@render children()}
|
||
</div>
|
||
|
||
<!-- with named slots → named snippets -->
|
||
<!-- Layout.svelte -->
|
||
<script lang="ts">
|
||
let { header, children, footer } = $props();
|
||
</script>
|
||
<div>
|
||
<header>{@render header?.()}</header>
|
||
<main>{@render children()}</main>
|
||
<footer>{@render footer?.()}</footer>
|
||
</div>
|
||
|
||
<!-- parent usage -->
|
||
<Layout>
|
||
{#snippet header()}<h1>Title</h1>{/snippet}
|
||
<p>Body content</p>
|
||
{#snippet footer()}<p>Footer</p>{/snippet}
|
||
</Layout>
|
||
```
|
||
|
||
### beforeUpdate / afterUpdate → $effect.pre
|
||
|
||
```svelte
|
||
<!-- ❌ Svelte 4 lifecycle hooks -->
|
||
<script lang="ts">
|
||
import { beforeUpdate, afterUpdate } from 'svelte';
|
||
|
||
let count = 0;
|
||
|
||
beforeUpdate(() => {
|
||
console.log('about to update', count);
|
||
});
|
||
|
||
afterUpdate(() => {
|
||
console.log('updated', count);
|
||
document.title = `Count: ${count}`;
|
||
});
|
||
</script>
|
||
|
||
<!-- ✅ Svelte 5 $effect and $effect.pre -->
|
||
<script lang="ts">
|
||
let count = $state(0);
|
||
|
||
// $effect.pre runs before DOM updates (like beforeUpdate)
|
||
$effect.pre(() => {
|
||
console.log('about to update', count);
|
||
});
|
||
|
||
// $effect runs after DOM updates (like afterUpdate)
|
||
$effect(() => {
|
||
console.log('updated', count);
|
||
document.title = `Count: ${count}`;
|
||
});
|
||
</script>
|
||
```
|
||
|
||
---
|
||
|
||
## 性能优化
|
||
|
||
### $state.raw 用于大型不可变数据
|
||
|
||
```svelte
|
||
<!-- ❌ 深度代理大型不可变数据 -->
|
||
<script lang="ts">
|
||
let searchResults = $state(largeResultArray); // deep proxy on every item
|
||
</script>
|
||
|
||
<!-- ✅ $state.raw 避免深度代理 -->
|
||
<script lang="ts">
|
||
let searchResults = $state.raw<SearchResult[]>([]);
|
||
|
||
async function search(query: string) {
|
||
searchResults = await fetchResults(query); // whole-array replacement
|
||
}
|
||
</script>
|
||
```
|
||
|
||
### Keyed {#each}
|
||
|
||
```svelte
|
||
<!-- ❌ 无 key 的 each → 低效 DOM diff -->
|
||
{#each items as item}
|
||
<div>{item.name}</div>
|
||
{/each}
|
||
|
||
<!-- ✅ 带唯一 key 的 each -->
|
||
{#each items as item (item.id)}
|
||
<div>{item.name}</div>
|
||
{/each}
|
||
|
||
<!-- ✅ 复合 key -->
|
||
{#each items as item (`${item.category}-${item.id}`)}
|
||
<div>{item.name}</div>
|
||
{/each}
|
||
```
|
||
|
||
### Streaming 与 load 中的 Promise
|
||
|
||
```typescript
|
||
// ❌ 串行等待所有数据 → 页面阻塞
|
||
// src/routes/+page.server.js
|
||
export async function load({ params }) {
|
||
const posts = await getPosts(); // slow
|
||
const comments = await getComments(); // slow
|
||
const tags = await getTags(); // slow
|
||
return { posts, comments, tags };
|
||
}
|
||
|
||
// ✅ 并行加载独立数据
|
||
export async function load({ params }) {
|
||
return {
|
||
posts: getPosts(), // return promises directly for streaming
|
||
comments: getComments(),
|
||
tags: getTags(),
|
||
};
|
||
}
|
||
```
|
||
|
||
```svelte
|
||
<!-- streaming in template with {#await} -->
|
||
{#await data.posts}
|
||
<p>Loading posts...</p>
|
||
{:then posts}
|
||
<ul>
|
||
{#each posts as post (post.id)}
|
||
<li>{post.title}</li>
|
||
{/each}
|
||
</ul>
|
||
{:catch error}
|
||
<p>Failed to load posts: {error.message}</p>
|
||
{/await}
|
||
```
|
||
|
||
---
|
||
|
||
## 安全审查
|
||
|
||
### 不暴露私有环境变量
|
||
|
||
```typescript
|
||
// ❌ 在 universal load 中暴露服务端 secrets
|
||
// src/routes/admin/+page.js (universal — runs on client too!)
|
||
export async function load() {
|
||
return {
|
||
apiKey: process.env.SECRET_API_KEY, // exposed to client bundle!
|
||
dbUrl: import.meta.env.DATABASE_URL, // leaks to browser!
|
||
};
|
||
}
|
||
|
||
// ✅ 私有环境变量只在 server load 中使用
|
||
// src/routes/admin/+page.server.js (server-only)
|
||
export async function load({ locals }) {
|
||
// secrets stay on server
|
||
const data = await fetch(process.env.SECRET_API_KEY + '/admin');
|
||
return { data }; // only derived data is sent to client
|
||
}
|
||
|
||
// ✅ 公开变量使用 PUBLIC_ 前缀
|
||
// .env
|
||
// PUBLIC_API_URL=https://api.example.com
|
||
// SECRET_API_KEY=xxx (no PUBLIC_ prefix = server-only)
|
||
```
|
||
|
||
### $lib/server/ 服务端隔离
|
||
|
||
```typescript
|
||
// ❌ 服务端代码放在可被客户端导入的位置
|
||
// src/lib/db.js
|
||
import { SECRET_DB_URL } from '$env/static/private';
|
||
// any client component importing this gets the secret!
|
||
|
||
// ✅ 放在 $lib/server/ 目录 → 客户端导入会编译报错
|
||
// src/lib/server/db.js
|
||
import { SECRET_DB_URL } from '$env/static/private';
|
||
|
||
export async function query(sql: string) {
|
||
// safe: client cannot import from $lib/server/
|
||
}
|
||
|
||
// usage in server files only
|
||
// src/routes/api/users/+server.js
|
||
import { query } from '$lib/server/db';
|
||
```
|
||
|
||
### CSRF 内建防护
|
||
|
||
```typescript
|
||
// ✅ SvelteKit 内建 CSRF 防护
|
||
// Origin header is checked automatically for POST/PUT/DELETE/PATCH
|
||
// No additional CSRF tokens needed for form actions
|
||
|
||
// ❌ 不要禁用 CSRF 检查(除非有充分理由)
|
||
// src/hooks.server.js
|
||
export const handle = sequence(
|
||
// do NOT do this without understanding the implications
|
||
// ({ event, resolve }) => resolve(event, { filterSerializedResponseHeaders: () => true })
|
||
);
|
||
```
|
||
|
||
### Cookie 安全设置
|
||
|
||
```typescript
|
||
// ❌ 不安全的 Cookie 设置
|
||
// src/hooks.server.js
|
||
export async function handle({ event, resolve }) {
|
||
const token = event.cookies.get('session');
|
||
// cookie without httpOnly, secure, sameSite flags
|
||
event.cookies.set('session', token, {
|
||
path: '/',
|
||
// missing: httpOnly, secure, sameSite
|
||
});
|
||
}
|
||
|
||
// ✅ 安全的 Cookie 配置
|
||
import { dev } from '$app/environment';
|
||
|
||
event.cookies.set('session', token, {
|
||
path: '/',
|
||
httpOnly: true, // not accessible via JS
|
||
secure: !dev, // HTTPS only in production
|
||
sameSite: 'lax', // CSRF protection
|
||
maxAge: 60 * 60 * 24 * 7 // 1 week, explicit expiry
|
||
});
|
||
```
|
||
|
||
---
|
||
|
||
## Review Checklist
|
||
|
||
### Runes: $state / $derived / $effect
|
||
|
||
- [ ] $state 只用于会变化的值,常量直接声明
|
||
- [ ] 大型不可变数据使用 $state.raw
|
||
- [ ] 没有解构 $state 对象(会丢失响应性)
|
||
- [ ] 外部库使用 $state.snapshot 传入普通对象
|
||
- [ ] $derived 中没有副作用
|
||
- [ ] 没有用 $effect 替代 $derived 做状态同步
|
||
- [ ] $effect 中不修改被追踪的状态(避免无限循环)
|
||
- [ ] $effect 有清理函数(订阅、定时器、WebSocket)
|
||
- [ ] async $effect 在 await 前读取所有需要追踪的状态
|
||
- [ ] 使用 untrack 排除不相关的依赖
|
||
|
||
### Load 函数
|
||
|
||
- [ ] 服务端逻辑放在 +page.server.js(不是 +page.js)
|
||
- [ ] 避免不必要的 await parent() 瀑布流
|
||
- [ ] 独立数据并行加载(Promise.all 或直接返回 Promise)
|
||
- [ ] server load 只返回可序列化的数据
|
||
|
||
### Form Actions
|
||
|
||
- [ ] 副作用操作(增删改)使用 form actions + POST
|
||
- [ ] fail() 不返回敏感信息(密码、内部数据)
|
||
- [ ] 使用 use:enhance 实现渐进增强
|
||
|
||
### Store 迁移
|
||
|
||
- [ ] writable/readable → $state 在 .svelte.js 文件中
|
||
- [ ] 不在普通 .js 文件中使用 runes
|
||
- [ ] 不使用遗留的 $ 前缀 store 语法
|
||
|
||
### SSR vs CSR 边界
|
||
|
||
- [ ] 不在根 layout 中全局禁用 SSR
|
||
- [ ] 浏览器 API(window、document)在 onMount 或 browser guard 中使用
|
||
- [ ] prerender 页面不包含 server actions
|
||
|
||
### Svelte 4 → 5 迁移
|
||
|
||
- [ ] $: → $derived / $effect
|
||
- [ ] export let → $props()
|
||
- [ ] on:click → onclick
|
||
- [ ] createEventDispatcher → 回调 props
|
||
- [ ] slot → @render children()
|
||
- [ ] beforeUpdate/afterUpdate → $effect.pre / $effect
|
||
|
||
### 性能优化
|
||
|
||
- [ ] 大型不可变数据使用 $state.raw
|
||
- [ ] {#each} 使用唯一 key
|
||
- [ ] load 函数返回 Promise 实现流式传输
|
||
- [ ] 独立数据并行加载
|
||
|
||
### 安全审查
|
||
|
||
- [ ] 私有环境变量只在 server load 中使用
|
||
- [ ] 服务端代码放在 $lib/server/ 目录
|
||
- [ ] 不禁用内建 CSRF 防护
|
||
- [ ] Cookie 设置 httpOnly、secure、sameSite
|
||
- [ ] server load 不泄露密钥和内部数据
|