Svelte 5 runes 实战:细粒度响应式状态管理

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 组件,也能把状态逻辑干净地抽离到组件之外复用。

上一篇 昇腾与国产推理芯片:2026 大模型算力自主实战
下一篇 Dapr 实战:用分布式构建块简化微服务开发
最新评论
Docker 网络模式详解:bridge/host 生产选型 - FSData
Docker 网络模式详解:bridge/host 生产选型 - FSData
8月20日
[…] 注意最后那条:把端口绑定到 127.0.0.1,再由宿主机上的 Nginx 反代进来,比直接 -p 80:80 暴露安全得多,也方便统一做
Vue3 + TypeScript 工程化:类型安全的前端架构 - FSData
Vue3 + TypeScript 工程化:类型安全的前端架构 - FSData
8月20日
[…] Pinia 的工程化用法,可以参考我们之前的实战文章 《Vue3 组合式 API + Pinia:状态管理从入门到实战》,那里覆盖了 Sto
2026 年最值得装的 10 个 VS Code 插件(AI/前端/后端/效率全覆盖) - FSData
2026 年最值得装的 10 个 VS Code 插件(AI/前端/后端/效率全覆盖) - FSData
8月19日
[…] 状态,点一下就能起停容器,配合我们讲过的 Docker Compose 编排 […]
Redis 缓存设计:穿透、击穿、雪崩与最佳实践 - FSData
Redis 缓存设计:穿透、击穿、雪崩与最佳实践 - FSData
8月19日
[…] 缓存三大坑的本质都是”缓存失效导致请求穿透到数据库”。记住口诀:穿透用布隆、击穿用锁、雪崩用错峰。配合合理的过期策略
GitHub Actions 实战:从零搭建 CI/CD 流水线 - FSData
GitHub Actions 实战:从零搭建 CI/CD 流水线 - FSData
8月19日
[…] Docker 镜像部署。结合我们之前写的 Docker Compose 一键编排实战,可以在 CI […]