跳到主要内容

Svelte 4 升到 Svelte 5 的实战笔记:runes、snippet 和三个必须避开的坑

把真实项目从 Svelte 4 迁到 Svelte 5,要改的不止是 $state 替换 let。本文用对照代码拆解迁移过程,讲清 snippet 新写法、$effect 的正确姿势,以及最常踩的三个深层响应性坑。

发布于 作者 李雷
#svelte #svelte5 #迁移 #runes #snippet #前端

Svelte 4 升到 Svelte 5 的实战笔记:runes、snippet 和三个必须避开的坑

Svelte 5 在 2024 年 10 月正式发布。从 4 升到 5,表面上看是几个 API 换名,实际上是整套响应式心智模型的替换。我把一个中型后台项目(12 个页面组件、约 3,000 行 .svelte 代码)从 Svelte 4 全量迁到 5,花了两个工作日,踩了几个有规律的坑。这篇文章把迁移路径整理清楚,附真实对照代码,配合 Svelte 速查表 的代码片段一起用,效率会高很多。

为什么 Svelte 5 的迁移比升一个大版本更重

Svelte 4 里,响应式依赖隐式约定:变量写在 <script> 里就自动响应,$: 块按位置顺序执行,createEventDispatcher 是发事件的标准姿势,<slot /> 接受父组件传进来的内容。

Svelte 5 把这四件事全部换掉了:

  • 响应式变量必须用 $state 显式声明
  • 派生值和副作用分别用 $derived$effect
  • 自定义事件换成回调 props
  • <slot /> 换成 snippet 系统

理论上这些都是"兼容性迁移",旧写法在 Svelte 5 的 legacy mode 里还能编译。但新旧两套混用一个组件时,有些行为会出乎意料。稳妥的策略是按组件逐个迁,改到哪就彻底迁到哪,不留半截旧写法。

根据 js-framework-benchmark 的测试数据,Svelte 5 在同等功能下打包体积约 2.5 KB gzip,比 React + ReactDOM 的约 44 KB 小近 17 倍,这个优势在迁移后仍然保留。

三类响应式改写:有规律可循

$state 替换 let

这是改动量最大但最机械的一步。所有需要触发视图更新的变量,从 let 改成 $state(初始值)

Svelte 4(改前):

<script lang="ts">
  let count = 0;
  let name = '';
  let items: string[] = [];
</script>

Svelte 5(改后):

<script lang="ts">
  let count = $state(0);
  let name = $state('');
  let items = $state<string[]>([]);
</script>

不需要响应的局部变量(比如 const PREFIX = 'app')一律不用动,留着 const 就行。

$derived 和 $effect 替换 $: 块

Svelte 4 里所有 $: 块统一一种语法,Svelte 5 把它拆成了两个意图明确的 API:

  • $: 纯计算值 → $derived(表达式)$derived.by(() => 复杂逻辑)
  • $: 带副作用的语句 → $effect(() => { 副作用代码 })

实际改写:

<!-- Svelte 4 改前 -->
<script>
  let count = 0;
  $: doubled = count * 2;
  $: label = count > 10 ? '超出范围' : `当前:${count}`;
  $: {
    if (count > 10) console.warn('count 超过 10');
    document.title = `count = ${count}`;
  }
</script>
<!-- Svelte 5 改后 -->
<script>
  let count = $state(0);
  let doubled = $derived(count * 2);
  let label = $derived(count > 10 ? '超出范围' : `当前:${count}`);
  $effect(() => {
    if (count > 10) console.warn('count 超过 10');
    document.title = `count = ${count}`;
  });
</script>

我在迁移时发现,项目里约 70% 的 $: 块是纯派生值,改成 $derived 就完事。剩下 30% 有 DOM 操作或日志,改成 $effect。两类一旦分清,改写过程几乎是机械的。

props 和事件统一

export let 改成 $props() 解构,createEventDispatcher 改成接受回调函数的 prop:

<!-- Svelte 4 -->
<script>
  export let value = '';
  export let disabled = false;
  import { createEventDispatcher } from 'svelte';
  const dispatch = createEventDispatcher();
  function handleChange(e) {
    dispatch('change', e.target.value);
  }
</script>
<input {value} {disabled} on:change={handleChange} />
<!-- Svelte 5 -->
<script>
  let { value = '', disabled = false, onChange } = $props();
</script>
<input {value} {disabled} onchange={(e) => onChange?.(e.target.value)} />

父组件这边,on:change={handler} 改成 onChange={handler},和 React 的写法高度对齐。

slot 改成 snippet:表面像重命名,实际更强

<slot /> 系统和 snippet 系统的对应关系乍看直接,但 snippet 有个 slot 没有的能力:带参数。

默认 slot 的等价改法:

<!-- Svelte 4 Card.svelte -->
<div class="card">
  <slot />
</div>

<!-- Svelte 5 Card.svelte -->
<script>
  let { children } = $props();
</script>
<div class="card">
  {@render children()}
</div>

具名 slot 改成具名 snippet prop:

<!-- Svelte 4 Modal.svelte -->
<div class="modal">
  <header><slot name="header" /></header>
  <main><slot /></main>
</div>

<!-- Svelte 5 Modal.svelte -->
<script>
  let { header, children } = $props();
</script>
<div class="modal">
  <header>{@render header()}</header>
  <main>{@render children()}</main>
</div>

父组件使用时:

<!-- 父组件 Svelte 5 -->
<Modal>
  {#snippet header()}
    <h2>确认删除</h2>
  {/snippet}
  <p>此操作不可逆,确认继续?</p>
</Modal>

snippet 的真正优势体现在需要向内容传数据时。比如一个表格组件,让父组件控制每行的渲染,Svelte 4 做不到(slot 没法传参给父组件),Svelte 5 里是自然的:

<!-- DataTable.svelte -->
<script>
  let { rows, renderRow } = $props();
</script>
{#each rows as row}
  {@render renderRow(row)}
{/each}

这类"headless 组件"写法,在 Svelte 速查表 的 snippet 分类里有完整片段,改自己的组件时对照过来改就行。

三个必须提前知道的坑

坑 1:rune 不能写在条件或循环里

<!-- 编译直接报错 -->
<script>
  if (someCondition) {
    let x = $state(0); // ❌
  }
</script>

runes 只能在 <script> 顶层或 .svelte.ts 模块顶层声明,这是硬约束,和 React hooks 的"不能有条件调用"同一类限制。改法:把状态提到顶层,条件判断放在使用的地方。

坑 2:$state.raw 不追踪字段改动

<script>
  let data = $state.raw({ count: 0 });
</script>
<button onclick={() => data.count++}>
  {data.count}
</button>
<!-- 点按钮,视图不更新 -->

$state.raw 是故意退出深层追踪的,只有整体重赋才触发更新(data = { count: 1 })。如果需要追踪字段改动,用普通 $state,不要加 .raw。这个坑在写工具库或往对象里 push 数据时很容易踩到。

坑 3:$effect 里读取的变量不在 $effect 外部时可能丢失响应

<script>
  let items = $state([1, 2, 3]);
  // ❌ 常见错误:把 items 解构到局部变量
  let first = items[0];
  $effect(() => {
    console.log('first =', first); // first 不是响应式的,不会重跑
  });
</script>

正确写法是直接在 $effect 里读取 state:

<script>
  let items = $state([1, 2, 3]);
  $effect(() => {
    console.log('first =', items[0]); // ✓ 直接读 state
  });
</script>

$effect 自动追踪它执行时读取的 state,中间如果经过一层局部变量(且那个变量不是 $state),追踪链就断了。

TypeScript 用户额外注意一点

Svelte 5 里 $props() 支持泛型:

<script lang="ts">
  interface Props {
    value: string;
    onChange: (v: string) => void;
    disabled?: boolean;
  }
  let { value, onChange, disabled = false }: Props = $props();
</script>

如果你同时用 TypeScript 写 Svelte,TypeScript 速查表 里有 utility types 和范型约束的常用写法,迁移 Svelte 组件时遇到类型问题可以对照查。


Svelte 5 的迁移比"改几个 API"要重,但它是有规律可循的:响应式声明显式化、事件换成回调 prop、slot 换成 snippet。按组件逐个迁,用 Svelte 速查表 的搜索功能查具体写法,整个过程比我预想的顺很多。


Made by Toolora · Updated 2026-06-17