React.js. Приложение каталога рецептов
27.08.2021
Теги: API • Frontend • JavaScript • Web-разработка • Компонент • Практика
Небольшое приложение — каталог рецептов блюд всего мира, не имеет практической ценности, сделано исключительно с целью изучения React. Для оформления используется css-фреймворк materialize.css
, http-запросы на получение рецептов отправляются сервису themealdb.com
. Итак, разворачиваем 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 {
padding-top: 20px;
/*
* может как увеличиваться, так и уменьшаться, чтобы вместе с
* <header> и <footer> занять всю высоту viewport браузера
*/
flex: 1 1 auto;
}
.page-footer {
padding-top: 10px;
padding-bottom: 10px;
}
nav {
box-shadow: none;
}
import {BrowserRouter as Router} from 'react-router-dom'; import Header from './layout/Header'; import Footer from './layout/Footer'; import Content from './layout/Content'; export default function App() { return ( <Router> <Header /> <Content /> <Footer /> </Router> ); }
Создадим директорию src/layout
и разместим в ней три компонента — Header.js
, Footer.js
и Content.js
.
import {Link} from 'react-router-dom'; export default function Header(props) { return ( <header> <nav className="teal lighten-1"> <div className="nav-wrapper container"> <Link to="/" className="brand-logo">Meal</Link> <ul id="nav-mobile" className="right hide-on-med-and-down"> <li><Link to="/about">About</Link></li> <li><Link to="/contact">Contact</Link></li> </ul> </div> </nav> </header> ); }
export default function Footer() { return ( <footer className="page-footer teal lighten-1"> <div className="container"> © {new Date().getFullYear()} All rights reserved </div> </footer> ); }
import {Route, Switch} from 'react-router-dom'; import Catalog from '../catalog/Catalog'; import About from '../pages/About'; import Contact from '../pages/Contact'; import NotFound from '../components/NotFound'; export default function Content() { return ( <main className="container"> <Switch> <Route exact path="/" component={Catalog} /> <Route exact path="/about" component={About} /> <Route exact path="/contact" component={Contact} /> <Route component={NotFound} /> </Switch> </main> ); }
На главной странице будет каталог рецептов, это компонент Catalog.js
в директории src/catalog
:
export default function Catalog() { return <h1>Categories</h1> }
Компоненты статичных страниц About.js
и Contact.js
разместим в директории src/pages
:
export default function About() { return ( <> <h1>About page</h1> <p> Lorem ipsum dolor sit amet consectetur adipisicing elit. Non dolore maiores, adipisci, iste culpa consectetur corporis deserunt ratione amet, magnam quas. Non, cum quos doloremque consequuntur incidunt eveniet corrupti, et commodi temporibus fugit modi similique hic aut voluptas officia placeat numquam! Dicta eveniet, autem id veritatis. </p> </> ); }
export default function Contact() { return ( <> <h1>Contact page</h1> <p> Lorem ipsum dolor sit amet consectetur adipisicing elit. Laborum temporibus voluptatum laboriosam nam? Dolore optio autem eveniet culpa reprehenderit architecto blanditiis pariatur cum nihil, voluptate sit atque, commodi fuga molestiae officia velit repudiandae aliquam unde nisi quia laborum mollitia, voluptatem quo saepe. Sed rem, recusandae vero error facilis quod mollitia. </p> </> ); }
Кроме того, создадим директорию src/components
для вспомогательных компонентов:
export default function NotFound() { return <h1>Page not found</h1> }
export default function Preloader() { return ( <div className="progress"> <div className="indeterminate"></div> </div> ); }
В файле public/index.html
подключим css-фреймворк и иконки:
<!DOCTYPE html> <html lang="ru"> <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="Meal from all over the world" /> <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" /> <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet"> <title>Meal</title> </head> <body> <noscript>You need to enable JavaScript to run this app.</noscript> <div id="root"></div> </body> </html>
Работа с сервисом themealdb.com
Для изучения API потребуется установить расширение REST Client для VS Code (см. здесь). Создаем файл api.http
:
### Все категории GET www.themealdb.com/api/json/v1/1/categories.php ### Рецепты категории www.themealdb.com/api/json/v1/1/filter.php?c=Seafood ### Отдельный рецепт www.themealdb.com/api/json/v1/1/lookup.php?i=53050
Ответ на запрос на получение всех категорий:
{ "categories":[ { "idCategory": "1", "strCategory": "Beef", "strCategoryThumb": "https://www.themealdb.com/images/category/beef.png", "strCategoryDescription": "Beef is the culinary name for meat from cattle, particularly skeletal muscle..." }, .......... ] }
Ответ на запрос на получение рецептов категории:
{ "meals": [ { "strMeal":"Baked salmon with fennel & tomatoes", "strMealThumb":"https://www.themealdb.com/images/media/meals/1548772327.jpg", "idMeal":"52959" }, .......... ] }
Ответ на запрос на получение отдельного рецепта:
{ "meals": [ { "idMeal": "53050", "strMeal": "Ayam Percik", "strDrinkAlternate": null, "strCategory": "Chicken", "strArea": "Malaysian", "strInstructions": "In a blender, add the ingredients for the spice paste and blend until smooth....", "strMealThumb": "https://www.themealdb.com/images/media/meals/020z181619788503.jpg", "strTags": null, "strYoutube": "https://www.youtube.com/watch?v=9ytR28QK6I8", "strIngredient1": "Chicken Thighs", "strIngredient2": "Challots", "strIngredient3": "Ginger", "strIngredient4": "Garlic Clove", "strIngredient5": "Cayenne Pepper", "strIngredient6": "Turmeric", "strIngredient7": "Cumin", "strIngredient8": "Coriander", "strIngredient9": "Fennel", "strIngredient10": "Tamarind Paste", "strIngredient11": "Coconut Milk", "strIngredient12": "Sugar", "strIngredient13": "Water", "strIngredient14": "", "strIngredient15": "", "strIngredient16": "", "strIngredient17": "", "strIngredient18": "", "strIngredient19": "", "strIngredient20": "", "strMeasure1": "6", "strMeasure2": "16", "strMeasure3": "1 1/2 ", "strMeasure4": "6", "strMeasure5": "8", "strMeasure6": "2 tbs", "strMeasure7": "1 1/2", "strMeasure8": "1 1/2", "strMeasure9": "1 1/2 ", "strMeasure10": "2 tbs", "strMeasure11": "1 can", "strMeasure12": "1 tsp", "strMeasure13": "1 cup", "strMeasure14": "", "strMeasure15": "", "strMeasure16": "", "strMeasure17": "", "strMeasure18": "", "strMeasure19": "", "strMeasure20": "", "strSource": "http://www.curiousnut.com/roasted-spiced-chicken-ayam-percik/", "strImageSource": null, "strCreativeCommonsConfirmed": null, "dateModified": null } ] }
Ключ API и URL, на который будем выполнять запросы, сохраним в отдельный файл config.js
:
const API_KEY = '1'; export const API_URL = `https://www.themealdb.com/api/json/v1/${API_KEY}/`;
Функции для выполнения запросов
Функции, которые будут выполнять запросы к сервису themealdb.com
, разместим в отдельном файле api.js
.
import {API_URL} from './config'; // получить все категории const getAllCategories = async () => { const response = await fetch(API_URL + 'categories.php'); return await response.json(); }; // получить рецепты категории const getCategoryRecipes = async (name) => { const response = await fetch(API_URL + 'filter.php?c=' + name); return await response.json(); }; // получить один рецепт по id const getOneRecipe = async (id) => { const response = await fetch(API_URL + 'lookup.php?i=' + id); return await response.json(); }; export {getAllCategories, getCategoryRecipes, getOneRecipe};
Дорабатываем компонент Catalog
Компонент Catalog
должен получить от сервиса themealdb.com
список категорий и сохранить их в состоянии. До тех пор, пока список не получен — показывается компонент Preloader
.
import {useState, useEffect} from 'react'; import {getAllCategories} from '../api'; import Preloader from '../components/Preloader'; import CategoryList from './CategoryList'; export default function Catalog() { const [categories, setCategories] = useState([]); const [loading, setLoading] = useState(true); useEffect(() => { getAllCategories().then(data => { data.categories && setCategories(data.categories); setLoading(false); }); }, []); return ( <> <h1>Categories</h1> {loading ? ( <Preloader /> ) : categories.length ? ( <CategoryList items={categories} /> ) : ( <p>Не удалось загрузить список</p> )} </> ); }
Компоненты для показа списка категорий
Создаем компонент CategoryList
, который будет отвечать за показ списка категорий и компонент CategoryCard
, который будет отвечать за показ элемента списка.
import CategoryCard from './CategoryCard.js'; export default function CategoryList(props) { return ( <div className="items"> {props.items.map(item => <CategoryCard key={item.idCategory} {...item} />)} </div> ); }
import {Link} from 'react-router-dom'; export default function CategoryCard(props) { const { strCategory, strCategoryThumb, strCategoryDescription } = props; return ( <div className="card"> <div className="card-image waves-effect waves-block waves-light"> <img className="activator" src={strCategoryThumb} alt="" /> </div> <div className="card-content"> <span className="card-title activator grey-text text-darken-4"> {strCategory} </span> <p>{strCategoryDescription.slice(0, 90)}...</p> </div> <div className="card-action"> <Link to={`/category/${strCategory}`} className="btn-small"> View recipes </Link> </div> </div> ); }
Дорабатываем компонент Content
Кроме показа списка категорий нам еще нужно показывать отдельную категорию (компонент CategoryItem
) и отдельный рецепт (компонент RecipeItem
):
import {Route, Switch} from 'react-router-dom'; import Catalog from '../catalog/Catalog'; import About from '../pages/About'; import Contact from '../pages/Contact'; import NotFound from '../components/NotFound'; import CategoryItem from '../catalog/CategoryItem'; import RecipeItem from '../catalog/RecipeItem'; export default function Content() { return ( <main className="container"> <Switch> <Route exact path="/" component={Catalog} /> <Route exact path="/about" component={About} /> <Route exact path="/contact" component={Contact} /> <Route exact path="/category/:name" component={CategoryItem} /> <Route exact path="/recipe/:id(\d+)" component={RecipeItem} /> <Route component={NotFound} /> </Switch> </main> ); }
Создаем компонент CategoryItem
Компонент CategoryItem
должен получить от сервиса themealdb.com
список рецептов категории и сохранить их в состоянии. До тех пор, пока список не получен — показывается компонент Preloader
.
import {useState, useEffect} from 'react'; import {useParams, useHistory} from 'react-router-dom'; import {getCategoryRecipes} from '../api'; import Preloader from '../components/Preloader'; import RecipeList from './RecipeList'; export default function CategoryItem(props) { const params = useParams(); const history = useHistory(); const [recipes, setRecipes] = useState([]); const [loading, setLoading] = useState(true); useEffect(() => { getCategoryRecipes(params.name).then(data => { data.meals && setRecipes(data.meals); setLoading(false); }); }, [params.name]); return ( <> {loading ? ( <Preloader /> ) : recipes.length ? ( <> <h1>{params.name}</h1> <button className="btn" onClick={history.goBack}>Go Back</button> <RecipeList items={recipes} /> </> ) : ( <p>Не удалось загрузить список</p> )} </> ); }
Компоненты для показа списка рецептов
Создаем компонент RecipeList
, который будет отвечать за показ списка рецептов категории и компонент RecipeCard
, который будет отвечать за показ элемента списка.
import RecipeCard from './RecipeCard.js'; export default function RecipeList(props) { return ( <div className="items"> {props.items.map(item => <RecipeCard key={item.idMeal} {...item} />)} </div> ); }
import {Link} from 'react-router-dom'; export default function RecipeCard(props) { const { idMeal, strMeal, strMealThumb } = props; return ( <div className="card"> <div className="card-image waves-effect waves-block waves-light"> <img className="activator" src={strMealThumb} alt="" /> </div> <div className="card-content"> <span className="card-title activator grey-text text-darken-4"> {strMeal} </span> </div> <div className="card-action"> <Link to={`/recipe/${idMeal}`} className="btn-small"> View recipe </Link> </div> </div> ); }
Создаем компонент RecipeItem
Компонент RecipeItem
должен получить от сервиса themealdb.com
рецепт по идентификатору и сохранить его в состоянии. До тех пор, пока рецепт не получен — показывается компонент Preloader
.
import {useState, useEffect} from 'react'; import {useParams, useHistory} from 'react-router-dom'; import {getOneRecipe} from '../api'; import Preloader from '../components/Preloader'; export default function RecipeItem(props) { const params = useParams(); const history = useHistory(); const [recipe, setRecipe] = useState({}); const [loading, setLoading] = useState(true); useEffect(() => { getOneRecipe(params.id).then(data => { data.meals[0].idMeal && setRecipe(data.meals[0]); setLoading(false); }); }, [params.id]); return ( <> {loading ? ( <Preloader /> ) : recipe.idMeal ? ( <> <h1>{recipe.strMeal}</h1> <button className="btn" onClick={history.goBack}>Go Back</button> <img src={recipe.strMealThumb} alt="" /> <p>Category: {recipe.strCategory}</p> {recipe.strArea !== 'Unknown' && <p>Area: {recipe.strArea}</p>} <p>{recipe.strInstructions}</p> </> ) : ( <p>Не удалось загрузить рецепт</p> )} </> ); }
Добавляем описание категории
Плохо, что на странице категории мы не можем показать описание категории. Потому что описание есть в ответе на получение списка категорий, но нет в ответе на получение списка рецептов категории. Давайте будем сохранять список категорий в контексте, чтобы компонент CategoryItem
мог из него получить описание категории.
import {createContext, useState, useEffect} from 'react'; import {getAllCategories} from '../api'; const CatalogContext = createContext(); const CatalogContextProvider = (props) => { // здесь будем хранить все категории, после того, как их получим const [categories, setCategories] = useState([]); // при монтрировании будет выполнен запрос к сервису на получение // категорий; до того, как категории получены, будем показывать // компонент Preloader; после получения изменяем loading на false, // чтобы вызвать повторный рендер и показать полученные категории const [loading, setLoading] = useState(true); useEffect(() => { getAllCategories().then(data => { data.categories && setCategories(data.categories); setLoading(false); }); }, []); const getCategory = (name) => categories.find(item => item.strCategory === name); const value = { categories: categories, loading: loading, getCategory: getCategory, }; return ( <CatalogContext.Provider value={value}> {props.children} </CatalogContext.Provider> ); } export {CatalogContext, CatalogContextProvider};
import {BrowserRouter as Router} from 'react-router-dom'; import Header from './layout/Header'; import Footer from './layout/Footer'; import Content from './layout/Content'; import {CatalogContextProvider} from './catalog/Context'; export default function App() { return ( <Router> <Header /> <CatalogContextProvider> <Content /> </CatalogContextProvider> <Footer /> </Router> ); }
import {useState, useEffect, useContext} from 'react'; import {useParams, useHistory} from 'react-router-dom'; import {getCategoryRecipes} from '../api'; import {CatalogContext} from './Context'; import Preloader from '../components/Preloader'; import RecipeList from './RecipeList'; export default function CategoryItem(props) { const params = useParams(); const history = useHistory(); const [recipes, setRecipes] = useState([]); const [loading, setLoading] = useState(true); const { loading: loadingContext, getCategory: getCategoryContext } = useContext(CatalogContext); useEffect(() => { getCategoryRecipes(params.name).then(data => { data.meals && setRecipes(data.meals); setLoading(false); }); }, [params.name]); const category = getCategoryContext(params.name); return ( <> {loading || loadingContext ? ( <Preloader /> ) : recipes.length ? ( <> <h1>{params.name}</h1> <button className="btn" onClick={history.goBack}>Go Back</button> {category && <p>{category.strCategoryDescription}</p>} <RecipeList items={recipes} /> </> ) : ( <p>Не удалось загрузить список</p> )} </> ); }
Кэшируем результаты запросов
Мы можем сохранять в контексте не только список категорий, но и список рецептов категории — после того, как получим этот список от сервиса themealdb.com
. Если пользователь просмотрел список рецептов трех категорий, а потом решил просмотреть эти списки повторно — нам не нужно будет еще раз обращаться к сервису, чтобы получить эти три списка.
import {createContext, useState, useEffect} from 'react'; import {getAllCategories} from '../api'; const CatalogContext = createContext(); const CatalogContextProvider = (props) => { const [categories, setCategories] = useState([]); const [loading, setLoading] = useState(true); // рецепты каждой категории, которую пользователь уже просмотрел; // как только рецепты категории получены — сохраняем их здесь const recipes = {}; useEffect(() => { getAllCategories().then(data => { data.categories && setCategories(data.categories); setLoading(false); }); }, []); const getCategory = (name) => categories.find(item => item.strCategory === name); const getRecipes = (name) => { return recipes[name] ? recipes[name] : []; }; const setRecipes = (name, items) => { recipes[name] = items; }; const value = { categories: categories, loading: loading, getCategory: getCategory, getRecipes: getRecipes, setRecipes: setRecipes, }; return ( <CatalogContext.Provider value={value}> {props.children} </CatalogContext.Provider> ); } export {CatalogContext, CatalogContextProvider};
import {useContext, useState, useEffect} from 'react'; import {useParams, useHistory} from 'react-router-dom'; import {CatalogContext} from './Context'; import {getCategoryRecipes} from '../api'; import Preloader from '../components/Preloader'; import RecipeList from './RecipeList'; export default function CategoryItem(props) { const params = useParams(); const history = useHistory(); const [recipes, setRecipes] = useState([]); const [loading, setLoading] = useState(true); const { loading: loadingContext, getCategory: getCategoryContext, getRecipes: getRecipesContext, setRecipes: setRecipesContext } = useContext(CatalogContext); useEffect(() => { const saved = getRecipesContext(params.name); if (saved.length === 0) { getCategoryRecipes(params.name).then(data => { if (data.meals) { setRecipes(data.meals); // сохраняем рецепты категории в контекст setRecipesContext(params.name, data.meals); } setLoading(false); }); } else { setRecipes(saved); setLoading(false); } }, []); const category = getCategoryContext(params.name); return ( <> {loading || loadingContext ? ( <Preloader /> ) : recipes.length ? ( <> <h1>{params.name}</h1> <button className="btn" onClick={history.goBack}>Go Back</button> {category && <p>{category.strCategoryDescription}</p>} <RecipeList items={recipes} /> </> ) : ( <p>Не удалось загрузить список</p> )} </> ); }
В принципе, мы могли пойти дальше и хранить в контексте подробную информацию о рецептах, которые пользователь уже просмотрел — на тот случай, если он захочет просмотреть эти рецепты еще раз.
About
и Contact
— компонент CatalogContextProvider
отработает и выполнит запрос к сервису themealdb.com
на получение категорий. Хотя для просмотра этих страниц категории не нужны, они требуются только для главной страницы и для страницы списка рецептов категории. С другой стороны, если пользователь не ограничится просмотром одного рецепта или одной статичной страницы — список категорий обязательно потребуется.
Поиск рецептов
Сервис themealdb.com
позволяет искать рецепты по названию — давайте реализуем такую возможность для нашего приложения. Первым делом добавим еще одну функцию в api.js
.
import {API_URL} from './config'; /* .......... */ const getFoundRecipes = async (seacrh) => { seacrh = encodeURIComponent(seacrh); const response = await fetch(API_URL + 'search.php?s=' + seacrh); return await response.json(); } export {getAllCategories, getCategoryRecipes, getOneRecipe, getFoundRecipes};
Компонент SearchInput
для ввода поискового запроса:
import {useState, useEffect} from 'react'; import {useLocation} from 'react-router'; export default function SearchInput(props) { const location = useLocation(); const [value, setValue] = useState(''); useEffect(() => { const search = decodeURIComponent(location.search); if (search === '') { setValue(''); return; } const [name, input] = search.split('='); if (name.trim() !== '?str') { setValue(''); return; } if (input.trim() === '') { setValue(''); return; } setValue(input.trim()); }, [location.search]); const handleEnter = (event) => { if (event.key === 'Enter') { props.searchHandler(value); } } return ( <div className="row"> <div className="input-field col s12"> <input type="text" value={value} onChange={event => setValue(event.target.value)} onKeyUp={handleEnter} placeholder="Search recipes, for example — Arrabiata" /> <button className="btn search" onClick={() => props.searchHandler(value)}> Search </button> </div> </div> ); }
Это управляемый компонент. Введенный пользователем поисковый запрос сохраняется в состоянии. При нажатии клавиши Enter
или кнопки — вызывается функция searchHandler
, которую компонент получает через пропсы (и которую мы реализуем ниже). Хук useLocation
позволяет получить доступ к строке поискового запроса ?str=Beef
, чтобы задавать значение атрибута value
элемента <input>
.
Компонент SearchResult
, который выполняет запрос к сервису и показывает результаты:
import {useState, useEffect} from 'react'; import {useLocation} from 'react-router-dom'; import {getFoundRecipes} from '../api'; import Preloader from '../components/Preloader'; import RecipeList from './RecipeList'; export default function SearchResult(props) { const location = useLocation(); const [recipes, setRecipes] = useState([]); const [loading, setLoading] = useState(true); useEffect(() => { // проверяем корректность данных const search = decodeURIComponent(location.search); if (search === '') { setLoading(false); return; } const [name, value] = search.split('='); if (name.trim() !== '?str') { setLoading(false); return; } if (value.trim() === '') { setLoading(false); return; } // выполняем запрос к сервису getFoundRecipes(value).then(data => { if (data.meals) { setRecipes(data.meals); } setLoading(false); }); }, [location.search]); return ( <> {loading ? ( <Preloader /> ) : recipes.length ? ( <> <h1>Search results</h1> <RecipeList items={recipes} /> </> ) : ( <p>Ничего не найдено</p> )} </> ); }
Хук useLocation
позволяет получить доступ к строке поискового запроса ?str=Beef
, чтобы извлечь значение Beef
и выполнить запрос к сервису themealdb.com
. Перед этим мы проводим дополнительные проверки, что location.search
не равен пустой строке и т.п.
Изменяем компонент Content
, чтобы он обрабатывал нажатие клавиши Enter
— при этом в историю браузера добавляется еще одна страница /search?str=Beef
. Обрабатывет эту страницу компонент SearchResult
— потому что есть новый <Route>
внутри <Switch>
.
import {Route, Switch} from 'react-router-dom'; import Catalog from '../catalog/Catalog'; import About from '../pages/About'; import Contact from '../pages/Contact'; import NotFound from '../components/NotFound'; import CategoryItem from '../catalog/CategoryItem'; import RecipeItem from '../catalog/RecipeItem'; import SearchInput from '../catalog/SearchInput'; import {useHistory} from 'react-router-dom'; import SearchResult from '../catalog/SearchResult'; export default function Content() { const history = useHistory(); // если пользователь ввел поисковый запрос и нажал Enter, // то переходим на страницу server.com/search?str=Beaf и // добавляем ее в историю посещенных страниц браузера const handleSearch = (str) => { if (str.trim() === "") return; history.push({ pathname: '/search', search: `?str=${str.trim()}`, }); }; return ( <main className="container"> <SearchInput searchHandler={handleSearch} /> <Switch> <Route exact path="/" component={Catalog} /> <Route exact path="/about" component={About} /> <Route exact path="/contact" component={Contact} /> <Route exact path="/category/:name" component={CategoryItem} /> <Route exact path="/recipe/:id(\d+)" component={RecipeItem} /> <Route exact path="/search" component={SearchResult} /> <Route component={NotFound} /> </Switch> </main> ); }
Исходные коды здесь, демо-сайт здесь.
Поиск: API • JavaScript • Web-разработка • Frontend • Компонент • Практика