响应式 React Hooks 状态管理库——Bistate 介绍
今天介绍我的一个实验性项目——Bistate,读作 bai-state。
Bi 有表示双的意思。Bistate 在这里是指可以在 immutable state 和 mutable state 两种形态中切换的状态。
这篇文章将介绍 Bistate 的开发背景、技术思路和考量,希望能带给大家一点启发。
1、mutable vs immutable
我们知道,React 跟 Vue 的一大分歧是,对状态的是否可变持不同看法。React 偏爱 immutable data 不可变数据,Vue 则青睐 mutable data + reactive 响应式的可变数据。
immutable data 有非常多的好处。它可以让我们的函数在多次执行时,保持可预测性。我们知道变量的值永远不会突变,所以相同的参数,函数输出相同的结果。
mutable data 难以保证这点,在两次执行函数的间隙,闭包里的变量、或者对象的属性、或者全局变量,可能被其它程序所修改。那么,相同的参数,在第二次执行时,函数可能输出不同的结果。在复杂的大型程序里,这种不可预测的性质,不容易维护,也可能包含诸多隐患。
不过 Vue 包含 reactive 的方式,可以追踪状态变化的来源,在某种程度上缓解了 mutable data 的问题。并且,在 JavaScript 里,其实 mutable data 编写起来更加自然。
要在 JS 里编写 immutable 的状态管理代码,默认情况下是比较别扭的。
比如操作数组时,不能使用 push, pop, sort, splice 等方法(它们会修改数组本身),要使用 map, filter 等方法(它们不会修改数组本身,而是返回新数组);
比如更改对象属性时,要通过 state = {...state, [key]: value} 这种冗长的做法。修改第一层的属性还好,如果是更新深层属性,每一层我们都得那样做。代码量会剧增,也更难阅读。
可以说,在还没有享受到 immutable data 带来的可预测性之前,我们代码先丢掉了可读性。
写过 React 代码,特别是写过 Redux 代码的朋友们,对此应该深有体会。
在这里,我们看到了 mutable 和 immutable 对立的一面。
2、mutable + immutable
Mobx 作者写了一个叫 immer 的库,以独特的方式,优化了immutable 代码的编写 。
如上图所示,produce(state, producer) 函数的 producjer 参数也是一个函数,它是一个 mutable 的世界,接受一个 draftState 草稿状态,可以随意修改它,不会直接影响到 immutable state。
在 producer 函数执行完毕之后,immer 会根据 mutable 的草稿和 immutable state,创建下一个新的 immutable state。
如此,immer 实现了在 mutable 和 immutable 两个空间中切换和互通的过程。
我们既可以用 JS 里更自然的方式更新状态,又能得到 immutable data 所带来的好处。
React Core Team 成员 Sebastian 给了 immer 一个高度评价,说它在 React 的角度上看完全正确。
immer 确实让我们看到了 mutable 和 immutable 结合的一面。
3、mutable + immutable + reactive
有趣的是,immer 源码里切换 mutable/immutable 的做法,跟 Vue 实现 reactive system 所依赖的语言特性是一样的。
在支持 ES2015 的环境里,使用的是 Proxy;在不支持 Proxy 的环境里,使用降级版本,ES5 的 Object.defineProperty 特性。
那么,一个很自然的想法是,我们能不能把 mutable, immutable 和 reactive 三者结合起来?
Bistate 就是我在这个方向上的一个尝试,它的思路异常简单,代码量也很少。
3.1 ES2015 Proxy
首先粗略地介绍一下 ES2015 Proxy。
如上所示,Proxy 人如其名,就是代理的意思。代理对象 p 接管了 target 对象的一系列操作。p 跟 target 不是同一个对象,p 相当于对 target 进行了一个包装和隔离,我们操作 p,p 按照 handler 里指定的函数,将操作应用到 target 上。
如上所示,target 还是普通对象,p 则是代理对象,p 跟 target 并不相等。
如果 Handler 为空,那么我们对 p 的所有操作,都走默认路径,直接作用在 target 身上。
上面我们指定了 get 这个 handler。那么,在对 p 进行 get 操作时,它有了一个中间路径,就是我们定义的 get 方法。它在 key 不存在于 target 时,返回 37。
除了 get 以外,还有 set, apply, deleteProperty 等可选的劫持步骤,基本上覆盖了操作数组和对象的主要方式。
相比 Object.defineProperty 里只包含了 get/set,Proxy 更充分的让我们可以追踪状态的修改。不仅仅是追踪,还能改变默认行为,这是一项强大的元编程能力。
3.2 Bistate 设计思路
我们可以参考 immer,通过 Proxy 劫持所有操作,明确的隔离出 mutable 和 immutable 两个操作空间。
在 immutable 空间,只允许对状态进行 read 操作,不允许对状态进行 write 写入操作;在 mutable 空间则开放 write 操作。
就像 immer 里用 draftState 草稿状态,隔离对 target state 的直接操作一样。在 Bistate 的 mutable 空间,我们有一个 scapegoat 替身状态。所有的 read/write/delete 都不会作用到 target,而是作用在 scapegoat 替罪羊身上。
代码如下所示:
我们立一个 isMutable 的 flag,通过切换 flag 来切换 mutable 空间和 immutable 空间。任何想在 immutable 空间 set 状态的,都会抛出错误。
在 mutable 空间里,则用 Reflect 反射特性,将操作作用在 scapegoat 上,保持 target 的不可变特性。(请不必对 Reflect.set 特性感到陌生和害怕,可以把它看作 scapegoat[key] = value 这个语句的另一种写法)。
如此,我们实现了 mutable + immutable 的结合,target 永远是 immutable 的,mutable 的是 scapegoat。接下来,我们只要根据 scapegoat 构造下一个 Bistate 即可。
细心的读者可能发现,上图代码里的 set 方法里,还有一个 notify() 调用。它正是 reactive 的部分。
在 Bistate 创建时,每个父节点都会作为依赖,存入子节点的隐藏属性。亦即,依赖追踪,追踪的是父节点。
子节点的 notify 会 mark 当前对象为 dirty;然后通知 parent 父节点,让父节点也去 mark 为 dirty,构成一个冒泡的过程。最终冒泡到根节点(即没有父节点的节点)。
然后从根节点出发,重新创建新的状态树。非 dirty 的子状态得到复用,对 dirty 的子状态进行 target 和 scapegoat 的分离,清空它的 scapegoat,让它没有替身,不再能被修改,进入完全 immutable 的阶段。
最后用替身数据,构造下一个 Bistate 即可。相当于替身转正为 target,此时这个转正的替身,也得到了自己的替身。
Bistate 就是替身状态不断转正的过程。
3.3、Bistate 与 immer 的异同
Bistate 和 immer 的相同之处在于,它们都能在 mutable 和 immutable 空间中切换。
Bistate 跟 immer 的不同之处,如下图所示:
回顾 immer 可以看到,immer 的默认用法里, draftState 在 producer 内部使用。draftState 不等于 state,有两个明确不同的状态给我们操作。
而 Bistate 里,state 只有一个,默认情况下它是 immutable 的,在 mutate 函数内部,它被切换成 mutable 的(即所有操作将作用于替身状态)。
对于用户而言,只有一个状态,双状态隐藏在内部。immer 把双状态都暴露给用户。
Bistate 可以被 watch 监听,监听函数可以接收到下一个状态 nextState。
另一个不同点如下图所示:
immer 不是 reactive 的,我们不能通过修改 todo 节点,就自动更新了 baseState。我们必须 produce(baseState, producer) 然后在内部通过父节点 draftState 去更新子节点 todo,而不能直接通过子节点 draftTodo 去反向更新父节点 state。
bistate 是 reactive 的,对 todo 的修改,会通过依赖追踪找到父节点,然后从根节点出发创建新状态。
4、Bistate + React-Hooks
裸用 Bistate 很难带来助益,因为每个 bistate 都只能被 watch 一次,在 watch 里拿到 nextState,还得手动 watch,否则拿不到 next nextState。很麻烦,没人愿意写下面的代码:
React-Hooks 的 useXXX 拥有神奇的魔力,像哆啦A梦的百宝袋空间一样,可以把层叠的东西扁平化。结合两者,我们可以得到 useBistate。
如上所示,在经典的 Counter App 示例中,我们通过 useBistate 创建了一个 bistate 状态,通过 useMutate 定义了修改状态的更新函数,并将它们绑定到 onClick 事件中。
React 自身的执行流程,分为两个阶段。一个叫 render phase,它要求代码是 immutable 的,没有副作用。一个叫 commit phase,react 在这个阶段将 virtual-dom 应用到 real-dom 上,执行开发者用 useEffect 注册的副作用函数。
Bistate 在 render phase 时,处于immutable 操作空间;在 useMutate 内部,进入 mutable 操作空间。很好的融入了 React 的机制。
5、Bistate 有什么使用优势?
Counter 并不能充分反映 Bistate 的特点,我们来看一下 TodoApp 的案例。
如上所示,第一个箭头的 input 输入框用以创建新的 todo,第二个箭头的 input 输入框用以编辑当前的 todo。它们其实是同一个组件,叫 TodoInput。
在传统做法里,TodoInput 通常只是展示,没有状态管理。状态的更新,是它调用 props.onChange 触发父级组件的更新函数,改变父级组件的状态。
如果 TodoInput 用它自己的局部状态去管理,那么 add 按钮,作为另一个组件,它们彼此隔离,无法探测到 TodoInput 内部的状态,拿不到 todoContent,无法构造新的 todo。所以,我们不得不 lift TodoInput 的状态到上层组件,这样 add 按钮和 TodoInput 可以拿到公共父级组件通过 props 传递过来的同一份数据 todoContent。
props 不仅仅要传递 text 数据,还得传递 updateText 更新函数,否则子组件无法更新。
状态和更新状态的函数,通常是写在一起的;当我们把所有状态都 lift 到顶层组件,通常意味着我们得在顶层组件里编写大量更新函数。子组件全部被架空为展示型组件。
当我们把状态 lift 提升到 React 组件外部时,它成为了 global-state/redux 模式,更新函数变成了 reducer/dispatch/actions。
提升和聚合状态,通过 props/context 传递状态,其实是可以理解和接受的,也很容易处理。真正麻烦的是,更新函数也得提升和聚合,它们在一次次渲染中反复创建,一层层传递中造成大量 props 冗余。
比如 TodoInput 组件,有多少个组件用到它,就得构造多少次以上的 onChange 函数,里面通常只是 setText(event.target.value) 一行代码罢了。但这行代码得在每个用到 TodoInput 的父级组件编写 N 次。
既不利于编写和维护,也不利于性能优化。如果可以只传递数据/状态,而不必传递更新函数,React 状态管理的代码将会简单很多。
通过 Bistate 里的 reactive 机制,我们可以解决这个问题,实现尽可能只传递数据,而不必夹带过多的更新函数。
如上所示,我们在 TodoInput 内部使用 useMutate 直接修改 text.value,通过 reactive 的依赖追踪,它会自动追踪到根节点及其关联的父级组件,隐式的触发父级组件的更新,而不必手动调用 props.onChange 显式触发。
任何使用 TodoInput 的组件,只需要传递 text 这个 bistate 即可。
这种能力,可以让我们提升和聚合状态到顶层组件,但在各个子组件中,去更新状态。如此使我们的状态管理,在组件中得到了更匀称的分布。
此外,基于依赖追踪,我们还能实现 remove 函数。
如上图所示,每个 Todo 组件内部都包含一个 remove 按钮,点击后删除 todo。在传统做法中,todo 是不可能删除自身在父节点 list 里的引用的。因此 remove 函数,需要从父组件传递进来。
而 reactive 的 bistate,内部包含了对父节点的的引用,所以可以向上回溯。如果父节点是数组,则通过数组的 splice 去删除,如果父节点是对象,则通过 delete 关键字去删除。
bistate 的父子节点之间存在 bidirectional connection 双向链接。严格来说,已经超出了 State Tree 的定义,成为了一个小型的 State Graph,从而得到比 Tree 更强大的数据表达能力。
尾声
不像 immer 有 Object.defineProperty 的 fallback 方案,Bistate 目前只能运行在支持 ES2015 Proxy 的浏览器环境里。因此,Bistate 当前浏览器兼容性不佳。
感兴趣的同学,可以点击阅读原文,访问和体验 Bistate。或许一两年左右,国内浏览器环境发生显著变化,Bistate 模式能得以广泛使用。