Svelte 遗留模式深度解析:响应式 `let/var` 声明的运作原理、编译机制与向 Runes 迁移
本文聚焦 Svelte 5 中遗留(legacy)模式下「顶层 let/var 声明自动响应式」这一机制:它为何能触发 UI 更新、为什么 .push() 这类数组方法不能触发更新而必须自赋值(numbers = numbers)、编译器在底层如何把普通变量转换为响应式信号,以及如何平滑迁移到 runes 模式的 $state。读完后你能够解释遗留响应式的完整链路(从 <script> 中的声明,到 $.mutable_source 信号,再到组件更新),并准确判断哪些写法会导致「UI 不更新」的坑。
遗留模式与 runes 模式:两种响应式范式的分界
Svelte 5 引入了 runes($state、$derived、$effect 等)作为新的 API 体系,Svelte 3/4 的部分语法因此被标记为 deprecated(当前仍可用,最终会移除)。官方建议增量式迁移现有代码,参见 v5 迁移指南。
按照 遗留特性总览 的说法:
- 遗留模式的文档面向两类读者:仍在用 Svelte 3/4 的人,以及已在 Svelte 5 中但部分组件尚未迁移的人;
- Svelte 3/4 的语法在 Svelte 5 中仍然可用,因此官方刻意区分 legacy mode(遗留模式)与 runes mode(runes 模式);
- 一旦组件进入 runes 模式——通过使用任意 rune,或显式设置
runes: true编译器选项——该组件内的 legacy 特性将不再可用。
本文的主角是遗留模式的核心能力:顶层 let/var 声明自动获得响应式。
顶层 let 声明:自动成为响应式状态
在 runes 模式中,响应式状态必须显式用 $state rune 声明。而在遗留模式中,组件顶层声明的变量会被自动视为响应式变量:对其重新赋值或就地修改(count += 1 或 object.x = y)都会导致 UI 更新。
典型示例(即 01-legacy-let.md 的原始示例):
<script>
let count = 0;
</script>
<button on:click={() => count += 1}>
clicks: {count}
</button>
这里没有任何「响应式标记」:let count = 0 只是普通 JS 声明,但点击按钮时模板中的 {count} 会随 count += 1 自动刷新。这正是遗留模式与 runes 模式最根本的差异——响应性由「声明位置 + 编译器分析」隐式赋予,而非由作者显式标注。
注意前提:自动响应式只作用于组件顶层的变量声明。声明在函数内部或条件块内部的变量不具备此能力;此外遗留模式的响应式建立在「赋值」(assignment)之上,这一事实直接引出下一节的坑。
关键陷阱:.push()/.splice() 不触发更新,必须自赋值
遗留模式的响应式以赋值为触发条件。因此像 .push()、.splice() 这类「就地修改」数组元素的方法,不会自动触发更新——必须跟随一次赋值来「告知」编译器需要更新 UI:
<script>
let numbers = [1, 2, 3, 4];
function addNumber() {
// this method call does not trigger an update
numbers.push(numbers.length + 1);
// this assignment will update anything
// that depends on `numbers`
numbers = numbers;
}
</script>
这里 numbers = numbers(把同一个引用赋回给自己)看似无意义,却是遗留模式约定俗成的「刷新信号」:编译器只关心「左值发生了赋值」,从而标记信号为脏、让所有读取 numbers 的模板表达式重新求值。
源码视角:编译器为什么只认赋值
编译器在分析阶段会为遗留组件顶层的 let/var 声明打上 state 绑定标记。到客户端代码生成阶段,VariableDeclaration.js 中非 runes 分支的逻辑会检查声明的绑定类型(binding.kind === 'state'),并调用 create_state_declarators() 把普通变量改写为响应式信号:
// packages/svelte/src/compiler/phases/3-transform/client/visitors/VariableDeclaration.js
declarations.push(
...create_state_declarators(
declarator,
context,
/** @type {Expression} */ (declarator.init && context.visit(declarator.init))
)
);
而 create_state_declarators() 最终生成的就是 $.mutable_source(value, immutable) 调用(同文件 L398-L436)。也就是说,let count = 0 在编译产物中等价于一个可调用的信号包装,所有对 count 的读/写都被重定向到信号上——这就是「赋值触发更新」的实现基础:写操作会执行 set,读操作会订阅信号变化。
mutable_source 与 mutate:自赋值如何生效
运行时实现位于 sources.js。遗留模式的 mutable_source 与 runes 模式的 state 有两点关键区别:
// packages/svelte/src/internal/client/reactivity/sources.js
export function mutable_source(initial_value, immutable = false, trackable = true) {
const s = source(initial_value);
if (!immutable) {
s.equals = safe_equals;
}
// bind the signal to the component context, in case we need to
// track updates to trigger beforeUpdate/afterUpdate callbacks
if (legacy_mode_flag && trackable && component_context !== null && component_context.l !== null) {
(component_context.l.s ??= []).push(s);
}
return s;
}
s.equals = safe_equals:非不可变模式下用深度相等判断,保证对象/数组内容的变化也能被识别;- 当处于遗留模式时,该信号会被推入组件上下文(
component_context.l.s),用于跟踪更新以触发beforeUpdate/afterUpdate生命周期回调——这是遗留模式特有的行为,runes 模式的信号不做此绑定。
而对于 numbers = numbers 这类「同引用自赋值」,同文件的 mutate() 函数 负责兜底:
export function mutate(source, value) {
set(
source,
untrack(() => get(source))
);
return value;
}
它先 untrack 地取出当前值,再执行一次 set——即便新值与旧值相同也会走通知路径,从而让「依赖 numbers 的一切」重新计算。这正是文档示例中 numbers = numbers 一行能真正刷新 UI 的底层原因。
另一面:invalidate_inner_signals 手动失效
除自赋值外,遗留模式还提供手动失效工具。legacy.js 中实现了 invalidate_inner_signals():它先执行 fn,通过 capture_signals() 捕获执行期间被读到的所有信号(内部依赖全局变量 captured_signals),再逐个调用 internal_set(signal, signal.v) 强制标记为脏。这是遗留模式「按信号粒度手动刷新」的运行时基础,与 runes 模式下由编译器静态跟踪依赖的方式形成对比。
与 $: 响应式语句的关系
遗留模式还有另一半:$: 响应式语句(reactive declarations),详见 02-legacy-reactive-assignments.md。顶层语句前缀 $: 后会在依赖变化时重新执行,例如:
<script>
let a = 1;
let b = 2;
$: console.log(`${a} + ${b} = ${sum}`);
$: sum = a + b;
</script>
理解两者的分工很重要:
- 本文的「响应式
let」解决的是「状态从哪来」——顶层变量自动成为响应式源; $:语句解决的是「状态如何联动」——编译期做依赖分析(语句中只读不改的变量即依赖),并按拓扑序执行。
依赖是编译期确定的,因此间接引用会失效:$: doubled = double()(double 内部读 count)不会在 count 变化时重跑,因为编译器「看不见」这个依赖。此外 $: 语句在服务端渲染阶段也会执行,浏览器专属代码需要显式用 if (browser) 包裹。
在 runes 模式中,这套组合对应 let count = $state(0) + $derived/$effect。
遗留 let 与 export let 的边界
同属遗留模式顶层声明的还有 props 声明:带 export 关键字的 let 成为可接收默认值的 prop(见 03-legacy-export-let.md)。从源码结构看,VariableDeclaration.js 的非 runes 分支先取声明的绑定列表,若其中存在 bindable_prop 类型的绑定就走 prop 路径(生成 $.prop 来源),否则才走 state 路径(mutable_source)——即「export let 是 prop,普通 let 是状态」这一语义在编译器中是明确分流的。
顺带一提,遗留模式还有 $$props/$$restProps(04-legacy-$$props-and-$$restProps.md)等周边语法,迁移时可对照 遗留特性文档目录 逐项核对。
从遗留模式迁移到 runes:$state 对照
当组件进入 runes 模式后,本文所述机制不再可用,等价写法如下:
<!-- 遗留模式 -->
<script>
let count = 0;
let numbers = [1, 2, 3, 4];
</script>
<!-- runes 模式 -->
<script>
import { $state } from 'svelte';
let count = $state(0);
let numbers = $state([1, 2, 3, 4]);
</script>
从源码看,两者生成的信号类型也不同:runes 分支在 VariableDeclaration.js 中对 $state 声明调用 $.state(value),并对可代理的值包裹 $.proxy(value)——这意味着 runes 模式的 $state 通过代理(proxy)机制使 .push() 等就地修改天然可追踪,不需要 numbers = numbers 这样的自赋值;而遗留模式依赖 mutable_source 的整体式更新,这正是两者在数组更新行为上表现不同的根源。
生命周期钩子也随之变化:onMount/afterUpdate 等遗留钩子在 runes 组件中不可用,对应 $effect(legacy lifecycle hooks 文档 中有说明)。整体迁移步骤与 runes: true 的渐进启用策略参见 v5-migration-guide。
小结与实践建议
- 自动响应式有严格边界:只覆盖遗留组件「顶层」
let/var,且只认赋值;.push()/.splice()/深层属性修改后,按文档约定补一次自赋值(numbers = numbers)或换用$:语句派生。 - 底层链路已验证:
let count = 0→ 编译器分析打state标记(2-analyze)→ 客户端生成为$.mutable_source(VariableDeclaration.js)→ 运行时信号绑定组件上下文以驱动beforeUpdate/afterUpdate(sources.js)→ 自赋值经mutate()强制通知(sources.js)。 - 迁移路径清晰:
let x = 0→let x = $state(0);$:语句 →$derived/$effect;export let→$props。单组件可通过使用 rune 或runes: true编译器选项独立切换,新旧组件可并存,便于增量迁移。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00