Svelte 5 Runes 深度解析:从编译时魔法到运行时信号的范式转变

2385 字
12 分钟
Svelte 5 Runes 深度解析:从编译时魔法到运行时信号的范式转变

Svelte 5 Runes 深度解析:从编译时魔法到运行时信号的范式转变#

Svelte 5 是这个框架诞生以来最大的一次重写。Runes(符文)系统彻底改变了 Svelte 的响应式模型——从”编译器隐式分析赋值语句”转向”显式的信号原语”。这不只是 API 变化,是设计哲学的根本转向。本文将深入分析 Runes 的设计动机、实现原理和对前端生态的影响。

一、Svelte 4 的编译时魔法:优雅但有极限#

1.1 赋值即响应#

Svelte 4 的核心卖点是”没有运行时”。你写的就是普通 JavaScript,编译器帮你加上响应式:

<script>
// Svelte 4:赋值触发更新,编译器负责追踪
let count = 0
function increment() {
count += 1 // 编译器识别赋值,自动插入 $$invalidate 调用
}
$: doubled = count * 2 // $: 标签 = 响应式声明
$: console.log('count changed:', count) // $: 也能做副作用
</script>
<button on:click={increment}>
{count} × 2 = {doubled}
</button>

编译后:

function instance($$self, $$props, $$invalidate) {
let count = 0
let doubled
function increment() {
$$invalidate(0, count += 1) // 编译器插入的更新通知
}
// $: 声明被编译为 beforeUpdate 钩子中的脏检查
$$self.$$.update = () => {
if ($$self.$$.dirty & 1) { // bit 0 = count
$$invalidate(1, doubled = count * 2)
}
if ($$self.$$.dirty & 1) {
console.log('count changed:', count)
}
}
return [count, doubled, increment]
}

1.2 魔法的代价#

这种方案在简单场景下无懈可击,但随着应用复杂度上升,问题逐渐暴露:

问题 1:响应性被困在组件内部

utils/counter.js
// ❌ 这个在 Svelte 4 中不是响应式的!
export let count = 0
export function increment() {
count += 1 // 编译器无法追踪 .js 文件中的赋值
}

只有 .svelte 文件中的赋值才会被编译器处理。要在组件外共享状态,必须用 store

stores/counter.js
import { writable } from 'svelte/store'
export const count = writable(0)
<script>
import { count } from './stores/counter.js'
// $count 是 store 的语法糖,自动 subscribe/unsubscribe
</script>
<button on:click={() => $count += 1}>{$count}</button>

两套心智模型:组件内用赋值,组件外用 store。不统一。

问题 2:$: 的语义模糊

<script>
$: doubled = count * 2 // 这是派生值
$: console.log(count) // 这是副作用
$: if (count > 10) alert('!') // 这也是副作用
$: { // 这是代码块
const x = count * 2
doubled = x + 1
}
</script>

$: 同时承担了 computedwatcheffect 三种职责。对新手来说,这三种行为用同一个语法表达,容易困惑。

问题 3:数组/对象的变更检测陷阱

<script>
let items = [1, 2, 3]
function addItem() {
items.push(4) // ❌ 不触发更新!push 不是赋值
items = items // ✅ 需要这个"假赋值"来触发更新
// 或者
items = [...items, 4] // ✅ 创建新引用
}
</script>

二、Runes:显式信号原语#

2.1 核心 Runes#

Svelte 5 引入了一组以 $ 开头的”符文”(Runes),作为编译器指令:

<script>
// $state:声明响应式状态
let count = $state(0)
let items = $state([1, 2, 3])
// $derived:派生值(替代 $: x = ...)
let doubled = $derived(count * 2)
// $derived.by:复杂派生逻辑
let stats = $derived.by(() => {
const sum = items.reduce((a, b) => a + b, 0)
return { sum, avg: sum / items.length }
})
// $effect:副作用(替代 $: console.log(...))
$effect(() => {
console.log('count is now', count)
// 自动追踪依赖,自动清理
})
function increment() {
count += 1 // ✅ 直接赋值
items.push(4) // ✅ push 也能触发更新了!
}
</script>
<button onclick={increment}>
{count} × 2 = {doubled}
</button>

2.2 $state 的深层响应性#

Svelte 5 的 $state 对对象和数组使用 Proxy 实现深层响应式(是的,和 Vue 3 的策略一样):

<script>
let user = $state({
name: 'Alice',
address: {
city: 'Shanghai'
},
hobbies: ['coding']
})
function update() {
user.name = 'Bob' // ✅ 触发更新
user.address.city = 'Beijing' // ✅ 深层属性也行
user.hobbies.push('reading') // ✅ 数组方法也行
}
</script>
<p>{user.name} lives in {user.address.city}</p>
<p>Hobbies: {user.hobbies.join(', ')}</p>

这是 Svelte 历史上第一次引入运行时 Proxy。从”零运行时”到”轻量运行时”,Rich Harris 做出了务实的取舍。

2.3 $state 的细粒度变体#

<script>
// 深层响应式(默认)
let deep = $state({ nested: { value: 1 } })
// 浅层响应式:只追踪顶层属性
let shallow = $state.raw({ nested: { value: 1 } })
// 快照:获取纯 JavaScript 对象(脱离 Proxy)
let snapshot = $state.snapshot(deep)
// snapshot 可以安全地序列化、传递给第三方库
</script>

三、Runes 的编译原理#

3.1 $state 编译#

// 你写的代码
let count = $state(0)
count += 1
// 编译输出(简化)
import { source, set, get } from 'svelte/internal/client'
let count = source(0) // 创建信号源
set(count, get(count) + 1) // 读取 + 设置

编译器将所有对 $state 变量的读取替换为 get() 调用,所有赋值替换为 set() 调用。

3.2 $derived 编译#

// 你写的代码
let doubled = $derived(count * 2)
// 编译输出
import { derived } from 'svelte/internal/client'
let doubled = derived(() => get(count) * 2)

$derived 变为一个惰性计算的信号,只在被读取时才重新计算,并且自动缓存。

3.3 $effect 编译#

// 你写的代码
$effect(() => {
console.log(count)
})
// 编译输出
import { user_effect } from 'svelte/internal/client'
user_effect(() => {
console.log(get(count))
})

四、跨越组件边界:Runes 的统一性#

Runes 最大的突破是可以在 .svelte.js / .svelte.ts 文件中使用

stores/counter.svelte.js
// ✅ Runes 在组件外也能用!
export function createCounter(initial = 0) {
let count = $state(initial)
let doubled = $derived(count * 2)
function increment() {
count += 1
}
function decrement() {
count -= 1
}
return {
get count() { return count }, // getter 保持响应性
get doubled() { return doubled },
increment,
decrement
}
}
App.svelte
<script>
import { createCounter } from './stores/counter.svelte.js'
const counter = createCounter(10)
</script>
<button onclick={counter.increment}>{counter.count}</button>
<p>Doubled: {counter.doubled}</p>

注意 getter 模式: 返回对象时必须用 getter,因为 { count: count } 会在创建时读取一次值,后续不会更新。这是信号系统的通用模式(Vue 的 toRefs 解决同样的问题)。

五、propsprops 和 bindable:组件接口的革新#

5.1 新的 Props 声明#

<script>
// Svelte 4
export let name = 'world'
export let count
// Svelte 5
let { name = 'world', count } = $props()
</script>

$props() 返回的是一个响应式对象,解构不会丢失响应性(编译器保证)。

5.2 $bindable:显式声明双向绑定#

Input.svelte
<script>
let { value = $bindable('') } = $props()
// $bindable 标记这个 prop 支持 bind:
</script>
<input bind:value={value} />
<!-- Parent.svelte -->
<script>
let name = $state('')
</script>
<!-- bind: 只对 $bindable 的 prop 有效 -->
<Input bind:value={name} />

这比 Svelte 4 的隐式双向绑定(所有 export let 都可以 bind)更安全、更明确。

六、Runes 与其他框架信号系统的对比#

6.1 信号大统一时代#

2024-2026 年,几乎所有主流框架都在拥抱 Signals 模式:

// Vue 3 - Composition API
const count = ref(0)
const doubled = computed(() => count.value * 2)
watchEffect(() => console.log(count.value))
// Svelte 5 - Runes
let count = $state(0)
let doubled = $derived(count * 2)
$effect(() => console.log(count))
// Solid.js - Signals
const [count, setCount] = createSignal(0)
const doubled = createMemo(() => count() * 2)
createEffect(() => console.log(count()))
// Angular - Signals
const count = signal(0)
const doubled = computed(() => count() * 2)
effect(() => console.log(count()))
// Preact - Signals
const count = signal(0)
const doubled = computed(() => count.value * 2)
effect(() => console.log(count.value))

6.2 核心差异#

特性Svelte 5Vue 3SolidAngular
访问方式直接变量名.value函数调用()函数调用()
编译器依赖必须不需要不需要不需要
运行时大小~6KB~16KB~7KB~大(框架整体)
深层响应性$state 默认深层reactive 深层store 深层不内置
组件外使用.svelte.js任意 .js任意 .js任意 .ts

Svelte 5 的独特之处在于:编译器消除了语法噪音。你写 count,不是 count.value、不是 count()。编译器在构建时将其转换为信号读取。这是 DX 的胜利。

6.3 权衡#

编译器魔法的代价是可调试性。当你在浏览器 DevTools 中打断点,看到的是编译后的 get(count) 而非源码中的 count。source map 能缓解,但不能完全消除这种认知摩擦。

Vue 3 的 .value 看似冗余,但它有一个优势:所见即所得。代码在运行时的行为和你阅读源码时理解的完全一致。

七、迁移策略:从 Svelte 4 到 Svelte 5#

7.1 自动迁移工具#

Terminal window
# 官方迁移命令
npx sv migrate svelte-5
# 自动转换:
# let x = 0 → let x = $state(0)
# export let prop → let { prop } = $props()
# $: derived = expr → let derived = $derived(expr)
# $: { sideEffect() } → $effect(() => { sideEffect() })

7.2 需要手动处理的场景#

<script>
// Svelte 4:$: 同时做派生和副作用
$: {
doubled = count * 2
console.log('updated') // 副作用
}
// Svelte 5:需要拆分
let doubled = $derived(count * 2)
$effect(() => {
console.log('updated:', doubled)
})
</script>

7.3 渐进式迁移#

Svelte 5 的兼容模式允许旧语法和新语法共存。你可以逐个组件迁移,不需要一次性重写。

八、对前端生态的启示#

Svelte 5 的 Runes 重写验证了几个趋势:

  1. Signals 是终局:从 Vue 到 Svelte 到 Angular,所有框架都在收敛到信号模式。TC39 的 Signals 提案如果落地,将彻底统一这个领域。

  2. 编译器是差异化武器:当运行时模型趋同(都是信号),框架的竞争力将更多来自编译器——谁能在构建时做更多优化,谁能提供更好的开发者体验。

  3. 务实大于教条:Svelte 从”零运行时”的纯粹主义转向”轻量运行时 + 编译器优化”的务实路线。Rich Harris 说:“我宁愿有一个 6KB 的运行时和更好的 DX,也不要零运行时和一堆 footgun。”

  4. 跨边界的响应性是刚需:所有框架都在解决”组件外状态管理”的问题。Runes 在 .svelte.js 中可用、Vue 的 Composition API 在任意 JS 中可用、Solid 的 signals 天然跨边界——这是必然方向。


前端框架的战争正在从”运行时范式之争”转向”编译器能力之争”。理解这个趋势,比学会某个具体 API 更重要。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

Svelte 5 Runes 深度解析:从编译时魔法到运行时信号的范式转变
https://boke.hackerdream.xyz/posts/svelte-5-runes-paradigm-shift/
作者
晴天
发布于
2026-04-03
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
晴天
Hello, I'm 晴天.
公告
欢迎来到我的博客!这是一则示例公告。
音乐
封面

音乐

暂未播放

0:00 0:00
暂无歌词
分类
标签
站点统计
文章
125
分类
17
标签
287
总字数
257,955
运行时长
0
最后活动
0 天前

目录