FRP 入门教程 - 从 Sodium 到响应式编程

FRP(Functional Reactive Programming)是一种结合函数式编程和响应式编程的范式。本文将从 Sodium 库入手,带你理解 FRP 的核心概念。

前言

为什么要写这篇教程?因为 FRP 是一个容易被误解的概念。网上的资料要么过于抽象(数学符号满天飞),要么过于肤浅(只是简单的事件监听封装)。

本文的目标是:

  1. 建立直观理解 - 不靠数学符号,用代码说话
  2. 源码级解析 - 深入 Sodium 实现,理解内部机制
  3. 对比学习 - 与 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
// Sodium 核心类型定义(简化版)
interface Cell<A> {
// 获取当前值
get(): A;
// 监听值变化
listen(f: (a: A) => void): Listener;
// 采样另一个 Cell 的值
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
// 1. 定义基础类型
type Listener = () => void;

interface Observable<A> {
getValue(): A | null;
addObserver(observer: (a: A) => void): Listener;
}

// 2. 实现 Cell
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
// 3. map 操作符
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
// 创建 Cell
const counter = new Cell(0);
const doubled = new MapCell(counter, x => x * 2);

// 监听
doubled.addObserver(v => console.log('Doubled:', v));

// 触发更新
counter.send(1); // 输出: Doubled: 2
counter.send(5); // 输出: Doubled: 10

应用场景

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));

总结

关键要点

  1. Cell vs Stream - Cell 表示持续的值,Stream 表示离散的事件
  2. 事务机制 - 确保批量更新的正确性
  3. 组合性 - 所有的操作符都应该是可组合的

延伸阅读


系列文章

本系列持续更新中:

  • FRP 入门教程 - 从 Sodium 到响应式编程(本文)
  • 进阶:实现完整的 FRP 框架
  • 实战:用 FRP 构建坦克大战游戏

如果你对 FRP 有更深入的兴趣,欢迎关注后续文章。