首页
/ Svelte 遗留模式深度解析:响应式 `let/var` 声明的运作原理、编译机制与向 Runes 迁移

Svelte 遗留模式深度解析:响应式 `let/var` 声明的运作原理、编译机制与向 Runes 迁移

2026-09-06 13:34:38作者:沈韬淼Beryl

本文聚焦 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 += 1object.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_sourcemutate:自赋值如何生效

运行时实现位于 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;
}
  1. s.equals = safe_equals:非不可变模式下用深度相等判断,保证对象/数组内容的变化也能被识别;
  2. 当处于遗留模式时,该信号会被推入组件上下文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

遗留 letexport 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/$$restProps04-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 组件中不可用,对应 $effectlegacy lifecycle hooks 文档 中有说明)。整体迁移步骤与 runes: true 的渐进启用策略参见 v5-migration-guide

小结与实践建议

  1. 自动响应式有严格边界:只覆盖遗留组件「顶层」let/var,且只认赋值;.push()/.splice()/深层属性修改后,按文档约定补一次自赋值(numbers = numbers)或换用 $: 语句派生。
  2. 底层链路已验证let count = 0 → 编译器分析打 state 标记(2-analyze)→ 客户端生成为 $.mutable_sourceVariableDeclaration.js)→ 运行时信号绑定组件上下文以驱动 beforeUpdate/afterUpdatesources.js)→ 自赋值经 mutate() 强制通知(sources.js)。
  3. 迁移路径清晰let x = 0let x = $state(0)$: 语句 → $derived/$effectexport let$props。单组件可通过使用 rune 或 runes: true 编译器选项独立切换,新旧组件可并存,便于增量迁移。
登录后查看全文
热门项目推荐
相关项目推荐