12k
All articles

Svelte 5 中基于 Runes 的状态管理

Svelte 5 使用 runes 的状态管理:用 $state、$derived 和 $effect 共享组件状态,并避免 SvelteKit 中的 SSR 泄漏。

OpenReplay Team
OpenReplay Team
Svelte 5 中基于 Runes 的状态管理

Runes 是编译器指令——而非运行时导入——它使响应性变得显式且可移植:$state 声明一个响应式单元,$derived 从中计算派生值,$effect 响应状态变化——三者共同取代了 Svelte 4 中隐式的 $: 声明和 writable store 在这三个角色上的职责。Svelte 文档将 runes 描述为编译器可识别的关键字,这也是为什么你不能对它们进行别名引用、导入或条件调用。在单个组件中接入 $state 是容易的部分。真正的难点——也是在生产环境中容易出问题的部分——在于如何构建多个组件共享的状态而不丢失响应性,以及在 SvelteKit 进行服务端渲染时如何正确地限定状态作用域。

本文将状态管理视为一条主线来逐步展开:从 $state 实现本地状态,到 $derived 实现计算状态,再到 $effect 处理副作用,最后是大多数教程所忽略的部分——通过 .svelte.ts 模块跨文件共享状态、为 SSR 按请求限定状态作用域,以及在每个环节选择正确工具的决策规则。贯穿全文的核心思想是:Svelte 5 的响应性是按值显式声明的,几乎所有的陷阱都源于代理边界的终止位置——类实例、解构绑定以及 ESM 的 let 导出均处于代理边界之外。本文所引用的版本针对 Svelte 5.x 和 SvelteKit 2.x。

核心要点

  • 任何可以表达为现有状态纯函数的值都应放在 $derived 中,而非 $effect;使用 $effect 将一个状态与另一个状态同步是对该 rune 最常见的误用。
  • .svelte.ts 模块中导出可重新赋值的 $state 会触发 state_invalid_export 编译错误;官方认可的两种修复方案是:导出一个返回该状态的函数,或导出一个 const 对象或类实例并通过修改其属性来更新状态。
  • 应用级客户端状态的推荐模式是:将带有 $state 字段的类以 const 实例的形式导出——const 绑定永不重新赋值,因此 state_invalid_export 问题自然消失。
  • .svelte.ts 模块中声明顶层 $state 会在每个服务器进程中创建一个共享实例,这在 SvelteKit SSR 中会导致用户间的状态泄漏;应通过从 load 函数返回数据并借助 setContext/getContext 共享,来实现按请求限定状态作用域。
  • $state 对象进行解构只会捕获解构时刻的值,而非响应式绑定——应通过代理读取,或传递 getter 函数以保持响应性。

$state 是什么,本地响应式状态如何工作?

$state 声明一个响应式单元,其读取操作会注册依赖,其写入操作会调度更新;对于基本类型,你可以正常赋值和重新赋值;对于对象和数组,Svelte 会返回一个深层代理,使直接修改操作也能被追踪。根据 $state 文档,传入一个普通对象或数组会使其具备深层响应性——你可以原地修改它,依赖它的 UI 会随之更新。

<script lang="ts">
  let count = $state(0);
  let todos = $state([{ id: 1, text: 'Learn runes', done: false }]);

  function addTodo() {
    todos.push({ id: Date.now(), text: 'New todo', done: false }); // 被追踪
  }

  function toggle(id: number) {
    const todo = todos.find((t) => t.id === id);
    if (todo) todo.done = !todo.done; // 属性赋值被追踪
  }
</script>

<button onclick={() => count++}>Clicked {count} times</button>

在深层响应式的 $state 对象或数组上,push() 和直接属性赋值均有效,因为代理会在任意深度拦截修改操作。文档指出一个边界:代理化在类实例处停止。普通对象或数组会变为深层响应式;类实例则不会,除非其字段本身使用 $state 声明。

对于大型扁平数据,如果你倾向于整体替换而非原地修改,可以使用 $state.raw,它只追踪重新赋值——而非原地修改。

<script lang="ts">
  let payload = $state.raw(largeApiResponse);

  // payload.foo = 'x';  // 不会触发更新——原地修改不被追踪
  payload = { ...payload, foo: 'x' }; // 触发更新——重新赋值被追踪
</script>

$state.raw 跳过代理创建,从而避免了对大型扁平对象进行深层代理时的逐属性描述符开销。当数据量大、实际上不可变、且以整体为单位替换时(如解析后的 JSON 响应、配置数据、查找表),可以考虑使用它。其他情况下,默认使用 $state

$derived$derived.by 如何处理计算状态?

$derived 声明一个从响应式状态计算得出的值,当其依赖发生变化时自动更新,取代了 Svelte 4 中的 $: 响应式声明。根据 $derived 文档,它会在任意依赖变化后的下一次读取时重新计算。自 Svelte 5.25 起,非 const 的派生值也可以被临时重新赋值以覆盖其计算结果——这对乐观 UI 更新很有用——之后当某个依赖再次变化时,它会恢复为计算值。

<script lang="ts">
  let count = $state(0);
  let doubled = $derived(count * 2);
  let isEven = $derived(count % 2 === 0);
</script>

对于需要循环、条件判断或多条语句的计算,请使用 $derived.by,它接受一个函数而非表达式:

<script lang="ts">
  let items = $state([{ price: 10, quantity: 2 }]);

  let summary = $derived.by(() => {
    let total = 0;
    let count = 0;
    for (const item of items) {
      total += item.price * item.quantity;
      count += item.quantity;
    }
    return { total, count };
  });
</script>

能够预防大多数响应性 bug 的规则:任何可以表达为现有状态纯函数的值都应放在 $derived 中,而非 $effect。使用 $effect 将一个状态与另一个状态同步会引入冗余的响应式循环,这是对该 rune 最常见的误用。如果你发现自己在编写一个从其他状态设置状态的 effect,请将其替换为 $derived

何时应该使用 $effect

$effect 仅用于副作用——订阅、日志记录、手动 DOM 操作和数据持久化——绝不用于派生值。根据 $effect 文档,effect 在 DOM 更新后运行,自动追踪其内部读取的响应式值,在这些值变化时重新运行,且仅在浏览器中运行——在 SSR 期间从不执行。不需要依赖数组;Svelte 通过你读取的内容自动检测依赖。

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

  $effect(() => {
    const id = setInterval(() => console.log('tick', count), 1000);
    return () => clearInterval(id); // 在重新运行和组件销毁前执行清理
  });
</script>

返回的函数是一个清理回调——它在 effect 重新执行之前以及组件销毁时运行,用于清除定时器、监听器和订阅。由于 effect 完全跳过 SSR,不要依赖它们来生成服务端渲染的输出;这类工作应放在 load 函数或 $derived 中。

有一个值得牢记的误用场景:不要使用 $effect 来复制状态。编写 $effect(() => { fullName = `${first} ${last}` }) 会创建一个回写循环,而使用 $derived 既正确又更简洁:

<script lang="ts">
  let first = $state('Ada');
  let last = $state('Lovelace');
  let fullName = $derived(`${first} ${last}`); // 正确——无需 effect
</script>

如何在 Svelte 5 的多个组件间共享状态?

要跨文件共享响应式状态,请将其放在 .svelte.ts 模块中——但你不能直接导出可重新赋值的 $state 绑定。以下直觉性写法会失败:

// src/lib/counter.svelte.ts
export let count = $state(0);
export function increment() {
  count += 1;
}

从模块中导出可重新赋值的 $state 绑定会触发文档中记录的 state_invalid_export 编译错误,错误信息为:“Cannot export state from a module if it is reassigned. Either export a function returning the state value or only mutate the state value’s properties.”(如果模块中的状态被重新赋值,则不能将其导出。请导出一个返回该状态值的函数,或只修改状态值的属性。)触发原因是重新赋值操作(count += 1),而非导入本身——错误的触发是因为被导出的 $state 发生了重新赋值,而 ESM 的 let 导出无法在模块边界上安全地执行重新赋值。官方认可的修复方案有两种。

方案一——导出 const 对象并修改其属性。 绑定本身永不重新赋值;只有属性发生变化,因此每个导入方都读取同一个代理。

// src/lib/counter.svelte.ts
export const counter = $state({ count: 0 });
export function increment() {
  counter.count += 1; // 属性修改,而非重新绑定
}

方案二——将状态保持为文件私有并导出 getter。 这样可以保持单元私有并防止外部重新赋值。

// src/lib/counter.svelte.ts
let count = $state(0);
export function getCount() {
  return count;
}
export function increment() {
  count += 1;
}

应用级客户端状态的推荐模式是方案一的最简洁形式:将带有 $state 字段的类以 const 实例的形式导出。const 绑定永不重新赋值,因此 state_invalid_export 问题自然消失,每个导入该实例的组件都从同一个响应式代理中读取数据。

// src/lib/todo-store.svelte.ts
class Todo {
  done = $state(false);
  text = $state('');
  constructor(text: string) {
    this.text = text;
  }
}

class TodoStore {
  items = $state<Todo[]>([]);
  filter = $state<'all' | 'active' | 'done'>('all');

  get visible(): Todo[] {
    if (this.filter === 'all') return this.items;
    const wantDone = this.filter === 'done';
    return this.items.filter((t) => t.done === wantDone);
  }

  add(text: string) {
    this.items.push(new Todo(text));
  }

  remove(todo: Todo) {
    this.items = this.items.filter((t) => t !== todo);
  }
}

export const todoStore = new TodoStore();

任何导入 todoStore 的组件都读取并修改同一个响应式实例——无需 context 传递,无需订阅样板代码。注意 $state<Todo[]>([]) 的类型参数:TypeScript 可以从初始值推断类型,但对于空数组、空对象或比初始值更宽泛的联合类型,需要显式传递类型参数。这种模式在 SSR 中有一个重要注意事项,将在下文介绍。

$state 在类中的行为是什么?

$state 可作为类字段使用,编译器会将每个字段转换为由私有 signal 支撑的原型 get/set 访问器对。$state 类文档描述了这一转换,它有两个值得了解的后果:由于访问器位于原型而非实例上,Object.keys(instance) 不会列出响应式字段,{ ...instance } 展开操作也会忽略它们。

class Todo {
  done = $state(false);
  text = $state('');
  constructor(text: string) {
    this.text = text;
  }
  toggle() {
    this.done = !this.done;
  }
}

让所有人都踩过的陷阱是事件处理器中的 this 绑定问题。传递方法引用会使其脱离实例,文档直接涵盖了这种情况:

<!-- this === <button> 元素,而非 Todo 实例——错误 -->
<button onclick={todo.toggle}>toggle</button>

<!-- this === todo——正确 -->
<button onclick={() => todo.toggle()}>toggle</button>

由于读写操作需要经过原型访问器,方法需要正确的 this。有两种修复方式可以保持绑定:在调用处用箭头函数包裹(() => todo.toggle()),或将方法定义为箭头函数字段,使其通过闭包捕获 this

class Todo {
  done = $state(false);
  toggle = () => {
    this.done = !this.done; // 箭头函数字段——`this` 被永久绑定
  };
}

当你打算将方法作为引用传递时,使用箭头函数字段形式;当你总是以 todo.toggle() 的方式调用时,使用普通方法。

使用 Runes 时有哪些主要的响应性陷阱?

大多数 runes 相关的 bug 都可以追溯到一条规则:响应性是按值显式声明的,代理在类实例、解构绑定和 ESM 导出处停止。以下三个陷阱占据了响应性丢失问题的绝大多数。

解构捕获的是值,而非绑定。$state 对象进行解构会读取解构时刻的值;它不会创建响应式引用。

const user = $state({ name: 'Ada', age: 36 });
const { name } = user;
user.name = 'Grace'; // user.name 更新
console.log(name); // 仍然是 'Ada'——在解构时被捕获

要保持响应性,请通过原始代理读取(user.name),或传递一个 getter 函数(() => user.name),该函数通过闭包捕获代理并在每次调用时重新读取。每当你将某个值重构为变量后发现它”停止更新”,这就是应该采用的修复方式。

原生的 MapSetDateURL 都是类实例,因此代理在它们处停止。 请使用 svelte/reactivity 中的响应式等价类,它们会追踪对 .size.get().has() 和迭代操作的读取:SvelteMapSvelteSetSvelteDateSvelteURLSvelteURLSearchParams

import { SvelteMap } from 'svelte/reactivity';

const cache = new SvelteMap<string, number>();
cache.set('a', 1); // 响应式

文档指出一个注意事项:存储在响应式 MapSet 内部的值不会变为深层响应式。如果你在 SvelteMap 中存储了一个普通对象,并期望对其属性的修改能触发响应式更新,请先用 $state 包裹该对象。

代理无法跨越某些 API 边界。 structuredClonepostMessage 以及某些序列化器会拒绝代理对象。请使用 $state.snapshot 在边界处获取一个普通的静态副本:

await fetch('/api/save', {
  method: 'POST',
  body: JSON.stringify($state.snapshot(user)),
});

仅在这些边界处使用 $state.snapshot,而非在代码中随处使用——在其他所有地方,代理才是你真正需要的。

何时应该使用模块全局状态,何时应该使用 Svelte context?

模块全局状态对于纯客户端应用是安全的,但对于 SSR 则不安全;按请求限定作用域的状态应放在 load 函数中,并通过 Svelte 的 context API 共享。在 .svelte.ts 模块顶层声明的 $state 会在每个服务器进程中创建一个共享实例。在客户端,这正是你想要的。而在 SvelteKit SSR 中,这存在请求间状态泄漏的风险:SvelteKit 状态管理文档指出,服务器是长期运行的且在用户间共享,你不能将每用户数据存储在共享模块变量中,并给出了一个用户的私密信息泄漏到另一个用户渲染结果中的典型示例。

泄漏场景如下——在服务端渲染期间被修改的模块 store 对下一个命中同一进程的请求可见:

// src/lib/user.svelte.ts — 在 SSR 中存在危险
export const currentUser = $state({ name: '' });
// +page.server.ts — 在 SSR 期间设置共享状态
import { currentUser } from '$lib/user.svelte';

export function load({ locals }) {
  currentUser.name = locals.user.name; // 跨请求泄漏
}

规范的修复方案是从 load 函数返回每请求数据,并通过 setContext/getContext 共享,这将值的作用域限定在单个组件树——也就是单个请求——而非整个进程范围的模块。

// +layout.server.ts
export function load({ locals }) {
  return { user: locals.user }; // 每请求数据
}
<!-- +layout.svelte -->
<script lang="ts">
  import { setContext } from 'svelte';
  let { data } = $props();
  setContext('user', data.user); // 作用域限定在当前请求的组件树
</script>

在生产环境的 SvelteKit 应用中,未正确限定作用域的模块级状态会在会话回放中表现为:用户短暂看到其他用户的数据,或在初始加载时看到陈旧的值——这是状态本应通过从 load 函数返回每请求数据并借助 context 共享来按请求限定作用域,却被保存在模块全局中所导致的可见症状。这类实现的会话回放能够暴露该 bug,因为它们捕获的是初始服务端渲染的 DOM,而非仅仅是水合后的状态。

贯穿整条主线的决策规则:

对于单个组件所拥有的值,使用组件本地的 $state;对于从该状态计算得出的任何内容,使用 $derived;对于应用级客户端状态,使用以 const 实例形式导出的模块级类 store(export const store = new Store());对于不能在用户间泄漏的按请求限定或 SSR 状态,使用从 load 函数返回并通过 setContext/getContext 共享的每请求数据。

Props 传递——$props$bindable 以及 $inspect 调试辅助工具——不在这条状态管理主线的范围内;将它们视为组件接口和调试工具,而非状态容器。

从 Svelte 4 stores 迁移到 Runes

大多数 Svelte 4 响应性模式都可以直接映射到 runes,包括 writable store 曾经负责的共享状态场景。下表涵盖了中级开发者最常遇到的迁移对照;完整的迁移内容请参阅 Svelte 5 迁移指南

Svelte 4Svelte 5 runes
let count = 0;let count = $state(0);
$: doubled = count * 2;let doubled = $derived(count * 2);
$: console.log(count);$effect(() => console.log(count));
export let name = 'world';let { name = 'world' } = $props();
const count = writable(0); $count++let count = $state(0); count++
跨组件共享的 writable storeexport const store = new Store() 形式导出的类 store
SSR layout 中订阅的每用户 storeload 返回数据 → setContext/getContext

最后两行是大多数教程所忽略的:在整个应用中共享的 writable store 变为以 const 形式导出的类实例,而 SSR 上下文中的每用户 store 变为从 load 函数返回并通过 context 传递的每请求数据。

Runes 使响应性模型变得显式,但真正的价值在于架构层面:让状态尽可能保持本地化,仅当多个组件确实需要共享时才将其提升为模块级类 store,一旦涉及 SSR 就立即通过 context 限定其作用域。审查你的 .svelte.ts 模块,检查其中参与服务端渲染的顶层 $state——这一项检查能够发现 SvelteKit 中最高严重级别的状态 bug。

常见问题

Svelte 5 中 $state.raw 和 $state 有什么区别?

$state 返回一个深层代理,可在任意深度追踪修改操作,因此 push() 和属性赋值都会触发更新;$state.raw 跳过代理创建,仅追踪重新赋值,这意味着原地修改会被忽略,你必须整体替换该值才能触发更新。对于大型、实际上不可变、以整体为单位替换的数据(如解析后的 JSON 响应、配置数据或查找表),在跳过逐属性代理开销有实际意义时,可使用 $state.raw。其他情况下,默认使用 $state。

为什么我的 $state 值在解构后停止更新?

对 $state 对象进行解构会读取解构时刻的值,并创建一个普通的非响应式变量;它不会创建与代理的响应式绑定。执行 const name = user.name 后,后续对 user.name 的修改会更新代理,但解构出的 name 会冻结在其原始值。要保持响应性,请在使用时通过原始代理读取 user.name,或传递一个 getter 函数(如 () => user.name),该函数通过闭包捕获代理并在每次调用时重新读取。

$effect 回调在 SvelteKit 的 SSR 期间会执行吗?

不会。$effect 回调仅在浏览器中运行,在服务端渲染期间从不执行。Effect 在 DOM 更新后运行,并在其读取的响应式值发生变化时重新运行,这意味着它们无法为服务端渲染的 HTML 做出贡献。不要依赖 $effect 在 SSR 期间产生输出;请将这类工作放在 load 函数或 $derived 中。Effect 用于仅在浏览器中执行的副作用,如订阅、日志记录、手动 DOM 操作、定时器和数据持久化,并可选择性地返回一个清理回调。

存储在 SvelteMap 或 SvelteSet 内部的值是否具有深层响应性?

不具备。svelte/reactivity 中的 SvelteMap 和 SvelteSet 会追踪对 .size、.get()、.has() 和迭代操作的读取,并响应条目的添加或删除,但存储在其内部的值不会变为深层响应式。如果你在 SvelteMap 中存储了一个普通对象,并期望修改其属性能触发更新,请先用 $state 包裹该对象,使内部对象成为响应式代理。响应式集合追踪的是结构变化,而非任意存储值的内部状态。

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.