编程 Svelte 5 深度拆解:当编译时框架学会「显式响应式」——Runes 符文、信号机制与零虚拟 DOM 如何重写前端性能心智模型

2026-07-16 03:12:49 +0800 CST views 7

Svelte 5 深度拆解:当编译时框架学会「显式响应式」——Runes 符文、信号机制与零虚拟 DOM 如何重写前端性能心智模型

如果你在 2024 年以前用过 Svelte,一定对这个画面不陌生:在组件里写 let count = 0,写 $: doubled = count * 2,然后浏览器里的数字就自动跟着变。没有 setState,没有 ref,没有 useMemo——这一切靠的是 Svelte 在编译阶段对代码做的静态分析。

这套"编译时响应式"当年是 Svelte 的护城河:别人还在运行时里跑虚拟 DOM diff,Svelte 已经把组件直接编译成操作真实 DOM 的命令式代码,几乎零运行时。但静态分析有个绕不过去的死穴——它只能看懂编译器自己能看懂的依赖。一旦依赖藏在函数里、藏在嵌套对象里、藏在 .js 文件里,编译器就瞎了。

Svelte 5 的解法不是打补丁,而是换一套心智模型:引入 Runes(符文),把隐式的编译时依赖分析,升级为显式声明 + 信号(signal)驱动的细粒度响应式。这篇文章我们从背景、核心概念、编译器架构、代码实战到性能优化,把 Svelte 5 的响应式系统彻底拆开。


一、背景:前端响应式的三条路线,以及 Svelte 4 撞上的墙

要理解 Svelte 5 为什么这么改,得先看清前端响应式经过十年演进,沉淀出的三条路线。

路线一:运行时 + 虚拟 DOM(React / Vue 3)。 你写的是"菜谱"——声明式的组件描述。框架把运行时代码打包进产物,在用户浏览器里"现场做菜":状态变化 → 生成新虚拟 DOM → 和旧树 diff → 把差异打到真实 DOM。优点是心智简单、生态庞大;代价是运行时体积和 diff 开销一直存在,再怎么优化也是"把开销降到可忽略",开销本身跑不掉。

路线二:编译时(Svelte 4)。 你写的是"菜谱",但 Svelte 在你电脑上就把菜做好了——构建阶段把组件编译成直接操作 DOM 的原生 JS,产物里没有 Svelte 运行时,只有你的业务逻辑。没有虚拟 DOM,没有 diff。

路线三:信号(Solid / Preact Signals / Vue 的响应式内核)。 把状态拆成一个个细粒度的"信号",读取信号时自动建立依赖,写入信号时只通知真正依赖它的那一个节点重算。Solid 把这个模型做到极致:组件函数只跑一次,之后更新精确到单个文本节点。

Svelte 4 走的是路线二,但它底层其实还是"组件级更新":一个 let 变量变了,编译器会重跑整个组件函数里相关的赋值块,再局部更新 DOM。问题在于,编译器靠静态分析来推断"哪些变量依赖哪些变量",而静态分析有硬边界。

典型的三道墙:

1. 依赖藏在函数里,编译器看不懂。 下面这段 Svelte 4 代码,content 不会随 count 更新——因为 count 没出现在 $: 表达式里:

<script>
  let count = 0;
  function getContent() {
    return `Total: ${count}`; // count 是函数体内的读取,编译器静态分析不到
  }
  let content = getContent();
</script>
<button on:click={() => count += 1}>{content}</button>

2. 响应式不能跨普通 .js 文件。 你想把"计数器"抽成一个可复用的 store,放在 .js 里用——抱歉,Svelte 4 的响应式只在 .svelte 组件内有效,.js 里只能用 writable() 这套 store API,和组件里的响应式是两套体系。

3. TypeScript 不友好,类组件里用不了。 let count = 0 在类型层面就是个普通 number,不是真正类型安全的 signal;而在 class 字段上,编译时分析更是无能为力。

这三道墙的本质是同一件事:响应式系统是被编译器"猜"出来的,而不是被你"声明"出来的。 Svelte 5 的 Runes 就是把"猜"变成"声明"——你用 $state$derived$effect 显式告诉编译器哪里是响应式的,于是一切边界都打开了。


二、核心概念:四个符文如何撑起一套响应式系统

Runes 是以 $ 开头的"编译指令",只存在于 Svelte 5 的 runes 模式(组件里用到任意一个 rune,整个组件自动进入 runes 模式;也可以在 svelte.config.jscompilerOptions: { runes: true } 强制全局开启)。

2.1 $state:从"变量"到"信号"

最基础的符文。它把一个普通变量变成响应式状态:

<script>
  let count = $state(0);

  function increment() {
    count += 1; // 写入即通知依赖更新
  }
</script>

<button onclick={increment}>点了 {count} 次</button>

关键点在于 $state 对不同类型的处理完全不同

  • 基础类型(number / string / boolean / null):编译成一个 signal(source + getter/setter 对)。读取走 getter,写入走 setter(setter 会 bump 版本号并通知订阅者)。
  • 对象 / 数组:Svelte 5 默认做深层代理(deep proxy)。你读写 user.namelist[3] 这种嵌套属性,都会被精确追踪。换句话说,深对象里的任意一层属性变化,都能触发只依赖那一层的更新。
  • $state.raw():跳过深代理,只追踪"整体被重新赋值"。当你有一个上万条的大数组、且只会整体替换时,用 $state.raw 能省掉为每一层套代理的开销——代价是你必须重新赋值(data = newData)才能触发更新,改 data[0] = x 不会生效。

经验法则:默认用 $state(深代理最省心);只有"大数组/大对象且只整体替换"时,才用 $state.raw 换性能。

对比 Svelte 4 的 let count = 0$state(0) 在类型层面就是响应式的——TS 能正确推断它是 signal 包裹的值,不再有"明明是普通变量却会自己变"的违和感。

2.2 $derived:惰性 + 记忆化的派生状态

派生状态在 Svelte 5 里用 $derived 显式声明。它和 Svelte 4 的 $: 块最大的不同有两点:依赖是运行时求值追踪的(而非编译时静态分析的),且可以在任意作用域(函数、循环、if 分支里)使用

<script>
  let price = $state(100);
  let qty = $state(2);

  // 单行表达式
  let total = $derived(price * qty);

  // 多行逻辑用 $derived.by
  let discount = $derived.by(() => {
    if (total > 500) return total * 0.9;
    if (total > 200) return total * 0.95;
    return total;
  });
</script>

<p>应付:{discount}</p>

$derived 有两个被低估的价值:

  • 惰性:只有当 priceqty 真的变了,才会重算;值没变就不会通知下游。这相当于免费送了你一个 useMemo
  • 记忆化 + 短路:如果重算后的值和上次相等(引用/值相等),下游不会触发更新。所以即便你把一个派生值接了十层,只要最终值没变,链路就停下。

2.3 $effect:自动依赖追踪的副作用

$effect 用来处理"状态变化时要做的副作用"——操作 DOM、读写 localStorage、订阅浏览器 API、发请求等等。它首次挂载后运行一次,之后只要它读取过的 signal 变了,就自动重跑

<script>
  let theme = $state('light');

  $effect(() => {
    // 这里读取了 theme,于是 theme 变化时 effect 自动重跑
    document.body.dataset.theme = theme;
    localStorage.setItem('theme', theme);
  });
</script>

注意几个规则:

  • 只用来做副作用,不要用来"计算值"。要算值请用 $derived,否则容易出现"effect 改了自己依赖的 signal"的死循环。
  • $effect.pre:在 DOM 更新之前运行(适合需要在浏览器绘制前读布局的场景)。
  • $effect.root(fn):创建一个脱离组件生命周期的 effect(比如在一个普通模块里手动管理),必须手动调用返回的 destroy() 清理。
  • 死循环保护:如果你在 effect 里写了"改自己依赖的 signal"的逻辑,Svelte 会在短时间内检测到过多更新并抛出 ERR_SVELTE_TOO_MANY_UPDATES。这是框架在救你,不是 bug。

一个真实的坑(Svelte 官方 issue #9639):在 $effect 里用嵌套赋值去"同步"派生字段,会触发死循环:

<script>
  let note = $state({ contents: 'hi', lowercased: '' });

  // ❌ 错误:effect 读了 note.contents,又写了 note.lowercased,
  // 而 note 是深代理,写 lowercased 会 bump 整个 note 的版本,
  // 反过来又触发这个 effect → 死循环
  $effect(() => {
    note.lowercased = note.contents.toLowerCase();
  });
</script>

正确写法是用 $derived(纯计算,不进 effect):

<script>
  let note = $state({ contents: 'hi' });
  let lowercased = $derived(note.contents.toLowerCase()); // ✅
</script>

2.4 $props / $bindable:组件通信显式化

Svelte 4 用 export let 接收 props,Svelte 5 改用 $props(),并通过解构拿到每个属性,类型更清晰:

<script>
  let { title, value = 0, onchange } = $props();
</script>

<h2>{title}: {value}</h2>

双向绑定用 $bindable() 包裹,让父组件能真正"反向"修改子组件里的这个 prop:

<!-- Child.svelte -->
<script>
  let { model = $bindable() } = $props();
</script>
<input bind:value={model} />

<!-- Parent.svelte -->
<script>
  let name = $state('');
</script>
<Child bind:model={name} />
<p>父组件里也能读到:{name}</p>

小结:Svelte 5 = 编译时 + 信号

到这里可以点题了:Svelte 5 把 runes 编译成它自己的一套细粒度信号实现(基于版本号的 push-pull 混合模型)。它既有 Solid 那种"精确到单个节点"的细粒度更新能力,又保留了 Svelte 自己的编译时优势(零运行时、产物直接操作 DOM)。这是它和 Svelte 4 最本质的区别——从"组件级更新"跃迁到"信号级更新"


三、架构分析:编译器到底把 $state 变成了什么

光会用符文不够,理解编译器产物,才能在性能出问题时知道根因。下面我们用一个计数器,看 Svelte 5 大致把它编译成了什么(示意,便于理解本质,非逐字产物):

// 编译前(开发者写的)
let count = $state(0);
count += 1;

// 编译后(示意,Svelte 内部用 source/get/set 实现)
import { source, get, set } from 'svelte/internal/client';

const count = source(0);
set(count, get(count) + 1);

核心是三个机制:

3.1 依赖追踪:全局"当前反应"栈

Svelte 5 内部维护一个全局栈 current_reaction(当前正在执行的 effect / derived)。规则很简单:

  • 当你读取一个 signal(调用 getter),如果此刻 current_reaction 不为空,就把这个 signal 登记为当前 reaction 的依赖,同时把当前 reaction 登记为该 signal 的订阅者。
  • 当你写入一个 signal(调用 setter),bump 它的 version,并通知所有订阅者:"我变了"。

这就是 "push-pull 混合":写入时主动 push 通知订阅者(push 阶段),订阅者被标记为 dirty;等到真正读取/flush 时,再按需 pull 最新值并校验 version(pull 阶段)。这比纯 push(每次写入立即全量重算)省,比纯 pull(每次都从头遍历)准。

3.2 批处理:写入入队,microtask 统一 flush

如果你在一个事件里连改五个 signal,Svelte 不会重跑五次 effect。写入只是把 signal 标脏并入队,等到当前同步代码跑完、microtask 阶段统一 flush:脏的 effect 一次性重算,DOM 一次性更新。这正是为什么 Svelte 5 在快速连续更新时依然流畅——没有"抖动"。

3.3 $derived 的 DAG 与环检测

派生值之间可以像函数一样互相引用,形成一张有向无环图(DAG)。Svelte 在计算一个 $derived 时,会临时把它的 reaction 压入全局栈;如果计算过程中发现了自己正在被读取(A 依赖 B,B 又读 A),就会抛出一个循环依赖错误。这把"无限递归"在开发期就暴露出来,而不是让你线上卡死。

3.4 与 Svelte 4 的根本区别:组件级 → 信号级

这是性能故事的真正主角。Svelte 4 是组件级更新:一个 let 变量变,编译器会重跑整个组件函数里相关的赋值块,再局部更新 DOM 指令。Svelte 5 是信号级更新:只有真正读取了那个 signal 的 effect / 文本节点 / 属性绑定,才会重算和更新。一个组件里有 100 个状态,改其中一个,另外 99 个纹丝不动——连"重跑组件函数"这步都省了。

3.5 服务端:universal reactivity 与 hydration 快照

Svelte 5 的另一大升级是通用响应式(universal reactivity)。同一套 runes 代码在客户端和服务端都能"运行":SSR 时用信号生成 HTML;$effect 在服务端不执行(没有浏览器环境);而 hydration 阶段,Svelte 5 会利用 SSR 时序列化的状态快照(snapshot) 恢复客户端信号,让首屏状态天然正确,不需要客户端重新跑一遍初始化逻辑。这思路上接近 Qwik 的"可恢复性(resumability)",但 Svelte 5 仍是 hydration 模型——它解决的是"首屏状态正确"而非"零 JS 启动"。

3.6 .svelte.js:让 store 也能用 runes

前面说 Svelte 4 的响应式进不了 .js 文件。Svelte 5 用一个独立的 compileModule API 编译 .svelte.js 模块,于是你可以用 runes 写可复用的状态逻辑,跨组件共享,且和生产代码用同一套响应式机制:

// counter.svelte.js
export const counter = $state({ count: 0 });

export function increment() {
  counter.count += 1;
}

任何组件 import { counter, increment } 即可,状态是真正的全局响应式。


四、代码实战:从计数器到真实数据层

4.1 环境与最小计数器

用官方新 CLI 创建项目:

npx sv create my-app
cd my-app
npm install
npm run dev

Svelte 4 vs Svelte 5 的计数器对照:

<!-- Svelte 4 -->
<script>
  let count = 0;
  function increment() { count += 1; }
</script>
<button on:click={increment}>{count}</button>

<!-- Svelte 5(runes) -->
<script>
  let count = $state(0);
  function increment() { count += 1; }
</script>
<button onclick={increment}>{count}</button>

注意事件属性名从 on:click 变成了 onclick(Svelte 5 统一向标准 DOM 事件语法靠拢)。

4.2 深层响应式与列表操作

深代理让数组/对象操作天然响应式,无需不可变更新:

<script>
  let todos = $state([
    { id: 1, text: '写文章', done: false },
    { id: 2, text: '发版', done: true },
  ]);

  function toggle(id) {
    const t = todos.find(t => t.id === id);
    t.done = !t.done; // 深代理自动追踪,UI 实时更新
  }

  function add(text) {
    todos.push({ id: Date.now(), text, done: false }); // 不用返回新数组
  }
</script>

{#each todos as t (t.id)}
  <label>
    <input type="checkbox" checked={t.done} onchange={() => toggle(t.id)} />
    {t.text}
  </label>
{/each}

4.3 派生状态管线:过滤 + 统计 + 排序

<script>
  let todos = $state([/* ... */]);
  let keyword = $state('');
  let onlyUndone = $state(false);

  let filtered = $derived.by(() => {
    return todos
      .filter(t => t.text.includes(keyword))
      .filter(t => onlyUndone ? !t.done : true)
      .sort((a, b) => a.done - b.done);
  });

  let stats = $derived({
    total: todos.length,
    undone: todos.filter(t => !t.done).length,
    shown: filtered.length,
  });
</script>

<p>共 {stats.total} 条,未完成 {stats.undone},当前展示 {stats.shown}</p>
<input bind:value={keyword} placeholder="搜索" />
<label><input type="checkbox" bind:checked={onlyUndone} /> 只看未完成</label>
{#each filtered as t (t.id)}<li>{t.text}</li>{/each}

这里 filteredstats 都是 $derived:输入没变就零成本缓存,输入变了才重算。当 todos 很大时,这套管线的代价远低于每次手动 useMemo 还容易写错依赖数组。

4.4 副作用实战:localStorage 同步 + 自动聚焦

<script>
  let name = $state(localStorage.getItem('name') ?? '');

  // 状态 → 副作用(写回存储)
  $effect(() => {
    localStorage.setItem('name', name);
  });

  let inputEl;
  // DOM 挂载后自动聚焦
  $effect(() => {
    inputEl?.focus();
  });
</script>

<input bind:this={inputEl} bind:value={name} />

4.5 跨模块全局 store

// theme.svelte.js
export const theme = $state({ mode: 'light' });

export function toggleTheme() {
  theme.mode = theme.mode === 'light' ? 'dark' : 'light';
}

任意组件:

<script>
  import { theme, toggleTheme } from './theme.svelte.js';
</script>
<button onclick={toggleTheme}>当前:{theme.mode}</button>

4.6 综合案例:带防抖的实时搜索面板

把前面知识点串成一个能用的搜索面板:输入 → 防抖 → 过滤 → 统计,全程 runes:

<script>
  // 全部数据(真实项目里可能来自接口)
  let items = $state([
    { id: 1, name: 'PostgreSQL', tag: 'db' },
    { id: 2, name: 'Redis', tag: 'cache' },
    { id: 3, name: 'Svelte', tag: 'fe' },
    { id: 4, name: 'Rust', tag: 'lang' },
    { id: 5, name: 'Kubernetes', tag: 'cloud' },
  ]);

  let rawInput = $state('');      // 用户实时输入
  let query = $state('');         // 防抖后的查询词

  // 防抖:rawInput 变 → 300ms 后写 query
  $effect(() => {
    const value = rawInput;
    const t = setTimeout(() => { query = value; }, 300);
    return () => clearTimeout(t); // 清理函数,下次 rawInput 变化前执行
  });

  // 派生过滤结果(仅在 query 变时重算)
  let results = $derived(
    items.filter(i => i.name.toLowerCase().includes(query.toLowerCase()))
  );

  let hint = $derived(results.length === 0 ? '无匹配结果' : `匹配到 ${results.length} 条`);
</script>

<input bind:value={rawInput} placeholder="搜索技术栈…" />
<p>{hint}</p>
<ul>
  {#each results as i (i.id)}
    <li>{i.name} <small>[{i.tag}]</small></li>
  {/each}
</ul>

注意 $effect 返回的 clearTimeout 清理函数:这是 Svelte 5 的官方模式,effect 重跑前会先执行上次返回的清理函数,天然实现防抖。整条链路没有 useMemo、没有 useEffect 依赖数组、没有手动防抖 hook——全靠 runes 的组合。


五、性能优化:快在哪、坑在哪

5.1 细粒度 vs 组件级:为何快

在 js-framework-benchmark 这类基准里,Svelte 和 Solid 长期霸榜 "create rows / update rows / swap rows" 三项,核心原因相同:没有虚拟 DOM diff。Svelte 5 信号级更新后,改一个文本节点就是改一个文本节点,不触发任何"整棵组件树重跑"或"新旧 VDOM 树对比"。对于高频更新(表格、动画、实时数据流),差距会被放大。

5.2 $derived 的惰性是免费缓存

把任何"由其他状态算出来"的值都写成 $derived,而不是在 $effect 里赋值、更不是在渲染表达式里裸算。前者是记忆化的、精准的;后者要么反复重算,要么容易踩死循环。

5.3 大对象用 $state.raw 换性能

深代理很爽,但给一个 1 万条的大数组逐层套 Proxy 是有成本的。如果你的数据"只读 + 整体替换"(典型如接口返回后整体 setData(newList)),用 $state.raw 跳过深代理,只在整体赋值时触发一次更新,性能更好。

let bigList = $state.raw([]);
async function load() {
  bigList = await api.fetchAll(); // 必须整体重新赋值
}

5.4 不要在 $effect 里做重计算

$effect 的语义是"副作用",不是"计算值"。在里面写 results = compute() 既容易死循环,又丢失了 $derived 的记忆化优势。正确分工:计算交给 $derived,副作用(DOM/存储/订阅)交给 $effect

5.5 批处理时机与 flush

连续多次写入会被合并到 microtask 统一 flush,所以你不必手动 batch()。但如果你的逻辑依赖"flush 之后"的 DOM 状态(比如读取元素尺寸),用 $effect.pre(绘制前)或 $effect(绘制后)区分清楚。

5.6 包体积:几乎零运行时

因为响应式在编译期就展开成命令式代码,Svelte 5 的产物里框架运行时代码极少。一个简单的 Svelte 应用可能只有几 KB,而同等功能的 React 应用光框架就要 40KB+。对移动端、低带宽、首屏敏感场景,这是实打实的优势。

5.7 渐进迁移

Svelte 5 允许 runes 模式与 legacy 模式共存于同一项目:组件只要用了任意 rune 就进入 runes 模式,没用就保持 legacy。迁移策略是"先开 compilerOptions: { runes: true } 强制全局 → 逐个组件把 export let 改成 $props()、把 let x = 0 改成 $state(0)、把 $: 改成 $derived/$effect"。官方还提供 svelte-migrate 做大部分机械替换。

5.8 常见陷阱清单

  1. $effect 里改自己读取的依赖 → ERR_SVELTE_TOO_MANY_UPDATES 死循环。改用 $derived
  2. $derived 当函数调用(total())——它是值不是函数。
  3. 大数组滥用深代理 → 用 $state.raw
  4. class 字段忘记 $state → 普通字段不会触发更新。
  5. on:click 写成 Svelte 4 语法 → Svelte 5 用 onclick

六、总结与展望

Svelte 5 的 Runes 不是语法糖,而是一次响应式范式的升维:它把 Svelte 从"靠编译器猜依赖的编译时框架",升级为"显式声明 + 信号驱动的细粒度响应式框架",同时守住了编译时零运行时的老本。

它给我们的工程启示很清晰:

  • 显式优于隐式。 当系统的复杂度超过编译器的静态分析能力,显式声明(runes)比"自动推断"更可靠、更可维护、对 TypeScript 更友好。
  • 细粒度是性能天花板的钥匙。 信号级更新让我们在"高频交互 + 大数据"场景下,不用靠手动 memo 去赌 diff 开销。
  • 编译时与运行时不是二选一。 Svelte 5 证明:用编译时把信号"展开"成原生代码,可以同时拿到 Solid 的细粒度和 Svelte 的零运行时。

选型上,如果你的项目是性能敏感、包体积敏感、追求开发体验,Svelte 5 是 2026 年非常值得认真考虑的选择;大型团队若已有深厚 React/Vue 积累,迁移成本要权衡,但新项目完全可以从 Svelte 5 起步。

展望未来,Svelte 5 的 universal reactivity 已经为 SSR/hydration 的进一步优化铺好路;随着编译时能力的持续进化,我们大概率会看到更多"运行时逻辑被前移到构建期"的能力——比如更智能的派生图裁剪、更激进的死代码消除。前端框架的战场,正在从"谁的运行更快"转向"谁能让运行时更薄"。Svelte 5,已经站在了这条路的更前面。

推荐文章

服务器购买推荐
2024-11-18 23:48:02 +0800 CST
五个有趣且实用的Python实例
2024-11-19 07:32:35 +0800 CST
前端如何给页面添加水印
2024-11-19 07:12:56 +0800 CST
程序员茄子在线接单