FRP(Functional Reactive Programming)是一种结合函数式编程和响应式编程的范式。本文将从 Sodium 库入手,带你理解 FRP 的核心概念。
前言
为什么要写这篇教程?因为 FRP 是一个容易被误解的概念。网上的资料要么过于抽象(数学符号满天飞),要么过于肤浅(只是简单的事件监听封装)。
本文的目标是:
- 建立直观理解 - 不靠数学符号,用代码说话
- 源码级解析 - 深入 Sodium 实现,理解内部机制
- 对比学习 - 与 RxJS 对照,理解不同实现的选择
全局概览
FRP 核心概念
在开始之前,先建立一张概念地图:
graph TD
A[FRP] --> B[Behavior
行为/状态]
A --> C[Event
事件流]
A --> D[Transaction
事务]
B --> B1[Cell
持续变化的值]
B --> B2[Lazy
延迟求值]
C --> C1[Stream
事件传递管道]
C --> C2[Listener
监听器]
D --> D1[Cooldinaton
协调]
D --> D2[Isolation
隔离]
style A fill:#e1f5fe
style B1 fill:#c8e6c9
style C1 fill:#ffecb3
核心实体一览
| 实体 |
作用 |
类比 |
Cell<A> |
持有可观测值 |
BehaviorSubject |
Stream<A> |
事件流 |
Observable |
Transaction |
事务上下文 |
Scheduler |
Listener |
订阅句柄 |
Subscription |
Sodium 核心实现
1. Cell:行为的表示
Cell 是 FRP 中最核心的概念之一,它代表一个随时间变化的值。
1 2 3 4 5 6 7 8 9
| interface Cell<A> { get(): A; listen(f: (a: A) => void): Listener; sample(): A; }
|
2. Stream:事件流
Stream 用于表示离散的事件序列:
1 2 3 4 5 6 7 8 9 10
| interface Stream<A> { listen(f: (a: A) => void): Listener; map<B>(f: (a: A) => B): Stream<B>; filter(f: (a: A) => boolean): Stream<A>; merge(other: Stream<A>): Stream<A>; }
|
3. 关键实现:事务机制
FRP 的精髓在于事务性——确保在同一个事务内的多次更新能被正确处理:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| class Transaction { static current: Transaction | null = null;
static start<A>(callback: (t: Transaction) => A): A { const t = new Transaction(); const prev = this.current; this.current = t; try { return callback(t); } finally { this.current = prev; } }
post(task: () => void): void { this.tasks.push(task); }
run(): void { for (const task of this.tasks) { task(); } this.tasks = []; } }
|
对比:Sodium vs RxJS
虽然两者都实现了响应式编程,但设计理念有所不同:
| 维度 |
Sodium |
RxJS |
| 时间模型 |
拉取(pull) |
推送(push) |
| 垃圾回收 |
自动(GC friendly) |
需要手动 unsubscribe |
| 事务 |
显式 Transaction |
隐式 Scheduler |
| 学习曲线 |
较陡 |
较平缓 |
核心差异示例
RxJS 风格(推送):
1 2 3 4 5 6 7 8
| const clicks$ = fromEvent(button, 'click'); const moves$ = fromEvent(window, 'mousemove');
zip(clicks$, moves$).subscribe(([click, move]) => { console.log('Click at', move); });
|
Sodium 风格(拉取 + 事务):
1 2 3 4
| const result = Transaction.run(() => { return cellA.sample() + cellB.sample(); });
|
实践:构建一个简单的 FRP 系统
Step 1:定义核心类型
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| type Listener = () => void;
interface Observable<A> { getValue(): A | null; addObserver(observer: (a: A) => void): Listener; }
class Cell<A> implements Observable<A> { private value: A; private observers: Set<(a: A) => void> = new Set();
constructor(initial: A) { this.value = initial; }
getValue(): A | null { return this.value; }
addObserver(observer: (a: A) => void): Listener { this.observers.add(observer); return () => this.observers.delete(observer); }
send(a: A): void { this.value = a; this.observers.forEach(obs => obs(a)); } }
|
Step 2:实现操作符
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| class MapCell<A, B> implements Observable<B> { constructor( private source: Observable<A>, private f: (a: A) => B ) {}
getValue(): B | null { const v = this.source.getValue(); return v !== null ? this.f(v) : null; }
addObserver(observer: (b: B) => void): Listener { return this.source.addObserver(a => { observer(this.f(a)); }); } }
|
Step 3:使用示例
1 2 3 4 5 6 7 8 9 10
| const counter = new Cell(0); const doubled = new MapCell(counter, x => x * 2);
doubled.addObserver(v => console.log('Doubled:', v));
counter.send(1); counter.send(5);
|
应用场景
1. 状态管理
1 2 3 4 5 6 7
| const searchText = new Cell(''); const suggestions = Transaction.run(() => { return searchText .debounce(300) .switchMap(query => api.search(query)); });
|
2. 游戏开发
使用 FRP 处理游戏中的各种事件流:
1 2 3 4 5 6
| const shoot$ = keyPress('space'); const move$ = keyPress('arrows');
shoot$.subscribe(() => tank.fire()); move$.subscribe(dir => tank.move(dir));
|
总结
关键要点
- Cell vs Stream - Cell 表示持续的值,Stream 表示离散的事件
- 事务机制 - 确保批量更新的正确性
- 组合性 - 所有的操作符都应该是可组合的
延伸阅读
系列文章
本系列持续更新中:
- FRP 入门教程 - 从 Sodium 到响应式编程(本文)
- 进阶:实现完整的 FRP 框架
- 实战:用 FRP 构建坦克大战游戏
如果你对 FRP 有更深入的兴趣,欢迎关注后续文章。