$effect

effect 是一些在状态更新时运行的函数,可用于调用第三方库、在 <canvas> 上绘制,或发起网络请求等。它们只在浏览器中运行,不会在服务端渲染期间执行。

一般来说,你_不应该_在 effect 中更新状态,因为这会让代码变得更复杂,并常常导致永不停止的更新循环。如果你发现自己正在这样做,请参阅何时不该使用 $effect 了解替代方案。

你可以使用 $effect rune 创建一个 effect(示例):

<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);

		// 只要 `color` 或 `size` 变化,就会重新运行
		context.fillStyle = color;
		context.fillRect(0, 0, size, size);
	});
</script>

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

当 Svelte 运行一个 effect 函数时,它会追踪哪些状态(以及派生状态)被访问了(除非是在 untrack 内部访问的),并在该状态随后变化时重新运行函数。

[!NOTE] 如果你难以理解为什么你的 $effect 会重新运行或不运行,请参阅理解依赖关系。effect 的触发方式与你从 Svelte 4 迁移过来时习惯的 $: 块不同。

理解生命周期

你的 effect 会在组件挂载到 DOM 之后运行,并且是在状态变化后的一个微任务中。重新运行会被批处理(也就是说,在同一时刻改变 colorsize 不会导致两次独立的运行),并且发生在任何 DOM 更新应用之后。

只要是在某个父 effect 正在运行时调用,$effect 可以用在任何地方,而不仅仅是在组件的顶层。

[!NOTE] Svelte 在内部使用 effect 来表示模板中的逻辑和表达式——这正是 <h1>hello {name}!</h1> 能在 name 变化时更新的方式。

一个 effect 可以返回一个_清理函数(teardown function)_,它会在 effect 重新运行之前立即执行:

<!--- file: App.svelte --->
<script>
	let count = $state(0);
	let milliseconds = $state(1000);

	$effect(() => {
		// 每当 `milliseconds` 变化时,这个 interval 都会被重新创建
		const interval = setInterval(() => {
			count += 1;
		}, milliseconds);

		return () => {
			// 如果提供了清理函数,它会在以下时机运行:
			// a) effect 重新运行之前立即运行
			// b) 组件被销毁时
			clearInterval(interval);
		};
	});
</script>

<h1>{count}</h1>

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

清理函数也会在 effect 被销毁时运行,这发生在它的父级被销毁(例如组件被卸载)或父 effect 重新运行时。

理解依赖关系

$effect 会自动收集在其函数体内_同步_读取的所有响应式值($state$derived$props),并将它们注册为依赖。当这些依赖变化时,$effect 会安排一次重新运行。

如果 $state$derived 是在 $effect 内部直接使用的(例如在创建响应式类时),这些值将_不会_被视为依赖。

_异步_读取的值——例如在 await 之后或 setTimeout 内部——将不会被追踪。在这里,当 color 变化时会重绘 canvas,但当 size 变化时不会(示例):

declare let canvas: {
	width: number;
	height: number;
	getContext(type: '2d', options?: CanvasRenderingContext2DSettings): CanvasRenderingContext2D;
};
declare let color: string;
declare let size: number;

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

	// 只要 `color` 变化就会重新运行……
	context.fillStyle = color;

	setTimeout(() => {
		// ……但 `size` 变化不会
		context.fillRect(0, 0, size, size);
	}, 0);
});

一个 effect 只会在它读取的对象发生变化时重新运行,而不会在该对象内部的属性变化时重新运行。(如果你想在开发时观察对象_内部_的变化,可以使用 $inspect。)

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

	// 这会运行一次,因为 `state` 从未被重新赋值(只是被修改)
	$effect(() => {
		state;
	});

	// 只要 `state.value` 变化,这就会运行……
	$effect(() => {
		state.value;
	});

	// ……这个也是,因为 `derived` 每次都是一个新对象
	$effect(() => {
		derived;
	});
</script>

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

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

一个 effect 只依赖于它上一次运行时读取的值。这对于带有条件代码的 effect 有一些有趣的影响。

例如,如果在下面的代码片段中 conditiontrueif 块内的代码会运行,color 会被求值。这意味着 conditioncolor 的变化都会使 effect 重新运行

反之,如果 conditionfalsecolor 不会被求值,effect 将_只_在 condition 变化时重新运行。

declare module 'canvas-confetti' {
	interface ConfettiOptions {
		colors: string[];
	}

	function confetti(opts?: ConfettiOptions): void;
	export default confetti;
}

import confetti from 'canvas-confetti';

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

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

$effect.pre

在极少数情况下,你可能需要在 DOM 更新_之前_运行代码。为此我们可以使用 $effect.pre rune:

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

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

	// ...

	$effect.pre(() => {
		if (!div) return; // 尚未挂载

		// 引用 `messages` 数组的长度,以便这段代码在其变化时重新运行
		messages.length;

		// 添加新消息时自动滚动到底部
		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 的工作方式与 $effect 完全相同。

$effect.tracking

$effect.tracking rune 是一个高级特性,用于告诉你代码是否运行在追踪上下文中,例如 effect 内部或你的模板内部:

<!--- file: App.svelte --->
<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.pending

在组件中使用 await 时,$effect.pending() rune 会告诉你当前边界内有多少个 promise 处于等待状态(不包括子边界):

<!--- file: App.svelte --->
<script>
	let a = $state(1);
	let b = $state(2);

	async function add(a, b) {
		await new Promise((f) => setTimeout(f, 500)); // 人为延迟
		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}

$effect.root

$effect.root rune 是一个高级特性,它创建一个不会被自动清理的非追踪作用域。这对于你想要手动控制的嵌套 effect 很有用。这个 rune 还允许在组件初始化阶段之外创建 effect。

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

	return () => {
		// 清理
	};
});

// 之后……
destroy();

何时不该使用 $effect

总的来说,$effect 最好被视为一种_逃生舱口(escape hatch)_——适用于分析统计和直接操作 DOM 之类的事情——而不是一个你应该频繁使用的工具。尤其要避免用它来同步状态。不要这样做……

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

	// 不要这样做!
	$effect(() => {
		doubled = count * 2;
	});
</script>

……而应该这样做:

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

[!NOTE] 对于比 count * 2 这样简单表达式更复杂的情况,你也可以使用 $derived.by

如果你使用 effect 是因为你想能够重新赋值派生值(例如构建乐观 UI),请注意派生值可以直接被覆盖(自 Svelte 5.25 起)。

你可能会忍不住用 effect 做一些复杂的事情来把一个值链接到另一个值。下面的示例展示了两个分别用于 "花费金额" 和 "剩余金额" 的输入,它们相互关联。如果你更新其中一个,另一个也应相应更新。与其为此使用 effect……

<!--- file: App.svelte --->
<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>

……不如尽可能使用 oninput 回调,或者——更好的是——函数绑定

<!--- file: App.svelte --->
<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>

如果你绝对必须在 effect 内更新 $state,并且因为对同一 $state 既读又写而陷入无限循环,请使用 untrack