Svelte 4 升到 Svelte 5 的实战笔记:runes、snippet 和三个必须避开的坑
把真实项目从 Svelte 4 迁到 Svelte 5,要改的不止是 $state 替换 let。本文用对照代码拆解迁移过程,讲清 snippet 新写法、$effect 的正确姿势,以及最常踩的三个深层响应性坑。
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