使用React的应用程序状态管理

72 阅读11分钟

管理状态可以说是任何应用程序中最难的部分。这就是为什么有这么多的状态管理库,而且每天都有更多的库出现(甚至有些库是建立在其他库之上的......npm上有几百个 "更容易的redux "的抽象)。尽管状态管理是一个困难的问题,但我认为使其如此困难的原因之一是我们经常对问题的解决方案进行过度工程化。

有一个状态管理解决方案,我个人在使用React的过程中一直在尝试实现,随着React钩子的发布(以及React上下文的大规模改进),这种状态管理方法已经被大幅简化。

我们经常说React组件是构建应用程序的乐高积木,我想当人们听到这句话时,他们会认为这排除了状态方面。我个人对状态管理问题的解决方案背后的 "秘密 "是,考虑你的应用程序的状态如何映射到应用程序的树状结构。

redux如此成功的原因之一是react-redux解决了道具钻取问题。你可以通过简单地将你的组件传入一些神奇的connect 函数,在你的树的不同部分共享数据,这一事实非常好。它对reducer/action creators/等等的使用也很好,但我相信redux的普遍性是因为它解决了开发者的道具钻取痛点。

这也是我只在一个项目中使用过redux的原因。我一直看到开发者把他们所有的状态都放到redux中。不仅仅是全局应用状态,还有本地状态。这导致了很多问题,其中最重要的是,当你维护任何状态的交互时,它涉及到与还原器、动作创建者/类型和调度调用的交互,这最终导致你不得不打开许多文件,在你的头脑中追踪代码,以弄清楚正在发生什么,以及它对代码库的其他部分有什么影响。

更糟糕的是,这种方式不能很好地扩展。你的应用程序越大,这个问题就越难解决。当然,你可以连接不同的还原器来管理你的应用程序的不同部分,但通过所有这些动作创建者和还原器的间接性并不理想。

即使你没有使用Redux,将所有的应用程序状态放在一个单一的对象中也会导致其他问题。当React<Context.Provider> 得到一个新的值时,所有消耗该值的组件都会被更新,并且必须渲染,即使是一个只关心部分数据的函数组件。这可能会导致潜在的性能问题。(React-Redux v6也试图使用这种方法,直到他们意识到它不能与钩子一起工作,这迫使他们在v7中使用不同的方法来解决这些问题。)但我的观点是,如果你把你的状态更有逻辑地分开,并在反应树中更接近它的位置,你就不会有这个问题。


真正的关键是,如果你用React构建一个应用程序,你已经在你的应用程序中安装了一个状态管理库。你甚至不需要npm install (或yarn add )它。它不需要为你的用户花费额外的字节,它与npm上的所有React包集成,而且它已经被React团队很好地记录下来了。这就是React本身。

React是一个状态管理库

当你构建一个React应用程序时,你正在组装一堆组件,形成一棵组件树,从你的<App /> ,到你的<input />s、<div />s和<button />s。你不会在一个中央位置管理你的应用程序渲染的所有低级复合组件。相反,你让每个单独的组件来管理它,最终成为一个真正有效的方法来构建你的用户界面。你也可以对你的状态这样做,而且今天你很可能会这样做。

function Counter() {
  const [count, setCount] = React.useState(0)
  const increment = () => setCount(c => c + 1)
  return <button onClick={increment}>{count}</button>
}

function App() {
  return <Counter />
}

Edit React Codesandbox

请注意,我在这里说的一切也适用于类组件。 钩子只是让事情变得更容易一些(尤其是上下文,我们将在一分钟内讨论)。

class Counter extends React.Component {
  state = {count: 0}
  increment = () => this.setState(({count}) => ({count: count + 1}))
  render() {
    return <button onClick={this.increment}>{this.state.count}</button>
  }
}

"好的,Kent,当然在一个组件中管理一个单一的状态元素是很容易的,但是当我需要在不同的组件中分享这个状态时,你会怎么做? 例如,如果我想这样做:"

function CountDisplay() {
  // where does `count` come from?
  return <div>The current counter count is {count}</div>
}

function App() {
  return (
    <div>
      <CountDisplay />
      <Counter />
    </div>
  )
}

"count 是在<Counter /> 里面管理的,现在我需要一个状态管理库来从<CountDisplay /> 访问那个count 的值,并在<Counter /> 里更新它!"

这个问题的答案和React本身一样久远(更久远?),从我记事起就一直在文档中:Lifting State Up

"Lifting State Up "是React中状态管理问题的合法答案,它是一个坚实的答案。下面是你如何将其应用于这种情况。

function Counter({count, onIncrementClick}) {
  return <button onClick={onIncrementClick}>{count}</button>
}

function CountDisplay({count}) {
  return <div>The current counter count is {count}</div>
}

function App() {
  const [count, setCount] = React.useState(0)
  const increment = () => setCount(c => c + 1)
  return (
    <div>
      <CountDisplay count={count} />
      <Counter count={count} onIncrementClick={increment} />
    </div>
  )
}

Edit React Codesandbox

我们刚刚改变了谁对我们的状态负责,这真的很简单。而且我们可以一直把状态提升到我们应用的顶部。

"当然,肯特,好的,但道具钻井问题怎么办?"

好问题。你对这个问题的第一道防线是改变你构造组件的方式。利用组件组成的优势。 也许可以用以下方式代替。

function App() {
  const [someState, setSomeState] = React.useState('some state')
  return (
    <>
      <Header someState={someState} onStateChange={setSomeState} />
      <LeftNav someState={someState} onStateChange={setSomeState} />
      <MainContent someState={someState} onStateChange={setSomeState} />
    </>
  )
}

你可以这样来代替。

function App() {
  const [someState, setSomeState] = React.useState('some state')
  return (
    <>
      <Header
        logo={<Logo someState={someState} />}
        settings={<Settings onStateChange={setSomeState} />}
      />
      <LeftNav>
        <SomeLink someState={someState} />
        <SomeOtherLink someState={someState} />
        <Etc someState={someState} />
      </LeftNav>
      <MainContent>
        <SomeSensibleComponent someState={someState} />
        <AndSoOn someState={someState} />
      </MainContent>
    </>
  )
}

如果这句话不是很清楚(因为这句话超级有创意),迈克尔-杰克逊一个很好的视频,你可以看一下,以帮助澄清我的意思。

不过最终,即使是构图也不能为你做到这一点,所以你的下一步是跳入React的Context API。这实际上已经是一个 "解决方案 "了,但在很长一段时间里,这个解决方案是 "非官方的"。正如我所说的,许多人伸手去拿react-redux ,因为它用我所说的机制解决了这个问题,而他们不必担心React文档中的警告。但是现在,context 是React API官方支持的一部分,我们可以直接使用这个,没有任何问题。

import * as React from 'react'

const CountContext = React.createContext()

function useCount() {
  const context = React.useContext(CountContext)
  if (!context) {
    throw new Error(`useCount must be used within a CountProvider`)
  }
  return context
}

function CountProvider(props) {
  const [count, setCount] = React.useState(0)
  const value = React.useMemo(() => [count, setCount], [count])
  return <CountContext.Provider value={value} {...props} />
}

export {CountProvider, useCount}
import * as React from 'react'
import {CountProvider, useCount} from './count-context'

function Counter() {
  const [count, setCount] = useCount()
  const increment = () => setCount(c => c + 1)
  return <button onClick={increment}>{count}</button>
}

function CountDisplay() {
  const [count] = useCount()
  return <div>The current counter count is {count}</div>
}

function CountPage() {
  return (
    <div>
      <CountProvider>
        <CountDisplay />
        <Counter />
      </CountProvider>
    </div>
  )
}

Edit React Codesandbox

注意:那个特定的代码例子是非常有创意的,我不建议你用上下文来解决这个特定场景。请阅读Prop Drilling,以更好地了解为什么Prop Drilling不一定是个问题,而且通常是可取的。不要过早接触上下文。

这种方法最酷的地方在于,我们可以把所有常用的更新状态的逻辑放在我们的useCount 钩子里。

function useCount() {
  const context = React.useContext(CountContext)
  if (!context) {
    throw new Error(`useCount must be used within a CountProvider`)
  }
  const [count, setCount] = context

  const increment = () => setCount(c => c + 1)
  return {
    count,
    setCount,
    increment,
  }
}

Edit React Codesandbox

而你也可以很容易地把它改成useReducer ,而不是useState

function countReducer(state, action) {
  switch (action.type) {
    case 'INCREMENT': {
      return {count: state.count + 1}
    }
    default: {
      throw new Error(`Unsupported action type: ${action.type}`)
    }
  }
}

function CountProvider(props) {
  const [state, dispatch] = React.useReducer(countReducer, {count: 0})
  const value = React.useMemo(() => [state, dispatch], [state])
  return <CountContext.Provider value={value} {...props} />
}

function useCount() {
  const context = React.useContext(CountContext)
  if (!context) {
    throw new Error(`useCount must be used within a CountProvider`)
  }
  const [state, dispatch] = context

  const increment = () => dispatch({type: 'INCREMENT'})
  return {
    state,
    dispatch,
    increment,
  }
}

Edit React Codesandbox

这为你提供了巨大的灵活性,并在数量级上降低了复杂性。在这样做的时候,有几件重要的事情要记住。

  1. 在你的应用程序中,不是所有的东西都需要在一个单一的状态对象中。保持逻辑上的分离(用户设置不一定要和通知在同一个上下文中)。用这种方法你会有多个提供者。
  2. 并非所有的上下文都需要全局性的访问。尽可能地将状态保持在需要的地方。

更多关于第二点的内容。你的应用程序树可以看起来像这样。

function App() {
  return (
    <ThemeProvider>
      <AuthenticationProvider>
        <Router>
          <Home path="/" />
          <About path="/about" />
          <UserPage path="/:userId" />
          <UserSettings path="/settings" />
          <Notifications path="/notifications" />
        </Router>
      </AuthenticationProvider>
    </ThemeProvider>
  )
}

function Notifications() {
  return (
    <NotificationsProvider>
      <NotificationsTab />
      <NotificationsTypeList />
      <NotificationsList />
    </NotificationsProvider>
  )
}

function UserPage({username}) {
  return (
    <UserProvider username={username}>
      <UserInfo />
      <UserNav />
      <UserActivity />
    </UserProvider>
  )
}

function UserSettings() {
  // this would be the associated hook for the AuthenticationProvider
  const {user} = useAuthenticatedUser()
}

请注意,每个页面都可以有自己的提供者,拥有它下面的组件所需的数据。代码拆分对这些东西也是 "有效的"。 你如何将数据进入每个提供者,取决于这些提供者使用的钩子,以及你如何在你的应用程序中检索数据,但你知道从哪里开始寻找如何工作(在提供者中)。

关于为什么这种主机托管是有益的,请查看我的《国家主机托管将使你的React应用更快》《主机托管》博文。更多关于上下文的内容,请阅读《如何有效使用React Context》。

服务器缓存与UI状态

我想补充的最后一点是。状态有多种类别,但每一种类型的状态都可以归入两个桶中的一个。

  1. 服务器缓存--实际上存储在服务器上的状态,我们存储在客户端以便快速访问(如用户数据)。
  2. UI状态--只在UI中有用的状态,用于控制我们应用的交互部分(比如模态isOpen 状态)。

当我们把这两者结合起来的时候,我们就犯了一个错误。服务器缓存在本质上与UI状态有不同的问题,因此需要不同的管理。如果你接受这样一个事实:你所拥有的实际上根本不是状态,而是状态的缓存,那么你就可以开始正确地思考它,从而正确地管理它。

你肯定可以用你自己的useState ,或者useReducer,在这里和那里用正确的useContext ,来管理这个。但请允许我帮你切入正题,缓存是一个非常难的问题(有人说这是计算机科学中最难的问题之一),在这个问题上,站在巨人的肩膀上才是明智的。

这就是为什么我使用并推荐react-query来处理这种状态。我知道我知道,我告诉过你,你不需要一个状态管理库,但我并不真的认为react-query是一个状态管理库。我认为它是一个缓存。而且它是一个非常好的缓存。给它看看吧那个Tanner Linsley是个聪明的家伙。

性能方面如何?

当你遵循上面的建议时,性能很少会成为问题。 特别是当你遵循主机托管的建议时。 然而,在一些使用案例中,性能肯定会出现问题。 当你遇到与状态有关的性能问题时,首先要检查有多少组件因为状态变化而被重新渲染,并确定这些组件是否真的需要因为状态变化而被重新渲染。 如果是,那么性能问题不在于你的状态管理机制,而在于你的渲染速度,这种情况下你需要加快渲染速度

然而,如果你注意到有很多组件在渲染时没有DOM更新或需要的副作用,那么这些组件的渲染是不必要的。这种情况在React中经常发生,而且它本身通常不是一个问题(而且你应该首先专注于使不必要的重新渲染变得快速),但如果它真的是瓶颈,那么这里有一些方法来解决React上下文中状态的性能问题:

  1. 把你的状态分成不同的逻辑部分,而不是在一个大的存储中,所以对状态的任何部分的单一更新都不会触发对你的应用程序中的每个组件的更新。
  2. 优化你的上下文提供者
  3. 引入jotai

又来了,又是对一个库的推荐。的确,有一些用例并不适合React内置的状态管理抽象。在所有可用的抽象中,jotai对于这些用例是最有希望的。如果你好奇这些用例是什么,jotai能很好地解决的问题类型实际上在Recoil中描述得非常好。Recoil和jotai非常相似(并且解决相同类型的问题)。但根据我对它们的(有限的)经验,我更喜欢jotai。

在任何情况下,大多数应用程序都不需要像recoil或jotai这样的原子状态管理工具。

结论

同样,这是你可以用类组件来做的事情(你不需要使用钩子)。钩子使之更容易,但你可以用React 15来实现这一理念,没有问题。尽可能地保持状态在本地,只有在道具钻取真正成为问题的时候才使用上下文。这样做会让你更容易维护状态交互。