阅读前须知
- 本文献给对前端状态管理 state management 有思考的同学。
- 文章有涉及 函数式编程、响应式编程 概念
- 原文是 slide,所以是言不成章的。本文为了通顺,加了一些过渡。还有,由于 slide 常用于演讲,所以文字说明不是很多。我补上了一些个人的理解(以引用块的样式),但也不是很多,有时候会再出文章解释一些术语,如
lens
和atom
等。 - 文中的 state 和「状态」是同义的,有时为了强调和更易于理解,保留了这一术语未翻译,读者请自行脑内替换。
- 本文中的「我」指原作者
口味调查
在我给出我的口味前,下面几个矛盾,你会怎么选择?
- 无状态 vs 状态化
程序是基于状态的,所以它不可能被完全地清除,但它必须被管理起来。 - 可变状态 vs 不可变状态
状态随着时间而变化,所以不可变状态这个说法是自相矛盾的。人们可以在状态的一个点上捕捉到不可变的值,但状态本身并不全部不可变。 - 全局状态 vs 局部状态
来自外部的、共享的、全局状态实际上优于被封装在内部的本地状态。这也是本篇文章要讨论的要点之一。
前情提要
- 这篇文章不会提出新发明。
Most papers in computer science describe how their author learned what someone else already knew. — Peter Landin
- 我们的讨论基于我在 Calmm 中的实践
Calmm 是一个用于编写响应式 UI 的框架。鼓励使用外部共享的状态,和持续可观察的属性(continuous observable properties)。 在赞美 Calmm 之前,我们需要达成一些共识
本文的目标
希望咱们能从一个崭新的角度讨论 state ?
State
什么是 state
- has value,有值
- has identity,有索引
- can change over time, 随着时间会变化
状态管理难在哪里?
值、索引和时间相互交织,索引和时间尤其复杂。
- 追踪索引常常导致算法复杂化,比如 React 中的
key
- 随着时间变化,依赖于状态的一些计算会无效化
语言层面的局限
一般的语言 (比如 js)对 state 这种数据基本都不做原生支持。
这体现在,在这些语言中:
- 变量是可变的、对象上的字段也是可变的
- 根本上来说,是次类元素
- 无法组合
- 无法分形(decompose)
- 无法(随着时间)响应变化
什么叫次类元素?
这个说法对应于首类元素 first-class
,它
- 无法通过函数返回
- 无法作为参数传递
演示局限
无法(随着时间)响应变化
let x = 1 // 创建了一个可变的 state
let y = 2
let sum = x + y // 获取了 state 的快照 snapshot,值为 3
x = 3
sum // 值还是 3,sum 无法观察 x 赋值后的值,随之变化值为 5
state 不是语言中的 first-class
元素
function foo() {
let x = 1 // 创建可变的 state
bar(x) // 无法将 state 作为参数传递,只能传递值,即 1
x = 2 // 修改了 state ,但这对于函数 bar 来说是不可知的
return x // 也无法将 state 作为返回,只能返回值,即 2
}
如果你了解 js ,知道变量区分值类型和引用类型、形参实参的分别,那么就不会觉得上面的代码有任何奇怪的地方。甚至你会觉得如果 x 重新赋值后, sum 会随之变化为 5、对已经调用完毕的 bar 还能产生影响,那才太可怕了。
但其实上面的代码,并不是在挑战这些东西。而是假设我们创建的
x
都是一种名为state
的首类元素,它应当可以
- 作为函数的参数或返回值进行传递,而不仅仅只是传递其计算值,即满足其身为 first-class 的特性
- 可以被其它引用它的函数或对象观察到它的变化
当然,目前 js 中并不存在这样的首类元素。
Make State Fun Again
neta
Make American Great Again
, 哈哈
我们试试在 js 中模拟出 State
下文代码都是 typescript
State Interface
interface State<T> {
get(): T;
set(value: T): void;
}
构造首类元素 state
我们已经说过首类元素的特性了,可以作为函数的参数和返回值传递。
class Atom {
constructor(value) {
this.value = value
}
get() {
return this.value
}
set(value) {
this.value = value
}
}
现在在组件中,我们就可以声明一个 state 来作为参数了。
Observable state
class Atom {
constructor(value) {
this.value = value
this.observers = []
}
get() { return this.value }
set(value) {
this.value = value
this.observers.forEach(observer => observer(value))
}
subscribe(observer) {
observer(this.get())
this.observers.push(observer)
}
}
state 能独立于时间变化了(Independence from time)
可分形的 state
decomposable
class LensedAtom {
constructor({getter, setter}, source) {
this.getter = getter
this.setter = setter
this.source = source
}
get() {
return this.getter(this.source.get())
}
set(value) {
this.source.set(this.setter(value, this.source.get()))
}
}
把 store state 作为一个整体,而其分片的元素作为组件的 state
可组合的 state
class PairAtom {
constructor([lhs, rhs]) {
this.lhs = lhs
this.rhs = rhs
}
get() {
return [this.lhs.get(), this.rhs.get()]
}
set([lhs, rhs]) {
this.lhs.set(lhs)
this.rhs.set(rhs)
}
}
- 事务性
- 独立于存储
全局状态的场景
为什么说全局状态更好?
- 组件因此可以无状态、可以方便地组合
- 全局状态更容易检查
- 一切对全局状态的操作测试起来都很简单
- 全局状态是稳健的单一数据源
为什么不用局部状态
- 局部状态无法从外部访问
- 很难组合
- 只能间接地去测试局部状态
- 很容易变得散乱
常见的误解
流(streams)是无状态的
一般我们认为 stream 是无状态的,但是请看:
- 这是无状态的吗?
-
merge
+scan
引入了局部状态 - 组织很容易变得散乱
- 时间变得很重要
不过,从好的方便来说:
- 它可观察
可以使得依赖更精确:可以方便地观察「是什么触发了这个 stream ?」。
- 但是没必要。
任何人都可以修改状态将会是一团糟
是的,在我们的方案里,任何人得到了一个 state 的分片,都可以修改它。
但是在 calmm 中,我们已经
- (限定了)作用域
我们通过参数赋予组件一部分 state,组件只能修改这部分 state,而不是全部 - (宣告了)意图
如果你把可变 state 传递给了组件,这相当于就宣告说,你允许在这个组件中修改 state - 观察(了变化)
即使有人修改了 state,组件也能观察 state 的变化并随之应变。
课后思考
- 思考下,你到底想把 state 存储在哪里?
- 同时,你的组件如何持久化 state 呢?