前言
本文会逐步讲解React项目中的状态管理方式及实现方式。并顺便手撸一个Umi
的useModal
单状态 useState
父组件定义state,通过props向子组件传递,子组件共享父组件的状态,修改状态后,所有子组件同步更新。
tsx
function Parent1() {
const [count, setCount] = useState(0)
const handleClick = () => {
setCount(count => count + 1)
}
return (
<div>
<Parent1_Child1 count={count} handleClick={handleClick}/>
<Parent1_Child1 count={count} handleClick={handleClick}/>
</div>
)
}
function Parent1_Child1(props: {count: number, handleClick: () => void}) {
const { count, handleClick } = props
return <button type='button' onClick={handleClick}>{ count }</button>
}
多状态 useReducer
使用 useReducer
定义一个对象,写一个reducer
函数 通过dispatch
方法传递的actionType
判断执行逻辑,从而修改 state
tsx
type Parent2StateType = {
val1: number,
val2: number,
}
type ActionType<T extends keyof Parent2StateType = keyof Parent2StateType> = {
[K in T]: {
type: K;
value: Parent2StateType[K];
}
}[T] | { type: 'reset', value?: Parent2StateType }
const defaultState: Parent2StateType = {
val1: 0,
val2: 0,
}
const parent2Reducer = (state: Parent2StateType, action: ActionType) => {
if (action.type === 'reset') return action.value || defaultState
return {
...state,
[action.type]: action.value,
}
}
function Parent2() {
const [values, dispatch] = useReducer<React.Reducer<Parent2StateType, ActionType>>(
parent2Reducer,
defaultState
)
return (
<div>
<Parent2_Child1 count={values.val1} handleClick={() => dispatch({ type: 'val1', value: values.val1 + 1 })}/>
<Parent2_Child1 count={values.val2} handleClick={() => dispatch({ type: 'val2', value: values.val2 + 1 })}/>
</div>
)
}
上面的例子,看上去很麻烦,小伙伴便想:我将state定义成一个对象,也使用类似于reducer更新函数的方式修改state,那么和useReducer
有什么区别么?
答案是:没什么区别。。。。附源码:
ts
function rerenderReducer<S, I, A>(
reducer: (S, A) => S,
initialArg: I,
init?: I => S,
): [S, Dispatch<A>] {
...
if (lastRenderPhaseUpdate !== null) {
...
do {
const action = update.action;
newState = reducer(newState, action);
update = update.next;
} while (update !== firstRenderPhaseUpdate);
...
hook.memoizedState = newState;
...
queue.lastRenderedState = newState;
}
return [newState, dispatch];
}
对比 useState
和 useReducer
- 对于大量的state维护,
useReducer
会有更少的代码量。 useReducer
整合state修改逻辑到reducer方法中,逻辑更加清晰,更方便调试。
(说得很好,我使用 useState,实际应用中,我更倾向于按业务分离hooks来分别维护)
跨组件传递 useState + useContext
上述示例,state 都只能在组件内使用,或者传递给直接子组件使用。如果有多层子组件,或者有多个子组件,那么会涉及到大量的props传递逻辑,有没有办法,不通过props传递,可以直接在任意子组件中获取到最新的state呢?
答案便是结合useContext
tsx
const Parent3Context = createContext<{count: number, handleClick:() => void}>()
function Parent3() {
const [count, setCount] = useState(0)
const handleClick = () => {
setCount(count => count + 1)
}
return (
<Parent3Context.Provider value={{ count, handleClick }}>
<Parent3_Child1 />
<Parent3_Child2 />
</Parent3Context.Provider>
)
}
function Parent3_Child1() {
const { count, handleClick } = useContext(Parent3Context)
return <button type='button' onClick={handleClick}>{ count }</button>
}
function Parent3_Child2() {
return <Parent3_Child1 />
}
可以看到,如果我们将state放到context中,将会给子组件赋予跨层级获取state的能力,而且不需要将state层层传递。
于是有小伙伴就想了:那太好了,以后我一个页面就一个context,将所有state都塞进去,想在哪里用就在哪里取,再也不用层层传递props了。
我们来看下一个DEMO
tsx
function Parent4() {
const [count, setCount] = useState(0)
const handleClick = () => {
setCount(count => count + 1)
}
return (
<Parent4Context.Provider value={{ count, handleClick }}>
parent: {Math.random()} <br/>
<Parent4_Child1 /><br/>
<Parent4_Child2 /><br/>
<Parent4_Child3 /><br/>
</Parent4Context.Provider>
)
}
function Parent4_Child1() {
const { count, handleClick } = useContext(Parent4Context)
return <>
Parent4_Child1: {Math.random()} <br/>
<button type='button' onClick={handleClick}>{count}</button>
</>
}
function Parent4_Child2() {
return <>
Parent4_Child2: {Math.random()} <br/>
<Parent4_Child1 />
</>
}
function Parent4_Child3() {
return <>
Parent4_Child3: {Math.random()} <br/>
</>
}
我们在render里面添加随机数方法,如果随机数变化了,说明组件进行了重渲染。通过修改状态可以发现:只要状态改变了,那么Provider下面所有的子组件都会进行重渲染,和是否调用useContext
没任何关系。
这样的话,如果我们将整个页面的state都放在一个context里面,就相当于将所有的state都定义在页面顶层,任意state变化,都会导致整个页面重新渲染。这样来说,对性能影响是极大的。
我们希望:
- state定义能和页面层隔离开,方便维护。
- state变化仅影响当前获取state的组件,不影响其它组件
这里我们将state定义提取成useCount5
hook 组件,仅负责定义和赋值逻辑。将provider 提取成Parent5Context
组件,负责加入context。
tsx
const Parent5Context = createContext<{ count: number, handleClick: () => void }>()
const useCount5 = () => {
const [count, setCount] = useState(0)
const handleClick = () => setCount(count + 1)
return { count, handleClick }
}
function Parent5Provider(props: React.PropsWithChildren) {
const { count, handleClick } = useCount5()
return (
<Parent5Context.Provider value={{ count, handleClick }}>
{ props.children }
</Parent5Context.Provider>
)
}
function Parent5() {
return (
<Parent5Provider>
parent: {Math.random()} <br/>
<Parent5_Child1 /><br/>
<Parent5_Child2 /><br/>
<Parent5_Child3 /><br/>
</Parent5Provider>
)
}
如此修改后,可以通过自定义多个hooks来维护多个state,再统一加入到context中,Parent5
将不会因为state修改而重渲染,但是provider下面的所有每次state变化后重渲染。继续修改:
- 将state拆分为根state和子组件自己的state
- 根state每次更新,主动更新context中的state缓存,并且触发context中的回调集合
- 子组件state定义在自定义hook中,默认取值从context中获取state缓存,并将set函数添加到context中的回调集合中
- 如此之后,context相当于只是根state与多个子组件state之间的桥梁,负责同步数据,本身不会变化。
tsx
// context修改为`不会变化`,内部缓存state值,供 update 方法读取
const Parent6ContextData = {
// 子组件更新状态的回调函数集合
callbacks: new Set<(newState: any) => void>(),
// 缓存state
data: null as any,
// 子组件状态更新方法
update: () => Parent6ContextData.callbacks.forEach(callback => callback(Parent6ContextData.data)),
}
const Parent6Context = createContext(Parent6ContextData)
// 定义状态 hook
const useCount6 = () => {
const [count, setCount] = useState(0)
const handleClick = () => setCount(count + 1)
return { count, handleClick }
}
// 封装自定义hook,用来读取并返回最新state,并将set方法赋值到context中,用于context中更新state
const useParent6Modal = () => {
const context = useContext(Parent6Context)
// 默认将state设置为context中缓存的 state
const [state, setState] = useState(context.data)
// 将更新方法添加到 context 的子组件状态更新回调集合中
useEffect(() => {
function update(newState: any) {
setState(newState)
}
context.callbacks.add(update)
return () => {
context.callbacks.delete(update)
}
}, [])
return state
}
// 将根state放到与 children 同一级,避免state更新影响 children 重渲染
const Parent6Modal = () => {
const context = useContext(Parent6Context)
const state = useCount6()
context.data = state
// Parent6Modal 的 state变化时, 执行context的update,用来更新所有 useParent6Modal 中的 state
useEffect(() => {
context.update()
}, [state])
return null
}
function Parent6Provider(props: React.PropsWithChildren) {
return (
<Parent6Context.Provider value={Parent6ContextData}>
<Parent6Modal />
{ props.children }
</Parent6Context.Provider>
)
}
function Parent6() {
return (
<Parent6Provider>
parent: {Math.random()} <br/>
<Parent6_Child1 /><br/>
<Parent6_Child2 /><br/>
<Parent6_Child3 /><br/>
</Parent6Provider>
)
}
function Parent6_Child1() {
const { count, handleClick } = useParent6Modal()
return <>
Parent6_Child1: {Math.random()} <br/>
<button type='button' onClick={handleClick}>{count}</button>
</>
}
function Parent6_Child2() {
return <>
Parent6_Child2: {Math.random()} <br/>
<Parent6_Child1 />
</>
}
function Parent6_Child3() {
return <>
Parent6_Child3: {Math.random()} <br/>
</>
}
从上述例子可以看到,所有我们不希望重渲染的随机数,在state变化时已经不会重渲染了。整体逻辑:
- 根组件维护state,将整个state缓存到context 中
- 子组件state默认取值为 context 中缓存的state
- 任意子组件修改state,触发的是根state的set方法
- 根state变化,触发执行context的回调集合
- context的回调集合,缓存的是子组件state的set方法
- 所以,任意子组件触发更新state,会导致所有用到state的其它子组件更新,而不影响其它未用到state的组件、
但是如此修改后,添加多个state集合会比较麻烦,涉及多处修改,所以我们将modal的维护抽离出来:
tsx
// 将对象使用类的方式构建,内部取值更优雅, 将data和callbacks,修改为键值对,键名为model名称
class Parent7ContextClass<K extends keyof ModalTypes, S extends ReturnType<ModalTypes[K]>> {
callbacks: Record<K, Set<(newState: any) => void>> = {} as Record<K, Set<(newState: any) => void>>
data: Record<K, S> = {} as Record<K, S>
update = (name: K) => (this.callbacks[name] || []).forEach(callback => callback(this.data[name]))
}
const Parent7ContextData = new Parent7ContextClass()
const Parent7Context = createContext(Parent7ContextData)
// 添加入参为 model名称,内部配合context结构修改
const useParent7Modal = <K extends keyof ModalTypes, S extends ReturnType<ModalTypes[K]>> (name: K): S => {
const context = useContext(Parent7Context)
const [state, setState] = useState<S>(context.data[name] as S)
useEffect(() => {
function update(newState: S) {
setState(newState)
}
context.callbacks[name] ??= new Set()
context.callbacks[name].add(update)
return () => {
context.callbacks[name].delete(update)
}
}, [])
return state
}
// 添加入参为 model名称,和hook函数
const Parent7Modal = <K extends keyof ModalTypes>(props: { name: K, useHook: ModalTypes[K] }) => {
const { name, useHook } = props
const context = useContext(Parent7Context)
const state = useHook()
context.data[name] = state
useEffect(() => {
context.update(name)
}, [state])
return null
}
// 定义状态 hook
const useCount7_1 = () => {
const [count, setCount] = useState(0)
const handleClick = () => setCount(count + 1)
return { count, handleClick }
}
const useCount7_2 = () => {
const [count2, setCount] = useState(0)
const handleClick = () => setCount(count2 + 1)
return { count2, handleClick }
}
// 定义hooks函数集合
const modals = {
useCount7_1,
useCount7_2,
} as const
type ModalTypes = typeof modals
// 已hooks函数集合做循环渲染
function Parent7Provider(props: React.PropsWithChildren) {
return (
<Parent7Context.Provider value={Parent7ContextData}>
{Object.entries(modals).map(([name, useHook]) => {
return <Parent7Modal key={name} name={name as keyof ModalTypes} useHook={useHook} />
})}
{ props.children }
</Parent7Context.Provider>
)
}
function Parent7() {
return (
<Parent7Provider>
parent: {Math.random()} <br/>
<Parent7_Child1 /><br/>
<Parent7_Child2 /><br/>
<Parent7_Child3 /><br/>
</Parent7Provider>
)
}
// 修改入参为hook名称
function Parent7_Child1() {
const { count, handleClick } = useParent7Modal('useCount7_1')
return <>
Parent7_Child1: {Math.random()} <br/>
<button type='button' onClick={handleClick}>{count}</button>
</>
}
function Parent7_Child2() {
return <>
Parent7_Child2: {Math.random()} <br/>
<Parent7_Child1 />
</>
}
function Parent7_Child3() {
return <>
Parent7_Child3: {Math.random()} <br/>
</>
}
如此之后,我们只需要写 自定义hooks,然后把 hooks 添加到 modals 中,便可以在provider下的任意子组件中调用 useParent7Modal 传递 hook的名称,即可访问到最新state,但是这个方法每次state更新都会将所有依赖state的子组件都更新,有没有办法按指定依赖更新呢,有办法的:
- 我们
useParent7Modal
传入第二可选参数selector
为一个函数,入参为state本身,出参为 类state - 在组件更新方法中深比较 新state 和
selector
返回值,不同则执行update,否则不执行
tsx
const useParent8Modal = <K extends keyof ModalTypes, S extends ReturnType<ModalTypes[K]>> (name: K, selector?: (state: S) => any): S => {
const context = useContext(Parent7Context)
const [state, setState] = useState<S>(context.data[name] as S)
const stateRef = useRef<typeof state>(state)
useEffect(() => {
function update(newState: S) {
newState = selector?.(newState) || newState
if (!isEqual(newState, stateRef.current)) {
setState(newState)
stateRef.current = newState
}
}
context.callbacks[name] ??= new Set()
context.callbacks[name].add(update)
return () => {
context.callbacks[name].delete(update)
}
}, [])
return state
}
// 使用
const {count} = useParent8Modal('useCount7_1', model => ({count: model.count}))
以上,也是UMI
脚手架中 useModel
插件的完整逻辑
当然,state的维护不只有这一种方法,比如hox
、Redux
,但是基本都是以context作为state变化的桥梁。
完整代码
src/modals/useCount.tsx
tsx
import { useState } from 'react'
const useCount = () => {
const [count, setCount] = useState(0)
const handleClick = () => setCount(count + 1)
return { count, handleClick }
}
export default useCount
src/modals/index.ts
ts
import useCount from './useCount'
const modals = {
useCount,
} as const
export default modals
src/modals/provider.ts
tsx
import { createContext, useContext, useEffect, useRef, useState } from 'react'
import isEqual from 'fast-deep-equal'
import modals from '.'
type ModalTypes = typeof modals
class ModelContextClass<K extends keyof ModalTypes, S extends ReturnType<ModalTypes[K]>> {
callbacks: Record<K, Set<(newState: any) => void>> = {} as Record<K, Set<(newState: any) => void>>
data: Record<K, S> = {} as Record<K, S>
update = (name: K) => (this.callbacks[name] || []).forEach(callback => callback(this.data[name]))
}
const defaultModelContext = new ModelContextClass()
const ModelContext = createContext(defaultModelContext)
export const Modal = <K extends keyof ModalTypes>(props: { name: K, useHook: ModalTypes[K] }) => {
const { name, useHook } = props
const context = useContext(ModelContext)
const state = useHook()
context.data[name] = state
useEffect(() => {
context.update(name)
}, [state])
return null
}
function ModelProvider(props: React.PropsWithChildren) {
return (
<ModelContext.Provider value={defaultModelContext}>
{Object.entries(modals).map(([name, useHook]) => {
return <Modal key={name} name={name as keyof ModalTypes} useHook={useHook} />
})}
{ props.children }
</ModelContext.Provider>
)
}
export const useModal = <K extends keyof ModalTypes, S extends ReturnType<ModalTypes[K]>> (name: K, selector?: (state: S) => any): S => {
const context = useContext(ModelContext)
const [state, setState] = useState<S>(context.data[name] as S)
const stateRef = useRef<typeof state>(state)
useEffect(() => {
function update(newState: S) {
newState = selector?.(newState) || newState
if (!isEqual(newState, stateRef.current)) {
setState(newState)
stateRef.current = newState
}
}
context.callbacks[name] ??= new Set()
context.callbacks[name].add(update)
return () => {
context.callbacks[name].delete(update)
}
}, [])
return state
}
export default ModelProvider
app.tsx
tsx
import ModelProvider from '@/modals/provider.ts'
function App(props) {
return (
<ModelProvider>
{props.children}
</ModelProvider>
)
}
anyChild.tsx
tsx
import { useModal } from '@/modals/provider.ts'
function Child() {
const { count, handleClick} = useModal('useCount')
return <button type='button' onClick={handleClick}>{count}</button>
}