Svelte 5 带来的最大变化,是引入了一套名为 runes 的编译期响应式原语。相比 Svelte 4 那种“给变量赋值就自动触发更新”的隐式响应式,runes 用显式的 $state、$derived、$effect、$props 把响应式边界讲清楚,既保留了细粒度更新的高性能,又让逻辑在组件外、甚至在普通函数里也能复用。本文用可运行示例,带你把 Svelte 5 runes 写对、写好。
一、为什么 Svelte 5 要引入 runes
Svelte 4 的响应式靠编译器分析“赋值语句”推断依赖,写起来顺手,但有两个老问题:一是响应式只能在组件的 <script> 顶层生效,抽到独立函数或类里就失效;二是复杂的派生与副作用容易写出隐式耦合,重构时编译器误判依赖。runes 把“哪些是状态、哪些由状态算出、哪些要跟随变化执行”变成显式标注,编译器据此生成精确的细粒度更新代码,性能与可维护性同时提升。它与 Vue3 的 ref/reactive 响应式 思路相似,但更贴近编译期信号(signal)模型。
信号(signal)模型的核心,是“状态即一个可读可写的盒子,读它时登记依赖、写它时通知依赖”。Svelte 5 把它做进编译器:模板里用到 $state 的地方,编译后只会更新那一个文本节点或属性,而不是像传统虚拟 DOM 那样整棵组件比对。代价是写法从“隐式”变成“显式”——你需要主动用 rune 标注,但这点额外心智成本换来了更可控的依赖图和更少的无效渲染,在列表、表单、仪表盘这类高频更新场景收益尤其明显。对于从 Vue3 转过来的同学,$state 约等于 ref、$derived 约等于 computed、$effect 约等于 watch,心智模型可以快速对齐。
二、$state:声明响应式状态
2.1 基础用法
用 $state 包裹的初始值即成为响应式状态,读取它会被收集为依赖,写入它会触发依赖更新。
<script>
let count = $state(0);
function inc() {
count += 1; // 写入即通知依赖
}
</script>
<button onclick={inc}>
点击了 {count} 次
</button>
2.2 深层响应性与类
$state 默认对对象和数组做深层代理,嵌套属性改动也能被追踪。把它放进类,状态逻辑就能在组件外复用——这是 Svelte 4 很难做到的。
<script>
class Counter {
count = $state(0);
get double() { return this.count * 2; }
inc() { this.count += 1; }
}
const c = new Counter();
</script>
<p>{c.count} 的两倍是 {c.double}</p>
<button onclick={() => c.inc()}>+1</button>
关于深层响应性有两点容易踩坑。第一,$state 的代理是惰性的:只有真正被读取过的嵌套字段才会被追踪,后续新增的对象属性不会自动变成响应式的,需要整体替换或一开始就声明完整结构。第二,直接对数组做 arr[0] = x 或 arr.length = 0 这类索引/长度操作在代理下是可追踪的,但如果你把数组解构成普通变量再改,就脱离了响应式。稳妥做法是统一通过 $state 持有集合,并优先使用 push、map、filter 等返回新引用的方式更新。这样既能保证依赖被准确收集,也方便和派生逻辑组合。
三、$derived:派生状态
$derived 声明“由其他状态算出来的只读值”,仅当依赖变化时才重算,且带缓存。需要带副作用的复杂派生用 $derived.by(fn)。
<script>
let price = $state(100);
let qty = $state(2);
let total = $derived(price * qty);
let label = $derived.by(() => {
return `小计:${price} × ${qty} = ${price * qty}`;
});
</script>
<p>{label}</p>
$derived 是惰性的:它不会因为你写了就立即计算,只有当某个依赖真正读取它时才求值,并且结果会被缓存,直到依赖变化才重算。这意味着把一个昂贵计算放进 $derived 是安全的——它不会在每次渲染时白白跑一遍。需要提醒的是,$derived 里应当只有“计算”,不要放“动作”:如果你在里面发请求、改 DOM、写日志,这些副作用应该在 $effect 里做。把“算出值”和“产生效果”分开,是 runes 设计里最重要的一条纪律,也直接决定了组件是否好调试。
四、$effect:跟随变化的副作用
$effect 在依赖变化时自动执行,适合操作 DOM、同步外部库、打日志等。它必须在组件初始化或 effect 内部调用,不能在异步回调里裸调。
4.1 清理与防抖
effect 的返回值若为函数,会在下次执行前或组件销毁时调用,用于清理定时器、取消订阅等。
<script>
let keyword = $state("");
let debounce;
$effect(() => {
clearTimeout(debounce);
const k = keyword;
debounce = setTimeout(() => {
console.log("搜索:", k);
}, 300);
return () => clearTimeout(debounce);
});
</script>
<input bind:value={keyword} placeholder="输入关键词" />
$effect 的执行时机值得记住:它在组件渲染、DOM 更新完成之后运行,所以你在 effect 里读到的 DOM 是最新的。也正因为如此,effect 不适合用来“根据状态改另一个状态”——那种需求应当用 $derived,否则会多绕一圈渲染。effect 的清理函数不仅在依赖变化重跑前触发,在组件被销毁时也会触发一次,这点是做事件监听、定时器、第三方库绑定时防止内存泄漏的关键。如果你的副作用不依赖任何响应式值(比如只想在挂载时跑一次),用 $effect(() => { ... }) 配合空依赖即可,但不要在其中读会变化的 $state,否则仍会被反复触发。
五、$props:组件通信
Svelte 5 用 $props() 接收属性,解构即声明。配合 $bindable 可实现双向绑定,替代旧的 bind: 导出写法。
<!-- Child.svelte -->
<script>
let { value = 0, onchange = () => {} } = $props();
</script>
<button onclick={() => onchange(value + 1)}>{value}</button>
<!-- Parent.svelte -->
<script>
import Child from "./Child.svelte";
let n = $state(0);
</script>
<Child value={n} onchange={(v) => (n = v)} />
$props() 解构时可以给参数设默认值,未传入时使用默认值;想要收集“其余所有属性”用剩余参数 let { a, ...rest } = $props(),常用于把原生属性透传到根元素。需要父组件反向收到更新时,用 $bindable() 包裹参数并配合 bind: 绑定,等价于 Svelte 4 的双向绑定但语义更清晰。注意:runes 模式下 $props() 是响应式的,父级改了属性,子组件内部用到该属性的派生与 effect 会自动重算,不必再手动写 watch 去同步。
六、runes 与旧响应式写法的区别
| 维度 | Svelte 4 隐式响应式 | Svelte 5 runes |
|---|---|---|
| 状态声明 | 普通 let 赋值 | $state() 显式标注 |
| 依赖追踪 | 编译器扫描赋值语句 | 编译期信号图,精确到字段 |
| 逻辑复用 | 仅组件顶层 | 可进类/普通函数 |
| 双向绑定 | bind: 导出变量 | $bindable() + $props() |
| 迁移成本 | — | 旧写法仍可运行,逐步迁移 |
迁移策略上不必一步到位:Svelte 5 完全兼容 Svelte 4 的写法,旧组件不改写也能继续跑,编译器会自动识别是否混用了 runes。建议先在新组件里用 runes,再把状态逻辑复杂、依赖关系绕的旧组件逐步重写;一旦某个组件里出现了任意 rune,该组件就进入 runes 模式,不能再混用旧的顶层赋值响应式。这个“混合可共存、单组件二选一”的设计,让大型项目可以小步快跑地升级,而不是被逼着一次性重构。
七、真实场景:搜索框 + 列表过滤
下面把上面几个 runes 串起来:输入关键词,派生出过滤后的列表,并用 effect 同步到 URL。
<script>
let items = $state(["苹果", "香蕉", "橙子", "葡萄"]);
let q = $state("");
let list = $derived(
items.filter((x) => x.includes(q))
);
$effect(() => {
history.replaceState(null, "", "?q=" + encodeURIComponent(q));
});
</script>
<input bind:value={q} placeholder="过滤水果" />
<ul>
{#each list as it}
<li>{it}</li>
{/each}
</ul>
这个场景的妙处在于“细粒度”带来的性能:list 是 $derived,只有 q 或 items 变时才重算;模板里 {#each list as it} 只对变化的那一条 <li> 做 DOM 增删,没变的节点保持原样。相比“整列表重新渲染”,在成百上千条数据下差距明显。而 effect 同步 URL 是纯副作用,不参与渲染,因此输入时的交互依然顺滑。把“数据→派生→视图”和“副作用→外部”两条链路分清楚,是写出高性能 Svelte 5 表单的关键。
八、常见误区与排障
| 现象 | 根因 | 修复 |
|---|---|---|
| effect 不执行 | 在异步回调/事件外裸调 | 放在组件初始化或 effect 顶层 |
| 数组 push 不更新 | 用了 let 而非 $state 数组 | 用 $state([]) 并直接 push |
| 派生值反复重算 | 在 $derived 里写副作用 | 副作用移到 $effect |
| 类字段不响应 | 忘记给字段加 $state | 类内字段显式 field = $state(x) |
九、与 SvelteKit 和构建链路的协同
runes 在 SvelteKit 全栈应用 里同样适用,+page.svelte 的脚本直接写 runes 即可。打包侧 Svelte 5 的编译器已内置 runes 支持,配合 Vite 构建工具 无需额外插件;想进一步压首屏,可参考 前端性能优化清单 做代码分割与预加载。在 前端工程化 Monorepo 中把状态逻辑抽成共享类,也能直接跨包复用。
从心智模型看,runes 让 Svelte 5 与 Vue3 的差距进一步缩小,但实现路径不同:Vue3 的响应式是运行期 Proxy 拦截,Svelte 5 是编译期生成信号图。这带来一个实用差异——Svelte 5 的响应式在脱离框架运行时(比如纯 TS 函数、Node 脚本)里也能通过 runes 复用状态逻辑,而 Vue3 的 ref 虽然也能抽函数,但更依赖组件上下文做依赖收集。对团队来说,如果已经在用 Vue3 且生态成熟,不必为了 runes 迁移;但若是新项目、且看重编译期零运行时开销与更小的包体积,Svelte 5 是值得认真评估的选项。无论选谁,把“状态、派生、副作用”三者职责划清,都是写好响应式代码的共通基本功。
十、生产落地检查清单
- 状态一律用
$state声明,类字段也别忘了加。 - 只读计算走
$derived,副作用走$effect,二者职责不混。 - effect 内若有定时器、订阅,务必返回清理函数。
- 组件通信优先
$props()+$bindable()。 - 升级 SvelteKit 时先小范围试点 runes,再逐步替换旧写法。
- 用 VS Code 的 Svelte 官方插件获得 runes 类型提示与高亮。
runes 不是语法糖,而是 Svelte 把响应式模型彻底显式化的结果。吃透 $state/$derived/$effect/$props 四件套,你就能写出既高性能又好维护的 Svelte 5 组件,也能把状态逻辑干净地抽离到组件之外复用。




