首页
/ Svelte $state 深度指南:细粒度响应式状态的用法、边界与编译器实现原理

Svelte $state 深度指南:细粒度响应式状态的用法、边界与编译器实现原理

2026-09-05 23:53:03作者:董斯意

本篇基于 Svelte 官方文档 [02-state.md](https://gitcode.com/GitHubTrending/sv/svelte/blob/b2c22ab66d0978dfa14bb92196a6ab45ad0031a4/documentation/docs/02runes/02state.md](https://gitcode.com/GitHub_Trending/sv/svelte/blob/b2c22ab66d0978dfa14bb92196a6ab45ad0031a4/documentation/docs/02-runes/02-state.md?utm_source=gitcode_repo_files) 展开,系统讲解 $state rune 的完整能力面:深层响应式 state proxy、类实例状态、$state.raw / $state.snapshot / $state.eager 三个变体,以及跨函数、跨模块传递状态的正确姿势。读完本文,你不仅能直接写出可运行的响应式代码,还能结合 Svelte 客户端运行时源码(signal、proxy、clone 等模块)理解每一项特性背后的实现机制与适用边界。

基本用法:没有 API 的响应式

$state rune 用于创建_响应式状态_(reactive state)——当它变化时,UI 会随之更新:

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

<button onclick={() => count++}>
	clicks: {count}
</button>

与其他框架常见的做法不同,Svelte 没有专门与状态交互的 API:count 就是一个普通的数字,而不是某种包装对象或函数。你像更新任何普通变量一样更新它即可,编译器会在底层完成响应式接线。

从源码看,$state(0) 最终会落到客户端运行时的 state() 函数,它内部调用 source(v) 创建一个 signal(信号对象),持有当前值 v、依赖集合 reactions 以及读写版本号;对状态的读取和赋值在编译后分别被改写为对该 signal 的 get/set 调用。这正是文档中示例的“编译器输出”形态——状态被表达为 signal,而模板中的每次引用都被包进 get/set,从而建立依赖与更新链路。

深层状态(Deep state):state proxy 与递归代理

$state 作用于数组或普通对象时,结果是一个深度响应式的 state proxy。Svelte 借助 Proxy 在你读取或写入属性时(包括 array.push(...) 这类方法调用)执行代码,从而触发细粒度的更新。

Svelte 会递归地为状态建立代理,直到遇到既非数组也非普通对象的东西(例如类实例、Object.create 创建的对象)。以这样的状态为例:

let todos = $state([
	{
		done: false,
		text: 'add more todos'
	}
]);

修改某个 todo 的具体属性,只会触发依赖该属性的 UI 更新:

let todos = [{ done: false, text: 'add more todos' }];
// ...
todos[0].done = !todos[0].done;

向数组 push 一个新对象时,新对象也会被代理:

let todos = [{ done: false, text: 'add more todos' }];
// ...
todos.push({
	done: false,
	text: 'eat lunch'
});

注意:更新代理对象的属性时,原始对象不会被修改。如果你需要在 state proxy 中挂接自己的 proxy handler,应该在用 $state 包装之后再包装对象。

这一行为的实现依据在 proxy.jsproxy() 首先通过原型检查排除不可代理的值(null、已代理的值、组件实例),随后只有当原型是 Object.prototypeArray.prototype 时才真正包裹 new Proxy,这解释了为什么类实例不会被代理(见下节)。对于数组,源码还会立即为 length 创建一个专属 source(proxy.js#L91-L98),因此 pushpop 等改变长度的操作才能被精确追踪;同时每个属性拥有自己的 child source 和版本信号(version),这就是“修改单个属性只触发依赖该属性的更新”这一细粒度语义的来源。

解构不具响应性:与常规 JavaScript 一致,从响应式值中解构得到的引用在解构那一刻就被求值:

let todos = $state([{ done: false, text: 'add more todos' }]);
// ...
let { done, text } = todos[0];

// 这不会改变 `done` 的值
todos[0].done = !todos[0].done;

因此,需要持续追踪的变化时,请在模板或函数内部直接读取 todos[0].done,而不是提前解构。

类:用 $state 声明响应式字段

类实例不会被代理。正确做法是在类的字段声明中(无论 public 还是 private)或构造函数内对该属性的第一次赋值处使用 $state

// @errors: 7006 2554
class Todo {
	done = $state(false);

	constructor(text) {
		this.text = $state(text);
	}

	reset() {
		this.text = '';
		this.done = false;
	}
}

注意:编译器会把 donetext 转换为类原型上引用私有字段的 get/set 方法。这意味着这些属性不可枚举Object.keys(todo) 看不到它们)。

this 陷阱:JavaScript 中调用方法时 this 的指向很重要。下面这种写法不工作,因为 reset 执行时 this 会是 <button> 而不是 Todo 实例:

<button onclick={todo.reset}>
	reset
</button>

两种修复方式——使用内联函数:

<button onclick={() => todo.reset()}>
	reset
</button>

或者在类定义中把方法写成箭头函数属性:

// @errors: 7006 2554
class Todo {
	done = $state(false);

	constructor(text) {
		this.text = $state(text);
	}

	reset = () => {
		this.text = '';
		this.done = false;
	};
}

类字段使用 $state 时,编译器在 CallExpression.js 的分析阶段识别 $state / $state.raw / $state.eager / $state.snapshot 等调用并做针对性变换;非法放置(如字段上重复声明 $state、对状态变量重复初始化)会触发 errors.js 中定义的 state_field_invalid_assignmentstate_invalid_placement 等编译错误,帮助你在开发期尽早发现写法问题。

内置类的响应式实现

Svelte 提供了 SetMapDateURL 等内置类的响应式实现,可以从 svelte/reactivity 导入。当前仓库 index-client.js 中实际导出的成员包括:

官方 API 参考见 21-svelte-reactivity.md。由于原生 Set/Map/Date 的变更不走属性读写,普通 $state 无法感知它们内部的变化,因此这些“响应式版本”是对内置类的增强实现,配合测试用例(如 date.test.tsset.test.ts)验证其行为。

$state.raw:跳过高成本的深层代理

当你不希望对象和数组被深度代理时,使用 $state.raw

let person = $state.raw({
	name: 'Heraclitus',
	age: 49
});

// 这不会有任何效果
person.age += 1;

// 这样可以——因为我们创建了一个新对象
person = {
	name: 'Heraclitus',
	age: 50
};

$state.raw 声明的状态不可被变更(cannot be mutated),只能被_重新赋值_:不要给对象属性赋值、不要调用 push 这类数组方法,而是整体替换对象或数组。

这在对大型数组/对象“反正也不打算原地改”的场景下可以带来性能收益,因为它避免了建立深层代理的成本。注意 raw 状态内部可以包含响应式状态(例如一个装着响应式对象的 raw 数组)。与 $state 一样,你也能用 $state.raw 声明类字段。

在实现上,$state.raw 与普通 $state 的区别在于写入路径:运行时的 set() 接收一个 should_proxy 参数,普通 $state 的赋值会对新值调用 proxy() 建立深度代理,而 $state.raw 的赋值不经过代理处理,信号只在其整体被重新赋值时触发更新。

$state.snapshot:获取静态快照

要为深度响应式的 $state proxy 获取一份静态快照,使用 $state.snapshot

<script>
	let counter = $state({ count: 0 });

	function onclick() {
		// 打印 `{ count: ... }` 而不是 `Proxy { ... }`
		console.log($state.snapshot(counter));
	}
</script>

这在你需要把状态传给不期望收到 proxy 的外部库或 API(比如 structuredClone)时非常有用。如果值带有 toJSON 方法,快照会克隆 toJSON 返回的值,而不是原始对象。

其实现位于 clone.jssnapshot() 递归遍历值,对 Map/Set 直接构造新实例,对数组和普通对象逐字段深拷贝,遇到 Date 会先调用 getTime() 以触发追踪、再走 structuredClone;当检测到 toJSON 函数时(clone.js#L113-L122),会改为克隆 toJSON() 的返回值;最终无法克隆的值(如 EventTarget)会原样返回,并在开发模式下通过 state_snapshot_uncloneable 警告提示哪些路径未能克隆。此外开发模式下 console.log 直接输出 state proxy 也会触发 console_log_state 警告,建议优先使用 $inspect(...)$state.snapshot(...)

$state.eager:打破同步更新的延迟

当状态变化被 await 表达式使用时,由于 await 表达式会同步化更新,状态变化可能不会立即反映到 UI。如果你希望状态一变就立刻更新界面——典型例子是用户点击链接时让导航栏立刻给出视觉反馈,而不是等新页面加载完——可以使用 $state.eager(value)

<nav>
	<a href="/" aria-current={$state.eager(pathname) === '/' ? 'page' : null}>home</a>
	<a href="/about" aria-current={$state.eager(pathname) === '/about' ? 'page' : null}>about</a>
</nav>

谨慎使用这一特性,且只用于响应用户操作的即时反馈——一般来说,让 Svelte 协调更新会带来更好的用户体验。

从源码看,$state.eager 会为其依赖创建一个 eager effect 并登记到 sources.js 中的 eager_effects 集合;每当某个 source 被写入、internal_set() 完成脏标记后,会调用 flush_eager_effects()sources.js#L270)立即执行这些 effect,从而绕开常规的批量调度,实现“写入即更新”。

向函数传递状态:JavaScript 是值传递

JavaScript 是_值传递_语言——调用函数时,参数拿到的是_值_而不是_变量_。也就是说:

/**
 * @param {number} a
 * @param {number} b
 */
function add(a, b) {
	return a + b;
}

let a = 1;
let b = 2;
let total = add(a, b);
console.log(total); // 3

a = 3;
b = 4;
console.log(total); // 仍然是 3!

如果 add 希望访问 ab 的_当前_值并返回当前的 total,就必须改传函数:

/**
 * @param {() => number} getA
 * @param {() => number} getB
 */
function add(getA, getB) {
	return () => getA() + getB();
}

let a = 1;
let b = 2;
let total = add(() => a, () => b);
console.log(total()); // 3

a = 3;
b = 4;
console.log(total()); // 7

Svelte 中的状态没有例外——当你引用用 $state 声明的东西时,你拿到的是它的_当前值_:

let a = $state(1);
let b = $state(2);

需要注意的是,这里的“函数”是个宽泛概念——它同样涵盖 proxy 的属性访问和 get / set 访问器:

/**
 * @param {{ a: number, b: number }} input
 */
function add(input) {
	return {
		get value() {
			return input.a + input.b;
		}
	};
}

let input = $state({ a: 1, b: 2 });
let total = add(input);
console.log(total.value); // 3

input.a = 3;
input.b = 4;
console.log(total.value); // 7

每次访问 total.value 都会经由 proxy 的 get 拦截重新求值,因此始终拿到最新值。不过,如果你发现自己经常这样写,文档建议改用来表达这类封装。

跨模块传递状态:导出限制与两种合规模式

你可以在 .svelte.js / .svelte.ts 文件中声明状态,但只有当状态不会被直接重新赋值时才能导出它。也就是说,下面这种写法是不允许的:

// state.svelte.js
export let count = $state(0);

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

原因在于 Svelte 编译器会转换对 count 的每一处引用——上面的代码大致等价于:

// state.svelte.js(编译器输出,示意)
interface Signal<T> {
	value: T;
}

interface Svelte {
	state<T>(value?: T): Signal<T>;
	get<T>(source: Signal<T>): T;
	set<T>(source: Signal<T>, value: T): void;
}
declare const $: Svelte;

export let count = $.state(0);

export function increment() {
	$.set(count, $.get(count) + 1);
}

(你可以在官方 playground 的 "JS Output" 标签页查看 Svelte 生成的完整代码。)

由于编译器一次只处理一个文件,当另一个文件导入 count 时,Svelte 无法知道需要对每处引用包装 $.get / $.set

// state.svelte.js
export let count = 0;

// index.js
import { count } from './state.svelte.js';

console.log(typeof count); // 'object',而不是 'number'

因此共享模块级状态有两种合规方案

方案一:导出后不再重新赋值——此时更新的是属性而非变量本身,编译器就不会将其包装为 signal:

// 合法——我们更新的是 `counter.count` 而不是 `counter`,
// 所以 Svelte 不会把它包装进 $.state
export const counter = $state({
	count: 0
});

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

方案二:不直接导出状态本身,改用访问器函数:

let count = $state(0);

export function getCount() {
	return count;
}

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

如果违反该规则,编译器会直接报错而非静默降级:errors.js 中定义了 state_invalid_export 诊断,在分析阶段(VariableDeclarator.js$state / $state.raw 的导出检查)拦截“导出可被重新赋值的模块级状态”这类写法,确保跨文件引用的语义始终与单文件内的行为一致。

小结

能力 适用场景 关键约束
$state(v) 通用响应式状态;数组/普通对象自动深度代理 解构出的引用不再响应式
类字段 + $state 类实例的响应式属性 编译为原型上的 get/set,属性不可枚举;注意 this 指向
svelte/reactivity Set/Map/Date/URL 等内置类型的响应式版本 SvelteSetSvelteMap 等替代原生类型
$state.raw(v) 大型且不会原地修改的数组/对象 只能整体重新赋值,不能 mutate
$state.snapshot(v) 向不识别 proxy 的外部库/structuredClone 传值 toJSON 时克隆其返回值
$state.eager(v) 需要立即反馈的用户交互(如导航高亮) 建议少用,把更新协调交给 Svelte
模块级状态 跨模块共享 要么导出后不重新赋值,要么只导出 getter/updater

掌握以上边界,配合 sources.jsproxy.jsclone.js 这三个核心运行时文件的源码对照阅读,你就能准确判断每一种写法在实际项目中的行为与代价。

登录后查看全文
热门项目推荐
相关项目推荐