响应式引擎: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_reaction、active_effect、untracking 和 current_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 状态机
CLEAN、MAYBE_DIRTY 和 DIRTY 这三个状态标志构成了一套支持惰性求值的状态机。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 的水合协议。