如何利用 React Hooks API 做全局状态管理?
React 最新正式版已经支持了 Hooks API,先快速过一下新的 API 和大概的用法。
// useState,简单粗暴,setState可以直接修改整个state
const [state,setState] = useState(value);
// useEffect,支持生命周期
useEffect(()=>{
// sub
return ()=>{
// unsub
}
},[]);
// useContext,和 React.createConext() 配合使用。
// 父组件使用 Context.Provider 生产数据,子组件使用 useContext() 获取数据。
const state = useContext(myContext);
// useReducer,具体用法和redux类似,使用dispatch(action)修改数据。
// reducer中处理数据并返回新的state
const [state, dispatch] = useReducer(reducer, initialState);
// useCallback,返回一个memoized函数,第二个参数类似useEffect,只有参数变化时才会更改。
const memoizedCallback = useCallback(
() => {
doSomething(a, b);
},
[a, b],
);
// useMemo,返回一个memoized值,只有第二个参数发生变化时才会重新计算。类似 useCallback。
// useCallback(fn,inputs) 等效 useMemo(() => fn,inputs)。
const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b])
// useRef,返回一个可变的ref对象
const refContainer = useRef(initialValue);
// useImperativeMethods,详情自行查阅文档
// useMutationEffect,类似useEffect,详情自行查阅文档
// useLayoutEffect,类似useEffect,详情自行查阅文档
那么在新的 API 支持下,如何做全局的状态管理呢?
首先我们看到官方提供了 useReducer
方法,可以实现类似redux
的效果。
但是这个方案有一个明显的问题,这里定义的state
是和组件绑定的,和useState
一样,无法和其他组件共享数据。其实useReducer
内部也是用useState
实现的。
另外一个方案,基于useContext
,同时配合useReducer
一起使用。
我们知道,React.createContext()
是一种生产消费者的模式,我们可以在组件树顶层使用Context.Provider
生产/改变数据,在子组件使用Context.Consumer
消费数据。
那么基于这一点,我们可以在顶层组件使用const [state,dispatch] = useReducer(reducer, initialState)
,然后将返回的state
以及dispatch
方法传递给Context.Provider
,变通的实现数据共享,大约类似下面的代码:
const myContext = React.createContext();
const ContextProvider = ()=>{
const [state, dispatch] = useReducer(reducer, { count: 0 });
return (
<myContext.Provider value={{ state, dispatch }}>
{props.children}
</myContext.Provider>
);
};
然后就可以在子组件使用const { state, dispatch } = useContext(myContext);
获取到全局的state
和dispatch
了。
但是,这个方案的缺陷是,当数据太大,组件太多,会直接导致渲染性能下降。
每一次state
的变化,都会从顶层组件传递下去,性能影响比较大。
当然也有一些优化手段,比如使用memo()
或者useMemo()
,又或者拆分更细粒度的context
,对应不同的数据模块,再包装成不同的ContextProvider
,只是这样略显繁琐了。
那么还有木有其他的方案呢?
我们知道在使用hooks
时,顺序非常重要,并且不允许在条件分支、循环嵌套等代码中使用。
稍微了解一下 hooks
的实现原理,每一个无状态组件在调用 Hooks API 时,会将 state
存储在当前组件对象的一个叫做memoizedState
的属性中,类似这样:
// 假设一个屋状态组件被解析成这样一个对象:
const obj = {
..., // 其他的属性
memoizedState:{
memoizedState, // 存储第一次调用 useState 的 state
next: {
memoizedState, // 存储第二次调用 useState 的 state
next:{
memoizedState, // 第三次
next, // 继续,如果有更多次调用的话。
}
}
}
}
那么我们是不是通过实现一个发布订阅的模式,来将各个不同的组件和中心化的store
之间建立一个关联关系?
流程如下:
store
内部实现一个useModel
方法,内部调用useState
,但返回全局的store.state
和store.dispatch
。这一步实现了全局数据共享。store
内部将每次调用useState
返回的setState
方法储存到一个队列。- 当
store
的数据发生变化时,按顺序调用队列里的setState
方法,触发每个子组件的渲染。 - 根据调用
hooks
是有顺序的这个特点,利用useEffect
方法,安全的订阅和取消订阅,避免组件销毁了仍然通知组件数据变化。
一个粗糙的例子:
// Model 类
class Model {
state:{
name: 'lilei'
},
actions:{},
queue: [],
constructor({initialState,actions}){
this.state = initialState;
this.actions = {};
Object.keys(actions).forEach((name)=>{
this.actions[name] = (...args)=>{
this.state = actions[name].apply(this,args);
this.onDataChange();
}
});
},
useModel(){
const [, setState] = useState();
// 使用useEffect实现发布订阅
useEffect(() => {
const index = this.queue.length;
this.queue.push(setState); // 订阅
return () => { // 组件销毁时取消
this.queue.splice(index, 1);
};
});
return [this.state, this.actions];
},
onDataChange(){
const queues = [].concat(this.queue);
this.queue.length = 0;
queues.forEach((setState)=>{
setState(this.state); // 通知所有的组件数据变化
});
}
}
// models/user.js
const user = new Model({
initialState,
actions:{
changeName(name){
return {name};
}
}
});
// 组件
import {useModel} from '../models/user';
const Person = ()=>{
const [state,actions] = useModel();
return (
<div>
<span> My name is {state.name}.</span>
<button onClick={()=> actions.changeName('han meimei.')}>btn1</button>
</div>
)
};
我实现了一个稍微完整一些的例子,代码也不复杂,大约100行的样子,其中支持了异步方法、loading的处理,有兴趣的可以移步github
看看,地址在这里:
GitHub - yisbug/react-hooks-model
目前的实现还比较粗糙,也借鉴了很多其他方案。 但相比其他方案来说,如果理解了 Hooks 的用法,上手是非常简单的,没有太多的概念。
并且基于这个思路,也可以很容易的扩展一些功能,实现一个比较完整的model
层,再通过一个单例的store
统一管理所有model
,例如实现这样的效果:
import {useModel} from 'store';
const [userState, user] = useModel('user');
const [articleState, article] = useModel('article');
userEffect(()=>{
user.getUserInfo();
article.getList();
},[]);