Svelte 5 Runes 实践:从 `let` 赋值到 `$state`、`$derived`、`$effect`

Svelte 4 里让一个组件"活起来"很简单:声明一个顶层变量,赋值,视图就跟着更新。

1<script>
2  let count = 0;
3
4  function increment() {
5    count += 1;
6  }
7
8  $: doubled = count * 2;
9</script>
10
11<button on:click={increment}>{count}</button>
12<p>doubled: {doubled}</p>

这段代码没有任何显式的"注册响应式"动作,count += 1 这一行赋值本身就触发了更新,$: doubled = count * 2 这一行也不是普通 JS 语法,是 Svelte 编译器认出的响应式声明。这套机制能这么省心,靠的是编译期静态分析:编译器扫描 .svelte 文件的 <script> 块,把每一处顶层变量赋值改写成"赋值后调用组件内部的更新函数",把每一处 $: 标签语句解析成一段依赖关系图,再插入对应的调度代码。

问题是这套分析只认组件文件里的顶层作用域。如果我想把"进度百分比根据已完成任务数和总任务数计算"这段逻辑,从组件里挑出来放进一个普通的 .js 文件,让另外三个组件也能复用,会立刻卡住:

1// progress.js —— 这段代码在 Svelte 4 里没有意义
2let done = 0;
3let total = 0;
4$: percent = total === 0 ? 0 : Math.round((done / total) * 100);

$: 这种语法只有经过 Svelte 编译器处理的 .svelte 文件才认得,普通 .js/.ts 文件里的 $: 会被当成一个不存在的标签语句直接报语法错误。想复用,只能退回到写一个返回 { subscribe } 的 Svelte store(writablederived),或者把这段逻辑原样复制粘贴到每个用到它的组件里。我们内部那套发布看板,"当前环境的部署耗时"这个计算逻辑就在三个组件里各贴了一份,改一次要改三处,去年底一次调整超时阈值的判断逻辑,还是漏改了一处,导致灰度环境的耗时显示一直是错的。

$statelet 赋值的区别

Svelte 5 的 Runes 把这套隐式响应式换成了一组显式函数(准确说是编译器认识的特殊标识符,长得像函数调用),核心是 $state

1<script>
2  let count = $state(0);
3
4  function increment() {
5    count += 1;
6  }
7
8  let doubled = $derived(count * 2);
9</script>
10
11<button onclick={increment}>{count}</button>
12<p>doubled: {doubled}</p>

表面上看起来变化不大,count 还是一个普通变量,赋值方式也没变。但底层机制完全不同了:$state(0) 返回的不是一个原始数字,而是编译器在背后接管了这个变量的读写——读的时候建立依赖追踪,写的时候触发通知。这一步不再靠"扫描顶层赋值语句"这种模式匹配,而是任何被 $state 声明过的变量,无论它出现在组件顶层、函数内部、还是被传进另一个普通函数里,都保留响应式能力。

这个差异直接解决了前面那个 progress.js 的问题。Runes 允许在普通的 .svelte.js(或 .svelte.ts)文件里使用 $state$derived$effect,编译器识别的是文件后缀而不是"必须在组件里"这条规则:

1// progress.svelte.js
2export function createProgress() {
3  let done = $state(0);
4  let total = $state(0);
5
6  let percent = $derived(total === 0 ? 0 : Math.round((done / total) * 100));
7
8  function setDone(n) {
9    done = n;
10  }
11
12  function setTotal(n) {
13    total = n;
14  }
15
16  return {
17    get percent() { return percent; },
18    setDone,
19    setTotal
20  };
21}

这段逻辑现在可以被任意组件 import,各自拿到独立的一份状态,不再需要 store 那套 subscribe/$ 前缀语法,也不用把逻辑复制三份。我们把发布看板里那个耗时计算逻辑挪进一个 deploy-timing.svelte.js 之后,三个组件全部改成引用同一处实现,那次漏改超时阈值的问题类型直接从根上消失了。

值得留意的是返回值那几个 get percent():直接 return { percent } 是不行的,那样返回的是解构出来的一份快照值,后续 done/total 变化不会同步过去,因为 JS 对象属性访问本身不带响应式,必须用 getter 把"读取"这个动作留在闭包里、每次访问时重新求值。这是用 Runes 写可复用逻辑时最容易踩的一个点,我们团队另一个同事第一次写类似封装时就直接解构返回,调试了小半天才发现问题出在这儿。

$derived$derived.by:简单表达式和复杂推导

$derived 接受一个表达式,适合一行能写完的计算:

1<script>
2  let price = $state(99);
3  let quantity = $state(2);
4  let subtotal = $derived(price * quantity);
5</script>

但如果推导逻辑需要多个步骤——比如过滤、排序、聚合叠在一起,硬塞进一个表达式会写得很难看。这种情况用 $derived.by,接受一个函数:

1<script>
2  let deployments = $state([
3    { env: 'staging', status: 'success', duration: 42 },
4    { env: 'canary', status: 'running', duration: 0 },
5    { env: 'prod', status: 'failed', duration: 118 }
6  ]);
7
8  let summary = $derived.by(() => {
9    let finished = deployments.filter(d => d.status !== 'running');
10    let totalDuration = finished.reduce((sum, d) => sum + d.duration, 0);
11    let failedCount = finished.filter(d => d.status === 'failed').length;
12    return {
13      count: finished.length,
14      avgDuration: finished.length ? Math.round(totalDuration / finished.length) : 0,
15      failedCount
16    };
17  });
18</script>
19
20<p>已完成 {summary.count} 个环境,平均耗时 {summary.avgDuration}s,失败 {summary.failedCount} 个</p>

$derived.by 里能写任意同步逻辑,唯一的规则是这个函数体里读到的每一个响应式值都会被自动记进依赖列表,函数返回的结果会被缓存,只有依赖变化时才重新执行。这一点和 Svelte 4 的 $: 表达式其实是一致的——$: 背后本质也是"依赖变了才重跑",只是 Svelte 4 里想写多行逻辑得靠花括号包一个代码块,可读性和 IDE 的类型推导都要差一截,$derived.by 用的是一个真正的函数,编辑器补全和类型检查跟普通函数没有区别。

有一点从 Svelte 4 迁移过来容易搞混:$derived$derived.by 里都不能直接给依赖的响应式值赋值。如果在推导函数里顺手改了外层某个 $state 变量,运行时会报错提示不能在派生计算里产生副作用。这个限制在 Svelte 4 的 $: 表达式里其实没有强制检查,我们老代码里就有一处 $: { if (count > 10) count = 10 } 这种"表达式里带副作用"的写法,迁移到 $derived 时编译直接报错,倒逼我们把这段逻辑挪进了 $effect

$effect$: 副作用写法的差别

Svelte 4 的 $: 是个多面手,既能声明派生值,也能写副作用:

1<script>
2  let count = 0;
3
4  $: if (count > 5) {
5    console.log('count 超过 5 了');
6  }
7</script>

这行代码看起来和声明 doubled 的写法一模一样,但语义完全不同——前者是"计算并缓存一个值",后者是"执行一段动作"。两种意图共用同一个语法,代码审查时经常要多看一眼才能分清这段 $: 到底是在算东西还是在做事。

Runes 把这两种意图拆成了两个 API。副作用专用 $effect

1<script>
2  let count = $state(0);
3
4  $effect(() => {
5    if (count > 5) {
6      console.log('count 超过 5 了');
7    }
8  });
9</script>

$effect 回调里读到的响应式值同样会被自动追踪,依赖变化时重新执行整个回调。它和 $derived 的区别在使用场景上分得很清楚:$derived 用来算一个要在模板里展示或者被别的计算依赖的值,$effect 用来做和渲染无关的动作——发请求、写 localStorage、订阅一个外部事件源、操作 DOM。这条区分在 Svelte 4 里全靠开发者自觉,Runes 里则是两个不同的函数名,看到 $effect 就知道这段代码不是在算值。

$effect 支持返回一个清理函数,在依赖重新触发或者组件销毁时执行,这点和 React 的 useEffect 是一路的:

1<script>
2  let env = $state('staging');
3
4  $effect(() => {
5    let socket = new WebSocket(`wss://deploy.internal/ws?env=${env}`);
6    socket.onmessage = (e) => console.log('deploy event', e.data);
7
8    return () => {
9      socket.close();
10    };
11  });
12</script>

env 一变,上一个 socket 会先被清理函数关掉,再开一个新的连到新环境。Svelte 4 里做同样的事得靠 onDestroy 加一个手动维护的变量记住上一次的 socket 实例,逻辑分散在两个地方;Runes 里清理和重新执行天然是同一个函数的两半,读起来是连续的。

$effect 还有一个变体 $effect.pre,会在 DOM 更新之前运行,对应 Svelte 4 里 beforeUpdate 那类需要在视图刷新前读取旧尺寸、旧滚动位置的场景。日常业务代码里用得不多,但做虚拟滚动或者需要手动测量布局抖动的场景会用到。

$props 替代 export let

Svelte 4 声明 props 是给顶层变量加 export 关键字:

1<script>
2  export let title;
3  export let count = 0;
4  export let onSave = () => {};
5</script>

这套写法有个别扭的地方:export 在 JS 里本来是模块导出的语义,用来声明组件属性是 Svelte 编译器专门为组件文件定制的特殊用法,脱离 .svelte 文件语境的话很难猜出这是在干什么。Runes 里改成显式解构:

1<script>
2  let { title, count = 0, onSave = () => {} } = $props();
3</script>

这就是一句普通的 JS 解构赋值,默认值语法和普通函数参数默认值完全一致,也没有再借用某个 JS 关键字表达一层编译器专属含义。如果组件需要接收一些没有提前列出名字的额外属性(比如透传给根元素的 classstyle),用 rest 解构:

1<script>
2  let { title, ...rest } = $props();
3</script>
4
5<div {...rest}>{title}</div>

Svelte 4 对应的写法要靠 $$restProps 这个编译器注入的隐式变量,同样是"看代码猜不出含义、得查文档"的那类特殊语法,$props() 解构出来的 rest 就是普通对象,行为可预期。

把一个 Svelte 4 组件搬成 Runes 版本

发布看板里有个"环境状态卡片"组件,负责接收一个环境名和轮询间隔,展示当前部署状态并在状态变化时打日志。Svelte 4 版本长这样:

1<!-- EnvCard.svelte(Svelte 4)-->
2<script>
3  import { onDestroy } from 'svelte';
4
5  export let envName;
6  export let pollInterval = 5000;
7
8  let status = 'idle';
9  let lastChecked = null;
10
11  $: statusLabel = status === 'success' ? '正常'
12    : status === 'failed' ? '异常'
13    : status === 'running' ? '部署中' : '未知';
14
15  $: if (status === 'failed') {
16    console.warn(`${envName} 部署失败`);
17  }
18
19  let timer = setInterval(async () => {
20    let res = await fetch(`/api/status/${envName}`);
21    let data = await res.json();
22    status = data.status;
23    lastChecked = new Date();
24  }, pollInterval);
25
26  onDestroy(() => clearInterval(timer));
27</script>
28
29<div class="card">
30  <h3>{envName}</h3>
31  <p>{statusLabel}</p>
32</div>

改成 Runes 之后:

1<!-- EnvCard.svelte(Svelte 5)-->
2<script>
3  let { envName, pollInterval = 5000 } = $props();
4
5  let status = $state('idle');
6  let lastChecked = $state(null);
7
8  let statusLabel = $derived.by(() => {
9    if (status === 'success') return '正常';
10    if (status === 'failed') return '异常';
11    if (status === 'running') return '部署中';
12    return '未知';
13  });
14
15  $effect(() => {
16    if (status === 'failed') {
17      console.warn(`${envName} 部署失败`);
18    }
19  });
20
21  $effect(() => {
22    let timer = setInterval(async () => {
23      let res = await fetch(`/api/status/${envName}`);
24      let data = await res.json();
25      status = data.status;
26      lastChecked = new Date();
27    }, pollInterval);
28
29    return () => clearInterval(timer);
30  });
31</script>
32
33<div class="card">
34  <h3>{envName}</h3>
35  <p>{statusLabel}</p>
36</div>

两处改动值得说一下。一是 onDestroy + 手写 setInterval 变成了 $effect 加返回清理函数,好处是如果以后 pollIntervalexport let 改成可以运行时更新的响应式 prop,Svelte 4 版本的 setInterval 不会自动用新的间隔重开定时器(它只在组件初始化时跑一次那段顶层代码),Runes 版本的 $effect 只要读了 pollInterval,间隔一变就会自动清理旧定时器、按新间隔重开一个,行为更符合预期,不需要额外补一段"手动监听 pollInterval 变化"的代码。二是原来那句 $: if (status === 'failed') 判断到底是派生声明还是副作用,看代码看不出来,改完之后 $effect 这个名字本身就说明白了。

生态里的第三方库跟进情况

Svelte 5 是 2024 年底发布的正式版本,编译器对 Svelte 4 语法保留了兼容模式,一个项目里 Runes 组件和 export let/$: 老组件可以混用,.svelte 文件是否启用 Runes 由文件里出现没出现 $state 这类符号自动判断,官方还提供了 npx svelte-migrate 这个半自动迁移脚本,能处理大部分 export let$props、简单 $:$derived 的转换,复杂的副作用判断脚本处理不了,得手动过一遍。

SvelteKit 从 2.x 系列开始就是按 Svelte 5 兼容设计的,load 函数、+page.svelte 这套路由约定没有因为 Runes 发生结构性变化,data prop 现在推荐用 $props() 接收而不是 export let data,但两种写法目前都能跑。第三方组件库这边跟进速度不算慢,我们用的表单校验库和一个日期选择器组件今年上半年都出了适配 Runes 内部实现的大版本,用法层面 API 基本没变,主要是内部状态管理换成 $state,带来的直接好处是这些组件库自己也不再需要维护一份 store 包装。也有几个更小众的社区插件还停留在只支持 Svelte 4 语法的版本,团队里评估要不要引入新依赖时,这一条现在会单独确认一下。

和 Vue、React 的模型比一眼

$state 拿到的变量语法上和普通变量没有区别,赋值就是赋值,这一点和 Vue 3 的 ref 不一样——Vue 的 ref 返回一个带 .value 的对象,模板里可以省略 .value,脚本里不能省,读写都要多打几个字符。$derived 和 Vue 的 computed 概念上是一回事,都是惰性求值加缓存。React 的 useState 走的是另一条路:setCount 触发的是组件函数整体重新执行,靠 useMemo/useEffect 的依赖数组手动声明"这个值依赖哪些变量";Runes 里依赖关系是运行时自动追踪出来的,不需要手写依赖数组,也就不存在依赖数组漏写一个变量导致读到旧值这类问题——这类问题在 React 项目的代码审查里我们见得不少,Runes 这套设计从机制上把这类错误挪到了别处:如果一段响应式代码没有正确追踪到某个依赖,通常是因为那次读取被包在了 untrack 里或者写在了非响应式的普通函数调用之外,属于另一类需要留意的写法,但至少不再是"忘记往数组里加一项"这种纯靠记忆力的失误。

现在切,还是再等等

发布看板这个项目我们只把三个使用频率最高、逻辑最绕的模块换成了 Runes,其余部分暂时保留 Svelte 4 语法,没有做全量迁移。理由很实际:兼容模式让新旧语法能在同一个项目里共存,没有必须一次性切完的压力,先挑收益最明显的地方动手,看实际效果再决定要不要继续推进。

从这一轮试用下来的判断是,如果项目里有跨组件复用的响应式逻辑、之前靠 store 或者复制粘贴解决,.svelte.js 加 Runes 这套方案值得现在就上,代码量和可读性的提升是立刻能感觉到的,我们那处耗时计算逻辑合并成一份之后再也没出现过三处改一处漏的问题。如果项目是纯展示型、组件内部逻辑简单,$:$derived/$effect 的收益没那么大,可以按自己的节奏慢慢挪,不用赶时间。第三方依赖比较重的项目,动手前建议先过一遍依赖列表确认关键的库是不是还停留在只支持 Svelte 4 语法的版本,这条现在还得手动核对,官方还没有一份权威的兼容性清单能一次查完。团队内部工具这类改动成本可控、出问题影响范围小的项目,是练习 Runes 写法性价比最高的地方,等真正摸透了这套心智模型再往核心业务代码上迁移,踩坑成本会低很多。