Svelte 遗留模式 `$:` 响应式语句深度解析:从编译期依赖分析到 legacy_pre_effect 运行时实现
本篇聚焦 Svelte 遗留模式(legacy mode)中 $: 标签语句(reactive statement)这一机制:它的语法与语义、编译期如何识别和收集依赖、编译器如何将其转译为 $.legacy_pre_effect 调用并实现拓扑顺序执行,以及 SSR 场景下浏览器专属代码的规避写法。读完本文,你将能够准确使用 $: 语句及其变体(块语句、解构赋值),理解"依赖在编译期静态确定"这一核心限制背后的源码原理,并知道如何在 Svelte 5 的 runes 模式中用 $derived / $effect 替代它。
背景:什么是 legacy mode,$: 语句定位在哪
Svelte 5 引入了 runes(如 $state、$derived、$effect)。为了让 Svelte 3/4 代码继续可用,Svelte 5 保留了旧版语法,形成 legacy 模式 与 runes 模式并存的局面:
- 组件一旦进入 runes 模式(使用了 rune,或显式设置了
runes: true编译选项),legacy 模式特性即不可用; - 在 runes 模式下,状态更新的反应由
$derived和$effect承担,$:会被编译器直接拒绝(错误码legacy_reactive_statement_invalid,见 errors.js); - 在 legacy 模式下,
<script>中任何顶层语句(不在块或函数内部的语句)都可以用$:标签前缀变为响应式语句。
对"顶层"这一限定,编译器在分析阶段有严格判断——只有 ast_type === 'instance' 且父节点是 Program 的 $: 标签才会被识别为响应式语句;放在其他位置的 $: 会触发 reactive_declaration_invalid_placement 警告(见 LabeledStatement.js 与 warnings.js):
const is_reactive_statement =
context.state.ast_type === 'instance' && parent.type === 'Program';
基本用法:响应式语句与响应式赋值
响应式语句在 <script> 其余代码之后、组件 markup 渲染之前执行,此后每当其依赖的值发生变化时重新执行。完整示例如下:
<script>
let a = 1;
let b = 2;
// 这是一个 'reactive statement'(响应式语句),
// 当 `a`、`b` 或 `sum` 变化时它会重新运行
$: console.log(`${a} + ${b} = ${sum}`);
// 这是一个 'reactive assignment'(响应式赋值)——
// 当 `a` 或 `b` 变化时 `sum` 会被重新计算,
// 无需单独声明 `sum`
$: sum = a + b;
</script>
两个关键点:
- 执行时机:响应式语句不随书写位置立即执行,而是先收集、后统一执行(晚于
<script>中的普通代码,早于模板渲染),之后在依赖变化时重跑; - 拓扑排序:语句按其依赖与赋值关系被拓扑排序执行。上例中
console.log语句依赖sum,因此即使$: sum = a + b在源码中写在后面,sum也会先被计算。
"拓扑排序"在编译与运行两侧都有对应实现:
- 客户端转换阶段,每条
$:被登记到legacy_reactive_statements这个 Map 中,注释明确写着 "The $: calls, which will be ordered in the end"(见 transform-client.js 与 types.d.ts),最终按analysis.reactive_statements的收集顺序统一插入组件实例代码; - 运行时,每条语句被编译为
$.legacy_pre_effect(deps, fn)调用。legacy_pre_effect内部会创建一个 render effect:先执行deps()(读取依赖并建立跟踪),再在untrack(fn)中执行语句本体——语句体内的写入不会再次触发自身,从而避免无限循环(见 effects.js 中的legacy_pre_effect实现)。由于各语句的 effect 按依赖关系互相感知,写sum的语句先于读sum的语句被调度,宏观上即呈现出"按依赖拓扑执行"的行为;父级的legacy_pre_effect_reset渲染 effect 则负责在每次更新中遍历所有语句 token、将 CLEAN 的效果置为 MAYBE_DIRTY 并推进脏语句(同文件中legacy_pre_effect_reset)。
块语句与解构赋值
当一条响应式语句需要多行逻辑时,把语句放进块里即可。例如在 items 变化时重新累计 total:
// 当 `items` 变化时重新计算 `total`
$: {
total = 0;
for (const item of items) {
total += item.value;
}
}
响应式赋值左边既可以是单个标识符,也可以是解构赋值:
$: ({ larry, moe, curly } = stooges);
从源码看,编译器对赋值左值做了专门处理:在 分析阶段的 LabeledStatement.js 中,如果 $: 的 body 是形如 x = ... 的赋值表达式,会用 extract_identifiers 提取左值中的全部标识符(对成员表达式则退化为取对象部分),并把这些绑定标记为 legacy_reactive 种类、记录其 legacy_dependencies。这意味着 $: { larry, moe, curly } = stooges 会同时让 larry、moe、curly 三个变量都成为响应式变量,各自依赖 stooges。
这一标记在客户端转换阶段被兑现为信号声明:所有 kind === 'legacy_reactive' 的绑定会被生成 const x = $.mutable_source(...) 声明(见 transform-client.js 中 legacy_reactive_declarations 的构建逻辑)。也就是说,$: 赋值右侧"未声明即使用"的变量,实际上由编译器自动补上了一个可变源信号。
依赖是如何确定的:编译期静态分析
一条 $: 语句的依赖在编译期确定——规则是:语句内部被引用(referenced)但未被赋值(assigned)的变量。
分析阶段的具体逻辑(LabeledStatement.js):遍历当前作用域中该语句捕获到的所有引用,沿着 AST 路径向上回溯成员表达式(MemberExpression),只有当该标识符恰好是某个 = 赋值表达式左侧的一部分时才跳过,否则该绑定被记入 reactive_statement.dependencies:
// 每一个被引用的绑定都会成为依赖,
// 除非它位于 `=` 赋值的左侧
for (const [name, nodes] of context.state.scope.references) {
// ... 向上回溯到 MemberExpression 的根部 ...
if (
parent.type === 'AssignmentExpression' &&
parent.operator === '=' &&
parent.left === left
) {
continue; // 左值不算依赖
}
reactive_statement.dependencies.push(binding);
break;
}
客户端转换阶段同样只把这些"看得见"的依赖编译进 deps thunk(客户端 LabeledStatement.js):普通(非 import)的 normal 绑定会被跳过,因为 legacy 语句内部对它们的重读会通过信号本身建立跟踪;prop 及 $$props / $$restProps 等需要 $.deep_read_state 做深度读取,因为来自 runes 组件的 prop 可能是细粒度 $state,整体写入不会触发粗粒度更新。
陷阱一:间接引用不可见
因为依赖是编译期静态分析的,编译器"看不见"通过函数调用传递的依赖。下面的语句在 count 变化时不会重跑,因为 count 没有直接出现在语句文本中:
let count = 0;
let double = () => count * 2;
$: doubled = double();
要让它响应 count 变化,只能让 count 直接出现在 $: 语句里。
陷阱二:间接依赖破坏拓扑排序
同理,若依赖被间接引用,拓扑排序也会失效:下面的例子中 z 永远不会更新,因为更新发生时 y 未被标记为"脏"(setY 是外部函数,y = value 的写入发生在 $: setY(x) 的静态依赖视野之外)。把 $: z = y 移到 $: setY(x) 之后并不能从根上解决问题——文档给出的修复方式是调整语句顺序,让依赖链落在可见的静态引用上:
<script>
let x = 0;
let y = 0;
$: z = y;
$: setY(x);
function setY(value) {
y = value;
}
</script>
这两类陷阱的共同根源都是同一句话:依赖 = 语句内部被引用且未被赋值的标识符集合,函数体、闭包、属性访问链内部的写入对编译器都不可见。
服务端渲染:包裹浏览器专属代码
响应式语句在服务端渲染(SSR)时同样会执行,因此任何只应在浏览器中运行的代码必须包进 if 块:
$: if (browser) {
document.title = title;
}
这一点可以从服务端转换代码得到印证:SSR 转换器对 $: 的处理是保留 $ 标签后原样保留语句(保留标签是因为语句内部可能写 break $),并同样参与"最终排序"(见 server/visitors/LabeledStatement.js)——也就是说 $: 语句在 SSR 输出时是真实执行的,而不是被跳过。因此在涉及 document、window 等浏览器 API 的响应式语句中做环境判断是必须的。
与 runes 模式的对照与迁移路径
对仍在维护 Svelte 3/4 代码、或尚未迁移的组件,$: 语句是唯一的状态派生与副作用手段;但对新代码,官方建议是逐段迁移到 runes(参见 v5 迁移指南):
| legacy 写法 | runes 等价物 | 语义对应 |
|---|---|---|
$: sum = a + b |
let sum = $derived(a + b) |
派生值,无需手动声明变量 |
$: console.log(...) / 副作用语句 |
$effect(() => { ... }) |
依赖变化后执行的副作用 |
$: { 多行块 } |
$derived / $effect 按职责拆分 |
块内既计算又执行副作用时需分别落位 |
Svelte 编译器内置的 migrate 工具能够自动改写一部分响应式赋值:migrate/index.js 中,当赋值左值的所有绑定都是 legacy_reactive、右值不是字面量、不涉及 store 订阅且不是成员表达式赋值时,会被转换到 rune 形式。无法自动转换的场景(含成员表达式、store 等)需要人工改写。需要注意的适用前提:
- 组件一旦进入 runes 模式,任何遗留的
$:都会直接编译报错,不存在"部分兼容"的中间态; runes: true编译选项可显式切换组件模式;- Svelte 4 时代的 Svelte 3/4 语法完整文档可在 v4 文档站查阅(见 legacy 总览)。
小结
$: 响应式语句的完整心智模型可以概括为四步:
- 位置:仅限实例脚本的顶层(
Program直接子节点),其他位置会收到放置警告; - 收集:编译期沿作用域引用静态收集"被引用且未被赋值"的标识符作为依赖,并给左值绑定打上
legacy_reactive标记、自动生成mutable_source信号; - 执行:客户端编译为
$.legacy_pre_effect(deps, fn)——先读依赖建立跟踪、再在untrack中执行本体,配合legacy_pre_effect_reset按依赖关系拓扑推进,实现"晚于脚本、先于渲染、依赖变更即重跑"的语义;SSR 侧语句同样保留并执行; - 限制:间接依赖不可见,是绝大多数"我的
$:没有重跑"问题的根源;浏览器专属代码必须用if包裹;新代码应迁移到$derived/$effect。
相关源码入口,便于进一步深挖:
- 依赖收集:packages/svelte/src/compiler/phases/2-analyze/visitors/LabeledStatement.js
- 客户端转译:packages/svelte/src/compiler/phases/3-transform/client/visitors/LabeledStatement.js
- SSR 转译:packages/svelte/src/compiler/phases/3-transform/server/visitors/LabeledStatement.js
- 运行时 effect 实现:packages/svelte/src/internal/client/reactivity/effects.js
- runes 模式下的编译错误定义:packages/svelte/src/compiler/errors.js
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