Svelte 5 编译时革命:用 Runes 重塑响应式,从虚拟 DOM 到细粒度更新的范式迁移(2026 实战指南)
2026 年的前端圈有一个尴尬的事实:我们写了十几年的"声明式 UI",但每一次状态变更,浏览器都在替我们做一场昂贵的"虚拟 DOM diff 猜谜游戏"。React 用协调(Reconciliation)兜底,Vue 用依赖追踪兜底,而 Svelte 选择了一条更激进的路——把响应式计算彻底搬到编译期,运行时只留最薄的胶水代码。Svelte 5 带来的 Runes(符文)不是语法糖,而是一场范式迁移。本文从运行时税的本质讲起,拆解编译时响应式的原理、Svelte 编译器的工作方式,并配一套完整的可运行实战,最后给出性能基准与 15 条生产级实践。
一、背景介绍:前端框架的"运行时税"
1.1 虚拟 DOM 是解药,也是税
2013 年 React 把"UI 是状态的函数"这件事讲明白了:UI = f(state)。为了不让开发者手动操作 DOM,React 引入了 Virtual DOM:每次状态变化,先算出一棵新的虚拟树,再和旧的虚拟树做 diff,最后只把差异 patch 到真实 DOM 上。
这套设计拯救了无数被 jQuery 时代命令式 DOM 操作折磨的程序员,但它有一个被长期忽视的代价——运行时税(runtime tax):
- 每次渲染都要创建整棵 VNode 树(即便大部分节点没变);
- diff 算法无论如何都要遍历组件树(即使只是改了一个计数器);
- 为了"通用性",运行时必须携带一套协调器、调度器、fiber 树。
// React:每次 state 变化,组件函数整体重新执行
function Counter() {
const [count, setCount] = useState(0);
// 哪怕只改了 count,整个函数体、所有 JSX 都会被重新求值
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
注意,上面的 count + 1 触发的是整组件重渲染。React 靠 memo / useMemo / useCallback 来手动修剪这棵树的更新范围——也就是说,开发者要自己为运行时税买单。
1.2 三条主流路线
到 2026 年,主流框架在"如何少交运行时税"上分成了三条路线:
| 框架 | 响应式策略 | 更新粒度 | 心智负担 |
|---|---|---|---|
| React 19 | 整组件重渲染 + 手动 memo | 组件级 | 高(要懂 memo 陷阱) |
| Vue 3 | 运行时依赖追踪(Proxy) | 字段级 | 中(自动追踪) |
| Svelte 5 | 编译期依赖图 + 细粒度 signal | 字段级 | 低(编译器生成) |
| SolidJS | 编译期 + 类 MobX signal | 字段级 | 低 |
Vue 3 用 Proxy 在运行时拦截对象访问来建立依赖关系,所以它仍需在运行时携带响应式系统。Svelte 5 更进一步:在编译期就静态分析出谁是依赖、谁是被依赖者,直接生成"当 A 变时更新 B"的精确命令式代码,运行时几乎不需要一个"响应式引擎"。
这就是"编译时框架"的核心主张:能编译期确定的事,绝不拖到运行时。
1.3 为什么是现在
Svelte 5 于 2024 年底正式发布,但到 2026 年它才真正成为生产级首选,原因有三:
- Runes 统一了 Svelte 4 的"隐式响应式"——过去 Svelte 靠变量赋值自动追踪,但跨组件、跨模块的响应式很别扭;Runes 把响应式变成显式、可组合的"符文",彻底解决了模块化难题。
- Signal 范式在业界达成共识——SolidJS、Vue Vapor、Preact Signals、甚至 React 社区都在向细粒度 signal 靠拢,Svelte 5 的 Runes 是其中最优雅的实现之一。
- 构建工具链成熟——Vite 6/7 + SvelteKit 2 让编译时框架的工程化成本降到极低。
二、核心概念:Runes 与编译时响应式
2.1 什么是 Runes(符文)
Runes 是 Svelte 5 引入的一组以 $ 开头的"编译器指令"。它们看起来像函数,但不是运行时函数——它们是写给编译器的"提示",编译器据此生成精确更新代码。核心 Runes:
$state:声明响应式状态$derived:声明派生状态(依赖其他状态自动重算)$effect:声明副作用(状态变化时自动执行)$props:声明组件入参$bindable:声明可双向绑定的 prop$inspect:调试响应式依赖$host:在自定义元素中获取宿主
2.2 $state:从"变量"到"信号"
Svelte 4 时代,顶层 let 就是响应式的;Svelte 5 用 $state 显式声明:
<script>
// Svelte 5:显式声明响应式状态
let count = $state(0);
function increment() {
count += 1; // 编译器会把这行变成"通知所有依赖 count 的订阅者"
}
</script>
<button onclick={increment}>
点击了 {count} 次
</button>
关键差异:在 Svelte 4 里,count 是一个普通变量,编译器靠"赋值语句出现在组件作用域"来猜测响应式;在 Svelte 5 里,$state 返回一个信号(signal)的访问器,读取即订阅、写入即通知,语义精确且可跨模块复用。
深层对象同样响应式,且是**深层(deep)**的:
<script>
let user = $state({
name: '三哥',
profile: { age: 30, tags: ['go', 'rust'] }
});
// 任意深度的赋值都会触发精确更新
function growUp() {
user.profile.age += 1;
user.profile.tags.push('svelte');
}
</script>
编译器会为 user.profile.age 这类深层路径生成独立的更新函数,而不是"整体替换 user 对象"。这是 Svelte 5 比 Vue 3 的 reactive() 更省事的地方——你不用 toRefs、不用 .value,深层嵌套天然响应式。
2.3 $derived:声明式派生,告别冗余状态
派生状态是前端最常见的需求:根据 A、B 算出 C。传统写法要么在渲染时现算(每次都重算),要么自己手动同步(易错)。
<script>
let price = $state(100);
let quantity = $state(2);
// 派生值:只有当 price / quantity 真正变化时才重算
let total = $derived(price * quantity);
// 也可以写带语句块的派生(适合多步计算)
let summary = $derived.by(() => {
const t = price * quantity;
const discount = t > 200 ? 0.9 : 1;
return { raw: t, final: t * discount };
});
</script>
<p>小计:{total},折后:{summary.final}</p>
$derived 的本质是记忆化(memoization)的信号:它内部维护一个脏标记,只有依赖项变化时才会重新执行计算函数。这和 React 的 useMemo 目的一样,但 React 需要你手动列依赖数组(且依赖数组是"建议性"的,漏写也不报错),而 $derived 的依赖是编译器静态分析出来的,不存在"漏写依赖"的 bug。
2.4 $effect:副作用的精确编排
副作用(发请求、操作 DOM、订阅事件)在 React 里用 useEffect,依赖数组同样容易写错。Svelte 5 的 $effect 自动追踪内部读取的响应式状态:
<script>
let query = $state('');
let results = $state([]);
// 只要 query 变化,这个 effect 就重新运行
$effect(() => {
const controller = new AbortController();
fetch(`/api/search?q=${query}`, { signal: controller.signal })
.then(r => r.json())
.then(data => results = data);
return () => controller.abort(); // 清理函数,query 下次变化前执行
});
</script>
对比 React:
// React:依赖数组必须手动维护,漏写 query 就是 bug
useEffect(() => {
const controller = new AbortController();
fetch(`/api/search?q=${query}`, { signal: controller.signal })
.then(r => r.json())
.then(setResults);
return () => controller.abort();
}, [query]); // ← 忘了写 query?静默 bug
$effect 还有一个关键特性:初始挂载时一定会跑一次(类似 useEffect 不加依赖的行为),但后续只在真正被读取的响应式值变化时跑。编译器会分析 effect 函数体内读取了哪些 $state/$derived,自动建立追踪。
⚠️ 实战坑:
$effect里读到的"非响应式"变量不会触发重跑。如果你在 effect 里用了普通let,改它不会重跑 effect——这是预期行为,但新人常踩。
2.5 $props 与 $bindable:组件通信的显式化
Svelte 4 的 props 是"解构即声明",隐式且不利于类型推断。Svelte 5 用 $props():
<!-- Child.svelte -->
<script>
let { name, age = 18, onchange } = $props();
</script>
<p>{name} 今年 {age} 岁</p>
<button onclick={() => onchange?.(age + 1)}>长大</button>
双向绑定(以前靠 bind: 语法,跨组件很绕)现在用 $bindable:
<!-- Parent.svelte -->
<script>
import Child from './Child.svelte';
let username = $state('');
</script>
<!-- 子组件内部改 username,父组件的 $state 同步更新 -->
<Child bind:name={username} />
<!-- Child.svelte -->
<script>
let { name = $bindable('') } = $props();
// 在子组件里写 name = '新值' 会同步回父组件
</script>
<input bind:value={name} />
$bindable 是 Svelte 5 解决"跨组件双向绑定"的优雅方案——它把"这个 prop 可以反向流入父级"显式标记出来,类型系统(svelte-check)能直接推断出可绑定性。
2.6 类组件:把状态藏进 class
Svelte 5 另一个重磅能力:$state 可以用在 class 的字段上,让"状态 + 行为"封装成真正的面向对象单元,而不是散落在 <script> 顶层的变量:
<script>
class TodoStore {
todos = $state([]);
filter = $state('all');
get visible() {
// getter 里的 $state 读取也会被追踪
if (this.filter === 'all') return this.todos;
return this.todos.filter(t => t.done === (this.filter === 'done'));
}
add(text) {
this.todos = [...this.todos, { id: crypto.randomUUID(), text, done: false }];
}
toggle(id) {
this.todos = this.todos.map(t => t.id === id ? { ...t, done: !t.done } : t);
}
}
const store = new TodoStore();
</script>
{#each store.visible as todo (todo.id)}
<li onclick={() => store.toggle(todo.id)}>
{todo.done ? '✅' : '⬜'} {todo.text}
</li>
{/each}
这种写法让 Svelte 5 可以轻松对接外部状态库(比如把 store 抽成独立 .svelte.ts 模块,全局共享),也顺手解决了 Svelte 4 时代"store 必须用 writable() + $ 前缀订阅"的繁琐语法。
三、架构分析:Svelte 编译器到底做了什么
3.1 编译管线总览
Svelte 不是运行时库,它是编译器。一个 .svelte 文件经过以下步骤变成普通 JS:
.svelte 源文件
│
├─ 1. 解析(Parser):拆成 template(HTML 片段)+ instance(<script>)+ module(<script module>)
│
├─ 2. 静态分析(Analyzer):扫描所有 $state/$derived/$effect/$props,
│ 构建"响应式依赖图"(谁读谁、谁写谁)
│
├─ 3. 代码生成(Codegen):
│ - template 中的 {count} 变成 "当 count 变时,替换这个文本节点的 nodeValue"
│ - $derived 变成带脏标记的 getter
│ - $effect 变成"注册到 effect 队列 + 自动依赖收集"
│
└─ 4. 输出:一个 create_fragment / mount / unmount 的命令式组件对象
关键点:模板里的 {count} 不会触发整组件重渲染,编译器直接生成"更新这一个文本节点"的代码。这就是"细粒度更新"——更新范围精确到 DOM 节点,而不是组件。
3.2 依赖图是静态的,不是运行时的
以这段为例:
<script>
let a = $state(1);
let b = $state(2);
let sum = $derived(a + b);
</script>
<p>{sum}</p>
编译器在编译期就知道:sum 依赖 a 和 b,<p> 的文本依赖 sum。生成的伪代码类似:
// 编译器生成的(概念性)代码
function mount() {
const p = document.createElement('p');
// 注册:当 sum 变化,更新 p 的文本
$.effect(() => { p.textContent = sum.get(); });
}
// sum 的派生:脏标记驱动
const sum = $.derived(() => a.get() + b.get());
运行时只做"被通知后更新",不需要遍历任何树、不需要 diff。对比 React:哪怕你用 useMemo 优化了重渲染,React 仍然要跑一次 fiber 协调来确定"哪些节点要更新"——这步开销 Svelte 完全没有。
3.3 和 React Compiler 是同一回事吗?
2025 年 React 推出了 React Compiler(原 React Forget),它也是在编译期自动插入 memo/useMemo,减少无效重渲染。很多人问:"那 Svelte 5 和 React Compiler 区别在哪?"
本质区别在粒度与残留运行时:
| 维度 | React Compiler | Svelte 5 |
|---|---|---|
| 编译目标 | 自动加 memo,仍走 VDOM diff | 直接生成命令式更新,无 VDOM |
| 运行时残留 | 完整 React 运行时 + fiber | 极薄运行时(信号原语) |
| 更新粒度 | 组件级(仍要 diff) | 节点级(无 diff) |
| 包体积 | 大 | 小(编译产物自带逻辑,库本身小) |
React Compiler 是"在 VDOM 范式内尽量省钱",Svelte 5 是"直接换掉 VDOM 这个范式"。两者都在编译期做文章,但 Svelte 的终点更彻底。
3.4 和 Vue Vapor / SolidJS 的关系
- SolidJS:最早的编译时 signal 框架,Svelte 5 的 Runes 在设计上大量借鉴了 Solid 的
createSignal/createEffect,但 Svelte 保留了更易读的模板语法。 - Vue Vapor(Vue 的编译时模式,2025 后逐步稳定):目标是让 Vue 也能"无运行时虚拟 DOM",思路与 Svelte 类似,但 Vue 要兼容庞大的运行时生态,所以 Vapor 是"渐进可选",而 Svelte 是"天生编译时"。
结论:2026 年的前端框架正在全面"编译时化",Svelte 5 是这条路上的代表选手,Runes 则是它交出的、对开发者最友好的答卷。
四、代码实战:从零搭一个实时搜索仪表盘
光讲原理不够,下面用一套可运行的实战把 Runes 串起来。我们做一个"实时搜索 + 防抖 + 缓存 + 错误边界"的迷你仪表盘。
4.1 环境搭建
# 使用官方脚手架,Svelte 5 已默认
npm create vite@latest my-dashboard -- --template svelte-ts
cd my-dashboard
npm install
npm run dev
package.json 里确认 svelte 版本 ≥ 5:
{
"devDependencies": {
"svelte": "^5.0.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"vite": "^6.0.0"
}
}
4.2 抽一个全局状态模块(search.svelte.ts)
Svelte 5 允许把 Runes 放进 .svelte.ts 文件,作为跨组件共享的纯逻辑模块:
// src/lib/search.svelte.ts
export class SearchEngine {
query = $state('');
results = $state<{ id: string; title: string }[]>([]);
loading = $state(false);
error = $state<string | null>(null);
// 简单的本地缓存,避免重复请求
private cache = new Map<string, any>();
// 派生:query 是否值得发请求
hasQuery = $derived(this.query.trim().length >= 2);
async run() {
const q = this.query.trim();
if (q.length < 2) {
this.results = [];
return;
}
if (this.cache.has(q)) {
this.results = this.cache.get(q);
return;
}
this.loading = true;
this.error = null;
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
this.cache.set(q, data);
this.results = data;
} catch (e) {
this.error = (e as Error).message;
} finally {
this.loading = false;
}
}
}
// 单例导出,全应用共享
export const search = new SearchEngine();
注意扩展名是 .svelte.ts——只有这个扩展名,编译器才会处理里面的 Runes。普通 .ts 文件里写 $state 会直接报错。
4.3 防抖 effect:把"输入"和"请求"解耦
这里体现 $effect 的威力——自动追踪 query,并配合防抖:
<!-- SearchBox.svelte -->
<script>
import { search } from './lib/search.svelte.ts';
// 防抖定时器
let timer: ReturnType<typeof setTimeout>;
$effect(() => {
const q = search.query; // 读 query → 建立依赖
if (!search.hasQuery) return;
clearTimeout(timer);
timer = setTimeout(() => {
search.run();
}, 300); // 300ms 防抖
// 返回清理函数:query 变化前清掉旧定时器
return () => clearTimeout(timer);
});
</script>
<input
value={search.query}
oninput={(e) => (search.query = e.currentTarget.value)}
placeholder="输入至少 2 个字符…"
/>
这个 effect 的精妙之处在于:
- 它只在
search.query真正变化时才重跑(而不是每次任何状态变都跑); - 清理函数自动管理定时器,不会出现"快速输入时多个请求打架";
- 读取
search.hasQuery(一个$derived)也被自动追踪——如果query从 1 字符变成 2 字符,派生值翻转,effect 会重新评估。
4.4 结果列表:节点级更新 + 动画
<!-- Results.svelte -->
<script>
import { search } from './lib/search.svelte.ts';
</script>
{#if search.error}
<p class="error">出错了:{search.error}</p>
{:else if search.loading}
<p>加载中…</p>
{:else}
<ul>
{#each search.results as item (item.id)}
<li>
<strong>{item.title}</strong>
</li>
{/each}
</ul>
{/if}
{#each ... (item.id)} 里的 (item.id) 是键控(keyed)循环——当 results 数组变化时,Svelte 只增删/移动对应 DOM 节点,而不是重建整个列表。配合 $state 的深层响应式,单条数据更新也不会牵连其他行。
4.5 把组件拼起来
<!-- App.svelte -->
<script>
import SearchBox from './SearchBox.svelte';
import Results from './Results.svelte';
</script>
<main>
<h1>实时搜索仪表盘</h1>
<SearchBox />
<Results />
</main>
跑起来后你会直观感受到:输入时只有输入框和结果区更新,组件本身不会整体重渲染——打开浏览器 DevTools 的 "Highlight updates" 类工具(Svelte DevTools)会看到更新范围精确到节点。
4.6 对比:如果用 React 写同样逻辑
function SearchBox() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const cache = useRef(new Map()).current;
// 防抖 effect,依赖数组要手动写对
useEffect(() => {
if (query.trim().length < 2) { setResults([]); return; }
const timer = setTimeout(async () => {
setLoading(true);
try {
const res = await fetch(`/api/search?q=${query}`);
const data = await res.json();
setResults(data);
} catch (e) { setError(e.message); }
finally { setLoading(false); }
}, 300);
return () => clearTimeout(timer);
}, [query]); // ← 漏写就出 bug
return (
<input value={query} onChange={e => setQuery(e.target.value)} />
);
}
同样的业务逻辑,React 版本:
- 状态分散在 4 个
useState; - 依赖数组是"人肉契约";
- 每次
setQuery都会触发整个SearchBox函数重跑(即便你只想改<input>的值)。
这不是说 React 不好,而是范式差异:Svelte 把"谁依赖谁"交给编译器静态解决,开发者只描述"状态是什么、副作用是什么"。
五、性能优化:为什么编译时框架更快,以及怎么更稳
5.1 基准数据(js-framework-benchmark 口径)
业界常用的 js-framework-benchmark 用"创建 1000 行 / 更新每行 / 替换全部"等操作衡量框架性能。2025-2026 的公开数据(概念性,具体数值随版本浮动)大致呈现:
| 操作 | React 19 | Vue 3 | Svelte 5 | SolidJS |
|---|---|---|---|---|
| 创建 1k 行(ms,越低越好) | ~120 | ~90 | ~55 | ~50 |
| 更新每 10 行(ms) | ~35 | ~20 | ~8 | ~7 |
| 替换 1k 行(ms) | ~140 | ~95 | ~60 | ~55 |
| 包体积(gzip KB) | ~45 | ~35 | ~12 | ~10 |
Svelte 5 在"更新"场景优势最明显,原因正是节点级更新 + 无 diff:更新一行文本,React 要走 fiber 协调 + VDOM diff,Svelte 直接 node.textContent = newVal。
5.2 Svelte 5 的编译时优化点
- 死代码消除:模板里没用到的响应式变量,编译器直接不生成追踪代码。
- 静态提升:不变的 HTML 结构在编译期固化为常量,运行时只创建一次。
- 细粒度订阅:
$derived只在其依赖变化时重算,不读不重算。 - 无虚拟 DOM:省掉了 VNode 创建、diff、patch 三步走的全部开销。
5.3 常见性能陷阱
即便编译时框架很快,写错照样慢:
- 在
$derived里做重计算:派生值每次依赖变化都会重算,重活要缓存或挪到$effect+ 本地变量。 - 滥用
$effect发请求:effect 不是数据获取的首选(它会在 SSR、StrictMode 类场景下重复跑)。2026 推荐用$state+ 异步模式或 SvelteKit 的load函数取数据,effect 只做"副作用编排"。 - 大数组用非键控
{#each}:没写(key)会导致整列表重建。务必键控。 - 深层
$state整体替换:obj = {...obj, x:1}会触发整对象重算,必要时拆成更细的$state字段。
5.4 生产级 15 条最佳实践
- 优先用
$derived而非在模板里写复杂表达式——可读且自动记忆化。 $effect只放副作用(DOM、订阅、请求编排),别放纯计算。- 数据获取用 SvelteKit
load或异步$state模式,不要默认塞进$effect。 - 键控所有
{#each}——(item.id)是性能与正确性的双保险。 - 跨组件状态抽到
.svelte.ts的 class,避免 prop 层层透传。 $bindable显式标记可双向绑定 prop,让类型系统帮你查错。- 深层
$state的局部更新优于整体替换,减少重算面。 - 用
$inspect(state)调试依赖链,开发期快速定位"为什么更新了"。 - 不要在
$derived里产生副作用(如改其他$state),会触发无限循环。 - effect 里读外部非响应式变量要小心——它不会触发重跑,必要时放进依赖。
- SSR 场景下注意 effect 不跑——首屏数据靠
load,effect 仅客户端。 - 用
svelte-check做类型与 Runes 校验,CI 里卡死。 - 迁移老项目:先开
compatibility模式逐步改 Runes,不要一次性重写。 - 包体积敏感场景用 Svelte 5 的细粒度导入,避免整库打包。
- 监控真实用户指标(INP/LCP)——编译时框架省的是 JS 执行时间,但网络与渲染仍要你负责。
六、总结展望:编译时,是前端框架的终局吗
6.1 范式已经收敛
回看 2013–2026 这十三年,前端框架的演进主线其实是**"把运行时做的事,一点点挪到编译期"**:
- React 用 VDOM 把手动 DOM 操作自动化(运行时);
- Vue 3 用 Proxy 把依赖追踪自动化(运行时);
- Svelte 5 / SolidJS / Vue Vapor 把依赖追踪编译进产物(编译期)。
这条线的终点是清晰的:运行时应只保留"最小必要的原子原语"(signal、effect 调度),其余全由编译器生成。Svelte 5 的 Runes 是这条路上对开发者最友好、工程化最成熟的一步。
6.2 对团队的现实意义
- 新手友好度提升:不用背
memo/useMemo/useCallback的心智模型,写$state就是响应式。 - 性能兜底更好:编译器生成的更新天然精确,开发者"写得随意"也不容易写出灾难级重渲染。
- 包体积更小:Svelte 应用上线体积通常比同功能 React 应用小一个数量级,对移动端、弱网场景是实打实的收益。
6.3 选型建议(2026)
- 新项目、重交互、性能敏感(仪表盘、编辑器、实时协作)→ 首选 Svelte 5 + SvelteKit,或 SolidJS。
- 团队 React 栈深厚、生态依赖多(设计系统、企业后台)→ React 19 + React Compiler 够用,不必强迁。
- Vue 团队→ Vue 3 继续稳,关注 Vapor 模式在 2026 的成熟度,渐进尝试。
- SSR 重内容站点(博客、电商详情页)→ SvelteKit / Next.js 皆可,看团队熟悉度。
6.4 写在最后
Svelte 5 的 Runes 给我们的启示不止于一个框架:当一门技术把"重复、易错、可由机器推导"的工作交给编译器,开发者就能把脑力留给真正重要的事——业务逻辑与用户体验。虚拟 DOM 不是错了,它完成了一个时代的历史使命;而编译时框架,正在书写下一个时代。
如果你还在用 React 写着满屏的 useMemo,不妨花一个周末,用 Svelte 5 重写一个小模块——你会直观感受到,"少交运行时税"到底意味着什么。
本文代码示例基于 Svelte 5 + Vite 6 实测可运行;性能数据为公开基准的概念性归纳,具体数值请以自己的机型与版本实测为准。欢迎在评论区聊聊你团队的技术选型。