Chapter 03

响应式深入:$state/$derived/$effect

深入理解 Svelte 5 Runes 响应式系统的工作原理,掌握状态管理的正确姿势

$state() 深入解析

基本用法与类型推断

$state() 是 Svelte 5 响应式系统的核心。它创建一个响应式状态变量,当变量的值改变时,所有依赖它的模板和派生值会自动更新。与 React 的 useState 不同,$state 使用普通赋值语法更新,不需要 setter 函数。

<script lang="ts">
  // 原始类型:TypeScript 自动推断类型
  let count = $state(0);          // number
  let name = $state('');           // string
  let active = $state(false);     // boolean

  // 显式类型注解
  let items = $state<string[]>([]);
  let user = $state<{ name: string; age: number } | null>(null);

  // 普通赋值即可更新(不需要 setState)
  function increment() {
    count++;       // 直接修改!
  }
  function setName(n: string) {
    name = n;
  }
</script>

响应式对象与数组

$state() 包裹对象或数组时,Svelte 会使用 Proxy 使其内部属性也具有深层响应式。这解决了 Svelte 4 中数组变更不被追踪的痛点。

<script lang="ts">
  // 响应式对象——深层属性变更会触发更新
  let profile = $state({
    name: 'Alice',
    address: {
      city: '北京',
      district: '朝阳区'
    }
  });

  // 深层修改:直接赋值即可
  function changeCity() {
    profile.address.city = '上海';  // 自动触发更新!
  }

  // 响应式数组
  let todos = $state([
    { id: 1, text: '学习 Svelte', done: false },
    { id: 2, text: '构建项目', done: false }
  ]);

  // 所有数组方法都能触发响应式更新
  function addTodo(text: string) {
    todos.push({ id: Date.now(), text, done: false });
  }

  function toggleTodo(id: number) {
    const todo = todos.find(t => t.id === id);
    if (todo) todo.done = !todo.done;  // 深层修改也行!
  }

  function removeTodo(id: number) {
    const index = todos.findIndex(t => t.id === id);
    if (index !== -1) todos.splice(index, 1);
  }
</script>

{#each todos as todo (todo.id)}
  <div class:done={todo.done}>
    <input type="checkbox" checked={todo.done}
      onchange={() => toggleTodo(todo.id)} />
    {todo.text}
  </div>
{/each}

$state.raw() — 非深层响应式

当你不需要深层响应式时,可以使用 $state.raw() 获得性能更好的浅层响应式:

<script lang="ts">
  // $state.raw:只在整体重赋值时触发更新
  // 适合大型只读数据集
  let bigDataset = $state.raw(fetchData());

  function refresh() {
    bigDataset = fetchData();  // 整体替换才触发更新
    // bigDataset.someProperty = x;  // 这不会触发更新
  }
</script>

$derived() 派生状态

基本派生

$derived() 用于声明从其他响应式状态自动计算的值。当依赖的状态变化时,派生值会自动重新计算。它是只读的——你不能直接赋值给派生变量。

<script lang="ts">
  let price = $state(100);
  let quantity = $state(3);
  let discount = $state(0.1);  // 10% 折扣

  // 简单派生
  let subtotal = $derived(price * quantity);
  let discountAmount = $derived(subtotal * discount);
  let total = $derived(subtotal - discountAmount);

  // 复杂派生(用 $derived.by 传入函数)
  let formattedTotal = $derived.by(() => {
    return new Intl.NumberFormat('zh-CN', {
      style: 'currency',
      currency: 'CNY'
    }).format(total);
  });
</script>

<p>小计:{subtotal} 元</p>
<p>折扣:-{discountAmount} 元</p>
<p>总计:{formattedTotal}</p>

$effect() 副作用

基本副作用

$effect() 用于在响应式状态变化时执行副作用——如 DOM 操作、网络请求、订阅外部数据源、浏览器 API 调用等。Svelte 会自动追踪 effect 函数中访问的响应式变量,当这些变量变化时自动重新运行 effect。

<script lang="ts">
  let query = $state('');
  let results = $state<string[]>([]);
  let loading = $state(false);

  // $effect 自动追踪 query 依赖
  $effect(() => {
    if (!query) {
      results = [];
      return;
    }

    loading = true;

    // 防抖:取消上次的请求(通过返回清理函数)
    const timer = setTimeout(async () => {
      const res = await fetch(`/api/search?q=${query}`);
      results = await res.json();
      loading = false;
    }, 300);

    // 清理函数:effect 重新运行前执行
    return () => {
      clearTimeout(timer);
      loading = false;
    };
  });
</script>

Runes 与旧版 $: 语法对比

功能Svelte 4 ($:)Svelte 5 (Runes)
响应式状态let count = 0(魔法变量)let count = $state(0)
派生值$: doubled = count * 2let doubled = $derived(count * 2)
副作用$: console.log(count)$effect(() => { console.log(count) })
Propsexport let valuelet { value } = $props()
可跨文件否(只在组件内)是(可写在普通 .ts 文件)
类型安全有限完整 TypeScript 支持

跨文件共享响应式状态

Runes 最大的优势之一是可以将响应式逻辑提取到普通 TypeScript 文件中,在多个组件间共享:

// src/lib/stores/cart.svelte.ts
// 注意:文件扩展名是 .svelte.ts

interface CartItem {
  id: number;
  name: string;
  price: number;
  quantity: number;
}

function createCart() {
  let items = $state<CartItem[]>([]);

  let total = $derived(
    items.reduce((sum, item) => sum + item.price * item.quantity, 0)
  );

  function addItem(item: CartItem) {
    const existing = items.find(i => i.id === item.id);
    if (existing) {
      existing.quantity++;
    } else {
      items.push({ ...item, quantity: 1 });
    }
  }

  function removeItem(id: number) {
    const i = items.findIndex(item => item.id === id);
    if (i !== -1) items.splice(i, 1);
  }

  return {
    get items() { return items; },
    get total() { return total; },
    addItem,
    removeItem
  };
}

// 导出单例 store
export const cart = createCart();
文件命名约定

包含 Runes 的普通 TypeScript 文件需要命名为 .svelte.ts.svelte.js,这样 Svelte 编译器才能识别其中的 $state、$derived 等 Runes。普通 .ts 文件中不能使用 Runes。

$effect 的进阶用法

$effect.pre()
在 DOM 更新之前执行的 effect,用于读取更新前的 DOM 状态(如滚动位置)。普通 $effect 在 DOM 更新之后执行。
$effect.tracking()
返回布尔值,表示当前代码是否在响应式追踪上下文中运行(即是否在 $effect 或 $derived 内部)。用于编写可在追踪/非追踪上下文中都能运行的工具函数。
清理函数(Cleanup)
$effect 的回调函数可以返回一个清理函数。Svelte 会在 effect 重新运行之前、以及组件销毁时执行该清理函数。用于清除定时器、取消网络请求、取消订阅事件等。
$effect.root()
创建一个不绑定到组件生命周期的独立响应式根。在组件外(如 Node.js 服务端代码或测试中)创建响应式计算时使用,需要手动调用返回的清理函数。
<script lang="ts">
  let items = $state<string[]>([]);
  let listEl = $state<HTMLElement>();

  // $effect.pre:DOM 更新前执行,用于保存滚动位置
  $effect.pre(() => {
    if (!listEl) return;
    // 在 items 变化、DOM 更新前保存当前滚动位置
    const savedScrollTop = listEl.scrollTop;
    return () => {
      // 清理函数(在此场景:DOM 更新后恢复滚动)
      listEl.scrollTop = savedScrollTop;
    };
  });

  // 带清理函数的 effect:订阅 WebSocket 消息
  $effect(() => {
    const ws = new WebSocket('wss://api.example.com');

    ws.addEventListener('message', (e) => {
      items = [...items, e.data];
    });

    // Svelte 在组件销毁时自动调用此清理函数
    return () => ws.close();
  });

  // 带清理函数:订阅自定义事件总线
  $effect(() => {
    function handleThemeChange(e: CustomEvent) {
      document.body.setAttribute('data-theme', e.detail.theme);
    }

    document.addEventListener('theme-changed', handleThemeChange as EventListener);

    return () => {
      document.removeEventListener('theme-changed', handleThemeChange as EventListener);
    };
  });
</script>

<ul bind:this={listEl}>
  {#each items as item}<li>{item}</li>{/each}
</ul>
$effect 的常见误用

$state 的深层响应式原理

理解 Svelte 5 响应式系统的工作原理,有助于避免常见陷阱:

Svelte 5 响应式系统(细粒度信号) $state(value) ┌─────────────────────────────────────────────────┐ │ 创建响应式信号(Signal) │ │ ┌────────────┐ ┌────────────────────────┐ │ │ │ 读取信号 │────▶│ 追踪为依赖(当在 │ │ │ │ signal.get │ │ $derived/$effect 中时) │ │ │ └────────────┘ └────────────────────────┘ │ │ ┌────────────┐ ┌────────────────────────┐ │ │ │ 写入信号 │────▶│ 通知所有依赖它的计算 │ │ │ │ signal.set │ │ 重新执行(细粒度更新) │ │ │ └────────────┘ └────────────────────────┘ │ └─────────────────────────────────────────────────┘ 细粒度 vs 粗粒度: - React:组件级重渲染(整个组件函数重新执行) - Svelte 5:信号级更新(只有依赖该信号的计算重新运行) → Svelte 5 性能更好,无需 useMemo/useCallback 优化
<script lang="ts">
  // $state 对对象/数组使用 Proxy 实现深层响应
  let user = $state({
    name: 'Alice',
    address: {
      city: '上海',
      zip: '200001'
    },
    hobbies: ['编程', '阅读']
  });

  // 直接修改深层属性 ✓(Proxy 会捕获)
  user.address.city = '北京';
  user.hobbies.push('旅行');  // 数组变异方法也会触发响应

  // 替换整个对象 ✓
  user.address = { city: '广州', zip: '510001' };

  // ============================
  // $state.raw:不跟踪深层变化(性能优化)
  // 适合大型不可变数据(如图表数据集)
  let chartData = $state.raw([] as number[]);
  // chartData.push(1)  ← 不会触发响应(不被追踪)
  chartData = [...chartData, 42];  // 必须整体替换才触发

  // ============================
  // $state.snapshot:获取当前值的静态快照(非响应式副本)
  // 适合传递给非 Svelte 代码(如发送到 API)
  async function saveUser() {
    const snapshot = $state.snapshot(user);  // 普通对象,非 Proxy
    await fetch('/api/users', {
      method: 'POST',
      body: JSON.stringify(snapshot)  // Proxy 对象 JSON.stringify 可能有问题
    });
  }
</script>

$derived 复杂用法

<script lang="ts">
  let products = $state([
    { id: 1, name: 'MacBook', price: 12999, category: '电脑' },
    { id: 2, name: 'iPhone', price: 7999, category: '手机' },
    { id: 3, name: 'iPad', price: 4999, category: '平板' }
  ]);
  let searchTerm = $state('');
  let sortBy = $state<'name' | 'price'>('name');
  let minPrice = $state(0);

  // 链式 $derived:逐步过滤和排序
  let filtered = $derived(
    products.filter(p =>
      p.name.toLowerCase().includes(searchTerm.toLowerCase()) &&
      p.price >= minPrice
    )
  );

  let sorted = $derived(
    [...filtered].sort((a, b) =>
      sortBy === 'price' ? a.price - b.price : a.name.localeCompare(b.name)
    )
  );

  // $derived.by:处理复杂多步计算(使用函数体而非表达式)
  let stats = $derived.by(() => {
    if (filtered.length === 0) return { avg: 0, max: 0, min: 0 };

    const prices = filtered.map(p => p.price);
    return {
      avg: Math.round(prices.reduce((a, b) => a + b, 0) / prices.length),
      max: Math.max(...prices),
      min: Math.min(...prices)
    };
  });
</script>

<input bind:value={searchTerm} placeholder="搜索产品" />
<input type="number" bind:value={minPrice} placeholder="最低价格" />

<select bind:value={sortBy}>
  <option value="name">按名称排序</option>
  <option value="price">按价格排序</option>
</select>

<p>平均价格:¥{stats.avg},最高:¥{stats.max},最低:¥{stats.min}</p>
{#each sorted as product}
  <div>{product.name} - ¥{product.price}</div>
{/each}

本章小结

本章核心要点