12

前言

其实在一个多月前,我也已经在掘金发过hoox 的介绍。不过我觉得这么简单的东西,也没太大技术含量,也就是随便发发看,图个乐。最近发现基于 Hook 的状态管理器越来越多了,那我也就再在这里赶个集好了,免得以后再发显得有点儿山寨。

另外,我还是提前说,目前我的这个小玩具,还是 0.x 的版本。我还不敢发正式版,一方面是我自己觉得还有些未完善之处。另一方面,是它确实没有经过非常多项目的考验。不过,如果纯按业务流量来说,它已经在蚂蚁保险几个百万~千万 UV 级的 C 端页面上跑了很久了。目前来看,没有明显异常。这也是我发文章时稍微有的一丁点儿底气。

回归正文:

为什么又要造轮子

hook 自带轮子光环

关于 react hook 我就不多介绍了。hook 提供了抽象状态的能力,自然而然让人想到可以基于 hook 抽离全局状态。其天生自带轮子光环,所以社区也出现了不少基于 hook 的状态管理工具,比如说前阵子飞冰团队出的icestore,亦或者这个stamen,不过相对来说我更喜欢的还是这个unstated-next

那既然别人都已经造了那么多轮子了,为什么自己还要造呢?自然是因为:

别人的轮子不够用

比如说unstated-next,它本质上是把一个自定义 hook 全局化了。理念很好,就是状态逻辑比较复杂的话,写起来有点儿累。必须把 state、actions、effects 维持在一个自定义 hook 中。内部的一系列 actions、effects 需要加 useCallback、useMemo 也比较麻烦,如果抽离到外部,又要传很多参数。总之,如果项目相对比较复杂,写起来比较累。

stamen  其实也不错。声明一个 store,包含 state、reducer、effects。而且不需要给组件包裹 Provider,各个地方随意拔插,响应更新。就是 dispatch 我不太喜欢用,不太好直接定位到 action 或 effect 的声明,且丢了入参出参类型。

icestore  的问题也差不多。说是支持 TS,其实是残缺的,看了下源码,类型完全都丢失了。另外命名空间这一套我也不是很喜欢。

另外,前两天蚂蚁体验技术部的同学也出了一个hox。名字跟我的这个很像,但确实不是一个东西。它呢有点儿像 unstated-next 跟 statemen 的结合体。按我理解,它核心就是想在 unstated-next 的基础上,解决嵌套Provider的问题。不过这也不是我使用 unstated-next 时的痛点。另外,其内部使用ReactDOM.render来实现,没法实现 SSR。

当然上述这些问题人家也能优化。但是何必呢,本来也没几行代码,给人家提 PR 的时间,我自己都写好轮子了。所以总而言之,还是自己造吧。

我的理想型

那我自己想要的状态管理工具是怎么样的呢?在 hoox 之前呢,其实我还实现了一版,基本复制 dva 的 api 的一个版本(把 yield 换成 async/await )。有点儿像 icestore,只不过没有命名空间。但它有着 icestore 跟 stamen 同样的问题,不太好直接定位到 action/effect 的声明。

后来我总结了一下,我真正想要的是怎么样的:

  1. 全局状态管理,但非单一 store;
  2. actions 跟 effects 就是正常的函数,独立声明,直接引用;
  3. 完美的 TS 支持。

所以目标很简单,可以说就是  unstated-next  的去 hook 包裹版。于是我实现了一版,最终效果如下:

HooX

创建全局状态

// store.js
import createHoox from 'hooxjs'

// 声明全局初始状态
const state = {
  count: 1
}

// 创建store
export const {
  Provider, // 使用全局状态的组件或者其根组件,需要被Provider包裹
  useHoox, // 获取全局状态,以及更新全局状态的方法,类似useState
  getHoox // 获取全局状态,相比useHoox,其获取的状态更新时,并不会触发组件更新,常用于effect跟action中
} = createHoox(state)

// 创建一个 action
export const up = () => {
  const [{ count }, setHoox] = getHoox()
  return setHoox({ count: count + 1 })
}

// 创建一个 effect
export const effectUp = async () => {
  // getHoox 跟 useHoox
  const [{ count }, setHoox] = getHoox()
  const newState = { count: count + 1 }
  await fetch('/api/up', newState)
  return setHoox(newState)
  // 或者直接引用action
  // return up()
}

当然,如果action/effect场景简单的话,也有些简单的 api。

export const {
  // ...其他api
  setHoox
} = createHoox(state)

// 创建一个 action
export const up = () => setHoox(({ count }) => ({ count: count + 1 }))

可以看到,通过这样的方式,创建action/effect以及全局状态就脱离 hook 了。这样的好处有:

  1. action/effect不在 hook 中,避免每次 render 导致的函数重新声明(进而需要useCallback/useMemo)。
  2. 可方便的将方法抽离到其他文件,降低单个文件复杂度。

消费状态

在组件里使用全局状态,为了保证响应式,需要通过useHoox获取。如果是使用action/effect,那就比较简单了,直接引用即可。

切忌,组件不应该通过getHoox获取全局状态,因为它不具有响应式的逻辑。虽然也能获取到状态,但是并不会因为状态的变更而触发组件 render。

import { useHoox, up, effectUp } from './store'

function Counter() {
  const [state] = useHoox()
  return (
    <div>
      <div>{state.count}</div>
      <button onClick={up}>up</button>
      <button onClick={effectUp}>effectUp</button>
    </div>
  )
}

直接修改状态

如果场景较为简单,且不需要抽象action,也可以直接在组件内部更新状态。

import { useHoox } from './store'

function Counter() {
  const [state, setHoox] = useHoox()
  return (
    <div>
      <div>{state.count}</div>
      <input
        value={state.count}
        onChange={event => setHoox({ count: event.target.value })}
      />
    </div>
  )
}

如果这个组件只更改状态,不需要消费状态,也可以直接用setHoox

import { setHoox } from './store'

function Inputer() {
  return (
    <div>
      <input onChange={event => setHoox({ count: event.target.value })} />
    </div>
  )
}

重置状态

我们知道,在 class 组件中,通过 this.setState  是做状态的合并更新。但是在 function 组件中, useState  返回的第二个参数 setState  又是做替换更新。实际使用中,其实我们都有诉求。尤其是非 TS 的项目,状态模型可能是动态的,很可能需要做重置状态。为了满足所有人的需求,我也加了个 api 方便大家使用

import { useHoox } from './store';

function Counter() {
  const [state, setHoox, resetHoox] = useHoox()
  return (
    <div>
      {state ? <div>{state.count}</div> : null}
      <button onClick={() => resetHoox(null)>reset</button>
    </div>
  )
}

全局 computed

通过上述 api,其实我们还可以实现类似 vue 中 computed  的效果。

import { useHoox } from './store'

export function useDoubleCount() {
  const [{ count }] = useHoox()
  return count * 2
}

对于某些非常复杂的运算,我们也可以使用 react 的 useMemo  做优化。

import { useHoox } from './store'

export function usePowCount(number = 2) {
  const [{ count }] = useHoox()
  return useMemo(() => Math.pow(count, number), [count, number])
}

除此外,也可以实现一些全局 hooks。

connect

其实正常来说,我的业务代码基本不太会写connect的...直接useHoox即可。但也有两种情况是例外的:

  1. 引用了某些通用性的组件,想通过 connect props 来解耦全局状态逻辑。
  2. 一个是以前就写好的class组件不想改造成function组件,但又要用到全局状态。

所以实现了connect这个 api,方便解决这两个问题。

首先 store 中需要暴露出这个 api。

// store.js
export const {
  // ...其他api
  connect
} = createHoox(state)

然后对于函数式组件:

// Counter.js
import { connect } from './store'

const Counter = ({ count }) => {
  return <div>{count}</div>
}

const NewCounter = connect(state => ({ count: state.count }))(Counter)

export default NewCounter

connect以后,返回的NewCounter,就不需要再接受count这个prop,这个也已经做好了类型推导。

如果想用装饰器的话,函数组件是没有办法的,不过class可以。

import { connect } from './store'

@connect(state => ({ count: state.count }))
export default class Counter extends React.PureComponent {
  render() {
    return <div>{this.props.count}</div>
  }
}

但这个装饰器仅限于 js 环境,ts 环境下,装饰器不能改变 class 的返回类型。但是实际代码中,组件被connect后,我会返回一个新的函数式组件,并且改变了组件Props的类型(去除了全局状态注入的 props)。因此 ts 环境下,无法正常使用装饰器。当然 使用函数包裹依旧是可以的:

import { connect } from './store'

class Counter extends React.PureComponent<{ count: number }> {
  render() {
    return <div>{this.props.count}</div>
  }
}

export default connect(state => ({ count: state.count }))(Counter)

不够美好的地方

需要 Provider

hoox 底层基于contextuseState实现,由于把状态存在context了中,故而类似 Redux,消费状态的组件必须是相应Context.Provider的子孙组件。如:

import { Provider } from './store'
import Counter from './counter'

function App() {
  return (
    <Provider>
      <Counter />
    </Provider>
  )
}

这进而导致了,如果一个组件需要消费两个 store,那就需要成为两个Provider的子孙组件。

hoox 提供了一个语法糖createContainer,可以稍微的简化一下语法。

import { createContainer } from './store'
import Counter from './counter'

function App() {
  return <Counter />
}

export default createContainer(App)

虽然这样还是有些繁琐。尤其当有多个 store 互相调用的时候,需要特殊注意,用到状态的组件是否在相应的 Provider 包裹下。但我依旧不愿意使用类似stamenhox这样发布订阅的方法。因为 react 已经有一套自己响应式逻辑了。再在上面加个发布订阅的逻辑...我能力比较差,hold 不住... 目前我也想不到更好的办法,只能提供下语法糖,稍微简化一点点。

几个 api 第一次使用会分不清

getHooxuseHooxsetHoox什么的,确实 api 看着比较多,第一次用会有点儿懵。可能还会用错。不过新手用只要切记一点:没什么特殊要求,不要在组件里使用getHoox 只要牢记这点,基本只要能跑通,就没啥大问题。

用一小段时间后,明白getHoox是非响应式地获取全局状态,后续就 OK 了。最近团队里有个同学再研究eslint-plugin。后续让他帮忙写个hoox的 lint 插件就能改善一部分问题了。

目前来看,我也找不到其他更好的办法能解决这个问题,我必须有这几个api

其他不好的地方

留给评论区

写在最后

这个工具,目前我们团队内有 5-6 个人使用。整体而言,口碑还行,尤其是对于一些中小项目。有些组件稍微繁琐一些,就会有一堆 useMemo 来,useCallback 去的逻辑。通过hoox,将这些逻辑抽离出 hook,代码会清爽不少。另外,这些中小项目,引入dva/redux这些工具,确实显得偏重。通过函数式组件+hoox,即保证了轻量级,也满足了全局状态管理的场景。

但是呢,它确实还有不少缺点。而且如果是真的想吃透 99%的场景,可能还需要补充一些配套工具。包括提到的lint插件,甚至是类似redux-devtools这样的工具。目前的我,还不敢发正式版,也不敢拿自己部门来背书。所以这篇文章,包括在掘金,我都没有发到部门专栏。

不过如果你想用,基本还是可以放心的用。我们自己已经有多条业务在使用了,不是大版本,不可能 breaking change 了。只是说,它不一定是 React 状态管理工具的终态......未来你们遇到更好的,还是可能会选择迁移。

最后总结一下就是:问题不大,欢迎使用!

Github

具体的源码跟详细 api 介绍可以见 github:https://github.com/wuomzfx/hoox

关于源码部分我就不详细说明啦,也没几行代码,看看就能明白。


相学长
2.5k 声望189 粉丝