npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

ownflow

v0.4.1

Published

Reactive architecture with owned data flow — compile-time enforced single-writer ownership

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

当你发现两个东西在争夺一个值时:

  1. 问:它们对这个值的"含义"理解一致吗?
  2. 如果不一致 → 它们是两个不同的源状态,最终值是 derived
  3. 如果一致 → 只该有一个 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