Svelte 5 深度解析 $effect Rune:副作用的依赖追踪、生命周期与全套变体
本文以 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 生命周期的描述可以归纳为三条规则:
- 运行时机:effect 在组件挂载到 DOM 之后运行,且运行在状态变更之后的一个微任务(microtask)里;
- 批处理(batching):重跑是批量合并的——同一时刻修改
color和size不会触发两次独立运行; - 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_effect(effects.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_effect → execute_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 有重要含义。以下示例中,当 condition 为 true 时 color 会被求值,因此 condition 或 color 的变化都会触发重跑;而当 condition 为 false 时,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_effect(effects.js),与 $effect 的 EFFECT 标志不同,它携带 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>
文档给出的替代方案,按优先级排列:
- 单向派生 + 事件回调:
left直接用$derived(total - spent)计算,反向修改时通过oninput回调处理; - 函数绑定(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 文档。)
- 乐观 UI 场景的更新:如果你用 effect 只是为了能重新赋值派生值(例如构建乐观 UI),文档指出自 Svelte 5.25 起 derived 值可以直接被覆盖,无需绕道 effect。
- 最后手段:如果实在必须在 effect 中更新
$state,并且因为"读写了同一个$state"而陷入死循环,使用untrack切断写入侧的追踪。
总结:一个 rune 家族的完整心智模型
把本文内容压缩成一张速查表:
| Rune | 编译目标(client) | 运行时机 | 典型用途 |
|---|---|---|---|
$effect(fn) |
$.user_effect,顶层调用延迟至挂载后(见 effects.js) |
挂载后、状态变更后的微任务、DOM 更新之后 | canvas 绘制、第三方库、网络请求 |
$effect.pre(fn) |
$.user_pre_effect(RENDER_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。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00