我说过,React 不需要状态管理工具

2025-05-28

我说过,React 不需要状态管理工具

时不时还是会有人告诉我,他们在项目中使用了 REDUX 或类似的工具。我通常会回答说,我不会用它们,因为现在有了 hooks 和 context API,就不需要它们了。

但是上下文 API 通常会带来性能问题,并且正确使用起来也有点尴尬,所以今天我将尝试展示如何避免常见问题,以及如何构建自己的(微)状态管理工具,而不会做出任何妥协。

简单的解决方案

基本思想是在一个组件中管理状态并通过上下文传递整个状态,以便所有子组件都可以访问它,这样我们就可以避免 props 钻孔。



export const StateContext = createContext(null);
const Provider = () => {
  return (
    <StateContext.Provider value={state}>
      <ChildComponent />
    </StateContext.Provider>
  )
}


Enter fullscreen mode Exit fullscreen mode

使用 dispatch

但是,您还需要某种方法来修改子组件的状态。您可以将单独的函数传递给上下文,但我个人不喜欢这样做,因为状态会很快变得复杂。我喜欢调度事件的想法(类似于 REDUX),所以我们基本上传递一个函数,您可以使用它来调度所需的所有不同操作。我们可以将它与状态传递到同一个上下文中,但我不喜欢将它与状态混合,所以我将它传递到一个单独的上下文中。



const StateContext = createContext(null);
const DispatchContext = createContext(null);

export const Provider = () => {
  const [state, setState] = useState(...)

  const dispatch = (action) => {
    switch (action.type) {
      case 'CHANGE_STATE':
        setState(action.payload)
        break;
      ...
    }
  }

  return (
    <StateContext.Provider value={{state, ...}}>
      <DispatchContext.Provider value={dispatch}>
        <ChildComponent />
      </DispatchContext.Provider>
    </StateContext.Provider>
  )
}


Enter fullscreen mode Exit fullscreen mode

我还喜欢创建钩子来获取调度函数,使其更加明确:



export const useDispatch = () => {
  return useContext(DispatchContext)
}


Enter fullscreen mode Exit fullscreen mode

基本上,我们将数据与操作分离——提供组件向子组件提供数据。子组件可以调度操作来修改数据,但数据由提供组件控制,因此它拥有对数据的控制权。调度的操作可以理解为类似于 DOM 事件,只是我们知道谁会接收它。

图片描述

现在让我们看看性能方面,如果我们想用它来替代 REDUX,它需要能够处理大量订阅组件的大状态。

避免不必要的儿童再创造

这种配置效率极低,因为每次我们修改状态时,所有子组件都会重新渲染。这是因为每次我们更新 Provider 组件的状态时,它的所有子组件都会被重新创建。我们可以在子组件上使用 React.memo 来避免这种情况,但更好的解决方案是从上级组件传递子组件,这样当 Provider 更新时,子组件将保持不变。而且我们只更新上下文的实际使用者。



export const Provider = ({ children }) => {

  ...

  return (
    <StateContext.Provider value={{state, ...}}>
      <DispatchContext.Provider value={dispatch}>
        {children}
      </DispatchContext.Provider>
    </StateContext.Provider>
  )
}


Enter fullscreen mode Exit fullscreen mode

在父级中我们这样做:



export const Parent = ({ children }) => {
  return (
    <Provider>
      <ChildComponent />
    </Provider>
  )
}


Enter fullscreen mode Exit fullscreen mode

现在,提供程序组件正在管理上下文,但不再管理子组件(仅传递它们)。我花了一段时间才理解这个细微的差别,因为代码中的变化很小,却带来了很大的影响。

诀窍在于理解,当我们放置时<ChildComponent >,我们基本上每次都在创建新的 React.Node,因此所有子项都会重新渲染,除非我们将它们包装在内React.memo

因此,通过这一改变,我们只更新使用上下文的组件。

避免调度导致的重新渲染

目前,每次状态更改时,dispatch 函数都会重新创建,这意味着所有使用它的组件都将被重新渲染,即使它们没有使用 StateContext。通常,如果我们想要拥有稳定的函数,React 文档建议使用useCallback,但在这种情况下,它只能部分帮助我们,因为这基本上会导致 dispatch 函数的“缓存”,并且我们将无法在不包含外部作用域变量的情况下使用它们dependencies- 而且,当依赖项更改时,dispatch 函数仍然会被重新创建。我们需要使用ref来帮助我们解决这个问题。



...

export const Provider = ({ children }) => {
  const [state, setState] = useState(...)

  const dispatchRef = useRef()

  // new function with every render
  const dispatchRef.current = (action) => {
    switch (action.type) {
      case 'CHANGE_STATE':
        // we can use outer scope without restrictions
        setState({...action.payload, ...state})
        break;
      ...
    }
  }

  // stable dispatch function
  const dispatch = useCallback(
    (action: ActionType) => dispatchRef.current(action),
    [dispatchRef]
  );

  return (
    <StateContext.Provider value={{state, ...}}>
      <DispatchContext.Provider value={dispatch}>
        {children}
      </DispatchContext.Provider>
    </StateContext.Provider>
  )
}


Enter fullscreen mode Exit fullscreen mode

这样,稳定的调度函数就被传递给了DispatchContext,我们可以不受限制地使用外部范围。

可订阅上下文

我们需要的最后一个优化是让组件能够仅订阅部分状态。现在,组件只能使用整个状态,即使它们只需要一小部分(例如一个布尔值),每次我们更改状态时,它们都会收到通知。这不是最佳实践,因为我们仍然会进行不必要的重新渲染。解决这个问题的方法是通过use-context-selector

这个库非常简单,它允许使用选择器功能从状态中“挑选”我们想要的东西。



import { createContext } from 'use-context-selector';

const StateContext = createContext(null);

export const Provider = ({ children }) => {
  return (
    <StateContext.Provider value={{state, ...}}>
      <DispatchContext.Provider value={dispatch}>
        {children}
      </DispatchContext.Provider>
    </StateContext.Provider>
  )
}


Enter fullscreen mode Exit fullscreen mode


import { useContextSelector } from 'use-context-selector';

export const Subscriber = () => {
  const somePart = useContextSelector(StateContext, context => context.somePart)
}


Enter fullscreen mode Exit fullscreen mode

哦,等等,这是作弊!你不是说了你只会用 Context API 吗?

这个库是 React.Context API 的简单封装。它用于ref封装传递的值,这样组件就不会自动重新渲染,并保存订阅者列表。当值发生变化时,它会运行所有订阅的函数;如果来自选择器的值与之前不同,它会强制订阅的组件重新渲染。类似的概念也用在了 Redux 的useSelector hook 中。所以,我想说,这是一个相当标准的解决方案,既然已经有了,为什么还要重新构建一个呢?

PS:甚至有一个开放的 RFC可以将类似这样的内容直接添加到 React 中

最终产品

我们可以包装整个功能以实现可重复使用(+添加 TypeScript 类型)



import React, { useCallback, useRef } from 'react';
import { createContext, useContextSelector } from 'use-context-selector';

type DispatchType<ActionType, DispatchReturn> = (
  action: ActionType
) => DispatchReturn;

type SelectorType<StateType> = (state: StateType) => any;

export const createProvider = <
  StateType,
  ActionType,
  DispatchReturn,
  ProviderProps
>(
  body: (
    props: ProviderProps
  ) => [state: StateType, dispatch: DispatchType<ActionType, DispatchReturn>]
) => {
  const StateContext = createContext<StateType>(null as any);
  const DispatchContext = React.createContext<
    DispatchType<ActionType, DispatchReturn>
  >(null as any);

  const Provider: React.FC<ProviderProps> = ({ children, ...props }) => {
    const [state, _dispatch] = body(props as any);
    const dispatchRef = useRef(_dispatch);

    dispatchRef.current = _dispatch;

    // stable dispatch function
    const dispatch = useCallback(
      (action: ActionType) => dispatchRef.current?.(action),
      [dispatchRef]
    );

    return (
      <StateContext.Provider value={state}>
        <DispatchContext.Provider value={dispatch}>
          {children}
        </DispatchContext.Provider>
      </StateContext.Provider>
    );
  };

  const useDispatch = () => React.useContext(DispatchContext);
  const useStateContext = (selector: SelectorType<StateType>) =>
    useContextSelector(StateContext, selector);

  return [Provider, useDispatch, useStateContext] as const;
};


Enter fullscreen mode Exit fullscreen mode

使用示例



type ActionType =
  | { type: 'CHANGE_STATE'; payload: ... }
  ...

export const [
  TranslationsContextProvider,
  useTranslationsDispatch,
  useTranslationsSelector,
] = createProvider(
  (props /* provider props */) => {
    const [state1, setState1] = useState(...)
    const [state2, setState2] = useState(...)
    const {data, isLoading} = useQuery(...)

    const dispatch = (action: ActionType) => {
      switch (action.type) {
        case 'CHANGE_STATE':
          setState(action.payload)
          break;
        ...
      }
    }

    const state = {
      state1,
      state2,
      data,
      isLoading
    }

    // don't forget to return state and dispatch function
    return [state, dispatch]
  })


Enter fullscreen mode Exit fullscreen mode

让我们总结一下这个解决方案的优点:

  • 使用简单,不需要学习任何新内容,也不需要像 REDUX 等那样使用样板。
  • 比单纯使用 Context api 更高效
  • 当你拥有 hooks 的全部功能时,它会扩展
  • 您可以使用多个实例,并将它们限定在需要它们的应用程序部分

在 Tolgee.io 中,我们在最复杂的视图上使用它,我们在其中处理翻译表,并且我们还没有遇到任何问题。

你怎么认为?

附言:查看Tolgee.io并给我们github stars

文章来源:https://dev.to/tolgee_i18n/react-doesnt-need-state-management-tool-i-said-31l4
PREV
通过清除“if”语句来保持代码整洁
NEXT
我已经本地化了世界上最好的短链接平台