vortesnail / blog

:blue_book: 个人技术小文章,旨在对知识的总结,能帮助到别人就更好啦。
551 stars 45 forks source link

[译]使用 React Hooks 构建电影搜索应用程序 #10

Open vortesnail opened 5 years ago

vortesnail commented 5 years ago

[译]使用 React Hooks 构建电影搜索应用程序

前言:

在这篇文章中,我们将使用 React Hooks 构建一个非常简单的应用程序。因此,我们不会在此应用程序中使用任何class 组件。 我将解释一些API的工作原理,以便于使你能在构建其它应用程序时能更得心应手地使用 React Hooks。

以下是完成这个应用程序之后的页面截图:

image.png
我知道,这名字看起来很有创造性...

基本上,该程序可通过 OMDB API 来搜索电影并将结果返回给我们。构建此应用程序的目的在于使我们更加理解 React Hooks 并且助你在自己开发的项目中更好地使用它,那么,我们开始吧!在此之前,你需要做一些事情:

开始构建

创建 React app

这个教程将会使用 react 脚手架工具 create-react-app 来构建我们的应用,如果你还没有安装这个脚手架工具,在终端执行以下命令:

npm install -g create-react-app

接下来,创建我们的 React app,在终端输入以下命令:

create-react-app hooked

"hooked" 是我们创建的 app 的名字

完成后,我们应该有一个名为 “Hooked” 的文件夹,其目录结构如下所示:

image.png
初始化的项目结构

创建所需组件

此应用程序中包含4个组件,我来概述下每个组件及其功能:

让我们开始创建它们吧,在 src 目录下,创建一个新文件夹命名为 components ,这个文件夹存放我们所有的组件,将 App.js 文件拖进去。接着,我们创建一个新的文件命名为 Header.js ,并输入以下代码:

import React from 'react';

const Header = (props) => {
  return (
    <header className="App-header">
      <h2>{props.text}</h2>
    </header>
  )
}

export default Header;

这个组件不需要太多的解释,就是一个很基本的组件,接受 props ,并将 props.text 渲染为页面标题。

别忘记更新我们的 index.js 文件:

import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './components/App';    // 嘿,看这里,这里变化了
import * as serviceWorker from './serviceWorker';

ReactDOM.render(<App />, document.getElementById('root'));

// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorker.unregister();

现在你执行 npm run start 必然是成功不了的,一是我们 App.js 路径变了,引入 App.css 的路径也没改,而且许多默认构建的元素如 logo.svg 路径都没变,我们现在先不急,等我们把组件都写好之后,在集中修改 App.js 。

接下来在 components 下继续创建一个新的组件 Movie.js ,添加以下代码:

import React from "react";

const DEFAULT_PLACEHOLDER_IMAGE =
  "https://m.media-amazon.com/images/M/MV5BMTczNTI2ODUwOF5BMl5BanBnXkFtZTcwMTU0NTIzMw@@._V1_SX300.jpg";

const Movie = ({ movie }) => {
  const poster =
    movie.Poster === "N/A" ? DEFAULT_PLACEHOLDER_IMAGE : movie.Poster;
  return (
    <div className="movie">
      <h2>{movie.Title}</h2>
      <div>
        <img
          width="200"
          alt={`The movie titled: ${movie.Title}`}
          src={poster}
        />
      </div>
      <p>({movie.Year})</p>
    </div>
  );
}

export default Movie;

这就需要解释一下啦~ 但它也只是一个无状态组件(没有任何内部状态),用于呈现电影的标题,图像和年份。之所以使用 DEFAULT_PLACEHOLDER_IMAGE ,是因为从 API 检索的某些电影没有图片,因此我们以一个自己预设好的图片作为替换,而不是一个断开的链接,这对用户很不友好。

现在我们来创建组件 Search.js ,这部分很令人激动,因为在过去,为了处理内部状态,我们不得不创建一个 class 组件... 但是现在不用了!因为有了 hooks ,我们现在可以创建一个普通的函数就能处理内部状态,就问你厉不厉害。在文件夹 components 下创建文件 Search.js ,添加以下代码:

import React, { useState } from "react";

const Search = (props) => {
  const [searchValue, setSearchValue] = useState('');

  const handleSearchInputChanges = (e) => {
    setSearchValue(e.target.value);
  }

  const resetInputField = () => {
    setSearchValue("")
  }

  const callSearchFunction = (e) => {
    e.preventDefault();
    props.search(searchValue);
    resetInputField();
  }

  return (
    <form className="search">
      <input
        value={searchValue}
        onChange={handleSearchInputChanges}
        type="text"
      />
      <input onClick={callSearchFunction} type="submit" value="SEARCH" />
    </form>
  );
}

export default Search;

这真的太酷了,你不用像以前一样在 class 组件中的 constructor 中创建状态,利用 setState 更新状态,以及繁琐的 .bind(this) 。我相信你已经看过了我们使用的 useState ,顾名思义,它使我们可以将 React 状态添加到普通函数组件中。 useState 接受一个参数,该参数是初始状态,然后返回一个包含当前状态(等同于类组件的 this.state )和更新它的函数(等同于 this.setState )的数组。

在本例中,我们将当前状态作为搜索输入字段的值。 因为注册了 onChange 事件,在输入改变时,将调用 handleSearchInputChanges 函数,该函数使用新的输入值去更新当前状态。 resetInputField 函数就是重置输入框的值为空字符串。 点我了解更多 useState API 信息。

最后,我们来解决我们之前留下的坑,更新 App.js :

import React, { useState, useEffect } from "react";
import "../App.css";
import Header from "./Header";
import Movie from "./Movie";
import Search from "./Search";

// 下面这个地址你需要替换为你自己的
// 你用浏览器打开这个网址试试,看看是什么?
const MOVIE_API_URL = "https://www.omdbapi.com/?s=man&apikey=4a3b711b";

const App = () => {
  const [loading, setLoading] = useState(true);
  const [movies, setMovies] = useState([]);
  const [errorMessage, setErrorMessage] = useState(null);

  useEffect(() => {
    fetch(MOVIE_API_URL)
      .then(response => response.json())
      .then(jsonResponse => {
        setMovies(jsonResponse.Search);
        setLoading(false);
      });
  }, []);

  const search = searchValue => {
    setLoading(true);
    setErrorMessage(null);

    fetch(`https://www.omdbapi.com/?s=${searchValue}&apikey=4a3b711b`)
      .then(response => response.json())
      .then(jsonResponse => {
        if (jsonResponse.Response === "True") {
          setMovies(jsonResponse.Search);
          setLoading(false);
        } else {
          setErrorMessage(jsonResponse.Error);
          setLoading(false);
        }
      });
  };

  return (
    <div className="App">
      <Header text="HOOKED" />
      <Search search={search} />
      <p className="App-intro">分享一些为喜欢的电影</p>
      <div className="movies">
        {
          loading && !errorMessage ? (
            <span>loading...</span>
          ) : errorMessage ? (
            <div className="errorMessage">{errorMessage}</div>
          ) : (
                movies.map((movie, index) => (
                  <Movie key={`${index}-${movie.Title}`} movie={movie} />
                ))
              )
        }
      </div>
    </div>
  );
}

export default App;

让我仔细研究下上面的代码:我们使用了3个 useState 函数,是的,我们可以在一个组件中写多个 useState 函数,第一个用于处理加载状态(将loading设置为true时,它会呈现“ loading…”文本)。第二个用于处理从服务器获取的电影数组。 第三个用于处理发出API请求时可能发生的任何错误。

之后,我们遇到了应用程序中使用的第二个钩子 API: useEffect 钩子。 该钩子可以在功能组件中执行副作用。 所谓副作用,是指诸如数据获取,订阅和手动 DOM 操作之类的事情。 关于这个钩子的最好的部分是 React 官方文档中的这句话:

useEffect 就是一个 Effect Hook,给函数组件增加了操作副作用的能力。它跟 class 组件中的 componentDidMountcomponentDidUpdate 和 componentWillUnmount 具有相同的用途,只不过被合并成了一个 API。

其实就是说, useEffect 在首次渲染(componentDidMount)以及之后每次更新(componentDidUpdate)都被调用。

我知道你可能想知道如果每次更新后都调用它,那与 componentDidMount 有何相似之处呢? Emmm..,这是因为 useEffect 函数接受两个参数,一个是你要运行的函数,另一个是数组,你仔细看看官方文档的代码或上面我们自己写的代码。 在该数组中,我们只传入一个值,该值告诉 React 如果传入的值没有被更改,则跳过此次调用。

根据文档,这类似于我们在 componentDidUpdate 中添加条件语句时的情况:

// class 组件
componentDidUpdate(prevProps, prevState) {
  if (prevState.count !== this.state.count) {
    document.title = `You clicked ${this.state.count} times`;
  }
}

// 使用 hooks 的函数组件
useEffect(() => {
  document.title = `You clicked ${count} times`;
}, [count]); // 只有 count 值变了,才会重新执行

在我们的例子中,我们没有任何变化的值,因此我们可以传入一个空数组,该数组告诉 React 这个效果应该被调用一次。

如你所见,我们有3个 useState 函数,它们看起来有相关性,应该有可能将它们组合在一起。 为了做到这点,React 团队已经为我们想到了,于是他们制作了一个有助于此操作的钩子 - 该钩子称为 useReducer 。 让我们将App组件转换为使用 useReducer 的新组件,这样我们的 App.js 现在将如下所示:

import React, { useEffect, useReducer } from "react";
import "../App.css";
import Header from "./Header";
import Movie from "./Movie";
import Search from "./Search";

// 下面这个地址你需要替换为你自己的
const MOVIE_API_URL = "https://www.omdbapi.com/?s=man&apikey=4a3b711b";

const initialState = {
  loading: true,
  movies: [],
  errorMessage: null
}

const reducer = (state, action) => {
  switch (action.type) {
    case "SEARCH_MOVIES_REQUEST":
      return {
        ...state,
        loading: true,
        errorMessage: null
      };
    case "SEARCH_MOVIES_SUCCESS":
      return {
        ...state,
        loading: false,
        movies: action.payload
      };
    case "SEARCH_MOVIES_FAILURE":
      return {
        ...state,
        loading: false,
        errorMessage: action.error
      };
    default:
      return state;
  }
}

const App = () => {
  const [state, dispatch] = useReducer(reducer, initialState);

  useEffect(() => {
    fetch(MOVIE_API_URL)
      .then(response => response.json())
      .then(jsonResponse => {
        dispatch({
          type: "SEARCH_MOVIES_SUCCESS",
          payload: jsonResponse.Search
        });
      });
  }, []);

  const search = searchValue => {
    dispatch({
      type: "SEARCH_MOVIES_REQUEST"
    });
    fetch(`https://www.omdbapi.com/?s=${searchValue}&apikey=4a3b711b`)
      .then(response => response.json())
      .then(jsonResponse => {
        if (jsonResponse.Response === "True") {
          dispatch({
            type: "SEARCH_MOVIES_SUCCESS",
            payload: jsonResponse.Search
          });
        } else {
          dispatch({
            type: "SEARCH_MOVIES_FAILURE",
            error: jsonResponse.Error
          });
        }
      });
  };

  const { movies, errorMessage, loading } = state;

  return (
    <div className="App">
      <Header text="HOOKED" />
      <Search search={search} />
      <p className="App-intro">分享一些为喜欢的电影</p>
      <div className="movies">
        {
          loading && !errorMessage ? (
            <span>loading...</span>
          ) : errorMessage ? (
            <div className="errorMessage">{errorMessage}</div>
          ) : (
                movies.map((movie, index) => (
                  <Movie key={`${index}-${movie.Title}`} movie={movie} />
                ))
              )
        }
      </div>
    </div>
  );
}

export default App;

如果一切顺利,那么我们应该不会看到应用程序与之前相比有任何变化。 现在让我们来看一下 useReducer 挂钩的工作方式。

该 hook 接受3个参数,但在我们的用例中,我们将仅使用2个。典型的 useReducer 钩子如下所示:

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

reducer 参数类似于我们在 Redux 中使用的参数,看起来像这样:

const reducer = (state, action) => {
  switch (action.type) {
    case "SEARCH_MOVIES_REQUEST":
      return {
        ...state,
        loading: true,
        errorMessage: null
      };
    case "SEARCH_MOVIES_SUCCESS":
      return {
        ...state,
        loading: false,
        movies: action.payload
      };
    case "SEARCH_MOVIES_FAILURE":
      return {
        ...state,
        loading: false,
        errorMessage: action.error
      };
    default:
      return state;
  }
}

reducer 接收 initialState 和 action ,因此 reducer 根据 action.type 返回一个新的状态对象。 例如,如果调度的操作类型为 SEARCH_MOVIES_REQUEST ,则状态将使用新对象更新,其中 loading 的值为 true ,而 errorMessage 为 null。

值得一提的是,在搜索功能中,我们实际上是在分派三个不同的动作:

要了解有关 useReducer 钩子的更多信息,请查看官方文档

最后修改我们的 App.css (这部分不是重点,直接把现在所需的样式全给你们了,作为参考):

.App {
  text-align: center;
}

.App-header {
  background-color: #282c34;
  height: 70px;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  font-size: calc(10px + 2vmin);
  color: white;
  padding: 20px;
  cursor: pointer;
}

.spinner {
  height: 80px;
  margin: auto;
}

.App-intro {
  font-size: large;
}

/* new css for movie component */

* {
  box-sizing: border-box;
}

.movies {
  display: flex;
  flex-wrap: wrap;
  flex-direction: row;
}

.App-header h2 {
  margin: 0;
}

.add-movies {
  text-align: center;
}

.add-movies button {
  font-size: 16px;
  padding: 8px;
  margin: 0 10px 30px 10px;
}

.movie {
  padding: 5px 25px 10px 25px;
  max-width: 25%;
}

.errorMessage {
  margin: auto;
  font-weight: bold;
  color: rgb(161, 15, 15);
}

.search {
  display: flex;
  flex-direction: row;
  flex-wrap: wrap;
  justify-content: center;
  margin-top: 10px;
}

input[type="submit"] {
  padding: 5px;
  background-color: transparent;
  color: black;
  border: 1px solid black;
  width: 80px;
  margin-left: 5px;
  cursor: pointer;
}

input[type="submit"]:hover {
  background-color: #282c34;
  color: antiquewhite;
}

.search > input[type="text"]{
  width: 40%;
  min-width: 170px;
}

@media screen and (min-width: 694px) and (max-width: 915px) {
  .movie {
    max-width: 33%;
  }
}

@media screen and (min-width: 652px) and (max-width: 693px) {
  .movie {
    max-width: 50%;
  }
}

@media screen and (max-width: 651px) {
  .movie {
    max-width: 100%;
    margin: auto;
  }
}

你做到了!

哇!!! 我们已经走了很长一段路,我相信你对 hooks 的可能性感到兴奋。 就我个人而言,将初学者介绍给 React 非常容易,因为我不再需要解释 class 的工作方式或 this 的工作方式,或者在JS中 bind 的工作方式。

在本教程中,我们仅涉及了一些钩子,甚至没有介绍创建自己的自定义钩子等功能。 如果您还有其他用钩的用例,或者已经实现了自己的自定义钩,请添加评论并加入其中。

这篇文章的代码就不提供了,我希望任何能安心看下来的小伙伴能手动敲一边,收获还是有的!~

后记:

因为笔者也是刚刚学 React hooks,在掘金上另一篇文章中看到了推荐这个项目,自己读了一遍,做了一遍,发现作为入门还是不错的,故想翻译一下让更多学习 React hooks 的小伙伴能学习到~若是翻译有误,还请指正,谢谢啦🙏。

这篇文章收录于我自己的Github/blog,若对你有所帮助,欢迎 star,之后会陆续推出更多基础优质文章~