前言
其实在一个多月前,我也已经在掘金发过 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 的声明。
后来我总结了一下,我真正想要的是怎么样的:
- 全局状态管理,但非单一 store;
- actions 跟 effects 就是正常的函数,独立声明,直接引用;
- 完美的 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 了。这样的好处有:
-
action/effect
不在 hook 中,避免每次 render 导致的函数重新声明(进而需要useCallback/useMemo
)。 - 可方便的将方法抽离到其他文件,降低单个文件复杂度。
消费状态
在组件里使用全局状态,为了保证响应式,需要通过 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
即可。但也有两种情况是例外的:
- 引用了某些通用性的组件,想通过 connect props 来解耦全局状态逻辑。
- 一个是以前就写好的
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 底层基于 context
跟useState
实现,由于把状态存在 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 包裹下。但我依旧不愿意使用类似 stamen
跟hox
这样发布订阅的方法。因为 react 已经有一套自己响应式逻辑了。再在上面加个发布订阅的逻辑 … 我能力比较差,hold 不住 … 目前我也想不到更好的办法,只能提供下语法糖,稍微简化一点点。
几个 api 第一次使用会分不清
getHoox
、useHoox
、setHoox
什么的,确实 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
关于源码部分我就不详细说明啦,也没几行代码,看看就能明白。