<script lang="ts">
let items = $state<number[]>([1, 2, 3]);
let stats = $derived.by(() => {
let sum = 0;
let max = -Infinity;
for (const n of items) {
sum += n;
if (n > max) max = n;
}
return { sum, max, avg: sum / items.length };
});
</script>
<script lang="ts">
// huge dataset — Proxy overhead is wasteful
let scene = $state.raw({ vertices: bigArray, faces: bigFaces });
function loadScene(next: typeof scene) {
scene = next; // reassignment DOES trigger
}
// scene.vertices.push(…) // mutation does NOT trigger
</script>
<script lang="ts">
let total = $state(100);
// optimistic UI: show a guessed value, let the real one win later
let display = $derived(total);
function bump() {
display = total + 1; // temporary override
}
// next time `total` changes, `display` snaps back to deriving from it
</script>
<button onclick={bump}>{display}</button>
<script lang="ts">
let form = $state({ name: 'Lei', tags: ['a', 'b'] });
function save() {
// structuredClone / JSON / 3rd-party libs choke on the Proxy
const plain = $state.snapshot(form);
localStorage.setItem('draft', JSON.stringify(plain));
// `plain` is a non-reactive deep clone, safe to pass anywhere
}
</script>
<script lang="ts">
let count = $state(0);
// false at the top level of <script>
console.log($effect.tracking()); // false
$effect(() => {
count;
console.log($effect.tracking()); // true — inside an effect
});
</script>
<script lang="ts">
// one consistent id across SSR and hydration
const uid = $props.id();
</script>
<label for={`${uid}-email`}>Email</label>
<input id={`${uid}-email`} type="email" />
<p id={`${uid}-hint`}>We never share it.</p>
<input aria-describedby={`${uid}-hint`} />
<script lang="ts">
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
let seen = new SvelteSet<string>();
let cache = new SvelteMap<string, number>();
function visit(id: string) {
seen.add(id); // triggers re-render
cache.set(id, Date.now());
}
</script>
<p>{seen.size} visited</p>
<script lang="ts">
let query = $state('');
let results = $state<string[]>([]);
// effect that writes a DIFFERENT state it does not read — safe
$effect(() => {
const q = query.trim();
fetchResults(q).then((r) => {
results = r; // writes results, never reads it → no loop
});
});
</script>
<!-- Svelte 4 (legacy) -->
<svelte:component this={Selected} {...props} />
<!-- Svelte 5: components are values, render directly -->
<script lang="ts">
import A from './A.svelte';
import B from './B.svelte';
let Selected = $state(A);
</script>
<Selected {...props} />
说明:Svelte 5 里组件就是普通值,所以持有组件的变量直接写成 `<Selected />` 就能渲染,动态组件不再需要 `<svelte:component>` (它还能用但已弃用)。标签名要大写开头或含点号,编译器才当它是组件而非 HTML 元素。
Svelte 4 对照写法
<svelte:component this={Selected} />
svelte:fragment → snippet 不再需要包裹
Svelte 4 → 5
<!-- Svelte 4 (legacy) — fragment to avoid an extra <div> -->
<svelte:fragment slot="list">
<li>a</li>
<li>b</li>
</svelte:fragment>
<!-- Svelte 5 — a snippet can hold multiple elements directly -->
{#snippet list()}
<li>a</li>
<li>b</li>
{/snippet}
<script lang="ts">
import { tick } from 'svelte';
let text = $state('');
let el: HTMLInputElement;
async function append(s: string) {
text += s;
await tick(); // wait for the DOM to flush
el.scrollLeft = el.scrollWidth; // now the width is up to date
}
</script>
<script lang="ts">
let x = $state(0);
let color = $state('tomato');
</script>
<div
style:transform={`translateX(${x}px)`}
style:color
style:--accent={color}
>
hello
</div>
<script lang="ts">
let email = $state('');
function handle(e: SubmitEvent) {
e.preventDefault();
if (!email.includes('@')) return;
console.log('submit', email);
}
</script>
<form onsubmit={handle}>
<input type="email" bind:value={email} required />
<button type="submit">Send</button>
</form>
说明:给 form 挂普通 `onsubmit` handler,调 `e.preventDefault()` 阻止浏览器跳转。事件是带类型的 `SubmitEvent`。按钮保留 `type="submit"`、输入保留 `required`/`type="email"`,这样原生校验仍在 handler 之前触发。SvelteKit 里要完整渐进增强,优先用 form actions + use:enhance。
oninput 与 onchange: 实时与确认
事件
<script lang="ts">
let live = $state('');
let committed = $state('');
</script>
<!-- fires on every keystroke -->
<input oninput={(e) => live = e.currentTarget.value} />
<!-- fires on blur / Enter for text inputs -->
<input onchange={(e) => committed = e.currentTarget.value} />
<p>live: {live} · committed: {committed}</p>
<script lang="ts">
let dragging = $state(false);
let x = $state(0);
function down(e: PointerEvent) {
dragging = true;
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
}
function move(e: PointerEvent) { if (dragging) x = e.clientX; }
function up() { dragging = false; }
</script>
<div onpointerdown={down} onpointermove={move} onpointerup={up}
style:transform={`translateX(${x}px)`}>drag</div>
<script lang="ts">
import type { Action } from 'svelte/action';
const clickOutside: Action<HTMLElement, () => void> = (node, callback) => {
function handle(e: MouseEvent) {
if (!node.contains(e.target as Node)) callback();
}
document.addEventListener('click', handle, true);
return { destroy() { document.removeEventListener('click', handle, true); } };
};
let open = $state(true);
</script>
{#if open}
<div use:clickOutside={() => open = false}>menu</div>
{/if}
说明:action 是元素挂载时被调用的函数,接收元素和一个可选参数。返回对象,`destroy()` 用于清理,`update(newParam)` 用于参数变化。tooltip、click-outside、IntersectionObserver、接入第三方 DOM 库都靠它 , 任何需要把 DOM 行为绑到元素生命周期上又不想写包装组件的场景。
transition:fade , 进入和离开动画
指令
<script lang="ts">
import { fade } from 'svelte/transition';
let show = $state(true);
</script>
<button onclick={() => show = !show}>toggle</button>
{#if show}
<div transition:fade={{ duration: 200 }}>hello</div>
{/if}
说明:transition:fn 在元素进入 DOM 和离开 DOM 时都跑动画。内置有 fade、fly、slide、scale、blur、draw、crossfade。参数写在指令表达式里。进出动画不一样就用 `in:` 和 `out:` (例如进入从下飞入、离开向左飞出)。
in: / out: , 进出分别配
指令
<script lang="ts">
import { fly, fade } from 'svelte/transition';
let show = $state(true);
</script>
{#if show}
<div
in:fly={{ y: 20, duration: 200 }}
out:fade={{ duration: 150 }}
>panel</div>
{/if}
<script lang="ts">
let pick = $state<'a' | 'b' | 'c'>('a');
let tags = $state<string[]>([]);
</script>
<!-- radio: one value -->
<label><input type="radio" bind:group={pick} value="a" /> A</label>
<label><input type="radio" bind:group={pick} value="b" /> B</label>
<!-- checkbox: array of values -->
<label><input type="checkbox" bind:group={tags} value="svelte" /> svelte</label>
<label><input type="checkbox" bind:group={tags} value="kit" /> kit</label>
说明:bind:group 把多个表单控件绑到同一个响应式变量。radio 给你被选中那个的 value; checkbox 给你被选中的所有 value 组成的数组。变量类型自动对上 , radio 是 string,checkbox 是 string[]。省掉"循环控件再聚合"的样板。
bind:value 用于 textarea 和数字
指令
<script lang="ts">
let note = $state('');
let age = $state(0);
let pick = $state('a');
</script>
<textarea bind:value={note}></textarea>
<!-- number input coerces to a number automatically -->
<input type="number" bind:value={age} min="0" />
<!-- range slider -->
<input type="range" bind:value={age} min="0" max="120" />
<script lang="ts">
import { fade } from 'svelte/transition';
let outer = $state(true);
let inner = $state(true);
</script>
{#if outer}
{#if inner}
<!-- plays only when `inner` itself toggles (local, default) -->
<p transition:fade>local</p>
<!-- plays even when `outer` is what mounted/unmounted -->
<p transition:fade|global>global</p>
{/if}
{/if}
<script lang="ts">
let open = $state(false);
</script>
<button onclick={() => open = !open}>toggle from outside</button>
<details bind:open>
<summary>More info</summary>
<p>Content shown when open is {String(open)}</p>
</details>
说明:`bind:open` 把原生 `<details>` 的展开状态双向绑到响应式变量,既能读它是否展开,也能从页面别处用代码切换它。因为用的是真正的 `<details>` 而不是 div 重造,元素保持完整的可访问性和键盘可操作性。
流程 (10)
{#if} {:else if} {:else}
流程
<script lang="ts">
let status = $state<'loading' | 'ok' | 'error'>('loading');
</script>
{#if status === 'loading'}
<p>Loading…</p>
{:else if status === 'error'}
<p class="err">Failed</p>
{:else}
<p>Ready</p>
{/if}
说明:条件渲染。条件翻转时块被挂载/卸载 , 不是虚拟 DOM diff,元素真的进出。配 `transition:` 做进出动画。如果只是切换可见性又想保留 DOM (滚动位置、视频播放、焦点),应该改 class 而不是 #if。
<script lang="ts">
import DOMPurify from 'dompurify';
let untrusted = $state('<p>hi <img src=x onerror="alert(1)"></p>');
let safe = $derived(DOMPurify.sanitize(untrusted));
</script>
<!-- NEVER do this with user input: -->
<!-- {@html untrusted} -->
<!-- SAFE: sanitize first -->
{@html safe}
<script lang="ts">
let ready = $state<Promise<string>>(Promise.resolve('cached'));
</script>
<!-- no pending block: assumes it resolves fast -->
{#await ready then value}
<p>{value}</p>
{/await}
<!-- catch-only: ignore the value, only show errors -->
{#await ready catch err}
<p class="err">{err.message}</p>
{/await}
说明:自定义 store 就是任何暴露了 `subscribe(cb)` 的对象 , Svelte 不关心你怎么搭的。包一层 writable,只对外暴露想给的方法,把 set/update 藏起来。这就是怎么把 store 写得像 service (`counter.inc()` 而不是 `counter.update(n => n + 1)`),同时保留 `$counter` 响应式读。
store 还是 $state: 怎么选
Stores
// app-global, cross-route, lives in a .ts file → store
// stores/theme.ts
import { writable } from 'svelte/store';
export const theme = writable<'light' | 'dark'>('dark');
// local component state, or .svelte.ts module → $state
// Counter.svelte
<script lang="ts">
let count = $state(0); // not shared, just this component
</script>
// src/routes/(app)/+layout.server.ts
import type { LayoutServerLoad } from './$types';
import { redirect } from '@sveltejs/kit';
export const load: LayoutServerLoad = async ({ locals, url }) => {
if (!locals.user) throw redirect(302, `/login?from=${url.pathname}`);
return { user: locals.user };
};
// any +page.svelte under this layout:
// let { data } = $props(); → data.user is available
// +page.ts or +page.server.ts or +layout.ts
export const prerender = true; // build to static HTML at build time
export const ssr = true; // server-render on first load (default)
export const csr = true; // hydrate + client-side nav (default)
// a fully static marketing page:
// prerender = true, ssr = true, csr = false → zero JS shipped
<script lang="ts">
let { count } = $props();
// BAD — `double` is computed once, never updates
// let double = count * 2;
// GOOD — derive so it tracks `count`
let double = $derived(count * 2);
</script>
<p>{count} → {double}</p>
<script lang="ts">
let items = $state([{ id: 1, t: 'a' }, { id: 2, t: 'b' }]);
</script>
<!-- BAD — no key: inputs lose focus/value when list reorders -->
{#each items as item}
<input value={item.t} />
{/each}
<!-- GOOD — keyed by stable id: DOM nodes move, state survives -->
{#each items as item (item.id)}
<input value={item.t} />
{/each}
说明:没有 `(key)` 时 Svelte 按索引匹配 each 块的项,所以重排或删除会打乱哪个 DOM 节点持有哪份数据: 输入失焦、过渡断裂、组件状态挂到错误的行上。永远用 稳定唯一 id 做 key (别用数组索引,那等于白做)。key 是 Svelte 跨渲染保持身份的依据。
坑: 用 $effect 算派生值
常见坑
<script lang="ts">
let first = $state('Li');
let last = $state('Lei');
// BAD — extra state, runs after render, can flash stale
// let full = $state('');
// $effect(() => { full = first + ' ' + last; });
// GOOD — derived computes synchronously, no extra state
let full = $derived(`${first} ${last}`);
</script>
<p>{full}</p>
<script lang="ts">
// Once you use ANY rune, the component is in runes mode.
let count = $state(0);
// BAD — $: is legacy reactivity, illegal in runes mode
// $: doubled = count * 2; // compile error
// GOOD — use the rune equivalent
let doubled = $derived(count * 2);
</script>
<script lang="ts">
let items = $state([3, 1, 2]);
// BAD — sort() mutates in place, side effect inside derived
// let sorted = $derived(items.sort());
// GOOD — copy first, then sort the copy
let sorted = $derived([...items].sort((a, b) => a - b));
</script>
<p>{sorted.join(', ')}</p>
<script lang="ts">
let rows = $state<string[]>(['a', 'b', 'c']);
</script>
<!-- the `, i` index is fine to DISPLAY -->
{#each rows as row, i (row)}
<li>{i + 1}. {row}</li>
{/each}
<!-- but never key BY the index: (i) defeats identity tracking -->
<!-- {#each rows as row, i (i)} ← bad -->
let user = $state({ name: 'Lei', tags: ['a'] });
// BOTH WORK — $state returns a deep Proxy:
user.name = 'Hong'; // tracked
user.tags.push('b'); // tracked
user = { name: 'X', tags: [] }; // tracked (reassignment)
// BUT $state.raw is shallow — only reassignment triggers:
let big = $state.raw({ items: [] });
big.items.push(1); // NOT tracked
big = { items: [...big.items, 1] }; // tracked
// WRONG — runes cannot be inside if / for / try
if (someCondition) {
let count = $state(0); // compile error
}
for (let i = 0; i < 3; i++) {
let item = $state(0); // compile error
}
// RIGHT — declare at top, gate the USE
let count = $state(0);
if (someCondition) {
count++; // fine
}
// For per-item state, use an object array:
let items = $state(initial.map(x => ({ value: x, selected: false })));
<script lang="ts">
import { browser } from '$app/environment';
// BAD — window does not exist on the server
// const w = window.innerWidth;
// GOOD option 1: guard with browser flag
let w = $state(0);
$effect(() => {
if (browser) w = window.innerWidth;
});
// GOOD option 2: read in onMount / $effect (only runs on client)
$effect(() => {
const onResize = () => w = window.innerWidth;
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
});
</script>