ownflow
v0.4.1
Published
Reactive architecture with owned data flow — compile-time enforced single-writer ownership
Maintainers
Readme
ownflow
Reactive architecture with owned data flow.
编译期 + 运行时双重强制的响应式架构框架。基于 @vue/reactivity。
核心保证:每个 ref 只有一个写入者。当你看到一个 bug,你能立刻知道是哪个模块的问题——因为只有它能写那个值。
安装
pnpm add ownflow
# peer dependency
pnpm add @vue/reactivity心智模型
1. 争夺控制权 → 是不是同一个东西?
当两个模块"争夺"一个值的控制权时,停下来问:它们理解的是同一个东西吗?
实例:聊天界面的 scrollTop
一个聊天界面中,scrollTop 看起来被三方争夺:
- useEffect A(新消息到达时滚到底部)
- useEffect B(流式结束时滚到底部)
- 浏览器(用户手动滚动)
这导致了经典 bug:用户滚上去看历史消息,但生成结束后被强制拉回底部。AI 修了一条路径,另一条仍然触发——因为"scrollTop 有多个写入者"这个结构性问题没有解决。
正确建模:它们不是同一个东西。
- 用户滚动表达的是"运动意图"(我要往上看)
- 自动跟随表达的是"锚定策略"(新内容出现时保持在底部)
- scrollTop 是这两者的派生值:
scrollTop = anchor === 'bottom'
? contentHeight - viewportHeight
: savedPosition没有人"写" scrollTop——它是从 anchor + savedPosition + contentHeight 自动计算的 derived。
当你发现两个东西在争夺一个值时:
- 问:它们对这个值的"含义"理解一致吗?
- 如果不一致 → 它们是两个不同的源状态,最终值是
derived - 如果一致 → 只该有一个 owner,另一个应该是
watch
2. 解耦 = 模块只看自己的 watch/own
一个模块不理解、也做不到理解外部发生了什么。它类似一个无副作用的函数——不修改外部状态,只修改自己的 own 状态。
实例:ScrollModule 不知道"为什么 contentHeight 变了"。
可能是新消息到达、图片加载完成、用户展开了折叠区块——ScrollModule 不关心。它只知道"内容变高了",然后根据 anchor 策略决定 scrollTop 是否跟随。
这种无知是设计出来的。如果 ScrollModule 需要知道"是因为新消息才变高的",那它和消息系统就耦合了。
3. 接口最小化 = 只 watch 需要的
削减 watch,只保留自己消费的信息。即使外部没有直接提供可消费的值,也可以从外部嫁接一个 adapter computed——类似接水管。
实例:ThinkingModule 不需要整个 StreamPhase。
StreamModule 输出 phase: 'idle' | 'thinking' | 'writing' | 'done'(4 个状态)。但 ThinkingModule 只需要两位信息:
- "新一轮开始了吗"→ 重置 userOverride
- "当前是否活跃"→ 默认折叠策略
所以 ThinkingModule 的接口是:
const ThinkingModule = defineModule('Thinking', {
generation: watch<number>(), // 变化 = 新一轮
streamActive: watch<boolean>(), // 是否活跃
userOverride: own<boolean | null>(null),
}, ...)连接方式——用 adapter computed 接水管:
import { computed } from '@vue/reactivity'
const streamGeneration = computed(() => stream.generation.value)
const streamActive = computed(() => stream.isStreaming.value)
const thinking = ThinkingModule({
generation: streamGeneration,
streamActive: streamActive,
})ThinkingModule 不知道 StreamModule 的存在。它只知道"有一个数字会变"和"有一个布尔值"。这就是解耦。
4. 状态归属 — 数据由 source own,display 只 derived
ownflow 中没有"事件"概念。所有输入都是 own ref 的值变化。
一个常见的反模式是将"瞬时动作"建模为事件、发射给接收端、再由接收端接管状态。正确的做法是:数据由 source own,display 层只做 derived。
实例:Toast 通知
显示 toast → 自动淡出消失。谁应该 own toast 列表?
- ❌ display 层被迫 own
activeToasts,管理生命周期 - ✅ 数据源(触发 toast 的模块)own
toasts: own<Toast[]>,display 层纯 derived
// AppModule — 数据 source,own 自己的 toast 列表
const App = defineModule('App', {
currentTime: watch<number>(),
toasts: own<Toast[]>([]),
}, (ctx) => ({
onCleanup: on('currentTime', () => {
ctx.toasts = ctx.toasts.filter(
t => ctx.currentTime - t.createdAt < t.duration + 400
)
}),
}))
// 触发 toast:直接写自己的 own 状态
app.toasts.value = [...app.toasts.value, {
id: nanoid(),
message: 'Saved',
type: 'success',
createdAt: performance.now(),
duration: 3000,
}]
// ToastDisplay — 零 own 状态,纯 derived 展示
const ToastDisplay = defineModule('ToastView', {
toasts: watch<Toast[]>(),
currentTime: watch<number>(),
}, (ctx) => ({
displayToasts: derived(() =>
ctx.toasts.map(t => {
const progress = Math.min(1, (ctx.currentTime - t.createdAt) / t.duration)
return { id: t.id, message: t.message, opacity: 1 - easeOut(progress) }
})
),
}))display 层零 own 状态的好处:
- snapshot 只存 source → restore → display 自动恢复
- 测试 display 时只需 mock ref,不需要搭建数据源
关于"同值不触发":Vue watch 用 === 比较。如果确实需要同值重复触发,避免引入无意义的 seq。改用有业务语义的字段:
// ✅ 用时间戳(有语义)
ctx.scrollInput = { delta: -100, at: performance.now() }
// ✅ 用业务 id(有语义)
ctx.toasts = [...ctx.toasts, { id: nanoid(), message: 'Saved' }]原则:如果发现自己在写 { seq, payload },暂停——问"什么状态确实变了?"把答案建模为 own 状态。详见 docs/examples/state-vs-event.md。
完整概念文档见 docs/concepts/,使用示例见 docs/examples/。统一术语表见 CONTEXT.md。
API
defineModule(name, data, setup?)
定义一个模块工厂。
const MyModule = defineModule('MyModule', {
input: watch<string>(), // 只读输入
state: own<string[]>([]), // 独占状态
}, (ctx) => ({
handler: on('input', (_ctx, value) => {
ctx.state = [...ctx.state, value]
}),
count: derived(() => ctx.state.length),
}))watch<T>()
声明只读输入。实例化时传入 Ref<T> 或 ComputedRef<T>。
own<T>(initial)
声明独占状态。只有本模块的 ctx 能写入。实例上暴露为 ComputedRef(外部只读)。
derived(getter)
声明派生值(ComputedRef)。不可写入,自动追踪依赖。
on(watchKey, handler)
声明响应处理器。当 watchKey 对应的值变化时触发。
- 同步 handler:
(ctx, value, oldValue) => void - 异步 handler:
(ctx, value, oldValue) => Promise<void>ctx.active— 是否仍为最新ctx.signal— AbortSignal,下次触发时自动 abort(懒创建)- 过期写入被静默丢弃
instance.__extend(setup)
运行时注入额外 handler/derived。返回 dispose 函数。
const dispose = counter.__extend((ctx) => ({
logger: on('input', (_ctx, value) => console.log('got', value)),
}))
dispose() // 移除instance.__restore(values)
初始化阶段批量设置 own 字段(从持久化状态恢复)。
assemble(modules, wiring?)
组装多个模块实例,自动推断数据流接线。
toMermaid(meta, options?)
生成 Mermaid 格式的数据流图。
完整示例:聊天滚动
import { ref, computed } from '@vue/reactivity'
import { defineModule, watch, own, derived, on, assemble } from 'ownflow'
// ─── 事件类型 ───
type ScrollEvent = { delta: number; at: number }
// ─── ScrollModule ───
const ScrollModule = defineModule('Scroll', {
contentHeight: watch<number>(),
viewportHeight: watch<number>(),
scrollEvent: watch<ScrollEvent>(),
anchor: own<'bottom' | 'position'>('bottom'),
savedPosition: own(0),
}, (ctx) => ({
onScroll: on('scrollEvent', (_ctx, ev) => {
if (ev.delta === 0) return
const maxScroll = Math.max(0, ctx.contentHeight - ctx.viewportHeight)
if (ctx.anchor === 'bottom') {
ctx.savedPosition = maxScroll
ctx.anchor = 'position'
}
ctx.savedPosition = Math.max(0, Math.min(ctx.savedPosition - ev.delta, maxScroll))
if (ctx.savedPosition >= maxScroll - 80) ctx.anchor = 'bottom'
}),
// scrollTop 是 derived —— 不是争夺的资源,是计算结果
scrollTop: derived(() => {
const max = Math.max(0, ctx.contentHeight - ctx.viewportHeight)
return ctx.anchor === 'bottom' ? max : Math.min(ctx.savedPosition, max)
}),
}))
// ─── 使用 ───
const contentHeight = ref(1000)
const viewportHeight = ref(600)
const scrollEvent = ref<ScrollEvent>({ delta: 0, at: 0 })
const scroll = ScrollModule({ contentHeight, viewportHeight, scrollEvent })
// 用户滚动:
scrollEvent.value = { delta: 100, at: performance.now() }
// → scroll.anchor.value === 'position'
// → scroll.scrollTop.value 不再跟随 contentHeight
// 内容增长:
contentHeight.value = 2000
// → scroll.scrollTop.value 不变(因为 anchor === 'position')
// 这就是 v2 那个 bug 被结构性消除的方式约束强制执行
| 约束 | 编译期 | 运行时 | |------|:---:|:---:| | watch 字段只读 | ✅ | ✅ 内部 ComputedRef | | own 字段独占写入 | ✅ | ✅ | | own 字段外部只读 | — | ✅ ComputedRef | | derived 不可写 | ✅ | ✅ | | 异步 seq guard | — | ✅ | | 异步 abort | — | ✅ |
License
MIT
