首页
/ Svelte 5 深度解析 $effect Rune:副作用的依赖追踪、生命周期与全套变体

Svelte 5 深度解析 $effect Rune:副作用的依赖追踪、生命周期与全套变体

2026-09-05 18:12:46作者:虞亚竹Luna

本文以 Svelte 5 官方文档中的 $effect 说明为核心,完整覆盖 untrack$effect.pre$effect.tracking$effect.pending$effect.root 五个变体以及"何时不该用 $effect"的替代方案,并结合 Svelte 仓库编译器转换层(client/server 双端 visitor)与运行时 effects.js 的源码实现,说明每个 rune 在编译与执行阶段到底发生了什么。读完后,你将能够正确创建带依赖追踪与 teardown 的副作用、理解其微任务级批处理时序,并避免"用 effect 同步 state"这一典型反模式。

什么是 $effect:基本用法与适用场景

Effects 是一类"当状态更新时运行"的函数,适用于调用第三方库、在 <canvas> 上绘图、发起网络请求等场景。一个关键前提:Effects 只在浏览器中运行,不会在 SSR(服务端渲染)阶段执行。这一点在编译器源码中有直接体现:在 server 转换阶段,server CallExpression visitor 会将顶层的 $effect / $effect.pre 调用直接替换为 void 0(no-op),因此服务端产物里根本不存在这些副作用调用。

官方文档给出的标准示例是"用 $state 驱动 canvas 绘制":

<script>
	let size = $state(50);
	let color = $state('#ff3e00');

	let canvas;

	$effect(() => {
		const context = canvas.getContext('2d');
		context.clearRect(0, 0, canvas.width, canvas.height);

		// this will re-run whenever `color` or `size` change
		context.fillStyle = color;
		context.fillRect(0, 0, size, size);
	});
</script>

<canvas bind:this={canvas} width="100" height="100"></canvas>

Svelte 运行 effect 函数时,会记录函数体内访问了哪些 state(及派生 state)——除非这些访问发生在 untrack 之内——并在这些状态后续变化时重新运行该函数。文档中有一条重要提示:如果你的 $effect 重跑频率与预期不符,先阅读下文"依赖追踪"一节;与 Svelte 4 的 $: 语句块相比,effect 的触发机制有本质区别。

文档同时给出一个明确的使用倾向:总体上,你不应该在 effect 内更新 state,这会让代码变得更绕,并且常常导致永不停止的更新循环。如果你发现自己这么做了,请直接看文末的何时不该用 $effect一节。

从源码结构看,编译产物中 $effect 会被转换为运行时的 $.user_effect 调用——这一映射可以在 client CallExpression visitor 中确认:$effect 映射到 $.user_effect$effect.pre 映射到 $.user_pre_effect$effect.root 映射到 $.effect_root$effect.tracking 映射到 $.effect_tracking$effect.pending 则被编译成 $.eager(() => $.pending())

理解生命周期:挂载后、微任务内、批量合并

文档对 effect 生命周期的描述可以归纳为三条规则:

  1. 运行时机:effect 在组件挂载到 DOM 之后运行,且运行在状态变更之后的一个微任务(microtask)里;
  2. 批处理(batching):重跑是批量合并的——同一时刻修改 colorsize 不会触发两次独立运行;
  3. DOM 先于 effect:重跑发生在所有 DOM 更新已经应用之后。

此外,$effect 可以在任何位置使用,不限于组件顶层,只要它处于"某个父 effect 正在运行"的上下文中即可。

运行时源码印证了"顶层 effect 延迟到挂载后"这条规则。effects.js 中的 user_effect 在创建前先做了一次判断:如果当前处于一个未挂载组件的顶层(active_reaction 为空、父级是 BRANCH_EFFECT、且组件上下文尚未挂载),则不会立即创建 effect,而是把函数收集到 context.e 数组中,等挂载后再执行。这与文档"effects run after the component has been mounted to the DOM"的说法完全对应。

validate_effecteffects.js)还定义了 $effect 的合法调用位置:既不能出现在没有任何父 effect 的孤立位置(抛出 effect_orphan 错误),也不能出现在 teardown 阶段(抛出 effect_in_teardown 错误)。

一个容易被忽视但很有价值的事实:Svelte 在内部就使用 effect 来表示模板中的逻辑和表达式——<h1>hello {name}!</h1> 之所以会在 name 变化时更新,靠的正是底层 effect 机制。换言之,整个响应式渲染系统本身就是建立在 effect 之上的

Teardown 函数:清理副作用与组件销毁

Effect 函数可以返回一个 teardown function(清理函数),它会在以下两个时机立即执行:

  • effect 重跑之前
  • effect 被销毁时——即其父级被销毁(例如组件卸载),或父 effect 重跑。

官方示例:一个随 milliseconds 变化的 setInterval 计数器,milliseconds 变化时必须先清理旧定时器,否则会积累多个 interval:

<script>
	let count = $state(0);
	let milliseconds = $state(1000);

	$effect(() => {
		// This will be recreated whenever `milliseconds` changes
		const interval = setInterval(() => {
			count += 1;
		}, milliseconds);

		return () => {
			// if a teardown function is provided, it will run
			// a) immediately before the effect re-runs
			// b) when the component is destroyed
			clearInterval(interval);
		};
	});
</script>

<h1>{count}</h1>

<button onclick={() => (milliseconds *= 2)}>slower</button>
<button onclick={() => (milliseconds /= 2)}>faster</button>

在源码中,teardown 的调用路径是 destroy_effectexecute_effect_teardown:teardown 执行期间会显式把 active_reaction 置为 null(避免清理逻辑自身意外建立依赖),并标记 is_destroying_effect,使得在 teardown 里再调用 $effect 会直接触发 effect_in_teardown 错误。teardown 抛出异常时会被路由到最近的 <svelte:boundary> 错误边界系统。

理解依赖追踪:同步读取、异步断链与"上次读取"

这是理解 $effect 行为差异最大的部分,文档给出了四条规则:

规则 1:同步读取即依赖

$effect 会自动捕获函数体内被同步读取的响应式值($state$derived$props,包括经由函数调用间接读取的),并将它们注册为依赖。依赖变化时,$effect 调度一次重跑。

一个例外:如果 $state / $derived 是在 $effect 内部被"直接使用"的(例如在其中创建 reactive class),这些值不会被当作依赖。

规则 2:异步读取不会被追踪

await 之后、或 setTimeout 回调内读取的值不会被追踪。文档示例中,canvas 会在 color 变化时重绘,但不会size 变化时重绘,因为 size 的读取发生在定时器回调里:

$effect(() => {
	const context = canvas.getContext('2d');
	context.clearRect(0, 0, canvas.width, canvas.height);

	// this will re-run whenever `color` changes...
	context.fillStyle = color;

	setTimeout(() => {
		// ...but not when `size` changes
		context.fillRect(0, 0, size, size);
	}, 0);
});

规则 3:对象引用变化 ≠ 属性变化

Effect 只在它读取的对象本身变化时重跑,而不是对象内部属性变化时:

<script>
	let state = $state({ value: 0 });
	let derived = $derived({ value: state.value * 2 });

	// this will run once, because `state` is never reassigned (only mutated)
	$effect(() => {
		state;
	});

	// this will run whenever `state.value` changes...
	$effect(() => {
		state.value;
	});

	// ...and so will this, because `derived` is a new object each time
	$effect(() => {
		derived;
	});
</script>

<button onclick={() => (state.value += 1)}>
	{state.value}
</button>

<p>{state.value} doubled is {derived.value}</p>

三个 effect 的差异一目了然:只读 state 本身(从未重新赋值,只被 mutation)→ 只跑一次;读 state.value → 每次值变化都跑;读 derived → 因为 $derived 每次计算都产生新对象引用,所以也每次变化都跑。文档还提示:如果想在开发期观察对象内部的变化,可以使用 $inspect

规则 4:只依赖"上次运行时"实际读取的值

Effect 只依赖它上一次运行时读取到的值——这对含条件分支的 effect 有重要含义。以下示例中,当 conditiontruecolor 会被求值,因此 conditioncolor 的变化都会触发重跑;而当 conditionfalse 时,color 根本不会被读取,effect 就会在 condition 变化时重跑:

import confetti from 'canvas-confetti';

let condition = $state(true);
let color = $state('#ff3e00');

$effect(() => {
	if (condition) {
		confetti({ colors: [color] });
	} else {
		confetti();
	}
});

$effect.pre:在 DOM 更新之前运行

极少数场景需要在 DOM 更新之前执行代码,这时使用 $effect.pre。官方示例是消息列表的自动滚动:

<script>
	import { tick } from 'svelte';

	let div = $state();
	let messages = $state([]);

	// ...

	$effect.pre(() => {
		if (!div) return; // not yet mounted

		// reference `messages` array length so that this code re-runs whenever it changes
		messages.length;

		// autoscroll when new messages are added
		if (div.offsetHeight + div.scrollTop > div.scrollHeight - 20) {
			tick().then(() => {
				div.scrollTo(0, div.scrollHeight);
			});
		}
	});
</script>

<div bind:this={div}>
	{#each messages as message}
		<p>{message}</p>
	{/each}
</div>

时序上有两条必须注意的细节:

  • $effect.pre 运行在"它之后调度的 DOM 更新"之前,而不是本批次(flush)内每一次 DOM 变更之前——父组件的 DOM 可能已经更新;
  • 在使用 await 表达式 的组件中,同一组件里的 {#if ...}{#each ...} 等块级更新同样先于 $effect.pre 执行。

除时机不同外,$effect.pre 的行为与 $effect 完全一致。源码层面,$effect.pre 被编译为 user_pre_effecteffects.js),与 $effectEFFECT 标志不同,它携带 RENDER_EFFECT 标志——这正是"随渲染批次、在 DOM 变更前执行"的来源。

$effect.tracking:判断是否处于追踪上下文

$effect.tracking 是一个高级特性,用于判断当前代码是否运行在一个"追踪上下文"(tracking context)中,比如 effect 内部或模板表达式内:

<script>
	console.log('in component setup:', $effect.tracking()); // false

	$effect(() => {
		console.log('in effect:', $effect.tracking()); // true
	});
</script>

<p>in template: {$effect.tracking()}</p> <!-- true -->

它的主要用途是实现 createSubscriber 这类抽象:只有在值正在被追踪时才创建监听器来更新响应式值(例如在 effect/模板中读取时),而在非追踪上下文(如事件处理器)中读取则不会建立订阅。

实现非常精炼,effects.js 中的 effect_tracking 只有一行核心逻辑:

return active_reaction !== null && !untracking;

即:存在活跃的响应(reaction)且当前不在 untrack 之内。而在 server 端转换 中,$effect.tracking 被直接编译为字面量 false——服务端不存在追踪上下文。

$effect.pending:统计当前边界内的未决 Promise

当组件中使用 await 表达式 时,$effect.pending() 会告诉你当前 <svelte:boundary> 内(不含子边界)有多少个 promise 处于 pending 状态:

<script>
	let a = $state(1);
	let b = $state(2);

	async function add(a, b) {
		await new Promise((f) => setTimeout(f, 500)); // artificial delay
		return a + b;
	}
</script>

<button onclick={() => a++}>a++</button>
<button onclick={() => b++}>b++</button>

<p>{a} + {b} = {await add(a, b)}</p>

{#if $effect.pending()}
	<p>pending promises: {$effect.pending()}</p>
{/if}

编译上它被转换为 $.eager(() => $.pending())(见 client CallExpression visitor),$.pending 读取的是当前边界的 pending 计数器——这个计数器在 async.js 中通过 increment_pending / decrement_pending 随每个 async blocker 的创建与结算而增减,并经由 boundary.update_pending_count 汇总到边界上,因此它是响应式的:pending 数量变化会驱动模板重渲染。server 端则直接编译为字面量 0

$effect.root:脱离组件生命周期的独立 effect 作用域

$effect.root 是另一个高级特性:创建一个不被追踪、不会自动清理的独立作用域,用于创建需要手动控制的嵌套 effect;它同时允许在组件初始化阶段之外创建 effect:

const destroy = $effect.root(() => {
	$effect(() => {
		// setup
	});

	return () => {
		// cleanup
	};
});

// later...
destroy();

运行时实现 effect_root 展示了其本质:它调用 Batch.ensure() 确保批次系统就绪,以 ROOT_EFFECT | EFFECT_PRESERVED 标志创建 effect(不挂到任何组件树下),并返回一个 destroy 函数——调用它即触发 destroy_effect 销毁整棵子树。由于 ROOT_EFFECT 子树在父级销毁时会"升格"为独立根(见 destroy_effect_children 中对 ROOT_EFFECT 的特殊处理),$effect.root 创建的子树不受宿主组件卸载影响,完全由你掌握生命周期。

值得注意的 SSR 行为:在 server 转换 中,$effect.root() 调用被替换为一个空箭头函数——"mimics the cleanup function",即服务端返回的 destroy 是 no-op,与"effects 只在浏览器运行"的总原则保持一致。

何时不该用 $effect:把它当逃生舱,而不是状态同步工具

文档最后部分是最重要的实战指导:$effect 应当被视为一种逃生舱(escape hatch)——适合分析埋点、直接操作 DOM 这类场景,而不应该被频繁使用。尤其是不要用它来同步 state。

反面示例——用 effect 派生值:

<script>
	let count = $state(0);
	let doubled = $state();

	// don't do this!
	$effect(() => {
		doubled = count * 2;
	});
</script>

正确做法——用 $derived

<script>
	let count = $state(0);
	let doubled = $derived(count * 2);
</script>

对于比 count * 2 更复杂的逻辑,可以使用 $derived.by

另一个常见诱惑是用 effect 把两个值"双向联动"。文档给出了"花已花 / 剩余"两个滑块的反例:两个 $effect 互相写对方,形成冗余的同步循环:

<script>
	const total = 100;
	let spent = $state(0);
	let left = $state(total);

	$effect(() => {
		left = total - spent;
	});

	$effect(() => {
		spent = total - left;
	});
</script>

<label>
	<input type="range" bind:value={spent} max={total} />
	{spent}/{total} spent
</label>

<label>
	<input type="range" bind:value={left} max={total} />
	{left}/{total} left
</label>

<style>
	label {
		display: flex;
		gap: 0.5em;
	}
</style>

文档给出的替代方案,按优先级排列:

  1. 单向派生 + 事件回调left 直接用 $derived(total - spent) 计算,反向修改时通过 oninput 回调处理;
  2. 函数绑定(function bindings)——更优:
<script>
	const total = 100;
	let spent = $state(0);
	let left = $derived(total - spent);

	function updateLeft(left) {
		spent = total - left;
	}
</script>

<label>
	<input type="range" bind:value={spent} max={total} />
	{spent}/{total} spent
</label>

<label>
	<input type="range" bind:value={() => left, updateLeft} max={total} />
	{left}/{total} left
</label>

<style>
	label {
		display: flex;
		gap: 0.5em;
	}
</style>

(函数绑定的完整语法参见 bind 文档。)

  1. 乐观 UI 场景的更新:如果你用 effect 只是为了能重新赋值派生值(例如构建乐观 UI),文档指出自 Svelte 5.25 起 derived 值可以直接被覆盖,无需绕道 effect。
  2. 最后手段:如果实在必须在 effect 中更新 $state,并且因为"读写了同一个 $state"而陷入死循环,使用 untrack 切断写入侧的追踪。

总结:一个 rune 家族的完整心智模型

把本文内容压缩成一张速查表:

Rune 编译目标(client) 运行时机 典型用途
$effect(fn) $.user_effect,顶层调用延迟至挂载后(见 effects.js 挂载后、状态变更后的微任务、DOM 更新之后 canvas 绘制、第三方库、网络请求
$effect.pre(fn) $.user_pre_effectRENDER_EFFECT 标志) 本批次 DOM 更新之前 滚动位置修正等读取布局前的逻辑
$effect.tracking() $.effect_tracking,server 端为 false 同步求值 实现条件订阅(如 createSubscriber
$effect.pending() $.eager(() => $.pending()),server 端为 0 同步求值,响应式 显示当前 <svelte:boundary> 内未决 promise 数
$effect.root(fn) $.effect_root,server 端为 no-op 箭头函数 立即执行,返回 destroy 手动销毁 组件树之外、生命周期自管的嵌套 effect

记住三条核心纪律:依赖追踪只覆盖同步读取await / setTimeout 之后断链,untrack 内不追踪);effect 只依赖上一次运行时实际读取的值(条件分支会导致依赖集动态变化);$effect 是逃生舱而非状态同步工具——能用 $derived、函数绑定解决的数据联动问题,永远不要用两个互相写对方的 effect。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.12 K
2.72 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
528
588
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
906
1.83 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
854
1.34 K
docsdocs
暂无描述
Markdown
891
5.79 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.53 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.34 K
1.45 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
988
506
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384