Hooks 时代下的状态管理方案
date
Apr 1, 2022
slug
react-hooks-state
status
Published
tags
React
React Hooks
summary
在 React 推出 React Hooks 之前我们最常会使用 Redux 来做状态管理方案,集中化管理数据,可预测性,丰富的周边工具。几乎就是 React 下状态管理的正确答案。但是使用广泛并不意味着没有缺点:相对复杂的概念,繁重的模板代码,在大型工程下姑且可以作为最佳实践一般,规范不同成员的编写。但是小工程用起来总感觉是 短袖套棉袄,工程本身没多少逻辑代码,硬加了很多 Redux 的模板代码
type
Post
前言为什么会有这篇文章State 👋React State我想用 Context 做状态管理方案unstate-next 问题constate核心代码zustand核心代码jotaivaltio总结Ref
前言
在 React 推出 React Hooks 之前我们最常会使用 Redux 来做状态管理方案,集中化管理数据,可预测性,丰富的周边工具。几乎就是 React 下状态管理的正确答案。但是使用广泛并不意味着没有缺点:相对复杂的概念,繁重的模板代码,在大型工程下姑且可以作为最佳实践一般,规范不同成员的编写。但是小工程用起来总感觉是 短袖套棉袄,工程本身没多少逻辑代码,硬加了很多 Redux 的模板代码
Redux
核心流程图:
概念:
- Store:保存数据的地方,你可以把它看成一个容器,整个应用只能有一个 Store。
- State:Store 对象包含所有数据,如果想得到某个时点的数据,就要对 Store 生成快照,这种时点的数据集合,就叫做 State。
- Action:State 的变化,会导致 View 的变化。但是,用户接触不到 State,只能接触到 View。所以,State 的变化必须是 View 导致的。Action 就是 View 发出的通知,表示 State 应该要发生变化了。
- Action Creator:View 要发送多少种消息,就会有多少种 Action。如果都手写,会很麻烦,所以我们定义一个函数来生成 Action,这个函数就叫 Action Creator。
- Reducer:Store 收到 Action 以后,必须给出一个新的 State,这样 View 才会发生变化。这种 State 的计算过程就叫做 Reducer。Reducer 是一个函数,它接受 Action 和当前 State 作为参数,返回一个新的 State。
- dispatch:是 View 发出 Action 的唯一方法。
一个例子:
// action.js
export const INCREMENT = 'INCREMENT';
export const DECREMENT = 'DECREMENT';
export const RESET = 'RESET';
export function increaseCount() {
return ({ type: INCREMENT});
}
export function decreaseCount() {
return ({ type: DECREMENT});
}
export function resetCount() {
return ({ type: RESET});
}
// reducer.js
import {INCREMENT, DECREMENT, RESET} from '../actions/index'
const INITIAL_STATE = {
count: 0,
history: [],
}
function handleChange(state, change) {
const {count, history} = state;
return ({
count: count + change,
history: [count + change, ...history]
})
}
export default function counter(state = INITIAL_STATE, action) {
const {count, history} = state;
switch(action.type) {
case INCREMENT:
return handleChange(state, 1);
case DECREMENT:
return handleChange(state, -1);
case RESET:
return (INITIAL_STATE)
default:
return state;
}
}
// store.js
export const CounterStore = createStore(reducers)
原理:
核心代码在
createStore.js
,主要关注两个函数- subscribe 函数
注册监听事件,然后返回取消订阅的函数,所有的订阅函数统一放一个数组里(
nextListeners
),只维护这个数组- dispatch 函数
- 调用 Reducer,传参(currentState,action)。
- 按顺序执行 listener。
- 返回 action。
React Hooks 来临,得益于良好的逻辑封装能力和简洁的 API(最突出的就是使用极其方便的 useContext ),近似代数效应的表现
什么是代数效应?
代数效应
是函数式编程
中的一个概念,用于将副作用
从函数
调用中分离,将代码中的 what
和 how
分开一个例子:
function getTotalPicNum(user1, user2) {
const num1 = getPicNum(user1);
const num2 = getPicNum(user2);
return picNum1 + picNum2;
}
在这个函数里我们计算图片总数,但是不需要关心
getPicNum
的实现,单一职责。假如获取图片总数需要请求的方式呢?
async function getTotalPicNum(user1, user2) {
const num1 = await getPicNum(user1);
const num2 = await getPicNum(user2);
return picNum1 + picNum2;
}
但是
async / await
是具有传染性的,为此你不得不也改变调用 getTotalPicNum
函数的方式,它从同步变成了异步我们假设有一个实现了代数效应的语法
perform
,它能够在执行到的时候自动暂停原有代码,在获得 picNum
之后再恢复原有代码的执行function getPicNum(name) {
const picNum = perform name;
return picNum;
}
try {
getTotalPicNum('xiaoming', 'xiaohong');
} handle (who) {
// 或者执行一个请求
switch (who) {
case 'xiaoming':
resume with 230;
case 'xiaohong':
resume with 122;
default:
resume with 0;
}
}
React Hooks 在 React 函数编程中实现了类似代数效应的效果,你无需关心
useState
、useReducer
、useRef
这样的Hook
是如何保存你的数据。 但它不是真正的代数效应因此一大批基于 Hooks 的状态管理库诞生,包括 React-Redux 也提供了基于 Hooks 的 API 使用,推出了 redux-toolkit
redux-tookit
简化的例子
// counterSlice.js
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
export interface CounterState {
count: number
}
const initialState: CounterState = {
count: 0,
}
export const counterSlice = createSlice({
name: 'counter',
initialState,
reducers: {
increment: (state) => {
// Redux Toolkit allows us to write "mutating" logic in reducers. It
// doesn't actually mutate the state because it uses the Immer library,
// which detects changes to a "draft state" and produces a brand new
// immutable state based off those changes
state.count += 1
},
decrement: (state) => {
state.count -= 1
},
incrementByAmount: (state, action: PayloadAction<number>) => {
state.count += action.payload
},
},
})
// Action creators are generated for each case reducer function
export const { increment, decrement, incrementByAmount } = counterSlice.actions
export default counterSlice.reducer
// store.js
import { configureStore } from '@reduxjs/toolkit'
import counterReducer from '../features/counter/counterSlice'
export const store = configureStore({
reducer: {
counter: counterReducer,
},
})
// Infer the `RootState` and `AppDispatch` types from the store itself
export type RootState = ReturnType<typeof store.getState>
// Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState}
export type AppDispatch = typeof store.dispatch
为什么会有这篇文章
一次偶然的机会我遇见了 unstate-next ,轻巧简洁,借助 useContext 就可以实现基础的状态管理能力。虽然使用上依然会遇到一些小问题,但是具有十足的启发性,那 Hooks 下什么是理想的状态管理方案,这里会介绍下现在主流的几个状态管理仓库
State 👋
什么是 状态?
在
jQuery
时代,JS 代码混杂 DOM 操作,代码长且难以理解 面条式代码,程序代码就像面条一样扭曲纠结 😫
$("#btn1").click(function(){
$("p").append(" <b>Appended text</b>.");
var d = Math.floor(t/1000/60/60/24) <= 0 ? 0 : Math.floor(t/1000/60/60/24)
$("#t_d").html(d);
if (d <= 0) {
$("#day").hide();
$('#second').show();
}
});
随着现代前端框架的来临,它们都有一个重要的理念:数据驱动视图
由原来编写过程的 “命令式代码“ 改为 “操作各种数据”,而这些变化的数据就是 状态(State)
React State
Class Component
时的 state 是 this.state
Function Component
时的 state 是 useState
/ useReducer
为了避免过于复杂的应用代码,我们会通过“拆分”,形成多个组件,之间通过
props
传递 state
进行通信。同时遵守“单向数据流”React
也引入 Context
解决组件间状态通信导致 props
层层传递复杂的问题我想用 Context 做状态管理方案
假设现在我们有一个 Foo 组件,组件内部有一个 count state
const Foo = () => {
const [count, setCount] = React.useState(0);
return (
<>
<h1>{count}</h1>
<button onClick={() => setCount(v => v + 1)}>Click Me!</button>
</>
);
};
现在我们希望其他组件也能访问到这个 count state
const CountContext = React.createContext(null);
const CountProvider = ({ children }) => {
const [value] = React.useState(0);
return (
<Context.Provider value={value}>
{children}
</Context.Provider>
);
};
const Foo = () => {
const count = React.useContext(CountContext);
return (
<>
<h1>{count}</h1>
</>
);
};
const Bar = () => {
const count = React.useContext(CountContext);
return <h2>{count % 2 ? 'Even' : 'Odd'}</h2>
};
const Buz = () => (
<CountProvider>
<Foo />
<Bar />
</CountProvider>
);
进一步,我们更好的方式是将 Provider 提升到顶部
const App = () => (
<CountProvider>
<Layout />
</CountProvider>
);
进一步的我们发现,虽然我们共享了状态,但是没办法修改它?
将修改 state 的方法跟随 state 一起通过 Context 传下去
这时候我们写的代码已经非常接近 unstate-next 了
unstate-next
unstate-next 可以理解为提供了一种使用 context 作为状态管理的范式
源码非常简单,只有不到40行,并且仅 2 个 API(
useContainer
, createContainer
)Example
import React, { useState } from "react"
import { createContainer } from "unstated-next"
function useCounter(initialState = 0) {
let [count, setCount] = useState(initialState)
let decrement = () => setCount(count - 1)
let increment = () => setCount(count + 1)
return { count, decrement, increment }
}
let Counter = createContainer(useCounter)
function CounterDisplay() {
let counter = Counter.useContainer()
return (
<div>
<button onClick={counter.decrement}>-</button>
<span>{counter.count}</span>
<button onClick={counter.increment}>+</button>
</div>
)
}
function App() {
return (
<Counter.Provider>
<CounterDisplay />
<Counter.Provider initialState={2}>
<div>
<div>
<CounterDisplay />
</div>
</div>
</Counter.Provider>
</Counter.Provider>
)
}
使用的时候我们传入 封装了 state 和修改 state 的自定义 Hooks,返回 container,带有 Provider 和 useContainer
问题
provider 不支持 displayName
优化 DevTools 的显示
需要特别注意 context 重复渲染导致的性能问题
一个简单的 Context 例子:
Context 下的组件会因为 父组件的 render 而 render,为了避免重复渲染,可以采取的措施
- 子组件提升为 提升为 props.children
- “W memo”,对 context value 使用 useMemo,对子组件使用 React.memo 包裹
但是如果我们把所有 value 都放在一个 context 中,会导致不管子组件是否用到它,都会导致重渲染
- Split contexts
- 拆分 Contexts,对于不同上下文背景的 Contexts 进行拆分
- 将多变的和不变的 Contexts 分开,让不变的 Contexts 在外层,多变的 Contexts 在内层
借助 Split contexts 依然无法改变的就是 state 和 setState 的单一依赖重复渲染问题
随着你的应用 Provider 越来越多,它会变成这个样子,多美妙的图形【误
const App = () => (
<Context1Provider>
<Context2Provider>
<Context3Provider>
<Context4Provider>
<Context5Provider>
<Context6Provider>
<Context7Provider>
<Context8Provider>
<Context9Provider>
<Context10Provider>
<Layout />
</Context10Provider>
</Context9Provider>
</Context8Provider>
</Context7Provider>
</Context6Provider>
</Context5Provider>
</Context4Provider>
</Context3Provider>
</Context2Provider>
</Context1Provider>
);
constate
constate 使用方式和 unstate-next 非常相似,在 unstate-next 的基础上改进了几个问题
- 提供了 displayName
- 提供了selectors API,更加精细的使用 context value
Example
import React, { useState, useCallback } from "react";
import constate from "constate";
function useCounter() {
const [count, setCount] = useState(0);
// increment's reference identity will never change
const increment = useCallback(() => setCount(prev => prev + 1), []);
return { count, increment };
}
const [Provider, useCount, useIncrement] = constate(
useCounter,
value => value.count, // becomes useCount
value => value.increment // becomes useIncrement
);
function Button() {
// since increment never changes, this will never trigger a re-render
const increment = useIncrement();
return <button onClick={increment}>+</button>;
}
function Count() {
const count = useCount();
return <span>{count}</span>;
}
核心代码
根据 传入的 selectors 为每个创建 context
if (selectors.length) {
selectors.forEach((selector) => createContext(selector.name));
} else {
createContext(useValue.name);
}
根据创建的 contexts 自动生成嵌套的 Provider
const Provider: React.FC<Props> = ({ children, ...props }) => {
const value = useValue(props as Props);
let element = children as React.ReactElement;
for (let i = 0; i < contexts.length; i += 1) {
const context = contexts[i];
const selector = selectors[i] || ((v) => v);
element = (
<context.Provider value={selector(value)}>{element}</context.Provider>
);
}
return element;
};
zustand
2021最🔥的状态管理库
zustand 采用的全局的状态管理方案。基于观察者模式 。API 清晰简单,不需要额外处理 Context 的重复渲染的问题,不需要 Provider ,相对的通过 forceUpdate 实现组件的更新。脱离于 React 自身的状态,支持非 React 环境使用。
Example
import create from 'zustand'
const useStore = create(set => ({
bears: 0,
increasePopulation: () => set(state => ({ bears: state.bears + 1 })),
removeAllBears: () => set({ bears: 0 })
}))
function BearCounter() {
const bears = useStore(state => state.bears)
return <h1>{bears} around here ...</h1>
}
function Controls() {
const increasePopulation = useStore(state => state.increasePopulation)
return <button onClick={increasePopulation}>one up</button>
}
核心代码
Vanilla.ts
观察者模式的实现,提供了 setState、subscribe 、getState 、destroy 方法。并且前两者提供了 selector 和 equalityFn 参数
setState
代码
const setState: SetState<TState> = (partial, replace) => {
const nextState = typeof partial === 'function' ? partial(state) : partial
if (nextState !== state) {
const previousState = state
state = replace ? (nextState as TState) : Object.assign({}, state, nextState)
listeners.forEach((listener) => listener(state, previousState))
}
}
getState
代码
const getState: GetState<TState> = () => state
subscribe
代码
const subscribe: Subscribe<TState> = <StateSlice>(
listener: StateListener<TState> | StateSliceListener<StateSlice>,
selector?: StateSelector<TState, StateSlice>,
equalityFn?: EqualityChecker<StateSlice>
) => {
if (selector || equalityFn) {
return subscribeWithSelector(
listener as StateSliceListener<StateSlice>,
selector,
equalityFn
)
}
listeners.add(listener as StateListener<TState>)
// Unsubscribe
return () => listeners.delete(listener as StateListener<TState>)
}
createStore
function createStore(createState) {
let state: TState
const setState = /** ... */
const getState = /** ... */
/** ... */
const api = { setState, getState, subscribe, destroy }
state = createState(setState, getState, api)
return api
}
react.ts
基于 React Hooks API 接口的封装,实现状态的注册和更新,通过 forceUpdate 对组件重渲染
组件重渲染的方式
const [, forceUpdate] = useReducer((c) => c + 1, 0) as [never, () => void]
判断状态是否更新
const state = api.getState()
newStateSlice = selector(state)
hasNewStateSlice = !equalityFn(
currentSliceRef.current as StateSlice,
newStateSlice
)
useIsomorphicLayoutEffect(() => {
if (hasNewStateSlice) {
currentSliceRef.current = newStateSlice as StateSlice
}
stateRef.current = state
selectorRef.current = selector
equalityFnRef.current = equalityFn
erroredRef.current = false
})
状态更新通知组件重渲染
useIsomorphicLayoutEffect(() => {
const listener = () => {
try {
const nextState = api.getState()
const nextStateSlice = selectorRef.current(nextState)
if (!equalityFnRef.current(currentSliceRef.current as StateSlice, nextStateSlice)) {
stateRef.current = nextState
currentSliceRef.current = nextStateSlice
forceUpdate()
}
} catch (error) {
erroredRef.current = true
forceUpdate()
}
}
const unsubscribe = api.subscribe(listener)
if (api.getState() !== stateBeforeSubscriptionRef.current) {
listener() // state has changed before subscription
}
return unsubscribe
}, [])
context.ts
借助
React Context
分发 store
,实现创建多个互不干扰的 store 实例const useStore: UseContextStore<TState> = <StateSlice>(
selector?: StateSelector<TState, StateSlice>,
equalityFn = Object.is
) => {
const useProviderStore = useContext(ZustandContext)
return useProviderStore(
selector as StateSelector<TState, StateSlice>,
equalityFn
)
}
middleware.ts
中间件的本质是各种函数根据顺序互相嵌套调用,在 Zustand 中,中间件是将 setState 函数包裹起来,Zustand 天生就是使用这种函数式的设计
以内置的持久化状态中间件为例:
// 使用的例子
export const useStore = create(persist(
(set, get) => ({
fishes: 0,
addAFish: () => set({ fishes: get().fishes + 1 })
}),
{
name: "food-storage", // unique name
getStorage: () => sessionStorage, // (optional) by default, 'localStorage' is used
}
))
// middleware.ts 实现缩略代码
// 修改传入 options 的 set 函数
const configResult = config(
((...args) => {
set(...args)
void setItem()
}) as CustomSetState,
get,
api
)
jotai
同样的全局状态管理方案,跟
zustand
是同一个作者。借鉴了 Recoil
的原子化概念同样 API 也十分简单,
atom
和 useAtom
。通过 atom
创建原子化的数据源,调用useAtom
传入 atom
返回对应的状态值和修改方法。import { atom } from 'jotai'
const countAtom = atom(0)
const countryAtom = atom('Japan')
const citiesAtom = atom(['Tokyo', 'Kyoto', 'Osaka'])
const mangaAtom = atom({ 'Dragon Ball': 1984, 'One Piece': 1997, Naruto: 1999 })
function Counter() {
const [count, setCount] = useAtom(countAtom)
return (
<h1>
{count}
<button onClick={() => setCount(c => c + 1)}>one up</button>
</h1>
)
}
原子化的数据有非常强的派生能力
const doubledCountAtom = atom((get) => get(countAtom) * 2)
function DoubleCounter() {
const [doubledCount] = useAtom(doubledCountAtom)
return <h2>{doubledCount}</h2>
}
同时可以通过
get
和 set
修改原子状态的值和修改的方法const decrementCountAtom = atom(
(get) => get(countAtom),
(get, set, _arg) => set(countAtom, get(countAtom) - 1),
)
function Counter() {
const [count, decrement] = useAtom(decrementCountAtom)
return (
<h1>
{count}
<button onClick={decrement}>Decrease</button>
</h1>
)
}
虽然是全局状态管理方案,但同时支持 Provider 使用
方便查看源码的理解:
atom
类比key
state
类比store
atomState
类比value
原理:官方文档给的最简版本
import { useState, useEffect } from "react";
// atom 函数返回一个配置对象,其中包含初始值
export const atom = (initialValue) => ({ init: initialValue });
// 跟踪原子的状态
// 使用 WeakMap 存储 atomState
// 把 atom 当做key, atomState 当做 value 存储
const atomStateMap = new WeakMap();
const getAtomState = (atom) => {
let atomState = atomStateMap.get(atom);
if (!atomState) {
atomState = { value: atom.init, listeners: new Set() };
atomStateMap.set(atom, atomState);
}
return atomState;
};
// useAtom 钩子类似 useState
export const useAtom = (atom) => {
const atomState = getAtomState(atom);
const [value, setValue] = useState(atomState.value);
useEffect(() => {
const callback = () => setValue(atomState.value);
// 添加 atom 的更新回调,就是更新当前 Hooks 的 Value
atomState.listeners.add(callback);
callback();
return () => atomState.listeners.delete(callback);
}, [atomState]);
const setAtom = (nextValue) => {
atomState.value = nextValue;
// 更新时执行所有 listeners
atomState.listeners.forEach((l) => l());
};
return [value, setAtom];
};
支持派生 atom
const atomState = {
value: atom.init,
listeners: new Set(),
dependents: new Set()
};
valtio
借助 Proxy & Reflect API 代理状态
import { proxy, useSnapshot } from 'valtio'
const state = proxy({ count: 0, text: 'hello' })
// This will re-render on `state.count` change but not on `state.text` change
function Counter() {
const snap = useSnapshot(state)
return (
<div>
{snap.count}
<button onClick={() => ++state.count}>+1</button>
</div>
)
}
核心代码
const handler = {
get(target: T, prop: string | symbol, receiver: any) {
// 省略部分代码
return Reflect.get(target, prop, receiver)
},
deleteProperty(target: T, prop: string | symbol) {
const prevValue = Reflect.get(target, prop)
const childListeners = prevValue?.[LISTENERS]
if (childListeners) {
childListeners.delete(popPropListener(prop))
}
const deleted = Reflect.deleteProperty(target, prop)
if (deleted) {
notifyUpdate(['delete', [prop], prevValue])
}
return deleted
},
is: Object.is,
canProxy,
set(target: T, prop: string | symbol, value: any, receiver: any) {
const prevValue = Reflect.get(target, prop, receiver)
if (this.is(prevValue, value)) {
return true
}
const childListeners = prevValue?.[LISTENERS]
if (childListeners) {
childListeners.delete(popPropListener(prop))
}
if (isObject(value)) {
value = getUntracked(value) || value
}
let nextValue: any
if (Object.getOwnPropertyDescriptor(target, prop)?.set) {
nextValue = value
}
// 省略部分代码
Reflect.set(target, prop, nextValue, receiver)
notifyUpdate(['set', [prop], value, prevValue])
return true
},
}
const proxyObject = new Proxy(baseObject, handler)
总结
对于一个 状态管理方案 我们知道一定存在两个关键的概念:
- 数据源
- 数据消费
对于数据源:
以上两个库 都是完全基于 Context API 实现 value 传递,同时由于 Provider 可以放在应用的任何地方,也可以称作 局部状态管理方案,与之对应的就有 全局状态管理方案
(基于 Context API 自带 state 变化 重新渲染的机制,因此 全局状态管理方案 需要额外处理如何跟踪 state 的变化并重新渲染组件)
对于数据消费:
类似上面基于 context 和 redux 的不可变(immutable)数据消费
还有 Mobx 为代表的可变(mutable)数据
数据消费方式的不同也决定了他们 如何追踪 state 的变化,不可变(immutable)数据基于 发布订阅模式,而可变(mutable)数据基于 Proxy 代理 的方式