2

使用React hooks与context进行状态管理

合格的状态状态管理方案需要做到以下3件事情:

  1. 用户可以定义一个状态仓储来容纳被共享的状态。状态仓储可以是一个全局对象,或者是一系列依附于组件树的对象。一般都会形成一个树状的数据结构,同构于应用的功能树。
  2. 状态仓储可以定义更新状态的方法。状态更新的方法可以是同步或异步的。消费者只能通过调用这些方法来触发状态的更新。这样能提高数据模型的封装性。
  3. 消费者可以订阅状态。订阅者能够对状态的更新做出响应,渲染最新的状态。
  4. 【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应该提供一致的方式来支持完整的远程通信:

  1. components/hooks与上级组件通信。这一点目前的React context就可以做到。
  2. hook与当前环境下的其它兄弟hooks进行通信。这个目前只能通过返回值->参数传递来做到,无法通过context做到。
  3. 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>
  );
}

Edit react-hook-svs

一些关键特性:

以下“服务”指的是通过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上调试。


csRyan
1.1k 声望198 粉丝

So you're passionate? How passionate? What actions does your passion lead you to do? If the heart doesn't find a perfect rhyme with the head, then your passion means nothing.