首页
/ Svelte 遗留模式 `$:` 响应式语句深度解析:从编译期依赖分析到 legacy_pre_effect 运行时实现

Svelte 遗留模式 `$:` 响应式语句深度解析:从编译期依赖分析到 legacy_pre_effect 运行时实现

2026-09-06 13:39:35作者:胡易黎Nicole

本篇聚焦 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.jswarnings.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>

两个关键点:

  1. 执行时机:响应式语句不随书写位置立即执行,而是先收集、后统一执行(晚于 <script> 中的普通代码,早于模板渲染),之后在依赖变化时重跑;
  2. 拓扑排序:语句按其依赖与赋值关系被拓扑排序执行。上例中 console.log 语句依赖 sum,因此即使 $: sum = a + b 在源码中写在后面,sum 也会先被计算。

"拓扑排序"在编译与运行两侧都有对应实现:

  • 客户端转换阶段,每条 $: 被登记到 legacy_reactive_statements 这个 Map 中,注释明确写着 "The $: calls, which will be ordered in the end"(见 transform-client.jstypes.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 会同时让 larrymoecurly 三个变量都成为响应式变量,各自依赖 stooges

这一标记在客户端转换阶段被兑现为信号声明:所有 kind === 'legacy_reactive' 的绑定会被生成 const x = $.mutable_source(...) 声明(见 transform-client.jslegacy_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 输出时是真实执行的,而不是被跳过。因此在涉及 documentwindow 等浏览器 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 总览)。

小结

$: 响应式语句的完整心智模型可以概括为四步:

  1. 位置:仅限实例脚本的顶层(Program 直接子节点),其他位置会收到放置警告;
  2. 收集:编译期沿作用域引用静态收集"被引用且未被赋值"的标识符作为依赖,并给左值绑定打上 legacy_reactive 标记、自动生成 mutable_source 信号;
  3. 执行:客户端编译为 $.legacy_pre_effect(deps, fn)——先读依赖建立跟踪、再在 untrack 中执行本体,配合 legacy_pre_effect_reset 按依赖关系拓扑推进,实现"晚于脚本、先于渲染、依赖变更即重跑"的语义;SSR 侧语句同样保留并执行;
  4. 限制:间接依赖不可见,是绝大多数"我的 $: 没有重跑"问题的根源;浏览器专属代码必须用 if 包裹;新代码应迁移到 $derived / $effect

相关源码入口,便于进一步深挖:

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