代数效应定义能力边界
signal-read、signal-write 与 html<e> 说明代码可以请求什么;handler 决定如何解释请求。效应类型本身并不等于增量传播。
可执行技术报告 · 面向 UI 工作者
当 reify-trace 内的同步 tracked get 发生时,Kokaine 捕获的是这次读取之后的计算后缀;Sample-read 与普通 dispatch read 不捕获。
值改变后,运行时恢复这段后缀,而不是保存原始闭包并从函数开头重跑。
signal-read、signal-write 与 html<e> 说明代码可以请求什么;handler 决定如何解释请求。效应类型本身并不等于增量传播。
每个 tracked read occurrence 都通过 raw ctl 捕获自己的同步后缀;后续读取继续捕获,形成嵌套的 Trace-read。
source 本地索引真实 capture;写入排入 Resume-work(trace)。Draft 只在后缀与发布成功后变成 Live,失败或 final control 会回滚草稿。
01 / THE JOURNEY
增量性的关键不是“记住谁读过谁”,而是把同步执行在准确位置切开,并保存可以恢复的后半段。
例如 prefix(); val a = x.get; middle(); val b = y.get; suffix() 会产生嵌套轨迹:
Trace-read(x, child = Trace-read(y, child = Trace-end))。每个 read occurrence 都有自己的 K;重复读取同一 source 也不会复用一次捕获。
02 / LIVE DATA FLOW
蓝线是 source 本地 capture 索引的语义投影。这里是教学模拟,不是运行时探针;队列里的工作单位是 Resume-work(trace)。
索引项打包执行 plane、目标 trace 与 owning read。它不是 wake callback、Observer ID 或被保留的完整 action。
纯 derivation 只能保存 plane<total>;UI effect 保存宿主 effect plane。完整 flush 优先推进 pure work,再推进 effect work。
Draft、Live、Pending、Running、Dead 描述真实 resumption 的生命周期;没有平行的 dirty Observer 状态机。
03 / ALGEBRAIC EFFECTS, PLAINLY
两者缺一不可,但不是同一个概念:effect operation 是可处理的能力接口,continuation 是 operation 发生处之后的控制流。
组件 / 计算代码
API 与 effect row 表达“允许请求读取”,不承诺具体传播算法。
根处理器 / handler
运行时结果
同一 API 在 untrack 下会被 override 成 Sample-read,不捕获 K。
正常读取、untrack 采样读取、stateful memo 的入口读取共用一个 operation,却由局部 handler 赋予不同解释。effect row 还能把“纯追踪”与“允许写 DOM”分开。
如果 handler 只记录 callback,再从函数开头执行,effect API 仍然成立,但不具备这里的语义。Kokaine 保存 trace-resume,正常刷新不依靠被保留的 calculation action。
() -> <signal-read,pure> a 不能把写能力直接逸出;函数仍可在内部安装自己的 handler。类型说明未处理的能力,不等价于“源码里绝不出现被局部处理的写操作”。
04 / CORRECT & MINIMAL
下面是 Resume-work(trace) 与两条执行 plane 的语义模拟,不是实际队列采样。逐步执行,看 Pending capability 如何进入 Running、创建 Draft 并发布新 Live generation。
frontier 选择 Pending 且没有 Pending/Running ancestor 的工作;已退休的 stale ticket 可以暂留队列,取工作时再丢弃。同步 memo.get 只沿目标 producer 的 gate、input producer、entry target 与 child trace 定向 settle,不会顺带清空无关 pure work,更不会执行 effect plane。只有 derive 发布不相等的新值时,输出 source 才增加版本并触发下游 capture。
05 / BATCHING
source cell 仍然立即更新;batch 只延后 settle。一个 Live trace 第一次失效时转成 Pending 并产生工作票,之后的写入不会再凭空复制同一个 resumption。
在这条固定模拟路径中,写 1 次时两边相同。
06 / DYNAMIC DEPENDENCIES
“本轮执行到哪个 read occurrence”比静态扫描更精确。切换模式时,旧 child trace 变成 Dead,新 suffix 在 Draft frame 内按真实分支捕获。
当前卡片标题
访客 Koka 页面 suffix 运行 1 次07 / OWNERSHIP & CLEANUP
传播依赖 source-local capture;资源释放依赖结构化 frame/owner ledger。两者用途不同,不能把 ownership tree 当成 dependency graph。
退役完整 child trace 与结构后代,source 本地索引在 compact 时移除失效 capture。已在队列中的 stale ticket 可以保留到 frontier 扫描时被跳过。
恢复 Pending trace 时,新 capture 和结构孩子先属于 Draft frame。旧 generation 的 finalizer、用户 cleanup、计算与 publication 都成功后才激活新 trace;失败或 abortive final control 会同步清理 Draft,并让原 capability 保持 Pending 以供重试。触发本轮的 source write 已经提交,不会随 Draft 一起回滚。
08 / FRAMEWORK COMPARISON
这是对主流默认心智模型的简化比较,不是性能排名。各框架都有编译器、缓存或手动优化手段,实际结果取决于应用写法。
默认从发生更新的组件开始重新计算其返回结果,再把必要的 DOM 改动提交出去。
Proxy / ref 在运行时跟踪读写;组件本身以响应式 effect 进行渲染和更新。
运行时记录 signal 与计算之间的联系,静态 DOM 通常只建立一次。
source 索引实际 capture;pure plane 先恢复精确后缀,memo 不相等发布才让 effect-plane capture 进入 Pending。
| 观察角度 | React | Vue | SolidJS | Kokaine |
|---|---|---|---|---|
| 主要更新单位 | 组件返回结果 | 组件渲染 effect | 细粒度计算 / DOM 绑定 | 细粒度计算 / DOM 绑定 |
| 联系如何建立 | state 所在组件与 props 流向 | 运行时拦截属性读取 | 运行时 signal 读取 | 每个 signal-read occurrence 捕获 K,并索引到 source |
| 页面结构 | 重新得到元素描述并比较 | 编译提示 + 虚拟 DOM 补丁 | 静态结构建立一次 | html.emit 同步建立 view,DOM adapter 为 live binding 建 effect |
| “只读不能写” | 靠规则与工具检查 | 靠 API 约定 | 靠 API 约定 | 可出现在 Koka 函数的效应类型里 |
| 局部改变读取含义 | 通常换 Hook / API | 通常换 API | untrack 等工具 | 用局部 handler 转发读取操作 |
09 / FROM IDEA TO KOKA
这里不追求展示全部实现,只对应前面已经看见的几个关键动作。
val output = derive(root,0,fn()
prefix() // 不在 Kx 中
val x = source-x.get // 捕获 Kx
middle(x) // 在 Kx 中
val y = source-y.get // 捕获嵌套的 Ky
suffix(x,y) // 同时在 Kx 与 Ky 中
)
// Trace-read(x, child = Trace-read(y, child = Trace-end))
// x 写入恢复 Kx;prefix() 不会重放。
// y 写入恢复更窄的 Ky。
// 应用看到的是能力接口
pub effect signal-read
fun validate-derived(producer : derive-producer) : ()
ctl read-source(source : source<a>,
mode : read-mode,
producer : maybe<derive-producer>) : a
// 实现轮廓;真实节点还记录 gate、frame 与 finalizer
raw ctl read-source(source,Track-read,producer)
val current = Trace-read(
resume = fn(_) rcontext.resume(load(source.source-cell)),
child = cell(Trace-end), ...)
source.source-captures.prepend(
pack-capture(plane,target,current))
val nested = rcontext.resume(load(source.source-cell))
current.child := nested.built-root
Built-trace(current,nested.built-publish)
val changes = memo(root,0,fn(previous)
val value = count.get
previous + 1
)
// 内部入口,不把 memo 订阅回自己的 source:
val track = fn() calculate(
read-source(output,State-entry-read,Nothing))
// State-entry-read 捕获 Trace-entry。
// 每次恢复都注入最近一次成功提交的 previous;
// 失败的 Draft 不会污染下一次 previous。
// 阶段 1:trailing lambda 直接形成 tag block;attrs 默认 []
val page = view
main-tag(attrs=[class("instrument")])
h1("Signal console") // string leaf
p(attrs=[class { if expanded.get then "result is-open" else "result" }])
text("平方:")
text { square.get.show } // live text overload
input(attrs=[ // void helper
kind("checkbox"),
checked { expanded.get } // live property overload
])
dynamic
if expanded.get
then p("Expanded")
else p("Collapsed")
// 阶段 2:DOM adapter 解释 view
// Text → 一次创建 Text node
// Live-text → create-effect(track = read, apply = set Text.data)
// dynamic → Region → create-effect + 结构化 child frame
// Listener → 普通 JS callback + captured reentry
val dispose = mount(root,body(),page)
10 / THE BROWSER BOUNDARY
tracked computation 中的 get 发生在 handler 动态范围内,因此能捕获同步 K;浏览器稍后触发的事件只有普通 JS callback,不能恢复一个并不存在的“事件 continuation”。
raw ctl 截取 read operation 之后的同步控制流;source 写入最终安排 Resume-work(trace)。
listener 只保存 JS callback 与 opaque reentry:root、continuation gate、generation frame。它可在 frame 存活时重复调用,但不是 parked affine continuation。
reenter 检查安装 listener 的 frame 仍存活,恢复 effect-plane gate 与 frame,清除 state-entry target,重新安装 Kokaine 的读写 handler,并把整个 callback 作为一个 batch 执行。这样,callback 内新建的 effect 与 cleanup 归属于准确的 DOM generation。它不会伪造已经返回的任意用户 handler,也不会把宿主事件投递说成 continuation resume。wasmweb 若使用对象句柄,仍须另行管理 JS 对象与持久 callback 的保留和释放。
11 / REAL KOKA DEMO
下面的控件、派生值、动态分支、事件与 cleanup 都来自拆分成多个模块的 Koka 源码,并编译到浏览器。
需要先运行 make build-counter,再从仓库根目录启动静态服务器;本报告的 make serve-report 会自动完成这一步。
12 / CONCLUSION
在 Kokaine 中,增量 UI 的关键不是给订阅队列换名字,而是让 tracked
get留下可直接恢复的精确同步后缀;source 本地索引这些 capability,写入安排Resume-work(trace),成功的 Draft 才替换旧 generation。
框架对比以各项目官方文档描述的基础模型为准;所有队列动画、工作范围和次数都是 semantic simulation,用于解释状态转换,不是 runtime instrumentation 或 benchmark。