Hooks 时代下的状态管理方案

2,473 阅读12分钟

前言

在 React 推出 React Hooks 之前我们最常会使用 Redux 来做状态管理方案,集中化管理数据,可预测性,丰富的周边工具。几乎就是 React 下状态管理的正确答案。但是使用广泛并不意味着没有缺点:相对复杂的概念,繁重的模板代码,在大型工程下姑且可以作为最佳实践一般,规范不同成员的编写。但是小工程用起来总感觉是 短袖套棉袄,工程本身没多少逻辑代码,硬加了很多 Redux 的模板代码

React Hooks 来临,得益于良好的逻辑封装能力和简洁的 API(最突出的就是使用极其方便的 useContext ),近似代数效应的表现。因此一大批基于 Hooks 的状态管理库诞生,包括 React-Redux 也提供了基于 Hooks 的 API 使用,推出了 redux-toolkit

  • 扩展:什么是代数效应?

    代数效应函数式编程中的一个概念,用于将副作用函数调用中分离,将代码中的 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 函数编程中实现了类似代数效应的效果,你无需关心 useStateuseReduceruseRef这样的Hook 是如何保存你的数据。 但它不是真正的代数效应

为什么会有这篇文章

一次偶然的机会我遇见了 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();
  }
});
$("#btn2").click(function(){
    $("p").hide();
  $("#test2").html("<b>Hello world!</b>");
});
$("#btn3").click(function(){
  $("#test3").val("Dolly Duck");
    $("p").show();
});

Untitled

随着现代前端框架的来临,它们都有一个重要的理念:数据驱动视图

由原来编写过程的 “命令式代码“ 改为 “操作各种数据”,而这些变化的数据就是 状态(State)

React State

Class Component 时的 state 是 this.state

Function Component 时的 state 是 useState / useReducer

为了避免过于复杂的应用代码,我们会通过“拆分”,形成多个组件,之间通过 props 传递 state 进行通信。同时遵守“单向数据流

React 也引入 Context 解决组件间状态通信导致 props 层层传递复杂的问题

Redux

核心流程图:

Hooks%20%E6%97%B6%E4%BB%A3%E4%B8%8B%E7%9A%84%20f3b28/Untitledpng

概念:

  • 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 的唯一方法。

    一个例子:

    view it online

    // 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。

    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
    

我想用 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(****useContainercreateContainer**)

  • 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 的显示

    Untitled

  • 需要特别注意 context 重复渲染导致的性能问题

    context 重复渲染问题 - CodeSandbox

    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>;
    }
    

核心代码

diegohaz/constate

根据 传入的 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

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 也十分简单, atomuseAtom。通过 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>
}

同时可以通过 getset 修改原子状态的值和修改的方法

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 使用

    Hooks%20%E6%97%B6%E4%BB%A3%E4%B8%8B%E7%9A%84%20f3b28/Untitled%204.png

方便查看源码的理解:

  • 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,valtio 为代表的可变(mutable)数据

数据消费方式的不同也决定了他们 如何追踪 state 的变化,不可变(immutable)数据基于 发布订阅/观察者模式,而可变(mutable)数据基于 Proxy 代理 的方式

Ref