Svelte 5 Runes 深度实战:编译时响应式如何把虚拟 DOM 送进博物馆——从 $state 心智模型到手写一个极简 Signal 引擎
如果你写过几年 React,一定经历过这种瞬间:明明只改了一个字段,整个组件树却像被按了刷新键一样重新执行了一遍,然后虚拟 DOM 开始疯狂 Diff。React 很好,但「状态变了 = 重新运行函数 = 生成新 VDOM 树 = Diff = 更新真实 DOM」这条链路,本质上是一笔你每天都在支付的「认知税 + 性能税」。2026 年的前端,正在发生一场静悄悄的范式转移:Svelte 5 的 Runes、Vue 的 Vapor Mode、Solid 的细粒度 Signal、乃至 TC39 的 Signals 标准草案,都在指向同一个方向——编译时 + 细粒度响应式,让框架在构建阶段就把「谁依赖谁」算清楚,运行时只做「精确制导」式的 DOM 更新,彻底绕开虚拟 DOM。
这篇文章不吹框架,也不劝你明天就重构项目。我们从心智模型讲起,深入到编译器如何把
$state翻译成update 代码,再到性能数据,最后我会带你在 60 行 JavaScript 里手写一个能跑的迷你 Signal 引擎,让你真正看懂「细粒度响应式」到底是怎么一回事。读完你会发现:所谓 Signals,不过是「依赖收集 + 脏标记 + 精确通知」三板斧,但组合起来的工程美感,足以重新定义前端性能的上限。
一、背景:为什么虚拟 DOM 正在被「精确制导」取代
1.1 React 的三段式心智模型
React 的世界观极其优雅,可以用一句话概括:
UI 是状态的函数:
UI = f(state)
每次状态变化,组件函数重新执行,产出一棵新的虚拟 DOM 树,框架把新旧两棵树做 Diff,找出最小变更集合,再 patch 到真实 DOM。这条「函数式 + 不可变 + Diff」的链路,让前端第一次拥有了可预测的状态管理。
但它有一个绕不开的前提:组件函数必须重新执行。哪怕你只改了 count,React 也得把包含它的组件(以及没被 memo 包住的所有子组件)跑一遍。在大型应用里,这意味着每帧可能有成千上万个函数被调用、成千上万个虚拟节点被创建又丢弃——其中绝大部分是无效的。
function UserCard({ user }) {
// 即使 only `user.name` 变了,整个函数体也会重新执行
const displayName = user.name.toUpperCase(); // 重新计算
const avatar = `https://cdn.x/${user.id}`; // 重新拼接
return <div className="card">...</div>; // 重新创建 VDOM 节点
}
React 用 useMemo / React.memo / useCallback 让你手动「划出免死金牌」,但这是一种把优化责任甩给开发者的设计。你要在「过早优化」和「无谓重渲染」之间反复横跳。
1.2 Signals 的「推模型」
Signals 走的是另一条路,一句话:
状态自己知道谁依赖了它。状态变了,直接通知对应的那一个 DOM 节点更新,没有函数重跑,没有 VDOM,没有 Diff。
const count = signal(0);
count.subscribe(() => renderTextNode()); // 只有这个文本节点被订阅
count.set(1); // 直接更新那一个节点,函数不重跑
这听起来像「魔法」,但底层非常朴素。Vue 的 ref/computed 本质是 Signal 的变体;Solid 把 Signal 做到了「手术刀级」——改一个 Signal,真的只有那个文本节点在闪烁;Svelte 5 则更进一步,把这个模型编译进了原生 DOM 操作,运行时几乎没有框架代码。
2026 年腾讯云一篇深度文说得直接:「React 之后,前端的下一个『十年之约』叫 Signals。」这不是噱头——当 Angular、Vue、Solid、Svelte、Preact、Qwik、MobX、Wiz 的维护者联名起草 TC39 Signals 标准草案时,行业共识已经形成:细粒度响应式将成为 Web 平台的原生能力。
1.3 为什么是「现在」爆发
三个推力叠加:
- 性能焦虑加剧。Web 应用越来越重,React 的虚拟 DOM 开销在高频更新(表格、画布、实时仪表盘)场景下被无限放大。
- Solid 证明了可行性。它在 js-framework-benchmark 里常年霸榜,首次渲染和更新速度都远超 React,且 API 友好——打破了「高性能 = 难用」的刻板印象。
- 主流框架集体背书。Vue 3 的响应式系统本就接近 Signals;Svelte 5 用 Runes 把编译器驱动的细粒度更新做到极致;Qwik 用 Resumability 消除水合。当「对手」都变成了「同路人」,范式就赢了。
二、核心概念:Svelte 5 的 Runes 到底是什么
Svelte 5(2024 年底发布稳定版)引入了一套以 $ 开头的「符文」(Runes)语法,把响应式从「隐式」变成了「显式」。这是 Svelte 历史上最大的一次重写,但几乎完全向后兼容 Svelte 4。
2.1 四个最常用的 Runes
| Rune | 作用 | 替代的 Svelte 4 写法 |
|---|---|---|
$state | 声明响应式状态 | let x = 0 + 隐式追踪 |
$derived | 声明派生状态(记忆化) | $: doubled = count * 2 |
$effect | 声明副作用(自动追踪依赖) | $: 副作用 |
$props | 声明组件入参 | export let prop |
看一段最直观的对比:
<!-- Svelte 4 的隐式响应式 -->
<script>
let count = 0;
$: doubled = count * 2; // 编译器靠静态分析推断依赖
function inc() { count += 1; }
</script>
<button on:click={inc}>{count} / {doubled}</button>
<!-- Svelte 5 的 Runes:显式、可在任何作用域使用 -->
<script>
let count = $state(0);
let doubled = $derived(count * 2); // 显式派生,且可被记忆化
function inc() { count++; }
</script>
<button onclick={inc}>{count} / {doubled}</button>
关键差异:Svelte 4 的 $: 依赖编译器做静态分析,遇到 $: content = getContent() 这种「函数内部才读 count」的情况会失效(content 不会更新)。Runes 是运行时显式追踪——只要在读 count 的那一刻处于 effect/derived 上下文,依赖就建立,边界清晰、心智简单。
2.2 $state 是「深层响应式」的
Svelte 5 的 $state 对对象和数组做了深层代理(proxy),改嵌套属性也会触发更新:
<script>
let user = $state({ name: 'Ada', profile: { age: 36 } });
function birthday() {
user.profile.age += 1; // 深层变更,UI 自动更新
}
</script>
<p>{user.profile.age}</p>
如果你想要「不追踪深层、只在整体替换时更新」的语义(比如存一个大二进制或第三方不可变对象),用 $state.raw:
let buffer = $state.raw(new ArrayBuffer(1024));
buffer = new ArrayBuffer(2048); // 只有整体替换才触发,性能更好
2.3 $derived 是「懒 + 记忆化」的
$derived 不会立即计算,只有当有消费者读取它、且依赖发生变化时才重算,且结果会被缓存:
<script>
let items = $state([1, 2, 3]);
// 只有 items 变、且有人读取 total 时,才会重新求和
let total = $derived(items.reduce((a, b) => a + b, 0));
</script>
复杂派生用 $derived.by(fn),支持多步逻辑:
let summary = $derived.by(() => {
let s = 0;
for (const it of items) s += it.value;
return { sum: s, avg: s / items.length };
});
2.4 $effect:副作用的精确编排
$effect 在组件挂载后、或其依赖变化时自动运行,返回一个清理函数:
<script>
let visible = $state(true);
$effect(() => {
const el = document.getElementById('modal');
el?.classList.toggle('show', visible);
return () => console.log('effect 清理了'); // 下次运行前/卸载时调用
});
</script>
$effect 还提供 .pre(在 DOM 更新前跑,适合读布局)和 .root(脱离组件树的生命周期)等变体。一个常见最佳实践:不要在 $effect 里 setState 造成循环,用 $derived 表达「由其他状态算出来的状态」,用 $effect 只做「把状态同步到外部世界」(DOM、localStorage、WebSocket 等)。
2.5 $props 与 $bindable:组件通信
<!-- Child.svelte -->
<script>
let { value = 0, onchange } = $props();
// 允许父组件用 bind: 双向绑定
let { value = $bindable(0) } = $props();
</script>
<!-- Parent.svelte -->
<Child bind:value={count} />
$bindable 让 bind: 语法在 Runes 模式下继续工作,避免了一堆 onChange 样板代码。
三、架构分析:Svelte 编译器到底做了什么
理解 Runes 的关键,是理解编译时和运行时响应式图这两层。
3.1 编译时:把 $state 翻译成「手术刀」
Svelte 没有虚拟 DOM。它的编译器在构建阶段就把组件拆成「创建 DOM 节点」+「更新函数(update)」。你写的:
<script>let count = $state(0);</script>
<button onclick={() => count++}>{count}</button>
编译器生成的(简化后)大致是:
// 伪代码:编译器产物
let count = $.state(0);
const button = document.createElement('button');
const text = $.child(button); // 一个文本节点
// 建立「count 变化时,只更新 text 这一个节点」的订阅
$.user_effect(() => { $.set_text(text, $.get(count)); });
button.onclick = () => $.set(count, $.get(count) + 1);
注意:没有组件函数重跑,没有 VDOM 树,没有 Diff。只有一个文本节点和一个 update 闭包。当 count 变化,被通知的就是 $.set_text(text, ...) 这一行。这就是「细粒度」。
3.2 运行时:响应式有向无环图(DAG)
Svelte 的运行时维护一张信号图:
- Signal(信号):持有值,记录「谁在读我」(下游)。
- Derived(派生):依赖若干 Signal/其他 Derived,值变化时标脏。
- Effect(副作用):终端节点,依赖变化时重新运行(比如更新 DOM)。
$state(count)
│
▼
$derived(doubled) $state(visible)
│ │
└───────────┬───────────┘
▼
$effect(render) ──▶ 更新具体的 DOM 节点
更新流程是推(push)模型:
count.set(1)→ 标记count脏,通知下游doubled。doubled重算2,标脏并通知下游rendereffect。render重新执行,只更新它绑定的那几个真实 DOM 节点。
整个过程是同步、可预测、无 Diff的。对比 React 的「拉(pull)模型」——状态变化后要从组件根部重新执行、再 Diff 出变更——Svelte 的路径短得多。
工程启示:Svelte 5 的通用 Runes 运行时,在部分基准上比 Svelte 4 略慢(因为 4 是为特定场景手调的),但仍远快于 React/Vue 的 VDOM 路径,且开发体验、可组合性大幅提升。这是一个典型的「用一点运行时通用性,换巨大工程收益」的权衡。
3.3 深层响应式的 Proxy 实现
$state(obj) 返回的其实是一个 Proxy,对 . 访问做「读时收集依赖、写时触发更新」。这正是 Vue 3 的 reactive 思路(也基于 Proxy),Svelte 5 把它和编译时绑定结合起来,既保留了深层响应式的便利,又避免了 Vue 运行时需要在每个组件里埋 Proxy 的开销——因为 Svelte 在编译期就知道哪些字段会被模板读取。
四、代码实战:从计数器到生产级模式
4.1 一个完整的表单实战
<!-- LoginForm.svelte -->
<script>
let form = $state({ email: '', password: '', remember: false });
let error = $state('');
// 派生:校验状态,只有依赖变才重算
let isValid = $derived(
/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(form.email) && form.password.length >= 8
);
function submit(e) {
e.preventDefault();
if (!isValid) { error = '邮箱或密码不合法'; return; }
error = '';
// 调接口...
}
// 副作用:把状态同步到 localStorage(不造成渲染循环)
$effect(() => {
localStorage.setItem('login_draft', JSON.stringify(form));
});
</script>
<form onsubmit={submit}>
<input bind:value={form.email} placeholder="邮箱" />
<input type="password" bind:value={form.password} />
<label>
<input type="checkbox" bind:checked={form.remember} /> 记住我
</label>
{#if error}<p class="err">{error}</p>{/if}
<button disabled={!isValid}>登录</button>
</form>
注意这里 bind:value 直接双向绑定到 $state 的深层字段,校验逻辑用 $derived 表达而非 $effect,既清晰又不会触发无谓渲染。
4.2 Snippets:取代 Slot 的更强大组合
Svelte 5 用 {#snippet} 取代部分 slots 场景,把「可复用模板」当一等公民:
{#snippet card(title, body)}
<div class="card">
<h3>{title}</h3>
<p>{body}</p>
</div>
{/snippet}
{@render card('Hello', '这是内容')}
相比 Svelte 4 的 <slot>,snippets 可以传参、可以嵌套、可以被当作值传递,组合能力直接对齐 JSX 的函数组件。
4.3 同功能三方对比(让你看清差异)
React 写法:
function Counter() {
const [count, setCount] = useState(0);
const doubled = useMemo(() => count * 2, [count]); // 必须手写依赖数组
return <button onClick={() => setCount(c => c + 1)}>{count} / {doubled}</button>;
}
Vue 3 写法:
<script setup>
import { ref, computed } from 'vue';
const count = ref(0);
const doubled = computed(() => count.value * 2); // .value 心智负担
</script>
<button @click="count++">{{ count }} / {{ doubled }}</button>
Svelte 5 写法:
<script>
let count = $state(0);
let doubled = $derived(count * 2);
</script>
<button onclick={() => count++}>{count} / {doubled}</button>
三者都能跑,但心智模型负担依次为 React(依赖数组易错)> Vue(.value 割裂)> Svelte(读就追踪,写就更新)。这不是「谁对谁错」,而是「编译器替你干了多少活」。
五、手写一个极简 Signal 引擎(本文核心)
光看框架不够过瘾。下面我们用不到 60 行 JavaScript,实现一个能跑的迷你 Signal 引擎,让你看清「细粒度响应式」的全部秘密。它包含三个原语:signal、computed、effect。
5.1 设计:依赖收集的三板斧
核心思路:
- 全局栈
currentEffect:记录「当前正在运行的 effect」。 - signal 内部存两份:
value(值)和subs(订阅它的 effect/computed 集合)。 - 读时收集(track):
signal.get()时,如果栈顶有正在运行的 effect,就把这个 effect 加入自己的subs。 - 写时通知(trigger):
signal.set()时,遍历subs,把脏的下游重新运行。 - computed 也是 signal:它自己也有
subs,且当它的依赖变化时,标脏并通知下游。
5.2 完整实现
// ===== 极简 Signal 引擎 =====
let activeEffect = null; // 当前正在运行的 effect(收集上下文)
const effectStack = []; // 支持 effect 嵌套
function signal(initial) {
let value = initial;
const subs = new Set(); // 订阅者(effect 或 computed)
const read = () => {
// 读时收集:把当前 effect 注册为订阅者
if (activeEffect) {
subs.add(activeEffect);
activeEffect.deps.push(subs);
}
return value;
};
const write = (next) => {
if (Object.is(next, value)) return; // 无变化不通知
value = next;
// 复制一份再遍历,避免运行中订阅集被修改
[...subs].forEach(sub => sub.run());
};
return { get: read, set: write };
}
function computed(fn) {
const result = signal(undefined);
let cached;
let dirty = true;
const comp = {
run() {
if (dirty) {
const prev = activeEffect;
activeEffect = comp; // 进入收集上下文
effectStack.push(comp);
cached = fn(); // fn 内部读 signal → 建立依赖
effectStack.pop();
activeEffect = prev;
result.set(cached); // 触发下游
dirty = false;
}
},
deps: [],
// computed 被 effect 读取时,建立「effect → computed」依赖
get value() {
if (activeEffect) {
result.__subs?.add(activeEffect);
}
this.run();
return cached;
}
};
// 让 outer subs 通过 result 串联(简化版)
Object.defineProperty(comp, '__subs', { get: () => result.__subs });
return comp;
}
function effect(fn) {
const eff = {
deps: [],
run() {
// 清空旧依赖,重新收集
cleanup(eff);
const prev = activeEffect;
activeEffect = eff;
effectStack.push(eff);
fn();
effectStack.pop();
activeEffect = prev;
}
};
eff.run();
return eff;
}
function cleanup(eff) {
for (const dep of eff.deps) dep.delete(eff);
eff.deps = [];
}
// ===== 演示 =====
const count = signal(0);
const doubled = computed(() => count.get() * 2);
effect(() => {
console.log(`count=${count.get()}, doubled=${doubled.value}`);
});
// 输出: count=0, doubled=0
count.set(1);
// 输出: count=1, doubled=2
count.set(1); // Object.is 相等,不触发
doubled 本身只在 count 变时重算
这个实现刻意简化(真实框架用
ReactiveNode+ 版本号做拓扑排序、批量更新、循环检测),但它已经完整展示了**依赖收集(track)与精确通知(trigger)**的闭环。你写的框架代码,本质上就是这套逻辑的工业级版本——这也是 Svelte 编译器编译$state/$derived/$effect时生成的运行时骨架。
5.3 把它接到真实 DOM 上
把 effect 换成「更新 DOM」,你就得到了 Svelte 的核心理念:
const count = signal(0);
const textNode = document.createTextNode('0');
document.body.appendChild(textNode);
effect(() => {
textNode.textContent = count.get(); // 只有这个文本节点被更新
});
setInterval(() => count.set(count.get() + 1), 1000);
每秒 count 变化,DOM 上只有一个文本节点在变,没有任何组件函数重跑,没有 VDOM。这就是 Solid 和 Svelte 5 性能神话的底层真相。
六、性能优化:把 Runes 用对
6.1 基准数据(行业共识区间)
在 js-framework-benchmark(创建/更新/删除 1 万行表格)中,大致排序(越快越好):
- Solid / Svelte 5:首次渲染与高频更新都接近原生 JS,常年第一梯队。
- Vue 3(含 Vapor 预览):优秀,略逊于纯编译方案。
- React 19:功能全面,但在极端高频更新下因 VDOM Diff 开销落后一截。
注意:绝大多数业务应用根本触及不到这个性能边界。选框架的第一依据是团队与生态,不是 benchmark。但如果你在做实时协作、可视化大屏、高频交易面板,Signals 范式的优势是实打实的。
6.2 五个落地建议
用
$derived表达「算出来的状态」,别用$effect去 setState。前者是声明式、可记忆化;后者是命令式、易产生循环。// ❌ 反模式 let doubled = $state(0); $effect(() => { doubled = count * 2; }); // 循环风险 + 多余渲染 // ✅ 正确 let doubled = $derived(count * 2);不响应式的局部变量,用普通
let。别把所有东西都包成$state,减少代理开销和依赖噪音。大对象 / 不可变数据用
$state.raw。避免深层代理,整体替换时再触发。$effect只做「状态 → 外部副作用」:DOM 类库集成、localStorage、埋点、WebSocket 发送。把「读取状态」留给模板和$derived。批量更新与
$effect.tracking:Svelte 默认在微任务里批量刷新,避免一帧内多次 set 引发多次 DOM 写入。若你需要手动编排时序,用$effect的untrack包裹「读但不建立依赖」的逻辑。
七、总结与展望:前端的下一个十年
回到开头的问题:React 时代结束了吗? 我的判断是——React 不会消失,它仍是复杂应用、跨端(React Native)、生态成熟度上的王者。但「响应式范式的王座」正在易主:细粒度、编译时、Signal 优先正在成为新的事实标准。
几个值得盯紧的信号:
- TC39 Signals 提案:Angular、Vue、Solid、Svelte、Preact、Qwik、MobX、Wiz 的维护者联名起草,目标是把 Signals 变成 Web 原生能力(
new Signal.State())。一旦落地,跨框架状态同步将不再需要 Redux/Pinia 这类「胶水层」。 - Vue Vapor Mode:编译时抹平运行时,向 Svelte 的「零运行时」靠拢,首屏与高频更新性能大幅提升。
- Solid:把 Signal 做成「手术刀」,证明「无 VDOM + JSX 语法」可以既好用又极快。
- Qwik 的 Resumability:干脆不要水合,服务器把「可恢复的执行图」直接发到浏览器,秒开。
- Svelte 5 Runes:把细粒度响应式做成显式、可组合、向后兼容的一等公民,是当下最优雅的落地形态之一。
给工程师的选型建议:
- 新项目、性能敏感、团队愿意尝鲜 → SvelteKit / Solid 值得认真考虑。
- 存量 React 大型应用 → 不必重构,关注 React 19/20 的 Server Components 与编译优化方向。
- Vue 团队 → 继续跟 3.x,盯 Vapor Mode。
- 无论如何,理解 Signals 的心智模型(依赖收集 + 精确通知)已经是 2026 年前端工程师的必修课,因为它正在变成「平台级」能力。
最后一句心里话:技术选型没有银弹。虚拟 DOM 没「死」,它只是不再是唯一的最优解。真正成熟的做法是——看懂每种模型的天花板与代价,在合适的场景用合适的工具。而这,恰恰是一个工程师从「会用框架」到「懂框架」的分水岭。
附录:本文核心代码清单
$state/$derived/$effect/$props/$bindable基础用法- 登录表单(深层
$state+$derived校验 +$effect同步) - Snippets 组合模板
- React / Vue / Svelte 三方同功能对比
- 60 行手写迷你 Signal 引擎(signal / computed / effect + track / trigger)
- Signal 直连 DOM 的「精确制导」演示
想跑通手写引擎?把第五节代码存成
mini-signals.js用 Node 直接node mini-signals.js即可看到输出。它丑陋但诚实——所有「魔法」拆开,不过是一个全局栈、两个 Set、和两行「读时收集、写时通知」。