自从我们学习 React 的第一天起,我们就知道不要在 React 中使用 太多 状态。我们也知道应该尽可能多使用 无状态 的函数式组件,少使用 有状态 的类组件。
这些建议很容易让我们形成这样的判断,那就是 我们完全不应该使用状态,当我们需要用到状态的时候,应该使用 Redux 之类的第三方状态管理库。
我们不喜欢使用 React 原生状态并不是没有原因的:
- 当项目变大的时候,散落在许多组件中的局部状态很快会变得无法追踪;
- 组件之间难以共享状态;
- 从一个外部组件一层层将 prop 传递到内部组件很麻烦;这个问题被称作prop drilling;
- 状态逻辑通常和 UI 逻辑混合在一起,导致调试和重用状态逻辑都很困难;
- 在编译时,类组件比函数式组件更难优化。
严格来讲,prop drilling并不属于状态的问题,而是一个设计失误。它通常是由过多的不必要封装导致的。
Redux 的优缺点
Redux 是最流行的状态管理库,它通过将状态与 UI 隔离并中心化管理的方式减轻了以上所说的这些痛点。
它的整个逻辑十分简单,但却蕴含着强大的扩展性。我们可以用一个 单向 数据流图来描述 Redux 背后的运行机制:
除了 store 自身,所有其他的组件都是 纯函数。这是一个函数式编程中的概念,指的是函数的结果只取决于函数的输入,而不取决于任何其他的状态。
纯函数更容易测试、理解和调试。通过强制使用函数式编程这一编程风格,Redux 减少了维护状态逻辑的负担。
然而,Redux 也有自己特有的麻烦。一个人们广为诟病的问题就是在搭建项目初期,它需要写太多样板代码。
对于小项目而言,这个问题尤为严重。添加一个新的 action 通常需要我们定一个新 action、添加一个新 action creator、修改 reducer、更新 container 等一系列操作。为了完成这个功能,我们需要在许多个不同的文件之间跳转,最后把一个 5 行之内可以做完的事情扩展成 20 行。
甚至说,我们到底要不要中心化的 store 也是一个值得讨论的问题,就像我们要不要用全局变量一样。
和局部状态相比,全局状态更难重用。重构全局状态可能会意外地导致部分现有代码不可用。不合理地使用 Redux container 也会有性能问题。
在另一边,近几年 React 为了让状态管理更好用做出了许多努力,引入了 Context API 和Hooks等新特性来解决过去的痛点。
原生 React
React 支持两种组件:类组件(支持状态和 hook,也就是 componentDidMount 等函数)和函数式组件(不支持状态,更加简单)。
在过去,如果我们想使用函数式组件,又想维护某些内部状态,唯一的办法就是使用 Redux 之类的状态管理库。
在 React 增加了 Context API 和 hooks 之后,我们有了更多的选择。
因为这两个新 API 是 React 官方支持的,我建议在使用第三方的状态管理库之前,先了解一下它们再做决定。
React Hooks
React hooks API 提供了一种 在函数式组件中管理状态 的方法。
这句话第一眼看上去自相矛盾,因为 函数式 在某种程度上就意味着 无状态。但是,我们先将这一问题搁置在一边,只把函数式组件当作一种设计模式来看待。
和类组件相比,函数式组件有更紧凑的形式,需要更少的样板代码,更可读,对编译器来说也更容易分析和优化。
然而,不能够在函数式组件中维护状态为我们带来了极大的不便:即使是引入一个 boolean 这样小的状态,都需要我们将整个函数式组件重写为类组件。
React hooks API 通过允许我们在函数式组件中使用状态来解决了这个困境。而且,它也允许我们将状态逻辑从 UI 逻辑中剥离出来,重用到其他的 UI 组件中。
下面这个简短的例子展示了我们如何在函数式组件中使用 React hooks:
import React, {useState} from 'react'
const Counter: React.FC = () => {const [counter, setCounter] = useState(0)
return (
<div>
<p>Counter: {counter}</p>
<button onClick={() => setCounter(counter + 1)}>
Increment
</button>
</div>
)
}
useState返回当前状态和一个函数(这个函数可以用来更新状态),它的参数是初始状态。这个函数第一次被调用的时候,它将组件的内部状态初始化为给定的初始值。
这个状态是维护在这个组件 之内 的,不会被同一个组件的多个实例之间共享。
我们可以在一个函数式组件中调用 useState 许多次。React hooks API 鼓励我们将一个复杂的状态分解成多个可重用的简单状态。
我们可以进一步将状态逻辑封装在单独的函数中(称为custom hook),以便我们在其他组件中重用这个状态逻辑:
import React, {useState} from 'react'
// A custom hook
const useCounter = (initial: number) => {const [counter, setCounter] = useState(initial)
return {
counter,
increment () {setCounter(counter + 1)
},
reset () {setCounter(initial)
}
}
}
const Counter: React.FC = () => {const { counter, increment, reset} = useCounter(0)
return (
<div>
<p>Counter: {counter}</p>
<button onClick={increment}>
Increment
</button>
<button onClick={reset}>
Reset
</button>
</div>
)
}
每一个使用 setState 的地方,我们都可以用 React hooks 代替,因为 React hooks 全方面地超越了原来的 setState:它允许将一个组件的状态拆分成小的可重用的状态,鼓励我们多用函数式组件,少用类组件。
React Context API
React Context API 比 React hooks 更早引入 React,但是它是用来解决一个完全不同的问题的:状态共享 和prop drilling。
这个功能可能会让你联想到 Redux 的用途,但是 React Context API 事实上并不鼓励用它来维护一个巨大的中心化 store。官方文档中说到:
Context 主要是用在数据需要被不同层次的多个组件访问的时候。尽可能少用它,因为它会使组件重用更加困难。
React Context API 和 React hooks 的设计哲学是相同的,那就是 尽可能避免状态共享。
我们不得不使用 React Context API 的典型场景有:
- 用户登录信息;
- UI 主题;
- locale 设置。
例如,我们可以通过把 UI 主题放到 Context 中的方法来避免手动逐层传递:
import React, {useContext} from 'react'
const ThemeContext = React.createContext('light')
const UserComponent: React.FC = () => {const theme = useContext(ThemeContext)
return (
<div>
Current theme: {theme}
</div>
)
}
const App: React.FC = () => {
return (
<ThemeContext.Provider value="dark">
<UserComponent />
</ThemeContext.Provider>
)
}
通过合理地组合 React Context API 和 React hooks,我们可以在 完全不用 Redux的情况下管理程序状态。
然而,就像编程世界中的其他开放性问题一样,状态管理也没有万金油。具体怎么做取决于业务逻辑的复杂性、程序的规模和其他各种因素。
我们应该在实际中选择最合适的方法。我的建议是,在项目初期,可以使用 Context API 和 React hooks 作为开始,随着项目的进行,只在必要的时候才引入 Redux。