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 | npm create vite@latest svelte-demo -- --template svelte |
实测装上的东西:
1 | svelte-demo@0.0.0 |
目录里比 Vue/React 模板多一个 svelte.config.js(编译器选项,空文件也能工作)和 src/lib/(约定放组件与工具)。
入口:用 mount(),不是 new App()
1 | // src/main.js |
Svelte 4 的 new App({ target }) 在 5 里已经换成 mount()(对应还有 unmount()、hydrate())。
svelte 这个包导出了什么(实测)
1 | afterUpdate, beforeUpdate, createContext, createEventDispatcher, createRawSnippet, |
两个值得注意的点:
- 没有
VERSION:import { VERSION } from 'svelte'拿到的是undefined。想打印版本只能用npm ls或构建期注入。(对比:vue导出version、react也导出version。) - 有
tick()(等 DOM 更新完)、untrack()(读值但不建立依赖)、flushSync()(强制立刻更新)、fork()(派生 effect 分支)这些偏进阶的 API。
响应式:$state
runes(符文)就是以 $ 开头的编译器关键字,写在 <script> 里,不是普通函数,所以不能 import。
1 | <script> |
实测「深层改动」确实生效(改完读到的值):
1 | state 深层属性改动 :: a,b |
最大的坑:解构会失去响应性
1 | <script> |
实测:解构后拿到的就是当时的值 1,之后 obj.a 变化不会带动它。
1 | 解构 $state 后拿到的值(一次性快照) :: 1 |
三种正确写法:
- 直接
obj.a用; - 想要「独立的响应式变量」就各自
let a = $state(...); - 从别处(比如 store、props)拿值时用
$derived。
$state.raw(...):只认引用变化
1 | <script> |
实测:
1 | state.raw 改内部数组后 effect 是否重跑 :: 没重跑(只认引用变化) |
用途:传给 ECharts / 地图库这类「自己维护内部状态的大对象」,别让 Svelte 去代理它的每个字段,性能和正确性都更好。
$derived 与 $effect
$derived:派生值,自动缓存
1 | <script> |
实测「读两次会不会算两次」:
1 | 连续读两次 $derived,表达式执行增量 :: 0 |
也就是:依赖没变就不重算,和 Vue 的 computed 一样;但 $derived 是编译器语法,不是函数调用。
$derived 只能赋派生值,不能手动写:double = 5 会编译报错。要「可写的派生值」就写两个 $state 加一个函数。
$effect:副作用,返回值是清理函数
1 | <script> |
实测(改一次 count 后读记录到的顺序与次数):
1 | effect 顺序 :: effect 跑 → effect 清理 → effect 跑 |
三条纪律:
$effect是逃生舱,不是主力工具。能用$derived表达的别用 effect;能在事件处理函数里做的别放 effect。- 只在 effect 里读响应式值才会建立依赖;写在外面读一次不会。
- 官方明确不建议用它做「派生状态」或「同步到 store」,那类需求应该改数据结构。
tick():等 DOM 真的更新完
1 | import { tick } from 'svelte' |
(对应 Vue 的 nextTick;React 里则是 useEffect/flushSync。)
模板语法
Svelte 的模板是「HTML + 花括号块」,指令用 on: / bind: / use: / transition: / animate: 这些冒号形式,而事件绑定在 5 里推荐直接写 onclick={fn}。
插值与块
1 | <p>{name}</p> <!-- 插值 --> |
实测 {#await} 渲染结果:
1 | await 分支渲染 :: promise 里的值 |
{#each} 的 key 写法和另外两个框架不同(放在括号里),但作用完全一样。我照旧做了「填输入框 → 重排」的对照实验:
1 | 带 key 重排后第一个输入框 :: "输入了B",同行文字 "B" |
和 Vue、React 的实测结果一字不差——这是所有协调算法的共同要求。
属性、事件、双向绑定
1 | <img src={url} alt="说明" /> <!-- 属性绑定 --> |
onclick 与旧 on:click 的差别:on:click 会帮你做事件委托式处理并支持修饰符(|preventDefault 等),5 里改成普通属性后修饰符没有了,需要在处理函数里自己写 event.preventDefault()。实测用 onclick 一切正常:
1 | 子组件 onclick 调父传入的回调后父计数 :: 1 |
bind:value 跨组件也通(子组件要把变量声明成 $bindable,见组件那节):
1 | bind:value 把子组件输入写回父组件的 $state :: 外部写入 |
action:use:(直接操作 DOM 的小行为)
1 | <script> |
实测:
1 | action 加的 title :: 元素 title 属性 = action 加的 title |
编译器报错文案(值得记):use: 的值必须是包在花括号里的 JS 表达式,写成 use:tooltip="字符串" 会直接编译失败:
1 | E:/…/App.svelte:184:33 Directive value must be a JavaScript expression enclosed in curly braces |
(这个报错是我真撞出来的——Svelte 的错误码页面会直接给解释,比 Vue 的堆栈友好。)
过渡与动画
1 | <script> |
实测 transition:fly 挂上后元素的 class:
1 | transition:fly 加上的 class :: anim svelte-1n46o8q |
animate:flip 是 Svelte 的招牌功能:列表重排时自动做位置补间,不用像 React 那样引入 framer-motion。
组件:$props、$bindable 与 snippet
props 就是解构
1 | <!-- src/lib/Child.svelte --> |
父组件用法:
1 | <script> |
实测结果:
1 | 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 | <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 | import { writable, readable } from 'svelte/store' |
1 | <script> |
实测:
1 | store.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 | <!-- 祖先 --> |
实测:
1 | context 取回 :: Hexo-NexT |
约定:key 用 Symbol 或导出的常量,避免字符串撞名;传函数比传响应式值更安全(getContext 拿到的 $state 是活的,但传对象引用容易绕过响应式)。
生命周期:只剩两个钩子
1 | <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起手。
练习
- 解构陷阱复现:写一个
$state({ a: 1 }),分别用const { a } = obj和obj.a渲染,点按钮自增,看哪个更新哪个不动;再改成两个独立$state。 $state.raw的边界:把一个 1000 项数组用$state和$state.raw各存一份,比较「改内部元素」时两者的表现,并解释为什么图表库更适合后者。- 数派生执行次数:照抄本文的
runs++实验,把$derived换成普通函数调用,比较每轮渲染的执行次数。 - snippet 三件套:做一个
<Card>组件,支持header、默认children、footer三个 snippet,其中header要把「折叠状态」传给父组件渲染。 - 双向绑定组件:做一个
<ColorPicker bind:value>,内部改$bindable的 prop;再故意去掉$bindable()看编译器报什么错。 animate:flip列表:做 8 个卡片,点按钮打乱顺序,观察位置补间;再把 key 去掉,看动画为什么乱。- action 与过渡:写一个
use:clickOutside(点元素外面触发回调),配合transition:fly做一个下拉面板。 - store 迁移:先用
writable做一份跨组件共享的计数,再改成.svelte.js模块里的$state,比较代码量和更新时机。
参考
- Svelte 官方文档首页(Reactive state / Blocks / Special elements)
- Svelte 入门教程
$stateAPI$derivedAPI$effectAPI$props与$bindable{#snippet}与{@render}- 事件绑定(
onclick与旧on:的差别) - store(
writable/readable) - 过渡(
svelte/transition) - Svelte 5 迁移指南(runes 与旧语法对照)
- SvelteKit 文档
- Svelte 错误码解释页示例(
directive_invalid_value)