使用React hooks与context进行状态管理
合格的状态状态管理方案需要做到以下3件事情:
- 用户可以定义一个状态仓储来容纳被共享的状态。状态仓储可以是一个全局对象,或者是一系列依附于组件树的对象。一般都会形成一个树状的数据结构,同构于应用的功能树。
- 状态仓储可以定义更新状态的方法。状态更新的方法可以是同步或异步的。消费者只能通过调用这些方法来触发状态的更新。这样能提高数据模型的封装性。
- 消费者可以订阅状态。订阅者能够对状态的更新做出响应,渲染最新的状态。
- 【optional】提供异步流程管理方案,能够管理大量的并发异步流程。
React的useState
已经能做到前面2点。但是要做到第三点需要结合React context。比如unstated-next就是目前社区中比较流行的精简状态管理库,它将hooks与context结合在一起,将状态管理的样板代码降低到最少。
与传统的状态管理方案对比,这类方案的特点是:
- 它非常贴近React本身的理念,用户无需学习”额外的概念”(比如Redux的action、effect、store、dispatch……),使用起来非常自然
- 实现简洁。比如unstated-next的源码只有不到40行,非常容易理解和学习
- 自然的组合与抽象的能力。这一特点使得基于hooks的方案更加灵活和简练,注定具有长久的生命活力。但是我认为React hooks的组合抽象能力还有可以提高的地方。我会在后面说明为什么,并提出我的解决方案。
组合与抽象
摘自Structure and Interpretation of Computer Programs的全书第一段话:
一个强有力的程序设计语言,不仅是一种指挥计算机执行任务的方式,它还应该成为一种框架,使我们能够在其中组织自己有关计算过程的思想。这样,当我们描述一个语言时,就需要将注意力特别放在这一语言所提供的,能够将简单的认识组合起来形成更复杂认识的方式方法。每一种强有力的语言都为此提供了三种机制:
- 基本表达式:用于表示语言所关心的最简单的个体
- 组合的方法:从较简单的东西出发构造出复合的元素
- 抽象的方法:为复合对象命名,并将它们当做单元去操作
下面容我举例说明。
“组件化”之所以能够成为UI开发的金科玉律并长盛不衰,就是因为它提供了完备的组合与抽象方式:
- 基本元素:button、input、div、span等
- 组合方式:基本的UI元素可以通过顺序、嵌套的方式组合在一起,并且可以通过if、for这种方式来条件渲染、多份渲染。通过组合能够渲染更加复杂的UI。
- 抽象方式:将一种【UI元素的组合方式】,命名成一个【组件】,并将它当作基本UI元素来使用。在脑容量有限的前提下,抽象让人脑能够处理更复杂的UI。
- Angular、React、Vue、Flutter……或许在API上有所区别,但是它们的立命之本都是通过组件化来实现组合与抽象。
Rxjs本质上是发明了一种操作流的语言:
- 它的基本表达式是fromEvent、fromInterval这种基本的事件流;
- 它的组合方式包括处理工序的顺序的叠加,以及将多个流合并起来(有多种操作符支持各种策略的合并)。通过组合(处理工序的顺序的叠加、多个流的汇合),你能编写出任意复杂的行为。
- 它的抽象方式是:流经过一系列的操作符加工以后,得到的仍然是一个流。开发者可以给它赋予名称、含义,然后将它当做一个普通的流。外部无需了解这个流是如何加工得到的。在脑容量有限的前提下,抽象让人脑能够处理更复杂的流。
- 因此将Rxjs理解成像lodash一样的"工具函数库"是片面的。它为程序逻辑的编排发明了一套全新的语言,擅长异步、并发任务复杂的场景。
Redux-saga本质上是发明了一套管理副作用的语言:
- 基本流程:setState、发请求、设置定时器等副作用操作。
- 流程组合:你可以并发(同时触发多个流程)、串行(一个流程结束以后触发另一个流程)地执行多个流程,实现任意复杂的逻辑。
- 流程抽象:你可以将上述组合的结果赋予一个新的流程名称、职责,然后当做一个原子流程来理解、触发。
redux-thunk类似。
面向过程编程、面向对象编程、函数式编程也无一例外,都有自己的组合与抽象的方式,只不过很多人已经习以为常,不会意识到这是一种多么美妙的性质。
说回React。React hooks的杀手锏就是其强大的抽象与组合能力:
- 基本表达式是useState、useEffect等基本hooks;
- 组合方式:你可以顺序调用多个hooks,并将前面一个hook的结果传递给后面的hooks(串行)。hooks的组合让你能够实现任意复杂的行为。(如果你愿意,你能够只写一个hooks来实现应用的所有UI功能)
- 抽象方式:你可以将多个hooks的调用抽离成一个单独的hook,为它赋予一个新的名称、职责,然后将它当做一个原子hooks来使用。在脑容量有限的前提下,抽象让人脑能够处理更复杂的UI逻辑。
可以说hooks的组合抽象能力直接来自于函数的组合抽象能力,非常简单直接。
React hooks的出现使得UI应用中的状态管理、异步流程逻辑不再需要依托于组件提供的状态声明、生命周期,可以在组件之外进行组合与抽象。可以说,hooks是一种编排组件行为的语言。
Redux与之相比,没有一种自然的组合、抽象方式。1就是1,2就是2,你无法很自然地把1和2组合起来封装成3,然后把3当成一个原子来使用。主要原因是store、action是没有封装性的,你没有办法把某些state、action封装(隐藏)在另一个相同概念的背后。因此Redux是一种框架而不是一种“语言”。
React hooks在通信上的缺陷
如果React hooks仅仅通过参数、返回值来进行组合,那么它其实就是一种普通函数,组合抽象能力与函数相当,完全没有问题。
实际上大部分语言没有为函数提供远程通信机制。最近的一些新兴研究型语言提供了一种叫Algebraic Effects的函数远程通信机制。React团队借鉴过这个概念。
但是,React提供了一种远程通信的机制:context。React context提供了强大的组件间通信方式。但是React实际上还没有为hook提供一致的远程通信方式。我认为React应该提供一致的方式来支持完整的远程通信:
- components/hooks与上级组件通信。这一点目前的React context就可以做到。
- hook与当前环境下的其它兄弟hooks进行通信。这个目前只能通过
返回值->参数
传递来做到,无法通过context做到。 - hook与其内部嵌套调用的hooks进行通信(即使嵌套层级非常深)、hooks与其所在的组件进行通信(即使hook深层嵌套在其他的hooks中)。这个目前也只能通过逐层透传参数来做到,无法通过context做到。
我的期待有两个:完整和一致。
- 完整:以上三种通信都应该被支持。后面2个要求初看可能觉得有些有些不合理。但是你应该发现:hooks与组件在能力上本来就很相似,hooks的调用关系树其实和组件树一样,在层次比较深的时候会有通信需求。这也是React context当初被引入的原因。凭什么只支持跨组件远程通信,hooks间通信就只能逐层透传呢?
- 一致:虽然可以通过参数传递来支持第二种和第三种通信,但是我认为只有这种方式是不够的。因为这要求我们要为第2、3种场景刻意修改hooks的函数签名,通过参数来接受输入。修改函数签名后,不仅更加难用,而且在实现和用法上与第1种场景完全割裂,无法再用于跨组件的通信。我期望的“一致”方式:当一个hook调用
const val = useBetterContext(Context)
的时候,这个值可以来自上级组件、当前组件、兄弟hook或者上级hook,调用者无法分辨到底来自哪个(并且它不关心)。
由于上述的缺陷,React context只能在不同层级的组件之间通信,我们只能通过“提升”的方式来强行创造组件层级。这导致了深层嵌套的HOC以及provider hell:
<context1.Provider value={value1}>
<context2.Provider value={value2}>
<context3.Provider value={value3}>
<context4.Provider value={value4}>
<context5.Provider value={value5}>
</context5.Provider>
</context4.Provider>
</context3.Provider>
</context2.Provider>
</context1.Provider>
<context1.Consumer>
{value1 => <context2.Consumer>
{value2 => <context3.Consumer>
{value3 => <context4.Consumer>
{value4 => <context5.Consumer>
{value5 => (
null
)}
</context5.Consumer>}
</context4.Consumer>}
</context3.Consumer>}
</context2.Consumer>}
</context1.Consumer>
因此有很多人曾建议React引入useProvider(reactjs/rfcs/issues/101, facebook/react/issues/14620),但是一直没有一个很好的方案。
更糟糕的是,hook的组合能力收到了削弱。因为本组件通信的方式和跨组件的通信方式不一致,开发者需要明确知道自己是在做哪种通信,然后决定应该使用怎样的API。
React hooks状态管理范式与工具
为了克服上述的context通信缺陷,提高hooks+context的组合抽象能力,我实现了一个小工具:react-hook-svs(除去注释以外只有100行代码)。它与unstated-next类似,是一个帮助开发者结合hooks与context能力的工具,可以用来做远程通信、状态管理。两者的不同在于,unstated-next
无视了上述的通信问题,而react-hook-svs
解决了它,而且顺便解决了provider hell、被迫提升provider的问题。
这是一个基本示例,可以看出它的用法和unstated-next一样简洁:
import React, { useState } from "react";
import { createSvs } from "react-hook-svs";
// Services often declare state and state-updating routines
const CounterSvs = createSvs((scope, initial) => {
const [count, setCount] = useState(initial);
const decrement = () => setCount(count - 1);
const increment = () => setCount(count + 1);
return { count, decrement, increment };
});
export default function App() {
/**
* Make App component be the host of CounterSvs.
* You can get the service output immediately in the hosting component,
* without need to wrap it in a HOC.
*/
const [{ count, increment }, scope] = CounterSvs.useProvideNewScope(10);
// scope.injectTo make the service output available in this subtree
return scope.injectTo(
<div className="App">
<p>
Use count in Host: {count} <button onClick={increment}>+</button>
</p>
<div>
Use count in Children:
<CounterDisplay />
</div>
</div>
);
}
function CounterDisplay() {
// find CounterSvs from react context
const { count, decrement, increment } = CounterSvs.useCtxConsume();
return (
<div>
<button onClick={decrement}>-</button>
<span>{count}</span>
<button onClick={increment}>+</button>
</div>
);
}
一些关键特性:
以下“服务”指的是通过React context来广播输出结果的hooks,是状态管理和分发的基本单元。它就是状态管理方案中的“数据模型”的概念。
当你需要在宿主组件消费服务输出时,不需要强行将服务“提升”(用HOC包裹顶层组件)。react-hook-svs的服务就是hooks,直接在宿主组件调用,然后直接拿到结果。在保持hooks的调用简洁性的同时,还非常容易通过context来分发结果。
- 上面的基本示例就体现了这一点
服务组合。不管是本组件的服务,还是上层组件的服务,具有一致的消费方式。因此你在做服务组合的时候,不需要在意对方是本组件的服务还是上层组件的服务。
没有provider hell。scope对象负责收集一连串服务的调用结果和context provider。当你调用
scope.injectTo
的时候,会帮你按顺序用Provider包裹子组件。让你的jsx代码更加紧凑整洁。- 上面的服务组合示例体现了这一点
服务抽象。你可以将多个服务的调用、消费封装成一个服务。用户调用这个服务的时候,无法感知到内部服务的存在。封装服务可以按需re-export、re-name内部服务的输出(类似于esModule的重导出机制),供消费者获取。
- Typescript first. 过去很多方案,使用对象式的model定义、基于字符串的action分发(比如dva),对typescript是非常不友好的。而react-hook-svs的service(数据模型)是基于函数的方式来进行类型声明的,对于类型系统的支持与原生React hooks一样强大。
详细说明和API文档,请参考react-hook-svs的文档。它的源码、demo都可以在codesandbox上调试。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。