Svelte $state 深度指南:细粒度响应式状态的用法、边界与编译器实现原理
本篇基于 Svelte 官方文档 [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.js:proxy() 首先通过原型检查排除不可代理的值(null、已代理的值、组件实例),随后只有当原型是 Object.prototype 或 Array.prototype 时才真正包裹 new Proxy,这解释了为什么类实例不会被代理(见下节)。对于数组,源码还会立即为 length 创建一个专属 source(proxy.js#L91-L98),因此 push、pop 等改变长度的操作才能被精确追踪;同时每个属性拥有自己的 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;
}
}
注意:编译器会把
done和text转换为类原型上引用私有字段的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_assignment、state_invalid_placement 等编译错误,帮助你在开发期尽早发现写法问题。
内置类的响应式实现
Svelte 提供了 Set、Map、Date、URL 等内置类的响应式实现,可以从 svelte/reactivity 导入。当前仓库 index-client.js 中实际导出的成员包括:
SvelteDate(date.js)SvelteSet(set.js)SvelteMap(map.js)SvelteURL(url.js)SvelteURLSearchParams(url-search-params.js)MediaQuery(media-query.js)createSubscriber(create-subscriber.js)
官方 API 参考见 21-svelte-reactivity.md。由于原生 Set/Map/Date 的变更不走属性读写,普通 $state 无法感知它们内部的变化,因此这些“响应式版本”是对内置类的增强实现,配合测试用例(如 date.test.ts、set.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.js:snapshot() 递归遍历值,对 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 希望访问 a 和 b 的_当前_值并返回当前的 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 等内置类型的响应式版本 |
用 SvelteSet、SvelteMap 等替代原生类型 |
$state.raw(v) |
大型且不会原地修改的数组/对象 | 只能整体重新赋值,不能 mutate |
$state.snapshot(v) |
向不识别 proxy 的外部库/structuredClone 传值 |
走 toJSON 时克隆其返回值 |
$state.eager(v) |
需要立即反馈的用户交互(如导航高亮) | 建议少用,把更新协调交给 Svelte |
| 模块级状态 | 跨模块共享 | 要么导出后不重新赋值,要么只导出 getter/updater |
掌握以上边界,配合 sources.js、proxy.js、clone.js 这三个核心运行时文件的源码对照阅读,你就能准确判断每一种写法在实际项目中的行为与代价。
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