【译】仅使用 Context 和 Hooks 来管理 React 应用的状态

3,617 阅读12分钟
原文:How to manage state in a React app with just Context and Hooks
作者:Samuel Omole
译者:博轩
为保证文章的可读性,本文采用意译
自从 React Hooks 发布以来,数以千计关于它的文章,库和视频课程已经被发布。如果自己搜索下这些资源,您会发现我前段时间写的一篇文章,是关于如何使用 Hooks 构建示例应用程序。您可以在这里找到它。

基于该文章,很多人(实际上是两个)提出了有关如何仅使用 Context 和 Hooks 在 React 应用程序中管理 State 的问题,这让我对这个问题产生了一些研究。

因此,对于本文,我们将使用一种模式来管理状态,该模式使用两个非常重要的 Hooks ( useContextuseReducer ) 来构建简单的音乐画廊应用。该应用程序只有两个视图:一个用于登录,另一个用于列出该画廊中的歌曲。

采用登录页面作为示例的主要原因,是当我们想在组件之间共享登录(Auth)状态的时候,通常会采用 Redux 来实现。

等到完成的时候,我们应该会拥有一个如下图所示的应用程序:

对于后端服务,我设置了一个简单的 Express 应用程序,并将其托管于 Heroku 上。它有两个主要的接口:

  • /login - 用于认证。成功登录后,它将返回 JWT 令牌和用户详细信息。
  • /songs - 返回歌曲列表。

如果您想添加其他功能,可以在此处找到后端应用程序的储存库。

概述

在构建应用之前,让我们先来看下接下来要使用的 Hooks:

  • useState - 该 Hook 允许我们在函数组件中使用状态(相当于 this.statethis.setState 在类组件中的作用)
  • useContext - 该 Hook 接受一个上下文( Context )对象,并在 MyContext.Provider 中返回任何传入 value 属性的值。如果您还不了解上下文,那么这是一种将状态从父组件传递到组件树中任何其他组件的方法(不论组件的深度如何),而不必通过不需要该状态的其他组件进行传递(这个问题也叫做「prop drilling」)。您可以在此处阅读有关上下文( Context )的更多信息。
  • useReducer - 这是 useState 的替代方法, 可用于复杂的状态逻辑。这是我最喜欢的 Hook ,因为它使用起来就像 Redux 。 它可以接收一个类似下面这样的 reducer 函数:
(state, action) => newState

这个函数在返回新状态之前会接收一个初始状态。

入门

首先,我们可以使用 create-react-app 脚手架来开始构建这个项目。在此之前,需要准备一些东西:

  • Node (≥ 6)
  • 一个酷炫的文本编辑器

在您的终端,输入:

npx create-react-app hooked

或者,在全局安装 create-react-app

npm install -g create-react-app
create-react-app hooked

您将在本文结束时,创建5个组件:

  • Header.js —该组件将包含应用程序的顶部导航,并显示一个包含用户名的注销按钮。仅当用户通过身份验证时,该按钮才会显示。
  • App.js —这是顶级组件,我们将在其中创建身份验证上下文(我将在后面讨论)。如果用户未登录,则此组件会展示“登录”组件,如果已通过身份验证,则展示“主页”组件。
  • Home.js —该组件将从服务器获取歌曲列表并将其呈现在页面上。
  • Login.js —该组件将包含用户的登录表单。它还将负责向登录端点发出 POST 请求,并根据服务器的响应来更新身份验证的上下文。
  • Card.js —这是一个呈现组件(UI),用于呈现传递到其中的歌曲的详细信息。

Header.js

import React from "react";
export const Header = () => {
  return (
    <nav id="navigation">
      <h1 href="#" className="logo">
        HOOKED
      </h1>
    </nav>
  );
};
export default Header;

Home.js

import React from "react";
export const Home = () => {
return (
    <div className="home">
    </div>
  );
};
export default Home;

Login.js

import React from "react";
import logo from "../logo.svg";
import { AuthContext } from "../App";
export const Login = () => {
return (
    <div className="login-container">
      <div className="card">
        <div className="container">
        </div>
      </div>
    </div>
  );
};
export default Login;

App.js

最开始的时候,App.js 文件应该如下所示:

import React from "react";
import "./App.css";
function App() {
return (
      <div className="App"></div>
  );
}
export default App;

接下来,我们将创建 Auth 上下文,该上下文将 auth 状态从该组件传递到需要它的任何其他组件。代码如下:

import React from "react";
import "./App.css";
import Login from "./components/Login";
import Home from "./components/Home";
import Header from "./components/Header";
export const AuthContext = React.createContext(); // added this
function App() {
return (
    <AuthContext.Provider>
      <div className="App"></div>
    </AuthContext.Provider>
  );
}
export default App;

然后,我们添加 useReducer hook 来处理我们的身份验证状态,并有条件的展示 Login 组件和 Home 组件。

请记住,useReducer 具有两个参数,一个 reducer 函数 (这是一个将状态和操作作为参数并根据操作返回新状态的函数) 和一个初始状态,该状态也会传递给 reducer 函数。代码如下:

import React from "react";
import "./App.css";
import Login from "./components/Login";
import Home from "./components/Home";
import Header from "./components/Header";
export const AuthContext = React.createContext();
const initialState = {
  isAuthenticated: false,
  user: null,
  token: null,
};
const reducer = (state, action) => {
  switch (action.type) {
    case "LOGIN":
      localStorage.setItem("user", JSON.stringify(action.payload.user));
      localStorage.setItem("token", JSON.stringify(action.payload.token));
      return {
        ...state,
        isAuthenticated: true,
        user: action.payload.user,
        token: action.payload.token
      };
    case "LOGOUT":
      localStorage.clear();
      return {
        ...state,
        isAuthenticated: false,
        user: null
      };
    default:
      return state;
  }
};
function App() {
  const [state, dispatch] = React.useReducer(reducer, initialState);
return (
    <AuthContext.Provider
      value={{
        state,
        dispatch
      }}
    >
      <Header />
      <div className="App">{!state.isAuthenticated ? <Login /> : <Home />}</div>
    </AuthContext.Provider>
  );
}
export default App;

上面的代码片段中发生了很多事情,让我来解释每一部分:

const initialState = {
  isAuthenticated: false,
  user: null,
  token: null,
};

上面的片段是我们对象的初始状态,将在 reducer 函数中使用。该对象中的值主要取决于您的使用场景。在我们的示例中,需要检查用户是否登录,登录之后,服务器返回的信息是否包含 user 以及 token 数据。

const reducer = (state, action) => {
  switch (action.type) {
    case "LOGIN":
      localStorage.setItem("user", JSON.stringify(action.payload.user));
      localStorage.setItem("token", JSON.stringify(action.payload.token));
      return {
        ...state,
        isAuthenticated: true,
        user: action.payload.user,
        token: action.payload.token
      };
    case "LOGOUT":
      localStorage.clear();
      return {
        ...state,
        isAuthenticated: false,
        user: null,
        token: null,
      };
    default:
      return state;
  }
};

reducer 函数包含一个 switch-case 语句,该函数将根据某些设定的动作来返回新的状态。reducer 中的动作是:

  • LOGIN - 当执行这类动作时,还将传递一些数据(包含 user 和 token )。它将 user 和 token 保存到 localStorage ,然后返回新状态(设置 isAuthenticated 为 true),并为 user 和 token 属性赋值。
  • LOGOUT - 当这个动作被执行,我们会清空 localStorage 的所有数据,并将 user 和 token 置为 null。

如果为执行任何操作,将返回初始状态。

const [state, dispatch] = React.useReducer(reducer, initialState);

useReducer 会返回两个参数, statedispatchstate 包含组件中使用的状态,并根据执行的动作进行更新。dispatch 是在应用程序中用于执行动作,修改状态的函数。

<AuthContext.Provider
      value={{
        state,
        dispatch
      }}
    >
      <Header />
      <div className="App">{!state.isAuthenticated ? <Login /> : <Home />}</div>
 </AuthContext.Provider>

Context.Provider 组件中,我们正在将一个对象传递到 value prop 中。该对象包含 state 和 dispatch 函数,因此可以由需要该上下文的任何其他组件使用。然后,我们有条件地渲染组件–如果用户通过身份验证,则渲染 Home 组件,否则渲染 Login 组件。

登录组件

首先,添加一些表单的必要组件:

import React from "react";
export const Login = () => {
return (
    <div className="login-container">
      <div className="card">
        <div className="container">
          <form>
            <h1>Login</h1>
            
            <label htmlFor="email">
              Email Address
              <input
                type="text"
                name="email"
                id="email"
              />
            </label>
            
            <label htmlFor="password">
              Password
              <input
                type="password"
                name="password"
                id="password"
              />
            </label>
            
            <button>
                "Login"
            </button>
          
          </form>
        </div>
      </div>
    </div>
  );
};
export default Login;

在上面的代码中,我们添加了显示表单的 JSX,接下来,我们将添加 useState Hook 来处理表单状态。添加 Hook 后,我们的代码展示如下:

import React from "react";
export const Login = () => {
  const initialState = {
    email: "",
    password: "",
    isSubmitting: false,
    errorMessage: null
  };
const [data, setData] = React.useState(initialState);
const handleInputChange = event => {
    setData({
      ...data,
      [event.target.name]: event.target.value
    });
  };
return (
    <div className="login-container">
      <div className="card">
        <div className="container">
          <form>
            <h1>Login</h1>

            <label htmlFor="email">
              Email Address
              <input
                type="text"
                value={data.email}
                onChange={handleInputChange}
                name="email"
                id="email"
              />
            </label>

            <label htmlFor="password">
              Password
              <input
                type="password"
                value={data.password}
                onChange={handleInputChange}
                name="password"
                id="password"
              />
            </label>

        {data.errorMessage && (
              <span className="form-error">{data.errorMessage}</span>
            )}

            <button disabled={data.isSubmitting}>
              {data.isSubmitting ? (
                "Loading..."
              ) : (
                "Login"
              )}
            </button>
          </form>
        </div>
      </div>
    </div>
  );
};
export default Login;

在上面的代码中,我们将一个 initialState 对象传递给了 useState Hook。在该对象中,我们处理电子邮件、密码的状态,一个用于检查是否正在向服务器发送数据的状态,以及服务器返回的错误值。

接下来,我们将添加一个函数,用于处理向后端 API 提交表单。在该函数中,我们将使用 fetch API 将数据发送到后端。如果请求成功,我们将执行 LOGIN 操作,并将服务器返回的数据一起传递。如果服务器返回错误(登录信息有误),我们将调用 setData 并传递来自服务器的 errorMessage ,它将显示在表单上。为了执行 dispatch 函数,我们需要将 App 组件中的 AuthContext 导入到 Login 组件中,然后 dispatch 函数就可以使用了。代码如下:

import React from "react";
import { AuthContext } from "../App";
export const Login = () => {
  const { dispatch } = React.useContext(AuthContext);
  const initialState = {
    email: "",
    password: "",
    isSubmitting: false,
    errorMessage: null
  };
const [data, setData] = React.useState(initialState);
const handleInputChange = event => {
    setData({
      ...data,
      [event.target.name]: event.target.value
    });
  };
const handleFormSubmit = event => {
    event.preventDefault();
    setData({
      ...data,
      isSubmitting: true,
      errorMessage: null
    });
    fetch("https://hookedbe.herokuapp.com/api/login", {
      method: "post",
      headers: {
        "Content-Type": "application/json"
      },
      body: JSON.stringify({
        username: data.email,
        password: data.password
      })
    })
      .then(res => {
        if (res.ok) {
          return res.json();
        }
        throw res;
      })
      .then(resJson => {
        dispatch({
            type: "LOGIN",
            payload: resJson
        })
      })
      .catch(error => {
        setData({
          ...data,
          isSubmitting: false,
          errorMessage: error.message || error.statusText
        });
      });
  };
return (
    <div className="login-container">
      <div className="card">
        <div className="container">
          <form onSubmit={handleFormSubmit}>
            <h1>Login</h1>

            <label htmlFor="email">
              Email Address
              <input
                type="text"
                value={data.email}
                onChange={handleInputChange}
                name="email"
                id="email"
              />
            </label>

            <label htmlFor="password">
              Password
              <input
                type="password"
                value={data.password}
                onChange={handleInputChange}
                name="password"
                id="password"
              />
            </label>

            {data.errorMessage && (
              <span className="form-error">{data.errorMessage}</span>
            )}

           <button disabled={data.isSubmitting}>
              {data.isSubmitting ? (
                "Loading..."
              ) : (
                "Login"
              )}
            </button>
          </form>
        </div>
      </div>
    </div>
  );
};
export default Login;

Home 组件

Home 组件将处理从服务器获取的歌曲并显示他们。由于后端需要请求的时候带上身份信息,因此我们需要找一种方法,把存贮在 App 组件中的身份信息取出来。

让我们开始构建这个组件。我们需要获取歌曲数据并映射到列表,然后使用 Card 组件来渲染每一首歌曲。Card 组件是一个简单的函数组件,它会将 props 传递给 render 函数并渲染。代码如下:

import React from "react";
export const Card = ({ song }) => {
    
  return (
    <div className="card">
      <img
        src={song.albumArt}
        alt=""
      />
      <div className="content">
        <h2>{song.name}</h2>
        <span>BY: {song.artist}</span>
      </div>
    </div>
  );
};
export default Card;

因为它不处理任何自定义逻辑,只是展示 props 中的内容,我们称它为演示组件

回到我们的 Home 组件中,当大多数应用程序在处理网络请求时,我们通常通过三个状态来实现可视化。首先,在处理请求时(展示加载中),请求成功时(展示页面,并提示成功),最后请求失败时(展示错误通知)。为了在加载组件时发出请求并同时处理这三种状态,我们将使用 useEffectuseReducer Hook。

首先我们来创建一个初始状态:

const initialState = {
  songs: [],
  isFetching: false,
  hasError: false,
};

songs 将保留从服务器检索到的歌曲列表,初始值为空。isFetching 用于表示加载状态,初始值为 false。hasError 用于表示错误状态,初始值为 false。

现在,我们可以为此组件创建 reducer,并结合 Home 组件,代码如下:

import React from "react";
import { AuthContext } from "../App";
import Card from "./Card";
const initialState = {
  songs: [],
  isFetching: false,
  hasError: false,
};
const reducer = (state, action) => {
  switch (action.type) {
    case "FETCH_SONGS_REQUEST":
      return {
        ...state,
        isFetching: true,
        hasError: false
      };
    case "FETCH_SONGS_SUCCESS":
      return {
        ...state,
        isFetching: false,
        songs: action.payload
      };
    case "FETCH_SONGS_FAILURE":
      return {
        ...state,
        hasError: true,
        isFetching: false
      };
    default:
      return state;
  }
};
export const Home = () => {
  const [state, dispatch] = React.useReducer(reducer, initialState);
return (
    <div className="home">
      {state.isFetching ? (
        <span className="loader">LOADING...</span>
      ) : state.hasError ? (
        <span className="error">AN ERROR HAS OCCURED</span>
      ) : (
        <>
          {state.songs.length > 0 &&
            state.songs.map(song => (
              <Card key={song.id.toString()} song={song} />
            ))}
        </>
      )}
    </div>
  );
};
export default Home;

reducer 函数中定义了视图的三种状态,而视图中也根据状态设置了:加载中,请求失败,请求成功三种状态。

接下来,我们需要添加 useEffect 来处理网络请求,并调用相应的 ACTION。代码如下:

import React from "react";
import { AuthContext } from "../App";
import Card from "./Card";
const initialState = {
  songs: [],
  isFetching: false,
  hasError: false,
};
const reducer = (state, action) => {
  switch (action.type) {
    case "FETCH_SONGS_REQUEST":
      return {
        ...state,
        isFetching: true,
        hasError: false
      };
    case "FETCH_SONGS_SUCCESS":
      return {
        ...state,
        isFetching: false,
        songs: action.payload
      };
    case "FETCH_SONGS_FAILURE":
      return {
        ...state,
        hasError: true,
        isFetching: false
      };
    default:
      return state;
  }
};
export const Home = () => {
  const { state: authState } = React.useContext(AuthContext);
  const [state, dispatch] = React.useReducer(reducer, initialState);
React.useEffect(() => {
    dispatch({
      type: "FETCH_SONGS_REQUEST"
    });
    fetch("https://hookedbe.herokuapp.com/api/songs", {
      headers: {
        Authorization: `Bearer ${authState.token}`
      }
    })
      .then(res => {
        if (res.ok) {
          return res.json();
        } else {
          throw res;
        }
      })
      .then(resJson => {
        console.log(resJson);
        dispatch({
          type: "FETCH_SONGS_SUCCESS",
          payload: resJson
        });
      })
      .catch(error => {
        console.log(error);
        dispatch({
          type: "FETCH_SONGS_FAILURE"
        });
      });
  }, [authState.token]);

  return (
    <React.Fragment>
    <div className="home">
      {state.isFetching ? (
        <span className="loader">LOADING...</span>
      ) : state.hasError ? (
        <span className="error">AN ERROR HAS OCCURED</span>
      ) : (
        <>
          {state.songs.length > 0 &&
            state.songs.map(song => (
              <Card key={song.id.toString()} song={song} />
            ))}
        </>
      )}
    </div>
    </React.Fragment>
  );
};
export default Home;

如果您注意到,在上面的代码中,我们使用了另一个 hook,useContext。原因是,为了从服务器获取歌曲,我们还必须传递在登录页面上提供给我们的 token。但是,我们将 token 保存于另外一个组件,所以需要使用 useContextAuthContext 中取出 token

在 useEffect 函数内部,我们首先执行 FETCH_SONGS_REQUEST 以便显示加载中的状态,然后使用 fetchAPI 发出网络请求,并将 token 放到 header 中传递。如果响应成功,我们将执行该 FETCH_SONGS_SUCCESS 动作,并将从服务器获取的歌曲列表作传递给该动作。如果服务器出现错误,我们将执行 FETCH_SONGS_FAILURE 动作,以使错误范围显示在屏幕上。

使用 useEffect hook 要注意的最后一件事,我们在 hook 的依赖项数组中传递 token。这意味着我们只会在令牌更改时调用该 hook,只有在 token 过期且我们需要获取一个新 token 或以新用户身份登录时,才会触发该 hook。因此,对于此用户,该 hook 仅被调用一次。

好的,我们已经完成所有逻辑。

本文有点长,但是它确实涵盖了使用 hook 来管理应用程序中的状态的常见用例。

你可以访问github 地址来查看代码,也可以在此基础上添加一些功能。

我也写了栗子:git地址 | 在线预览地址