Read OSS

响应式引擎:Sources、Deriveds、Effects 与批量调度器

高级

前置知识

  • 第 1 篇:架构与代码库导览
  • 第 2 篇:编译器流水线(理解编译产物格式)
  • 响应式编程基础(信号机制、依赖追踪)
  • 位运算基础(标志位、掩码、&、|、^)
  • ES Proxy handler 陷阱函数

响应式引擎:Sources、Deriveds、Effects 与批量调度器

Svelte 5 的响应式引擎是一套基于拉取(pull-based)的信号系统。当源值发生变化时,它并不会立即向下传播,而是将依赖该值的 reaction 标记为"可能已脏",等到调度器处理时再惰性地重新求值。这一设计让派生值得以跳过不必要的重新计算,同时保持更新批处理的高效性。让我们从数据结构入手,逐步了解其工作原理。

信号图:Sources、Deriveds 与 Effects

响应式系统有三种基本原语,均以普通 JavaScript 对象的形式定义,共享相同的结构:

Sources 是整个响应式图的入口,也就是图的叶节点。通过 $.state() 创建,source 持有一个值,并追踪哪些 reaction 依赖于它。来自 sources.js

export function source(v, stack) {
    var signal = {
        f: 0,          // flags
        v,             // value
        reactions: null, // dependents
        equals,        // equality function
        rv: 0,         // read version
        wv: 0          // write version
    };
    return signal;
}

Deriveds 是惰性计算节点——在响应式图中既是读取方也是写入方。通过 $.derived() 创建,它们拥有与 source 相同的字段,另外还包含 fn(计算函数)、deps(上游依赖)以及父子节点指针。来自 deriveds.js

export function derived(fn) {
    var flags = DERIVED | DIRTY;
    // ...创建包含 fn、deps、parent、first、last 等字段的对象
}

Effects 是构成树形结构的副作用函数。通过 effects.js 中的 create_effect() 创建,包含 DOM 节点引用和树形指针:

var effect = {
    ctx: component_context,
    deps: null,
    nodes: null,       // { start, end } DOM anchors
    f: type | DIRTY | CONNECTED,
    first: null,       // first child effect
    fn,                // the effect function
    last: null,        // last child effect
    next: null,        // next sibling effect
    parent,            // parent effect
    prev: null,        // previous sibling effect
    teardown: null,    // cleanup function
    wv: 0,
    ac: null           // AbortController
};
graph TD
    subgraph Sources
        S1["source { v: 0, reactions: [...] }"]
        S2["source { v: 'hello', reactions: [...] }"]
    end
    subgraph Deriveds
        D1["derived { fn, deps: [S1, S2], reactions: [...] }"]
    end
    subgraph Effects
        E1["root_effect { first → E2 }"]
        E2["render_effect { deps: [D1], parent → E1 }"]
    end
    S1 -->|"reactions"| D1
    S2 -->|"reactions"| D1
    D1 -->|"reactions"| E2
    E1 -->|"first"| E2

位标志系统

每个响应式节点都有一个 .f(flags)整型字段。状态检查通过位运算完成,其性能远优于属性查找或字符串比较。所有标志定义在 constants.js 中:

标志 位值 用途
DERIVED 1 << 1 节点为派生计算
EFFECT 1 << 2 节点为用户 effect
RENDER_EFFECT 1 << 3 节点为渲染 effect(同步)
BLOCK_EFFECT 1 << 4 跨多次重运行保留子节点的 effect
BRANCH_EFFECT 1 << 5 表示条件分支的 effect
ROOT_EFFECT 1 << 6 顶层 effect(挂载点)
BOUNDARY_EFFECT 1 << 7 错误/suspense 边界
CONNECTED 1 << 9 已连接到 effect 树
CLEAN 1 << 10 值是最新的
DIRTY 1 << 11 值需要重新计算
MAYBE_DIRTY 1 << 12 上游可能已发生变化
INERT 1 << 13 已暂停(离屏或正在退场)
DESTROYED 1 << 14 已永久移除

检查标志只需一次按位与操作:(reaction.f & DIRTY) !== 0。设置用按位或:reaction.f |= DIRTY。清除用按位与非:reaction.f &= ~DIRTY。这些操作最终都会编译为单条 CPU 指令。

提示: 调试响应式引擎时,可以通过与标志常量对比来解码 .f 的值。例如,f = 2054 的二进制表示为 100000000110,对应 DIRTY | CONNECTED | DERIVED

依赖追踪:get() 与 reaction 上下文

get() 函数 是依赖追踪的核心。每当读取一个响应式值——无论是 source、derived,还是 proxy 属性——都会调用 get()。它会检查当前是否存在 active_reaction(正在执行的 effect 或 derived),如果有,则注册依赖关系:

export function get(signal) {
    if (active_reaction !== null && !untracking) {
        // ...register dependency
        if (signal.rv < read_version) {
            signal.rv = read_version;
            if (new_deps === null && deps !== null && deps[skipped_deps] === signal) {
                skipped_deps++;
            } else if (new_deps === null) {
                new_deps = [signal];
            } else {
                new_deps.push(signal);
            }
        }
    }
}

rv(读取版本)和 wv(写入版本)字段是为了避免重复注册依赖而设计的优化手段。每次 reaction 执行都会递增全局 read_version。若某个信号的 rv 与当前 read_version 一致,说明它已在本次执行周期中注册过,可以跳过。

skipped_deps 优化尤为精妙:如果一个 effect 以与上次完全相同的顺序读取了相同的依赖,它只会递增 skipped_deps,而不会分配新数组。只有当依赖顺序发生变化时,才会开始构建 new_deps。这意味着大多数重新运行不会产生任何垃圾对象。

sequenceDiagram
    participant Effect as Active Effect
    participant Get as get()
    participant Source as Source Signal

    Effect->>Get: read source.value
    Get->>Get: active_reaction !== null?
    Get->>Get: signal.rv < read_version?
    Get->>Get: deps[skipped_deps] === signal?
    alt Same order as last run
        Get->>Get: skipped_deps++
    else New dependency
        Get->>Get: new_deps.push(signal)
    end
    Get-->>Effect: return signal.v

runtime.js#L73-L95 中的模块级变量 active_reactionactive_effectuntrackingcurrent_sources 构成了执行上下文。这与第 2 篇编译器中看到的模块级状态模式如出一辙,使用原因也相同:在热路径中追求极致性能。

执行 Reaction:update_reaction()

update_reaction() 函数 是核心执行循环。它在运行 reaction 函数的同时,细心地管理依赖追踪上下文:

export function update_reaction(reaction) {
    // Save all context variables
    var previous_deps = new_deps;
    var previous_reaction = active_reaction;
    // ... 6 more saves

    // Set up new context
    new_deps = null;
    skipped_deps = 0;
    active_reaction = (flags & (BRANCH_EFFECT | ROOT_EFFECT)) === 0 ? reaction : null;
    set_component_context(reaction.ctx);

    try {
        var result = reaction.fn();
        // Reconcile dependency arrays...
    } finally {
        // Restore all context variables
    }
}

保存和恢复上下文变量的模式,确保了 reaction 可以嵌套执行(effect 内部嵌套 effect,或在 effect 执行期间对 derived 求值),而不会破坏追踪上下文。函数执行完毕后,会对依赖列表进行协调——从不再是依赖的信号中移除当前 reaction,并将其注册到新发现的依赖上。

flowchart TD
    Start["update_reaction(reaction)"] --> Save["Save 8 context variables"]
    Save --> Setup["Set active_reaction,<br/>reset new_deps, skipped_deps"]
    Setup --> Run["Execute reaction.fn()"]
    Run --> Reconcile{"new_deps !== null?"}
    Reconcile -->|yes| Update["Update deps array,<br/>register reactions on new deps"]
    Reconcile -->|no| Trim["Trim deps to skipped_deps length"]
    Update --> Restore["Restore 8 context variables"]
    Trim --> Restore

Effect 树结构

Effect 并非存储在扁平列表中,而是通过父子链表构成一棵树。first/last 指针连接一个 effect 与其子节点,prev/next 指针构成兄弟节点链,parent 指针则向上指向父节点。

这棵树与组件树的结构相互对应。一个组件的根 effect 包含每个模板区域的渲染 effect。控制流块({#if}{#each})以子节点的形式创建分支 effect。不同的 effect 类型决定了树的行为:

  • ROOT_EFFECT — 由 mount() 创建,掌管整个组件
  • RENDER_EFFECT — 用于 DOM 更新的同步 effect,在用户 effect 之前运行
  • EFFECT — 用户 effect($effect),在 DOM 更新之后运行
  • BLOCK_EFFECT — 包裹控制流,跨多次重运行保留子节点
  • BRANCH_EFFECT — block 内的条件分支({#if} 的某一个分支)
graph TD
    Root["ROOT_EFFECT<br/>(component mount)"]
    Root --> RE1["RENDER_EFFECT<br/>(template setup)"]
    Root --> Block["BLOCK_EFFECT<br/>({#if condition})"]
    Block --> Branch1["BRANCH_EFFECT<br/>(true branch)"]
    Branch1 --> RE2["RENDER_EFFECT<br/>(update text)"]
    Block --> Branch2["BRANCH_EFFECT<br/>(false branch)"]
    Root --> UE["EFFECT<br/>($effect user code)"]

当块的条件发生变化时,旧的 BRANCH_EFFECT 会被暂停(以便退场过渡动画得以执行),并创建新的分支。块 effect 本身永远不会被销毁——它只是切换当前活跃的分支。

脏检查:CLEAN/MAYBE_DIRTY/DIRTY 状态机

CLEANMAYBE_DIRTYDIRTY 这三个状态标志构成了一套支持惰性求值的状态机。is_dirty() 函数 负责判断一个 reaction 是否需要重新执行:

  • DIRTY — 直接依赖已发生变化,必须重新执行。
  • MAYBE_DIRTY — 上游某个 derived 可能已发生变化,需要先检查依赖。
  • CLEAN — 没有任何变化,直接跳过。

当向 source 写入新值时,它会将直接依赖它的节点标记为 DIRTY(如果是 effect)或向上传播 MAYBE_DIRTY(如果依赖方是 derived)。这是"推送"阶段。"拉取"阶段则发生在 is_dirty() 中:

export function is_dirty(reaction) {
    if ((flags & DIRTY) !== 0) return true;

    if ((flags & MAYBE_DIRTY) !== 0) {
        var dependencies = reaction.deps;
        for (var i = 0; i < dependencies.length; i++) {
            var dependency = dependencies[i];
            if (is_dirty(dependency)) {
                update_derived(dependency);
            }
            if (dependency.wv > reaction.wv) {
                return true;
            }
        }
        set_signal_status(reaction, CLEAN);
    }
    return false;
}

对于 MAYBE_DIRTY 状态的 reaction,它会递归检查每一个依赖。若某个 derived 依赖本身是脏的,则对其重新求值。然后比较写入版本(wv)——如果没有任何依赖的 wv 比当前 reaction 的 wv 更新,说明该 reaction 是干净的,无需任何处理。

这正是性能优化的关键所在:一个依赖于另一个 derived 的派生值,只有在上游值真正发生变化时才会重新计算。MAYBE_DIRTY 状态将求值推迟到真正有人需要该值的时候。

批量调度器

Batch 负责协调响应式更新的处理流程。当向 source 写入新值时,会调用 Batch.ensure() 获取或创建当前批次,然后调度受影响的 effect。

一个批次会收集信号变更,并将处理推迟到 microtask 中执行。这意味着多次同步写入会被合并为一次更新:

count = 1;  // schedules batch
count = 2;  // same batch
count = 3;  // same batch
// microtask fires: process all changes once

批次内部维护着几个数据结构:

  • current — 一个 Map<Value, [any, boolean]>,存储本批次中信号的当前值
  • previous — 一个 Map<Value, any>,存储批次开始前的值
  • #roots — 需要刷新的根 effect
  • #commit_callbacks — 推迟到提交阶段执行的 DOM 操作

处理过程会自顶向下遍历 effect 树,收集所有脏 effect。渲染 effect(同步 DOM 更新)优先执行,之后才是用户 effect。这一顺序确保了在任何用户 $effect 代码执行之前,DOM 已处于一致状态。

批量系统还支持分叉(fork)——用于并发渲染的并行批次。每个 fork 拥有独立的信号值集合,可以独立提交或丢弃。这为实验性的异步渲染模式提供了支撑,允许新分支在后台渲染,同时当前 UI 保持可见。

提示: flushSync() 会强制立即处理所有待处理的批次。在测试中,以及需要在读取 DOM 前确保其已更新时,这个函数非常有用。

通过 ES Proxy 实现深层响应式

当你编写 let obj = $state({ x: 1 }) 时,编译器会生成 $.proxy(...) 调用,将对象包装在 ES Proxy 中。proxy() 函数 只包装普通对象和数组——类实例、DOM 元素及其他特殊对象会直接透传:

export function proxy(value) {
    if (typeof value !== 'object' || value === null || STATE_SYMBOL in value) {
        return value;
    }
    const prototype = get_prototype_of(value);
    if (prototype !== object_prototype && prototype !== array_prototype) {
        return value;
    }
    // Create proxy with lazy source creation...
}

proxy 会惰性地创建 source 信号——每个属性对应一个,在首次访问时才分配。当你读取 obj.x 时,proxy 的 get 陷阱会对相应的 source 信号调用 get(),从而建立依赖关系。当你写入 obj.x = 2 时,set 陷阱会对该 source 调用 set(),触发响应式更新。

这种惰性方式意味着一个拥有 100 个属性的对象不会在初始化时创建 100 个 source——只有在响应式上下文中实际被读取的属性才会被追踪。当结构发生变化时(添加或删除属性、数组变更),一个 version source 会递增,以处理迭代和 Object.keys() 的追踪。

flowchart TD
    Proxy["ES Proxy for { x: 1, y: 2 }"]
    Proxy -->|"get trap: obj.x"| GetX["sources.get('x') ?? create source"]
    GetX --> Signal["get(source) → registers dependency"]
    Proxy -->|"set trap: obj.x = 3"| SetX["set(source, 3) → triggers reactivity"]
    Proxy -->|"ownKeys trap"| Version["get(version) → tracks structural changes"]

下一篇

响应式引擎解决了"更新什么"的问题——但 Svelte 究竟是如何创建和更新 DOM 的?在下一篇文章中,我们将深入探讨模板实例化系统,了解它如何借助 cloneNode() 实现快速 DOM 创建、控制流块({#if}{#each})的具体实现,以及让客户端 Svelte 能够接管服务端渲染 HTML 的水合协议。