跳转到内容

Push-Pull FRP — Functional Reactive Programming 实用化

是什么

Push-Pull FRP(函数式反应式编程的实用化版本)是一套把”事件流”和”信号”分开处理的方法。日常类比:报警系统是 push(出事立刻响),温度计是 pull(你要的时候再读)—— FRP 让两种语义共存。

  • 事件(Event):离散的、来一次处理一次(鼠标点击、键盘按下)→ 用 push(来了立刻通知所有订阅者)
  • 信号(Behavior):连续的、任意时刻都有值(鼠标位置、当前时间)→ 用 pull(要的时候再算)

Conal Elliott 把这两种本质不同的东西用两套类型分别建模,再提供算子让它们互转。

为什么重要

不理解 push-pull,下面这些事都没法解释:

  • 为什么 RxJS 的 Observable 经常需要 shareReplay “缓存最后一个值”——因为 RxJS 全是 push,没有原生 pull 语义
  • 为什么 SolidJS 的 createSignal 比 React 的 useState 性能好——signal 是 pull,需要时再算,effect 是 push 触发
  • 为什么 Conal Elliott 1997 年发明 FRP(FRAN)后又自己推翻——pure pull 太慢
  • 为什么响应式 UI 框架的”重新渲染”问题永远存在——push 和 pull 边界划在哪很难定

Conal Elliott 是 FRP 之父,这篇论文是他用 12 年时间反思 + 修复自己 1997 年作品的结果。后续 Reactive Banana / Reflex-FRP / Sodium 都是它的直接继承者,RxJS / SolidJS / Svelte 5 也间接受影响。

核心要点

Push-Pull FRP 的设计可以拆成 三件事

  1. 两种类型Event a(离散,push)vs Behavior a(连续,pull)。前者是 [(时间, 值)] 列表,后者是 时间 -> 值 函数。类型上的区分让运行时走不同 codepath。

  2. 两个操作Sample(拿当前 Behavior 的值)+ React(订阅 Event 触发回调)。前者是 pull 路径,后者是 push 路径。

  3. 互转算子stepper :: a -> Event a -> Behavior a 把事件流转成”分段常数信号”(事件来时跳变、之间保持不变);snapshot :: Behavior a -> Event b -> Event (a, b) 在事件触发瞬间采样信号。

性能关键:纯 pull 模型每帧都要重算所有 signal,CPU 满载;push-pull 让 signal 按需计算,论文宣称比纯 pull 快 10-100 倍。

实践案例

案例 1:鼠标点击 + 鼠标位置

最经典的 push-pull 场景:

mouseMove :: Event (Int, Int) -- 鼠标移动事件,push
mouseClick :: Event () -- 鼠标点击事件,push
mousePos :: Behavior (Int, Int) -- 鼠标位置信号,pull
mousePos = stepper (0, 0) mouseMove
clickPositions :: Event ((Int, Int), ())
clickPositions = snapshot mousePos mouseClick

读法:“每次点击时,拍一下当前鼠标位置”。

  • mousePos 是 pull——鼠标不动时不重算
  • mouseClick 是 push——点击时立刻触发
  • snapshot 是混合——event 触发时去 pull signal

案例 2:与 RxJS 对比

// RxJS 全是 push(Observable)
const mousePos$ = mouseMove$.pipe(
startWith({x: 0, y: 0}),
shareReplay(1)
)
const clickPositions$ = click$.pipe(withLatestFrom(mousePos$))

RxJS 用 shareReplay(1) “缓存最后一个值”假装连续——但代价是每个订阅者都要持有缓存,长跑容易内存泄漏。Push-Pull FRP 的 Behavior 在数学上就是 时间 -> 值,不需要缓存。

案例 3:与 SolidJS 对比

const [count, setCount] = createSignal(0) // signal:pull
createEffect(() => console.log(count())) // effect:push 触发

SolidJS 的 createSignal 接近 Push-Pull 的 Behavior(pull-when-read),createEffect 是 push 端订阅者。这就是为什么 Solid 比 React 快——它显式区分 push 和 pull,而 React 是单一 state pull、统一靠 re-render 触发。

踩过的坑

  1. Time-leak(时间泄漏):早期 Reactive 库实现 accumE(事件累积器)时,把整个事件历史存在内存里。跑 1 小时鼠标移动 → 100,000 个事件挂着不释放 → OOM。修复:用弱引用 + 增量结构 Reactive a(lazy 段链表)。

  2. 同时事件的顺序问题:两个事件 t 完全相等时谁先谁后?论文用 “stable merge”(左操作数优先)。Reactive Banana 早期忽略这个细节,导致 accumE 累积乱序。

  3. Push-only 表达不了连续 signalcurrentTime :: Behavior Time = id —— “任何时刻返回它本身”。RxJS 必须用 interval(16) 模拟 60fps,浪费 CPU 且不连续。

  4. debounce 表达不了:论文 6 个核心算子无法表达”300ms 内无新事件就触发”——因为 Event 类型只追加、没有”超时”语义。现代 Rx 的 debounceTime 都是在论文之外加的 IO timer。

适用 vs 不适用场景

适用

  • 需要严格区分 event vs signal 的反应式系统(动画、交互式 UI、游戏)
  • 性能敏感场景——纯 pull 太慢、纯 push 表达不了连续信号
  • 想要可证明正确性的反应式库(Reactive Banana / Reflex-FRP)

不适用

  • 简单 UI(双类型增加心智负担)→ 直接用 React/Vue 的单 state 模型
  • 分布式系统(论文假设单一时钟,跨进程要 Lamport 时钟扩展)
  • 重 IO 场景(论文一致性证明在 IO callback 下默默失效)
  • 不需要”任意时刻都有值”的应用 → RxJS push-only 够用且 API 更简单

历史小故事(可跳过)

  • 1997 年:Conal Elliott + Paul Hudak 在 ICFP 发表 FRAN(Functional Reactive Animation),把动画建模为 Behavior a = Time -> a。优雅但慢——每帧重算所有 behavior。
  • 2003-2008 年:Yampa / FrTime / Frapp 等尝试用 Arrow 或副作用追踪解决性能,但都”不够干净”——Yampa 写法冗长,FrTime 跨语言落地难。
  • 2009 年:Conal 自己 12 年后写出 Push-Pull FRP,承认”1997 年的纯 pull 是错的”——这篇论文本质是作者自己的修正
  • 2010+ 年:Microsoft Rx → RxJS 选择 push-only 路线,简化 API 但失去连续 signal 表达力。
  • 2018+ 年:SolidJS 的 createSignal 把 push-pull 简化到 hook 形态,重新流行。
  • 2024+ 年:Svelte 5 Runes 用编译器在编译期决定 push 还是 pull——用编译手段绕开”双模型 API 复杂”。

学到什么

  1. 类型上的区分能引导实现:Event vs Behavior 不只是命名差异,决定了运行时走 push 还是 pull 路径——这是一种用类型系统”硬编码”性能选择的思路
  2. 理论一致性 ≠ 工程一致性:论文证明了算子的语义一致性,但 IO callback / GC / 时钟离散化在工程层都会破坏它
  3. 时间是连续的还是离散的影响所有设计:论文用 Real⁺(连续),但实际 OS 时钟是 ns 离散的——很多 reactive bug 都来自这个鸿沟
  4. 作者推翻自己也算贡献:Conal 1997 → 2009 → 2013 三次反思,每次都在前作基础上修——比”宣称完美”诚实

延伸阅读

关联

  • lambda-calculus —— FRP 在 Haskell 里实现,必然依赖 lambda 求值模型
  • hindley-milner —— Behavior / Event 的类型推导走 HM 体系
  • self-adjusting —— 增量计算的”祖宗”,与 push-pull 共享”按需重算”思想
  • adapton —— 增量计算的工程化简化,Conal 思想的另一支衍生
  • effect-handlers —— Push-Pull 的 IO 副作用问题,代数效应给出更干净的解

反向链接

  • effect-handlers —— 代数效应(Algebraic Effects)
  • frenetic-2011 —— Frenetic 2011 — 把 OpenFlow 流表换成函数式程序
  • hindley-milner —— Hindley-Milner — 编译器自己猜变量类型
  • hughes-fp-matters —— Why FP Matters — 函数式真正赢在能拆能粘
  • islands-architecture —— Islands Architecture — 静态页面里只让需要交互的小块加载 JS
  • lambda-calculus —— λ-演算 — 用三条规则表达所有可计算函数
  • salsa-adapton —— Salsa / Adapton — 让程序只重算”真的变了”的那一小块
  • self-adjusting —— Self-Adjusting Computation — 输入小幅变化时只重算受影响的那部分