React.js. Приложение для поиска фильмов
02.08.2021
Теги: Frontend • JavaScript • React.js • Web-разработка • Компонент • Поиск • Практика
Небольшое приложение для поиска фильмов, не имеет практической ценности, сделано исключительно с целью изучения React. Для оформления используем css-фреймворк materialize.css
, http-запросы будем отправлять к сервису omdbapi.com. Чтобы отправлять запросы на поиск фильмов — нужно получить api-ключ, это бесплатно. Итак, разворачиваем react-приложение.
> npx create-react-app .
Подготовительные работы
Все лишнее из директории src
удалим, оставим только index.js
, index.css
и App.js
:
import React from 'react'; import ReactDOM from 'react-dom'; import './index.css'; import App from './App'; ReactDOM.render( <React.StrictMode> <App /> </React.StrictMode>, document.getElementById('root') );
body {
margin: 0;
padding: 0;
font-family: Arial, Helvetica, sans-serif;
}
#root {
/* flex-контейнер для <header>, <main> и <footer> */
display: flex;
/* flex-элементы <header>, <main> и <footer> выстаиваются по вертикали */
flex-direction: column;
/* вся высота viewport браузера */
min-height: 100vh;
}
main {
/*
может как увеличиваться, так и уменьшаться, чтобы вместе с
<header> и <footer> занять всю высоту viewport браузера
*/
flex: 1 1 auto;
}
import Header from './layout/Header'; import Footer from './layout/Footer'; import Main from './layout/Main'; function App() { return ( <> <Header /> <Main /> <Footer /> </> ); } export default App;
Создадим директорию layout
и разместим в ней три компонента — Header.js
, Footer.js
и Main.js
.
function Header() { return ( <header> <nav className="light-green darken-3"> <div className="nav-wrapper container"> <a href="#" className="brand-logo">Movies search</a> <ul id="nav-mobile" className="right hide-on-med-and-down"> <li><a href="#">Link one</a></li> <li><a href="#">Link two</a></li> </ul> </div> </nav> </header> ); } export default Header;
function Footer() { return ( <footer className="page-footer light-green darken-3"> <div className="container"> © {new Date().getFullYear()} All rights reserved </div> </footer> ); } export default Footer;
function Main() { return ( <main className="container"> Основной контент приложения </main> ); } export default Main;
В файле public/index.html
подключим css-фреймворк:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8" /> <link rel="icon" href="%PUBLIC_URL%/favicon.ico" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="theme-color" content="#000000" /> <meta name="description" content="Search for movies and serials" /> <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" /> <link rel="manifest" href="%PUBLIC_URL%/manifest.json" /> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/css/materialize.min.css" /> <title>Movies search</title> </head> <body> <noscript>You need to enable JavaScript to run this app.</noscript> <div id="root"></div> </body> </html>
Работа с сервисом omdbapi
Для изучения API потребуется приложение Postman, его можно скачать бесплатно. Вот пример поиска фильма по слову «matrix»:
http://www.omdbapi.com/?apikey=your-api-key&s=matrix
{ "Search": [ { "Title":"The Matrix", "Year":"1999", "imdbID":"tt0133093", "Type":"movie", "Poster":"https://m.media-amazon.com/images/M/MV5BN.jpg" }, { "Title":"The Matrix Reloaded", "Year":"2003", "imdbID":"tt0234215", "Type":"movie", "Poster":"https://m.media-amazon.com/images/M/MV5BD.jpg" }, { "Title":"The Matrix Revolutions", "Year":"2003", "imdbID":"tt0242653", "Type":"movie", "Poster":"https://m.media-amazon.com/images/M/MV5FZ.jpg" }, { "Title":"The Matrix Revisited", "Year":"2001", "imdbID":"tt0295432", "Type":"movie", "Poster":"https://m.media-amazon.com/images/M/MV5BM.jpg" }, { "Title":"Enter the Matrix", "Year":"2003", "imdbID":"tt0277828", "Type":"game", "Poster":"https://m.media-amazon.com/images/M/MV5SN.jpg" }, { "Title":"The Matrix: Path of Neo" ,"Year":"2005", "imdbID":"tt0451118", "Type":"game", "Poster":"https://m.media-amazon.com/images/M/MV5BZ.jpg" }, { "Title":"A Glitch in the Matrix", "Year":"2021", "imdbID":"tt9847360", "Type":"movie", "Poster":"https://m.media-amazon.com/images/M/MV5BFX.jpg" }, { "Title":"Armitage III: Dual Matrix", "Year":"2002", "imdbID":"tt0303678", "Type":"movie", "Poster":"https://m.media-amazon.com/images/M/MV5LK.jpg" }, { "Title":"CR: Enter the Matrix", "Year":"2009", "imdbID":"tt1675286", "Type":"game", "Poster":"https://m.media-amazon.com/images/M/MV5RQ.jpg" }, { "Title":"Sex and the Matrix", "Year":"2000", "imdbID":"tt0274085", "Type":"movie", "Poster":"N/A" } ], "totalResults":"114", "Response":"True" }
Запрос подробной информации о фильме по идентификатору imdbID
:
http://www.omdbapi.com/?apikey=your-api-key&i=tt0133093&plot=full
{ "Title": "The Matrix", "Year": "1999", "Rated": "R", "Released": "31 Mar 1999", "Runtime": "136 min", "Genre": "Action, Sci-Fi", "Director": "Lana Wachowski, Lilly Wachowski", "Writer": "Lilly Wachowski, Lana Wachowski", "Actors": "Keanu Reeves, Laurence Fishburne, Carrie-Anne Moss", "Plot": "Thomas A. Anderson is a man living two lives. By day he is an average computer programmer...", "Language": "English", "Country": "United States, Australia", "Awards": "Won 4 Oscars. 42 wins & 51 nominations total", "Poster": "https://m.media-amazon.com/images/M/MV5BN.jpg", "Ratings": [ { "Source": "Internet Movie Database", "Value": "8.7/10" }, { "Source": "Rotten Tomatoes", "Value": "88%" }, { "Source": "Metacritic", "Value": "73/100" } ], "Metascore": "73", "imdbRating": "8.7", "imdbVotes": "1,721,603", "imdbID": "tt0133093", "Type": "movie", "DVD": "01 Jan 2009", "BoxOffice": "$171,479,930", "Production": "Village Roadshow Prod., Silver Pictures", "Website": "N/A", "Response": "True" }
Создание компонентов
Создадим директорию components
и разместим в ней два компонента — Movies.js
и Card.js
.
import Card from './Card' function Movies(props) { return ( <div className="movies"> {props.movies.map(movie => <Card key={movie.imdbID} {...movie} /> )} </div> ); } export default Movies;
function Card(props) { const { Title, Year, imdbID, Type, Poster } = props; return ( <div id={"movie-" + imdbID} className="card"> <div className="card-image waves-effect waves-block waves-light"> <img className="activator" src={Poster} alt="" /> </div> <div className="card-content"> <span className="card-title activator grey-text text-darken-4"> {Title} </span> <p> <span>{Year}, {Type}</span> <a href="#" className="right">Read more</a> </p> </div> </div> ); } export default Card;
Компонент Main.js
переделаем с функционального на классовый:
import React from 'react'; import Movies from '../components/Movies'; class Main extends React.Component { state = { movies: [] } componentDidMount() { fetch('http://www.omdbapi.com/?apikey=your-api-key&s=matrix') .then(response => response.json()) .then(data => this.setState({movies: data.Search})); } render() { return ( <main className="container"> {this.state.movies.length ? ( <Movies movies={this.state.movies} /> ) : ( <div className="loader">Loading...</div> )} </main> ); } } export default Main;
И добавим стили, чтобы карточки фильмов красиво выстраивались по сетке:
.movies {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 20px;
}
Хорошо, список фильмов загружается, теперь нужно поле <input>
для ввода поискового запроса. Как нетрудно догадаться, мы его оформим в виде компонента Search.js
. И добавим новый компонент в метод render()
компонента Main
.
import React from 'react'; class Search extends React.Component { state = { search: "", } render() { return ( <div className="row"> <div className="input-field col s12"> <input type="text" value={this.state.search} onChange={event => this.setState({search: event.target.value})} placeholder="For example — matrix" /> </div> </div> ); } } export default Search;
class Main extends React.Component { /* ..... */ render() { return ( <main className="container"> <Search /> {this.state.movies.length ? ( <Movies movies={this.state.movies} /> ) : ( <div className="loader">Loading...</div> )} </main> ); } }
Когда пользователь набирает поисковый запрос и нажимает клавишу Enter
— мы должны передать строку запроса наверх, в компонент Main
. А компонент Main
при получении строки запроса — выполнить запрос к сервису и показать список найденных фильмов. Чтобы передать введенный запрос наверх — нам нужна callback-функция.
class Main extends React.Component { /* ..... */ handleEnter = (search) => { if (search.trim() === "") return; search = encodeURIComponent(search); fetch(`http://www.omdbapi.com/?apikey=your-api-key&s=${search}`) .then(response => response.json()) .then(data => this.setState({movies: data.Search})); } /* ..... */ render() { return ( <main className="container"> <Search enterHandler={this.handleEnter} /> {this.state.movies.length ? ( <Movies movies={this.state.movies} /> ) : ( <div className="loader">Loading...</div> )} </main> ); } }
class Search extends React.Component { state = { search: "", } handleEnter = (event) => { if (event.key === 'Enter') { this.props.enterHandler(this.state.search); } } render() { return ( <div className="row"> <div className="input-field col s12"> <input type="text" value={this.state.search} onChange={event => this.setState({search: event.target.value})} onKeyUp={this.handleEnter} placeholder="For example — matrix" /> <button className="btn" onClick={() => this.props.enterHandler(this.state.search)}> Search </button> </div> </div> ); } }
Для удобства пользователей добавлена кнопка Search
, которая делает то же самое, что и нажатие клавиши Enter
.
Фильтрация результатов
Собственно, на этом можно было бы и закончить, но давайте еще добавим фильтрацию результатов поиска, чтобы можно было искать только фильмы или только сериалы. Сервис omdbapi.com
позволяет отправлять дополнительный параметр type
, который может принимать значения movie
или series
.
import React from 'react'; class Search extends React.Component { state = { search: '', type: 'all', } handleEnter = (event) => { if (event.key === 'Enter') { this.props.enterHandler(this.state.search, this.state.type); } } handleFilter = (event) => { this.setState({type: event.target.value}); } render() { return ( <div className="row"> <div className="input-field col s12"> <input type="text" value={this.state.search} onChange={event => this.setState({search: event.target.value})} onKeyUp={this.handleEnter} placeholder="For example — matrix" /> <button className="btn" onClick={() => this.props.enterHandler(this.state.search, this.state.type)}> Search </button> </div> <p> <label> <input type="radio" name="type" value="all" onChange={this.handleFilter} checked={this.state.type === "all"} className="with-gap" /> <span>All</span> </label> <label> <input type="radio" name="type" value="movie" onChange={this.handleFilter} checked={this.state.type === "movie"} className="with-gap" /> <span>Movies only</span> </label> <label> <input type="radio" name="type" value="series" onChange={this.handleFilter} checked={this.state.type === "series"} className="with-gap" /> <span>Series only</span> </label> </p> </div> ); } } export default Search;
Теперь мы будем передавать в метод handleEnter
компонента Main
не один, а два аргумента:
class Main extends React.Component { /* ..... */ handleEnter = (search, type) => { if (search.trim() === "") return; search = encodeURIComponent(search); let url = `http://www.omdbapi.com/?apikey=your-api-key&s=${search}`; if (type !== 'all') { url = url + `&type=${type}`; } fetch(url) .then(response => response.json()) .then(data => this.setState({movies: data.Search})); } /* ..... */ }
Запрос при изменении type
Когда пользователь выполнил поиск фильмов и сериалов и хочет отфильтровать результаты — ничего не происходит. После выбора нужной радио-кнопки нужно еще раз нажать клавишу Search
. Хорошо, если бы при выборе фильтра — выполнялся запрос на сервер с новым значением type
и показывались только фильмы или только сериалы.
class Search extends React.Component { /* ...... */ handleFilter = (event) => { this.setState({type: event.target.value}); this.props.enterHandler(this.state.search, this.state.type); } /* ..... */ }
Но тут меня поджидал неприятный сюрприз — все работало совсем не так, как ожидалось. Проблема оказалась в том, что нельзя сразу после вызова setState()
использовать новое значение состояния. В документации написано — воспринимайте setState()
как запрос на обновление состояния, а не как команду немедленного обновления. В результате, на сервер отправлялось старое значение type
и результаты казались странными: вместо фильмов — сериалы, вместо сериалов — фильмы.
class Search extends React.Component { /* ...... */ handleFilter = (event) => { this.setState( {type: event.target.value}, () => this.props.enterHandler(this.state.search, this.state.type) ); } /* ..... */ }
Метод setState()
в качестве второго аргумента может принимать callback-функцию, которая будет вызвана после того, как состояние изменится.
Если ничего не найдено
Если ничего найти не удалось, у нас вылезает ошибка «TypeError: Cannot read property length of undefined». Если посмотреть ответ сервера, то понятно — почему это происходит. В ответе просто нет поля Search
— и мы получаем undefined
, когда к нему обращаемся.
{ "Response":"False", "Error":"Movie not found!" }
Давайте это исправим. Добавим в state
компонента Main
еще одно свойство loading
. Когда пользователь нажимает Enter
, оно принимает значение true
, когда получен ответ от сервера — false
. А метод render()
в зависимости от значения loading
будет показывать либо список фильмов, либо прелоадер.
class Main extends React.Component { state = { movies: [], loading: true, } handleEnter = (search, type) => { if (search.trim() === "") return; this.setState({loading: true}) search = encodeURIComponent(search); let url = `http://www.omdbapi.com/?apikey=your-api-key&s=${search}`; if (type !== 'all') { url = url + `&type=${type}`; } fetch(url) .then(response => response.json()) .then(data => { this.setState({ movies: data.Search ? data.Search : [], loading: false }); }); } componentDidMount() { fetch('http://www.omdbapi.com/?apikey=your-api-key&s=matrix') .then(response => response.json()) .then(data => { this.setState({ movies: data.Search ? data.Search : [], loading: false }); }); } render() { return ( <main className="container"> <Search enterHandler={this.handleEnter} /> {this.state.loading ? ( <div className="loader">Loading...</div> ) : ( <Movies movies={this.state.movies} /> )} </main> ); } }
Если json-ответ от сервера не содержит поля Search
с результатами поиска — значит, по запросу ничего не найдено. И мы присваиваем свойству movies
объекта state
пустой массив вместо undefined
. Осталось только в компоненте Movies
обработать эту ситуацию.
function Movies(props) { return ( <div className="movies"> {props.movies.length ? ( props.movies.map(movie => <Card key={movie.imdbID} {...movie} /> ) ) : ( <p>Nothing found</p> )} </div> ); }
Подробная информация о фильме
В списке найденных фильмов на каждой карточке есть ссылка «Read more», при клике на нее нужно выполнить запрос к сервису omdbapi для получения подробной информации о фильме. В момент клика нужно передать идентификатор фильма из компонента Card
в компонент Main
через callback-функцию. Чтобы понимать, что нужно показывать пользователю в данный момент — добавим свойство show
в объект state
компонента Main
.
class Main extends React.Component { state = { show: 'index', // index, search, movie movies: [], // массив найденных фильмов movie: {}, // информация о фильме loading: true, // идет запрос к серверу? } // пользователь набрал поисковый запрос и нажал Enter handleEnter = (search, type) => { if (search.trim() === "") return; this.setState({ loading: true, show: 'search' }); search = encodeURIComponent(search); let url = `http://www.omdbapi.com/?apikey=your-api-key&s=${search}`; if (type !== 'all') { url = url + `&type=${type}`; } fetch(url) .then(response => response.json()) .then(data => { this.setState({ movies: data.Search ? data.Search : [], loading: false }); }); } // пользователь кликнул ссылке по «Read more» для просмотра // подробной информации по найденному фильму или сериалу handleReadMore = (id) => { this.setState({ loading: true, show: 'movie' }); fetch(`http://www.omdbapi.com/?apikey=your-api-key&i=${id}&plot=full`) .then(response => response.json()) .then(data => { this.setState({ movie: data.Title ? data : {}, loading: false }); }); } componentDidMount() { fetch('http://www.omdbapi.com/?apikey=your-api-key&s=matrix') .then(response => response.json()) .then(data => { this.setState({ movies: data.Search ? data.Search : [], loading: false }); }); } render() { return ( <main className="container"> <Search enterHandler={this.handleEnter} /> {this.state.loading ? ( <div className="loader">Загрузка...</div> ) : this.state.show === 'movie' ? ( <Movie {...this.state.movie} /> ) : ( <Movies movies={this.state.movies} readMoreHandler={this.handleReadMore} /> )} </main> ); } }
Функцию handleReadMore()
мы должны спустить вниз через пропсы компоненту Movies
, а компонент Movies
— спустить дальше, компоненту Card
. А компонент Card
должен вызвать эту callback-функцию в момент клика по ссылке «Read more».
function Movies(props) { return ( <div className="movies"> {props.movies.length ? ( props.movies.map(movie => <Card key={movie.imdbID} readMoreHandler={props.readMoreHandler} {...movie} /> ) ) : ( <p>Nothing found</p> )} </div> ); }
function Card(props) { const { Title, Year, imdbID, Type, Poster } = props; const text = Title.replace(/^a-z0-9 /i, '').replace(/\s/, '+'); return ( <div id={"movie-" + imdbID} className="card"> <div className="card-image waves-effect waves-block waves-light"> {Poster !== 'N/A' ? ( <img className="activator" src={Poster} alt="" /> ) : ( <img className="activator" src={`https://via.placeholder.com/300x430.png?text=${text}`} alt="" /> )} </div> <div className="card-content"> <span className="card-title activator grey-text text-darken-4"> {Title} </span> <p> <span>{Year}, {Type}</span> <a href="#" className="right" onClick={(event) => { event.preventDefault(); props.readMoreHandler(imdbID) }} >Read more</a> </p> </div> </div> ); }
За показ информации об отдельном фильме будет отвечать компонент Movie
:
function Movie(props) { if (!props.Title) { return <p>Movie not found</p> } const { Title, Year, Runtime, Genre, Actors, Plot, Poster } = props; const text = Title.replace(/^a-z0-9 /i, '').replace(/\s/, '+'); return ( <div className="row"> <div className="col s12"> <h1 style={{marginTop: 0}}>{Title}</h1> </div> <div className="col s5"> {Poster !== 'N/A' ? ( <img className="responsive-img" src={Poster} alt="" /> ) : ( <img className="responsive-img" src={`https://via.placeholder.com/300x430.png?text=${text}`} alt="" /> )} </div> <div className="col s7"> <ul style={{marginTop: 0}}> <li>Year: {Year}</li> <li>Runtime: {Runtime}</li> <li>Genre: {Genre}</li> <li>Actors: {Actors}</li> </ul> <p>{Plot}</p> </div> </div> ); }
Вместо заключения
Сейчас изучаю хуки, так что переписал два классовых компонента Main
и Search
на функциональные.
import React, {useState, useEffect} from 'react'; import Movies from '../components/Movies'; import Search from '../components/Search'; import Movie from '../components/Movie'; function Main() { const [show, setShow] = useState('index'); const [movies, setMovies] = useState([]); const [movie, setMovie] = useState({}); const [loading, setLoading] = useState(true); useEffect(() => { fetch('http://www.omdbapi.com/?apikey=your-api-key&s=matrix') .then(response => response.json()) .then(data => { setMovies(data.Search ? data.Search : []); setLoading(false); }); }, []); // сработает только при монтировании // пользователь набрал поисковый запрос и нажал Enter const handleEnter = (search, type) => { if (search.trim() === "") return; setLoading(true); setShow('search'); search = encodeURIComponent(search); let url = `http://www.omdbapi.com/?apikey=your-api-key&s=${search}`; if (type !== 'all') { url = url + `&type=${type}`; } fetch(url) .then(response => response.json()) .then(data => { setMovies(data.Search ? data.Search : []); setLoading(false); }); } // пользователь кликнул ссылке по «Read more» для просмотра // подробной информации по найденному фильму или сериалу const handleReadMore = (id) => { setLoading(true); setShow('movie'); fetch(`http://www.omdbapi.com/?apikey=your-api-key&i=${id}&plot=full`) .then(response => response.json()) .then(data => { setMovie(data.Title ? data : {}); setLoading(false); }); } return ( <main className="container"> <Search enterHandler={handleEnter} /> {loading ? ( <div className="loader">Загрузка...</div> ) : show === 'movie' ? ( <Movie {...movie} /> ) : ( <Movies movies={movies} readMoreHandler={handleReadMore} /> )} </main> ); } export default Main;
import React, {useState} from 'react'; function Search(props) { const [search, setSearch] = useState(''); const [type, setType] = useState('all'); const handleEnter = (event) => { if (event.key === 'Enter') { props.enterHandler(search, type); } } const handleFilter = (event) => { setType(event.target.value); // здесь мы не можем передать вторым аргументом type, потому что значение // type не изменяется мгновенно, а callback-функция, как в setState() для // классового компонента, здесь не предусмотрена props.enterHandler(search, event.target.value); } return ( <div className="row"> <div className="input-field col s12"> <input type="text" value={search} onChange={event => setSearch(event.target.value)} onKeyUp={handleEnter} placeholder="For example — matrix" /> <button className="btn" onClick={() => props.enterHandler(search, type)}> Search </button> </div> <p> <label> <input type="radio" name="type" value="all" onChange={handleFilter} checked={type === "all"} className="with-gap" /> <span>All</span> </label> <label> <input type="radio" name="type" value="movie" onChange={handleFilter} checked={type === "movie"} className="with-gap" /> <span>Movies only</span> </label> <label> <input type="radio" name="type" value="series" onChange={handleFilter} checked={type === "series"} className="with-gap" /> <span>Series only</span> </label> </p> </div> ); } export default Search;
Исходные коды здесь, демо-сайт здесь.
Поиск: JavaScript • React.js • Web-разработка • Frontend • Компонент • Поиск • Практика