Adios Redux:有效使用 React Hooks 和 Context
2020年了,React 仍然是全球最受欢迎的前端框架。这不仅仅是因为它相对简单,而是它持续改进的事实让我一直着迷(无意的双关)。Hooks 的引入将 React 生态系统从基于类的组件转变为函数式组件,让编写 React 变得更加有趣。但目前为止,还没有一个特定的状态管理工具成为 React 的首选。
Redux 确实很流行。但 Redux 最大的缺点在于它一开始很难学,因为有很多样板代码。最近我看到一些推文
React Query 对一些 Redux 用户的影响让我难以置信。
我每天都看到越来越多的人开始将两者整合,最终只剩下少量的 Redux 状态,于是他们干脆放弃 Redux,转而使用 React 的上下文
。🤯2020年4月20日 下午22:34
这让我开始疯狂学习,并且我了解了一些令人兴奋的模式和包,它们可能会彻底改变你对钩子和全局状态的看法(对我来说确实如此)。
最初想写这个系列文章的时候,我脑子里有太多标题可选了。比如《状态管理 2020》、《React 中的自定义 Hooks》等等。但最终我决定用 Ciao Redux(再见 Redux),因为这似乎是这个系列文章的最终目标。
本文的灵感来自 Tanner Linsley 在 JSConf Hawaii 2020 上的精彩演讲。如果您还没有看过,我建议您观看。
那么让我们开始吧。
你如何看待React 中的状态?
有人会简单地说,状态是前端存在的所有数据,或者你从服务器获取的数据。但是,当你使用 React 构建应用程序一段时间后,你就会明白我的意思。
@jhooks所有应用都有两种状态:UI 状态和服务器缓存。将所有服务器缓存放入 react-query 中,其余状态就可以通过 React state/context 轻松管理。2020年4月22日 上午04:57
状态主要可分为两种类型:
- UI 状态
- 服务器缓存
你可能想知道我在说什么。让我解释一下。
UI 状态是用于管理 UI 的状态或信息。例如,深色/浅色主题、下拉菜单切换、表单错误状态管理等。服务器缓存是从服务器接收的数据,例如用户详情、产品列表等。
管理国家
让我们从基础开始。顺便构建一些示例。不,不是待办事项列表。我们已经有足够多的教程了。我们将构建一个包含登录屏幕和主屏幕的简单应用程序。
useState
钩子useState
允许我们在函数式组件内部使用状态。这样就不用再费力地在构造函数中声明状态,并通过 访问它了this
。只需这样做
import { useState } from 'react'
const [name, setName] = useState("")
我们得到name
变量和一个函数来更新变量setName
。
现在让我们利用这些知识为我们的页面制作一个登录表单。
import React, { useState } from 'react'
export default function Login() {
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [emailError, setEmailError] = useState(false)
const [passwordError, setPasswordError] = useState(false)
const [isLoading, setIsLoading] = useState(false)
async function handleSubmit() {
setIsLoading(true)
const res = await axios.post(url, {email, password})
if(res.data.status === "EMAIL_ERROR") {
setEmailError(true)
}
if(res.data.status === "PASSWORD_ERROR") {
setPasswordError(true)
}
// Otherwise do stuff
}
return (
<div>
<input
type="text"
value={email}
onChange={
e => setEmail(e.target.value)
}
/>
{emailError && <ErrorComponent type="EMAIL_ERROR" />}
<input
type="password"
value={password}
onChange={
e => setPassword(e.target.value)
}
/>
{passwordError && <ErrorComponent type="PASSWORD_ERROR" />}
{ isLoading
? <button onClick={() => handleSubmit()}>Sign in</button>
: <LoadingButton /> }
</div>
)
}
这可行。但这肯定不是最好的方法,对吧?而且,如果再添加一些其他因素或验证检查,就很容易失控。
useReducer
熟悉 Redux 的人肯定知道useReducer
它的工作原理。对于不熟悉 Redux 的人,下面是它的工作原理。
Action -------> Dispatch -------> Reducer --------> Store
你创建一个 action 并 dispatch 它,它会经过 reducer 并更新 store。让我们在前面的例子中实现它,看看它是如何工作的。
import React, { useReducer } from 'react'
const initialState = {
user: {
email: "",
password: ""
},
errors: {
email: false,
password: false
},
isLoading: false
}
const reducer = (state, action) => {
switch (action.type) {
case 'CHANGE_VALUE':
return {
...state,
user: {
...state.user,
[action.field]: action.data
}
}
case 'ERROR':
return {
...state,
errors: {
...state.errors,
[action.type]: true
}
}
case 'LOADING':
return {
...state,
isLoading: true
}
default:
return state
}
}
export default function Login() {
const [state, dispatch] = useReducer(reducer, initialState)
async function handleSubmit() {
dispatch({type: 'LOADING'})
const res = await axios.post(url, store.user)
if(res.data.status === "EMAIL_ERROR") {
dispatch({type: 'ERROR', field: "email"})
}
if(res.data.status === "PASSWORD_ERROR") {
dispatch({type: 'ERROR', field: "password"})
}
// Otherwise do stuff
}
return (
<div>
<input
type="text"
value={state.user.email}
onChange={
e => dispatch({type: "CHANGE_VALUE", data: e.target.value, field: "email"})
}
/>
{state.errors.email && <ErrorComponent type="EMAIL_ERROR" />}
<input
type="password"
onChange={
value={state.user.password}
e => dispatch({type: "CHANGE_VALUE", data: e.target.value, field: "password"})
}
/>
{state.errors.password && <ErrorComponent type="PASSWORD_ERROR" />}
<button onClick={() => handleSubmit()}>Sign in</button>
</div>
)
}
这看起来不错,我们不用处理单独的函数,只需声明一个 Reducer,并定义一些 Action 和相应的 Store 变更即可。这非常实用,因为在使用时useState
,随着需求的增长,我们很容易忘记变量的数量。你一定注意到了,这段代码比之前的代码长得多,这让我们进入下一部分。
从 UI 中抽象逻辑
在 React 中开发应用程序时,应始终尝试将业务逻辑与 UI 代码分开。与用户交互的 UI 组件应该只知道用户可以执行哪些交互(操作)。此外,这还能为您的代码库提供合理的结构和良好的可维护性。Redux 很好地支持了这一点,我们可以在其他地方定义操作,由它来处理所有逻辑,从而保持 UI 代码的简洁。但是,如何使用 Hooks 来实现这一点呢?自定义 Hooks 来帮您!
自定义钩子
React 允许你创建自定义 hooks,以便更好地在组件之间分离和共享逻辑。对于上面的示例,我们可以创建一个名为hooks/useLoginReducer.js
import { useReducer } from 'react'
const initialState = {
user: {
email: "",
password: ""
},
errors: {
email: false,
password: false
},
isLoading: false
}
const reducer = (state, action) => {
switch (action.type) {
case 'CHANGE_VALUE':
return {
...state,
user: {
...state.user,
[action.field]: action.data
}
}
case 'ERROR':
return {
...state,
errors: {
...state.errors,
[action.type]: true
}
}
case 'LOADING':
return {
...state,
isLoading: true
}
default:
return state
}
}
export default function useLoginReducer() {
const [store, dispatch] = useReducer(reducer, initialState)
return [store, dispatch]
}
然后在登录组件中
import React from 'react'
import useLoginReducer from '../hooks/useLoginReducer'
export default function Login() {
const [store, dispatch] = useLoginReducer()
...
}
瞧!我们把逻辑和组件分离了,现在看起来简洁多了。自定义钩子可以很好地实现关注点分离。
让我们进入最精彩的部分。
全局状态
像 Redux 这样的第三方库旨在提供全局状态管理,因为 prop 钻取实在太麻烦了。React 拥有 Context API,允许在组件之间传递数据。Context 允许你声明一个Provider
用于存储或初始化数据,以及Consumer
用于读取或更新数据的组件。Redux 在后台使用它,但是
- 很长一段时间都不稳定
- 需要渲染 props,导致可读性降低
然而,随着 React Hooks 的引入,使用 context 变得容易得多。我们可以轻松地声明全局状态,并通过组合hooks
和来使用它们context
。让我们看一下上面使用的示例。假设登录后,你想用用户的详细信息更新全局存储,这些信息可以在 Navbar 组件中显示用户的名称。
我们首先声明一个上下文,然后使用钩子来存储和更新数据。
const globalContext = React.createContext()
const intialState = {
user: {
...
}
}
const reducer = {
...
}
export const StoreProvider = ({children}) => {
const [store, dispatch] = React.useReducer(reducer, initialState)
//memoizes the contextValue so only rerenders if store or dispatch change
const contextValue = React.useMemo(
() => [store, dispatch],
[store, dispatch]
)
return (
<globalContext.Provider value={contextValue}>
{children}
</globalContext.Provider>
)
}
export function useStore() {
return React.useContext(globalContext)
}
那么让我通过这里的代码来解释一下。我们首先创建一个上下文。然后在组件中使用 useReducer 来创建 store 和 dispatch 方法。我们使用 useReduceruseMemo
创建一个上下文变量,仅当其中一个依赖项发生变化时才会更新。然后,我们返回context.Provider
带有上下文变量值的组件。在最后一部分,我们使用了useContext
hook,它允许我们在函数式组件中使用上下文,前提是它位于Provider
.
// App.js
import React from 'react';
import { StoreProvider, useStore } from './context';
function App() {
return (
<StoreProvider>
<Navbar />
...
</StoreProvider>
);
}
// Login.js
import React from 'react';
import { useStore } from './context'
function Login() {
const [, dispatch] = useStore()
...
function handleSubmit() {
...
dispatch(...)
}
}
// Navbar.js
import React from 'react';
import { useStore } from './context';
function Navbar() {
const [{user}, dispatch] = useStore()
return (
...
<li>{user.name}</li>
)
}
因此,我们将应用程序组件包装在其中StoreProvider
,并使用useStore
返回的函数访问嵌套组件中的存储值和调度函数。听起来很棒吧。嗯,其实不然。这其中有很多问题。我们来看看。
- 首先,由于我们同时导出了
store
和dispatch
。任何更新组件(仅使用 dispatch)且不使用 store 的组件也会在每次状态更改时重新渲染。这是因为每次 context 值更改时都会生成一个新的数据对象。这是不可取的。 - 其次,我们为所有组件使用同一个存储。当我们向 Reducer 的 initialState 添加任何其他状态时,数据量都会大幅增长。此外,所有使用该上下文的组件都会在状态发生变化时重新渲染。这是不可取的,可能会破坏你的应用程序。
那么我们该如何解决这些问题呢?几天前,我偶然看到了这条推文
@tannerlinsley @kentcdodds如果正确使用 React Context,大多数项目都不需要 Redux
很多问题都是因为人们试图将所有状态放在一个上下文中
如果你根据关注点将它们分开,请使用 useReducer,根据需要分离 get/set 上下文,根据需要使用 useMemo -> React Context 是黄金2020年4月21日下午14:47
@dibfirman 1. Context + useState
2. Context + useReducer
3. DispatchContext + StateContext + useReducer
4. 状态“切片”的#3 的多个提供程序
在任何阶段:针对慢速渲染进行分析,然后使用 useMemo
通过这 4 个阶段和 useMemo,我相信您可以解决 99% 的性能挑战。2020年4月20日 下午22:58
问题解决了。这就是我们需要的。现在我们来实现它,我会解释一下。
对于第一个问题,我们可以简单地将存储和调度分离到不同的上下文中,用于DispatchContext
更新存储和StoreContext
使用存储。
const storeContext = React.createContext()
const dispatchContext = React.createContext()
const intialState = {
user: {
...
}
}
const reducer = {
...
}
export const StoreProvider = ({children}) => {
const [store, dispatch] = React.useReducer(reducer, initialState)
return (
<dispatchContext.Provider value={dispatch}>
<storeContext.Provider value={store}>
{children}
</storeContext.Provider>
</dispatchContext.Provider>
)
}
export function useStore() {
return React.useContext(storeContext)
}
export function useDispatch() {
return React.useContext(dispatchContext)
}
然后我们只需根据我们的情况导入useDispatch
即可。useStore
// App.js
import React from 'react';
import { StoreProvider } from './context';
function App() {
return (
<StoreProvider>
<Navbar />
...
</StoreProvider>
);
}
//Login.js
import React from 'react';
import { useDispatch } from './context'
function Login() {
const dispatch = useDispatch()
...
function handleSubmit() {
...
dispatch(...)
}
}
// Navbar.js
import React from 'react';
import { useStore } from './context'
function Navbar() {
const {user} = useStore()
return (
...
<li>{user.name}</li>
)
}
现在来看第二个问题。其实很简单,我们不需要创建一个单独的 store。我之前使用 context 时遇到的困难主要就是因为这个原因。即使在 Redux 中,我们也会将 Reducer 分离并组合起来。
我们可以简单地定义一个函数,它接受一个参数initialState
并reducer
返回一个 store。让我们看看它是怎么做的。
import React from 'react'
export default function makeStore(reducer, initialState) {
const storeContext = React.createContext()
const dispatchContext = React.createContext()
const StoreProvider = ({children}) => {
const [store, dispatch] = React.useReducer(reducer, initialState)
return (
<dispatchContext.Provider value={dispatch}>
<storeContext.Provider value={store}>
{children}
</storeContext.Provider>
</dispatchContext.Provider>
)
}
function useStore() {
return React.useContext(storeContext)
}
function useDispatch() {
return React.useContext(dispatchContext)
}
return [StoreProvider, useStore, useDispatch]
}
然后我们可以按如下方式声明userContext
。
import makeStore from '../store'
const initalState = {
user: {
...
}
}
const reducer = (state, action) => {
switch (action.type) {
...
...
}
}
const [
UserProvider,
useUserStore,
useUserDispatch
] = makeStore(reducer, initalState)
export { UserProvider, useUserStore, useUserDispatch }
最后在需要的时候使用它
// App.js
import React from 'react';
import { UserProvider } from './userStoreContext';
function App() {
return (
<UserProvider>
<Navbar />
...
</UserProvider>
);
}
// Login.js
import React from 'react';
import { useUserDispatch } from './userStoreContext'
function Login() {
const dispatch = useUserDispatch()
...
function handleSubmit() {
...
dispatch(...)
}
}
// Navbar.js
import React from 'react';
import { useUserStore } from './userStoreContext'
function Navbar() {
const {user} = useUserStore()
return (
...
<li>{user.name}</li>
)
}
完成。如果我们想要另一个 store,只需创建另一个 store 并将其包装在我们的应用或要使用它的组件中即可。例如
function App() {
return (
<UserProvider>
<Navbar />
<ProductProvider>
<Products />
</ProductProvider>
</UserProvider>
);
}
哇哦!本系列的第一部分就到这里了。希望你已经学会了如何有效地使用钩子和上下文。在接下来的文章中,我将讨论react-query
如何处理服务器缓存。敬请期待。
进一步阅读
- https://reactjs.org/docs/hooks-custom.html
- https://reactjs.org/docs/hooks-reference.html#usereducer
- https://reactjs.org/docs/context.html#consuming-multiple-contexts
- https://reactjs.org/docs/hooks-reference.html#usecontext
- https://kentcdodds.com/blog/how-to-use-react-context-effectively