Svelte5入门

前言

  • 这篇是 Svelte 5 的上手笔记:runes($state / $derived / $effect / $props)、模板语法({#if}、{#each}、{#await}、{#key})、事件与双向绑定、snippet(取代插槽)、样式作用域、action 与过渡、遗留 store、以及 SvelteKit 是什么。
  • 环境是 Node v26.13.0 + npm 12.2.0 + svelte 5.57.3 + @sveltejs/vite-plugin-svelte 7.3.1 + vite 8.3.5。所有「实测」都是 npm create vite --template svelte 建的真项目,在浏览器里点出来的结果,输出原样贴在示例下面。
  • Svelte 5 是破坏性改版:<script> 里的响应式写法(runes)和 4 完全不同,插槽换成了 snippet。网上大量教程是 Svelte 4 时代的,抄过来会报错,所以这篇把「编译器的真实报错文案」也记了下来。

Svelte 是什么:编译器派

Vue / React 是运行时框架:浏览器里跑着它们的代码,负责 diff 与更新。Svelte 是编译器:.svelte 文件在构建时被编译成「直接操作 DOM 的 JS」,浏览器里不需要一个巨大的框架来协调。

不过「零运行时」这个说法要修正一下。我把实测台项目构建出来,在未压缩产物里搜:

1
index-DDKxA7ZU.js 47980 字节 | 含 svelte 字样 24 处

Svelte 5 的响应式(runes)确实带了一个小运行时(依赖图、effect 调度都在里面),只是它比 Vue/React 的运行时小得多。准确说法是:运行时很小,大部分工作编译进了你的代码。

体积上的差距是实打实的(同一台机器、同样的 Vite 8,gzip 后的 JS):

项目 JS 体积 gzip
Svelte 官方模板 demo 30.81 kB 12.40 kB
我的 Svelte 实测台(约 20 个特性) 48.60 kB 19.72 kB
Vue 官方模板 demo 65.53 kB 25.72 kB
我的 Vue 实测台(含 router + pinia) 120.45 kB 47.39 kB
React 官方模板 demo 222.44 kB 69.28 kB
我的 React 实测台 226.45 kB 71.16 kB

三个实测台功能不完全等价,但量级差是可信的:Svelte 的产物比 React 小得多。

至于要不要学:Svelte 的下载量(4,178,017 / 周,2026-11-03 实测 npm API,统计窗口 2026-10-11~10-17)比 Vue 和 React 小一个数量级,岗位也少;但它的写法最接近「普通 JS」,学一遍能反过来理解「响应式框架到底在干什么」。

环境准备与脚手架

1
2
3
4
5
npm create vite@latest svelte-demo -- --template svelte
cd svelte-demo
npm install
npm run dev
npm run build

实测装上的东西:

1
2
3
4
svelte-demo@0.0.0
├── @sveltejs/vite-plugin-svelte@7.3.1
├── svelte@5.57.3
└── vite@8.3.5

目录里比 Vue/React 模板多一个 svelte.config.js(编译器选项,空文件也能工作)和 src/lib/(约定放组件与工具)。

入口:用 mount(),不是 new App()

1
2
3
4
5
6
7
8
9
10
// src/main.js
import { mount } from 'svelte'
import './app.css'
import App from './App.svelte'

const app = mount(App, {
target: document.getElementById('app'),
})

export default app

Svelte 4 的 new App({ target }) 在 5 里已经换成 mount()(对应还有 unmount()、hydrate())。

svelte 这个包导出了什么(实测)

1
2
3
afterUpdate, beforeUpdate, createContext, createEventDispatcher, createRawSnippet,
flushSync, fork, getAbortSignal, getAllContexts, getContext, hasContext,
hydratable, hydrate, mount, onDestroy, onMount, setContext, settled, tick, untrack, unmount

两个值得注意的点:

  • 没有 VERSION:import { VERSION } from 'svelte' 拿到的是 undefined。想打印版本只能用 npm ls 或构建期注入。(对比:vue 导出 version、react 也导出 version。)
  • 有 tick()(等 DOM 更新完)、untrack()(读值但不建立依赖)、flushSync()(强制立刻更新)、fork()(派生 effect 分支)这些偏进阶的 API。

响应式:$state

runes(符文)就是以 $ 开头的编译器关键字,写在 <script> 里,不是普通函数,所以不能 import。

1
2
3
4
5
6
7
8
9
10
11
12
<script>
let count = $state(0) // 基本类型
let user = $state({ name: '苏寅', tags: ['a'] }) // 对象/数组:深层也是响应式的

function add() {
count++ // 直接赋值就是更新,没有 setter
user.tags.push('b') // 数组变异也有效
}
</script>

<button onclick={add}>{count}</button>
<p>{user.name} / {user.tags.join(',')}</p>

实测「深层改动」确实生效(改完读到的值):

1
$state 深层属性改动 :: a,b

最大的坑:解构会失去响应性

1
2
3
4
5
6
<script>
let obj = $state({ a: 1 })
const { a } = obj // 只是把当时的值拷出来,一次性快照
</script>

<p>{a}</p> <!-- 以后 obj.a 再怎么改,这里都不更新 -->

实测:解构后拿到的就是当时的值 1,之后 obj.a 变化不会带动它。

1
解构 $state 后拿到的值(一次性快照) :: 1

三种正确写法:

  • 直接 obj.a 用;
  • 想要「独立的响应式变量」就各自 let a = $state(...);
  • 从别处(比如 store、props)拿值时用 $derived。

$state.raw(...):只认引用变化

1
2
3
4
5
6
7
8
<script>
let raw = $state.raw({ list: [1] })
let hits = 0
$effect(() => { raw; hits++ })

function mutate() { raw.list.push(2) } // 改内部:不触发
function replace() { raw = { list: [1, 2, 3] } } // 换引用:触发
</script>

实测:

1
2
$state.raw 改内部数组后 effect 是否重跑 :: 没重跑(只认引用变化)
$state.raw 整体替换后 effect 是否重跑 :: 重跑了

用途:传给 ECharts / 地图库这类「自己维护内部状态的大对象」,别让 Svelte 去代理它的每个字段,性能和正确性都更好。

$derived 与 $effect

$derived:派生值,自动缓存

1
2
3
4
5
6
7
8
9
<script>
let count = $state(0)
let runs = 0
const double = $derived((runs++, count * 2)) // 表达式形式
const sentence = $derived.by(() => `count=${count}`) // 需要多行时用 $derived.by
</script>

<p>{double} / {sentence}</p>
<button onclick={() => count++}>{count}</button>

实测「读两次会不会算两次」:

1
2
连续读两次 $derived,表达式执行增量 :: 0
依赖变化后再读 $derived,表达式执行增量 :: 1

也就是:依赖没变就不重算,和 Vue 的 computed 一样;但 $derived 是编译器语法,不是函数调用。

$derived 只能赋派生值,不能手动写:double = 5 会编译报错。要「可写的派生值」就写两个 $state 加一个函数。

$effect:副作用,返回值是清理函数

1
2
3
4
5
6
7
8
9
10
11
<script>
let count = $state(0)

$effect(() => {
console.log('count 现在是', count) // 读到的每个响应式值都自动成为依赖
const id = setInterval(() => {}, 1000)
return () => {
clearInterval(id) // 依赖变化前、组件销毁时都会跑
}
})
</script>

实测(改一次 count 后读记录到的顺序与次数):

1
2
effect 顺序 :: effect 跑 → effect 清理 → effect 跑
effect 执行次数 :: 2

三条纪律:

  • $effect 是逃生舱,不是主力工具。能用 $derived 表达的别用 effect;能在事件处理函数里做的别放 effect。
  • 只在 effect 里读响应式值才会建立依赖;写在外面读一次不会。
  • 官方明确不建议用它做「派生状态」或「同步到 store」,那类需求应该改数据结构。

tick():等 DOM 真的更新完

1
2
3
4
5
6
7
import { tick } from 'svelte'

async function scrollToEnd() {
items = [...items, newItem]
await tick() // 等这一轮 DOM 更新
el.scrollTop = el.scrollHeight
}

(对应 Vue 的 nextTick;React 里则是 useEffect/flushSync。)

模板语法

Svelte 的模板是「HTML + 花括号块」,指令用 on: / bind: / use: / transition: / animate: 这些冒号形式,而事件绑定在 5 里推荐直接写 onclick={fn}。

插值与块

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<p>{name}</p>                                    <!-- 插值 -->
<p>{@html unsafeHtml}</p> <!-- 输出 HTML,慎用 -->
<p>{count > 10 ? '多' : '少'}</p> <!-- 任意表达式 -->

{#if admin}
<p>管理员可见</p>
{:else if editor}
<p>编辑可见</p>
{:else}
<p>游客</p>
{/if}

{#each items as item (item.id)} <!-- 括号里是 key -->
<li>{item.text}</li>
{:else}
<li>暂无数据</li>
{/each}

{#await promise then value}
<p>{value}</p>
{:catch error}
<p>出错了:{error.message}</p>
{/await}

{#key id} <!-- id 变了就重建里面的内容 -->
<Chart {id} />
{/key}

实测 {#await} 渲染结果:

1
await 分支渲染 :: promise 里的值

{#each} 的 key 写法和另外两个框架不同(放在括号里),但作用完全一样。我照旧做了「填输入框 → 重排」的对照实验:

1
2
带 key 重排后第一个输入框 :: "输入了B",同行文字 "B"
不带 key 重排后第一个输入框 :: "输入了A",同行文字 "B"

和 Vue、React 的实测结果一字不差——这是所有协调算法的共同要求。

属性、事件、双向绑定

1
2
3
4
5
6
7
8
9
10
11
12
<img src={url} alt="说明" />                 <!-- 属性绑定 -->
<img {src} {alt} /> <!-- 变量名与属性名相同时可简写 -->

<button onclick={handleClick}>点</button> <!-- 5 的推荐写法:就是普通属性 -->
<button onclick={() => count++}>自增</button>
<form onsubmit={submit}>…</form> <!-- 要阻止默认行为就在函数里 e.preventDefault() -->
<div onmouseenter={enter}>…</div>

<input bind:value={text} /> <!-- 双向绑定 -->
<textarea bind:value />
<select bind:value={picked}>…</select>
<div bind:this={el}></div> <!-- 拿到元素本身(替代旧的 ref) -->

onclick 与旧 on:click 的差别:on:click 会帮你做事件委托式处理并支持修饰符(|preventDefault 等),5 里改成普通属性后修饰符没有了,需要在处理函数里自己写 event.preventDefault()。实测用 onclick 一切正常:

1
子组件 onclick 调父传入的回调后父计数 :: 1

bind:value 跨组件也通(子组件要把变量声明成 $bindable,见组件那节):

1
bind:value 把子组件输入写回父组件的 $state :: 外部写入

action:use:(直接操作 DOM 的小行为)

1
2
3
4
5
6
7
8
9
10
11
<script>
function tooltip(node, text) {
node.title = text
return {
update(t) { node.title = t }, // 参数变化时被调用
destroy() { node.removeAttribute('title') }, // 移除时清理
}
}
</script>

<span use:tooltip={"action 加的 title"}>悬停我</span>

实测:

1
action 加的 title :: 元素 title 属性 = action 加的 title

编译器报错文案(值得记):use: 的值必须是包在花括号里的 JS 表达式,写成 use:tooltip="字符串" 会直接编译失败:

1
2
E:/…/App.svelte:184:33 Directive value must be a JavaScript expression enclosed in curly braces
https://svelte.dev/e/directive_invalid_value

(这个报错是我真撞出来的——Svelte 的错误码页面会直接给解释,比 Vue 的堆栈友好。)

过渡与动画

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<script>
import { fly, fade } from 'svelte/transition'
import { flip } from 'svelte/animate'
let show = $state(false)
</script>

<button onclick={() => (show = !show)}>开关</button>
{#if show}
<p transition:fly={{ x: 60, duration: 120 }}>我用 fly 进场</p>
{/if}

<ul>
{#each items as it (it.id)}
<li animate:flip={{ duration: 200 }}>{it.text}</li>
{/each}
</ul>

实测 transition:fly 挂上后元素的 class:

1
transition:fly 加上的 class :: anim svelte-1n46o8q

animate:flip 是 Svelte 的招牌功能:列表重排时自动做位置补间,不用像 React 那样引入 framer-motion。

组件:$props、$bindable 与 snippet

props 就是解构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<!-- src/lib/Child.svelte -->
<script>
let { title, oninc, value = $bindable(''), header, footer } = $props()
// title:普通 prop
// oninc:父组件传进来的回调(Svelte 没有 emit,事件就是 props)
// value = $bindable(''):允许父组件 bind: 的 prop,'' 是默认值
// header / footer:父组件传进来的 snippet

let inner = $state(0)
const hits = $derived(inner * 2)
function fire() {
inner++
oninc?.() // 可选链:父组件没传也不会炸
}
</script>

<p>{title} / {value}</p>
<input bind:value />
<button onclick={fire}>子组件按钮</button>
{@render header?.('来自子组件的数据')}
{@render footer?.()}

父组件用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<script>
import Child from './lib/Child.svelte'
let bindMe = $state('')
let parentHits = $state(0)
</script>

<Child title="父传子" bind:value={bindMe} oninc={() => parentHits++}>
{#snippet header(data)}
<p>snippet 拿到:{data}</p>
{/snippet}
{#snippet footer()}
<p>具名 snippet</p>
{/snippet}
</Child>

实测结果:

1
2
snippet 拿到 :: snippet 拿到:来自子组件的数据
具名 snippet :: 具名 snippet(相当于旧版具名插槽)

要点:

  • 没有 emit:子传父就是「父传一个函数 prop」,命名习惯是 onXxx。
  • $bindable() 标记可双向的 prop,父组件用 bind:value={...} 绑;没标 $bindable 的 prop 被父组件 bind: 会编译报错。
  • snippet 取代插槽:{#snippet name(args)}…{/snippet} 定义,子组件用 {@render name?.(args)} 渲染。children 也是一个名为 children 的 prop,所以 <Child>内容</Child> 这种「隐式默认插槽」在 5 里要写成 {#snippet children()}…{/snippet} 或者用 <Child>{@render ...}</Child> 形式;?. 保证父组件没传时不炸。
  • 想要「默认内容」就在解构时给默认值:let { children = null } = $props()。

组件的样式作用域(实测)

1
2
3
<style>
.app-root { padding: 8px; }
</style>

编译时 Svelte 给这个组件用到的元素加一个哈希 class,实测:

1
样式作用域 class :: child svelte-7buk55 | app-root svelte-1n46o8q

(.child 来自子组件,所以它的哈希是另一个。)因此:

  • 默认样式只作用于本组件模板里的元素,不用 BEM 也不会串。
  • 要影响子组件内部,得用 :global(...),或者干脆把类名当 prop 传下去。
  • 全局样式写在 src/app.css 里(模板已经引入了)。

store(遗留但还在用)

runes 出现之前的共享状态方案,Svelte 5 仍支持(svelte/store),但新代码更推荐「模块级的 $state + $derived」。

1
2
3
4
import { writable, readable } from 'svelte/store'

export const count = writable(10) // 可写,初始值 10
export const label = readable('常量') // 只读
1
2
3
4
5
6
<script>
import { count } from './store.js'
</script>

<p>{$count}</p> <!-- $ 前缀:自动订阅 + 自动退订 -->
<button onclick={() => count.update((v) => v + 5)}>+5</button>

实测:

1
2
3
4
store.update 后订阅回调次数 :: 2
$store 自动订阅语法读到的值 :: 15
$constStore(readable) :: 常量
unsubscribe 后再 update,订阅回调次数是否不再增加 :: 2

三条:

  • store.subscribe(cb) 注册时立刻回调一次(拿到当前值),返回的函数用来退订——$ 前缀语法帮你做了这件事,所以别手动订阅又用 $。
  • 只在组件顶层用 $xxx,在普通 JS 函数里要读值得用 get(store)。
  • 跨组件共享:writable 模块级导出即可;但 5 里更简单的做法是 let state = $state({...}) 放在 .svelte.js 模块里(runes 可以在 .svelte.js 文件里用)。

context 与生命周期

context:跨层传值(等价于 Vue 的 provide/inject)

1
2
3
4
5
6
7
8
9
10
11
<!-- 祖先 -->
<script>
import { setContext } from 'svelte'
setContext('site', 'Hexo-NexT')
</script>

<!-- 任意后代 -->
<script>
import { getContext, getAllContexts, hasContext } from 'svelte'
const site = getContext('site') // 第二个参数可给默认值
</script>

实测:

1
context 取回 :: Hexo-NexT

约定:key 用 Symbol 或导出的常量,避免字符串撞名;传函数比传响应式值更安全(getContext 拿到的 $state 是活的,但传对象引用容易绕过响应式)。

生命周期:只剩两个钩子

1
2
3
4
5
6
7
8
9
10
11
12
13
<script>
import { onMount, onDestroy } from 'svelte'

onMount(() => {
// DOM 已就绪,可以初始化第三方库、绑定原生事件
const id = setInterval(() => {}, 1000)
return () => clearInterval(id) // 返回的函数等价于「卸载时清理」
})

onDestroy(() => {
// 组件销毁时
})
</script>

实测:

1
onMount 里能读到 DOM :: true

Svelte 没有 beforeUpdate/afterUpdate 的推荐用法(afterUpdate 仍在导出里,但官方让位给 $effect),也没有 created/mounted 一堆阶段——挂载前没有钩子,因为 <script> 本身就是「创建阶段」。

SvelteKit 是什么

npm create vite 建的是纯客户端 SPA。要用路由、SSR、表单服务端提交、API 路由,就用 SvelteKit:

1
npx sv create my-app          # 官方脚手架(旧的 npm create svelte@latest 已并入 sv)
  • 路由是文件系统式:src/routes/+page.svelte、src/routes/blog/[slug]/+page.svelte。
  • 数据加载靠 +page.js / +page.server.js 导出的 load,在服务器或浏览器执行。
  • 表单动作、渐进增强(没有 JS 也能提交)、$app/stores、$app/forms 都是内置的。
  • 部署有 adapter:静态、Node、Vercel、Cloudflare 等。

本文的实测都在 Vite SPA 里做,没有实测 SvelteKit 的服务端渲染部分,写到这里只作为选型信息。

与 Vue、React 的对照

需求 Svelte 5 Vue 3 React 19
声明响应式状态 let x = $state(0) const x = ref(0) const [x, setX] = useState(0)
更新 x++ x.value++ setX(v => v + 1)(必须换新值)
派生值 $derived computed 直接算 / useMemo
副作用 $effect watch / watchEffect useEffect
等 DOM 更新 tick() nextTick() 无对应(effect / flushSync)
模板条件 {#if} v-if {cond && <X/>}
列表 {#each items as it (it.id)} v-for + :key map + key
事件 onclick={fn} @click="fn" onClick={fn}
双向绑定 bind:value v-model 手写 value + onChange
子传父 传函数 prop emit 传函数 prop
内容分发 snippet {@render} <slot> children
跨层传值 context provide/inject Context
DOM 引用 bind:this useTemplateRef useRef
样式作用域 内置(哈希 class) scoped 需 CSS Modules / styled-components
官方全家桶 SvelteKit Vue Router + Pinia 无(React Router / Next.js)

心智模型上的最大差别:Svelte 和 Vue 都让你「直接改」,React 逼你「换新值」;而 Svelte 比 Vue 更进一步的点是——它的 $ 前缀是编译器语法($store、$state),写起来像普通变量,代价是「哪些标识符有特殊含义」必须靠记忆和编译器报错来学。

常见坑速查

  • 解构 $state 对象后不更新:const { a } = obj 是一次性快照(实测拿到 1)。改用 obj.a,或各自声明 $state。
  • $derived 里赋值:报错。派生值只能读;要可写就 $state + 函数。
  • $effect 里做派生状态:官方明确反对,容易循环触发;派生就用 $derived。
  • use:xxx="字符串" 编译失败:指令值必须是花括号表达式(实测报错 Directive value must be a JavaScript expression enclosed in curly braces),写 use:tooltip={"文本"}。
  • bind: 报「不可绑定」:子组件那个 prop 必须声明成 $bindable()。
  • 旧教程的 export let xxx / $: 语句 / on:click / <slot>:Svelte 5 里分别换成 $props()、$derived/$effect、onclick={}、snippet。看到 $: 开头的代码就知道那是 4 的写法。
  • import { VERSION } from 'svelte' 是 undefined:Svelte 不导出版本号(实测),别照搬 Vue/React 的习惯。
  • 样式没作用到子组件内部:默认作用域隔离,用 :global(...) 或传 class。
  • $store 在普通函数里不更新:$ 前缀只在组件顶层有效,函数里用 get(store)。
  • {#each} 忘了 key:重排时同样串状态(本文实测与 Vue/React 一致),写法是 as it (it.id)。
  • 在 {#each} 里 as it 直接改 it 的属性:能生效(对象是响应式的),但整体替换数组更符合直觉,也便于做不可变更新。
  • 想上 SSR / 路由却用 Vite SPA:那是 SvelteKit 的活,npx sv create 起手。

练习

  1. 解构陷阱复现:写一个 $state({ a: 1 }),分别用 const { a } = obj 和 obj.a 渲染,点按钮自增,看哪个更新哪个不动;再改成两个独立 $state。
  2. $state.raw 的边界:把一个 1000 项数组用 $state 和 $state.raw 各存一份,比较「改内部元素」时两者的表现,并解释为什么图表库更适合后者。
  3. 数派生执行次数:照抄本文的 runs++ 实验,把 $derived 换成普通函数调用,比较每轮渲染的执行次数。
  4. snippet 三件套:做一个 <Card> 组件,支持 header、默认 children、footer 三个 snippet,其中 header 要把「折叠状态」传给父组件渲染。
  5. 双向绑定组件:做一个 <ColorPicker bind:value>,内部改 $bindable 的 prop;再故意去掉 $bindable() 看编译器报什么错。
  6. animate:flip 列表:做 8 个卡片,点按钮打乱顺序,观察位置补间;再把 key 去掉,看动画为什么乱。
  7. action 与过渡:写一个 use:clickOutside(点元素外面触发回调),配合 transition:fly 做一个下拉面板。
  8. store 迁移:先用 writable 做一份跨组件共享的计数,再改成 .svelte.js 模块里的 $state,比较代码量和更新时机。

参考

本文结束 感谢您的阅读
正在加载今日诗词....