https://github.com/bluesun147/news-viewer
비동기 작업에는 흔히 콜백함수가 쓰인다.
하지만 콜백함수가 여러번 중첩되어 가독성이 나빠지기 쉽고
이를 콜백 지옥 이라고 부른다.
<script>
function inc(number, callback) {
setTimeout(() => {
const result = number + 10;
if (callback) {
callback(result);
}
}, 2000)
}
/*console.log('콜백 지옥');
inc(3, result => {
console.log(result);
inc(4, result => {
console.log(result);
inc(5, result => {
console.log(result);
inc(6, result => {
console.log(result);
})
})
})
})*/
function inc2(number) {
const promise = new Promise((resolve, reject) => {
// resolve는 성공, reject는 실패
setTimeout(() => {
const result = number + 10;
if (result > 40) {
const e = new Error('too big');
return reject(e);
}
resolve(result); // 성공
}, 2000);
});
return promise;
}
inc2(0)
.then(number => { // Promise에서 resolve된 값은 .then 통해 받음
console.log(number);
return inc2(number);
})
.then(number => {
console.log(number);
return inc2(number);
})
.then(number => {
console.log(number);
return inc2(number);
})
.then(number => {
console.log(number);
return inc2(number);
})
.then(number => {
console.log(number);
return inc2(number);
})
.catch(e => { // 도중에 에러 발생시 .catch 통해 알수있다
console.log(e);
})
</script>
function inc2(number) {
const promise = new Promise((resolve, reject) => {
// resolve는 성공, reject는 실패
setTimeout(() => {
const result = number + 10;
if (result > 40) {
const e = new Error('too big');
return reject(e);
}
resolve(result); // 성공
}, 2000);
});
return promise;
}
async function runTasks() {
try {
let result = await inc2(0);
console.log(result);
result = await inc2(result);
console.log(result);
result = await inc2(result);
} catch (e) {
console.log(e);
}
import React, {useState} from 'react';
import axios from 'axios';
// App.js
const App = () => {
const [data, setData] = useState(null);
// axios.get 함수는 파라미터로 전달된 주소에 GET요청을 함.
// 이에 대한 결과는 .then 통해 비동기적으로 확인 가능
const onClick = () => {
// 아래 주소에서 제공하는 가짜 API 호출하고 이에 대한 응답 컴포넌트 상태에 넣어서 보여줌
axios.get('https://jsonplaceholder.typicode.com/todos/1').then(response => {
setData(response.data);
});
};
// async 적용시킨 onClick
// 화살표 함수에 async/await 적용시에는 async () => {} 와 같은 형식으로 적용
const onClick2 = async () => {
try {
const response = await axios.get(
'https://jsonplaceholder.typicode.com/todos/1'
);
setData(response.data);
} catch (e) {
console.log(e);
}
}
return (
<div>
<div>
<button onClick={onClick2}>불러오기</button>
</div>
{data && <textarea rows = {7} value = {JSON.stringify(data, null, 2)} readOnly={true}/>}
</div>
);
};
export default App;
https://newsapi.org/s/south-korea-news-api
사용할 API 주소는 전체 뉴스 불러오기, 특정 카테고리 뉴스 불러오기로 두가지 형태이다.
이전에 사용한 JSONPlaceholder 가짜 API를 전체 뉴스를 불러오는 API로 대체해 보자.
...
const onClick2 = async () => {
try {
const response = await axios.get(
'https://newsapi.org/v2/top-headlines?country=kr&apiKey=b1018dcfde7246b2a3924270fb22d4b1'
);
setData(response.data);
} catch (e) {
console.log(e);
}
}
...
이 데이터를 화면에 예쁘게 보여주면 됨.
뉴스 뷰어 UI 만들기
styled-components를 사용해 뉴스 정보를 보여줄 컴포넌트를 만들자.
NewsItem 만들기
각 뉴스 정보를 보여주는 컴포넌트
먼저 뉴스 데이터에 어떤 필드 있는지 확인해보자.
{
"source": {
"id": null,
"name": "Donga.com"
},
"author": null,
"title": ""새 집 냄새" "주택 청약 고마워!"…이시언 아파트 공개 - 동아일보",
"description": "배우 이시언(37)이 자신의 새 아파트를 공개했다. 이시언은 25일 방송한 MBC 예능 '나 혼자 산다'에서 정든 옛집을 떠나 새 아파트로 이사했다. 이사한 아파트에 도착한 …",
"url": "http://news.donga.com/Main/3/all/20190126/93869524/2",
"urlToImage": "http://dimg.donga.com/a/600/0/90/5/wps/NEWS/IMAGE/2019/01/26/93869523.2.jpg",
"publishedAt": "2019-01-26T00:21:00Z",
"content": null
}
위 코드는 각 뉴스 데이터가 지닌 정보로 이뤄진 JSON 객체이다.
그중 다음 필드를 리액트 컴포넌트에 나타낼 예정.
title: 제목
description: 내용
url: 링크
urlToImage: 뉴스 이미지
NewsItem 컴포넌트는 article이라는 객체를 props로 통째로 받아와 사용
import React from "react";
import styled from "styled-components";
// NewsItem.js
// 각 뉴스 정보를 보여주는 컴포넌트
const NewsItemBlock = styled.div`
display: flex;
.thumbnail {
margin-right: 1rem;
img {
display: block;
width: 160px;
height: 100px;
object-fit: cover;
}
}
.contents {
h2 {
margin: 0;
a {
color: black;
}
}
p {
margin: 0;
line-height: 1.5;
margin-top: 0.5rem;
white-space: normal;
}
}
& + & {
margin-top: 3rem;
}
`;
// article 객체를 props로 통째로 받아와 사용
const NewsItem = ({article}) => {
// 객체 구조 분해 할당
const {title, description, url, urlToImage} = article;
return (
<NewsItemBlock>
{urlToImage && ( // 이미지
<div className="thumbnail">
<a href = {url} target = "_blank" rel = "noopener noreferrer">
<img src = {urlToImage} alt = 'thumbnail' />
</a>
</div>
)}
<div className="contents">
<h2>
<a href = {url} target = "_blank" rel = "noopener noreferrer">
{title}
</a>
</h2>
<p>{description}</p>
</div>
</NewsItemBlock>
)
}
export default NewsItem;
import React from "react";
import styled from "styled-components";
import NewsItem from "./NewsItem";
// NewsList.js
// API 요청하고 뉴스 데이터가 들어있는 배열을
// 컴포넌트 배열로 변환해 렌더링해주는 컴포넌트
const NewsListBlock = styled.div`
box-sizing:border-box;
padding-bottom:3rem;
width:768px;
margin:>0 auto;
margin-top: 2rem
@media screen and (max-width:768px) {
width:100%
padding-left:1rem
padding-right:1rem
}`
// 나중에 이 컴포넌트에서 API 요청하게 됨
// 아직 데이터 불러오고 있지 않으니 예시 데이터 넣고 보이게.
const sapmleArticle = {
title: 'title',
description: 'des',
url: 'https://google.com',
urlToImage: 'https://via.placeholder.com/160'
};
const NewsList = () => {
return (
<NewsListBlock>
<NewsItem article = {sapmleArticle} />
<NewsItem article = {sapmleArticle} />
<NewsItem article = {sapmleArticle} />
<NewsItem article = {sapmleArticle} />
<NewsItem article = {sapmleArticle} />
<NewsItem article = {sapmleArticle} />
<NewsItem article = {sapmleArticle} />
</NewsListBlock>
);
};
export default NewsList;
import React from "react";
import NewsList from "./NewsList";
// App.js
const App = () => {
return <NewsList/>
};
export default App;
laoding이라는 상태로 관리하여 API 요청이 대기 중인지도 판별할 것.
요청 대기중일때는 loading 값이 true, 요청 끝나면 false
import React, {useState, useEffect} from "react";
import styled from "styled-components";
import NewsItem from "./NewsItem";
import axios from 'axios';
// NewsList.js
// API 요청하고 뉴스 데이터가 들어있는 배열을
// 컴포넌트 배열로 변환해 렌더링해주는 컴포넌트
const NewsListBlock = styled.div`
box-sizing:border-box;
padding-bottom:3rem;
width:768px;
margin:>0 auto;
margin-top: 2rem
@media screen and (max-width:768px) {
width:100%
padding-left:1rem
padding-right:1rem
}`
// 나중에 이 컴포넌트에서 API 요청하게 됨
// 아직 데이터 불러오고 있지 않으니 예시 데이터 넣고 보이게.
const sapmleArticle = {
title: 'title',
description: 'des',
url: 'https://google.com',
urlToImage: 'https://via.placeholder.com/160'
};
const NewsList = () => {
const [articles, setArticles] = useState(null);
// API 요청 대기중일때 true, 끝나면 false
const [loading, setLoading] = useState(false);
useEffect(() => {
// async 사용하는 함수 따로 선언
const fetchData = async() => {
setLoading(true); // 요청 대기중
try{
const response = await axios.get(
'https://newsapi.org/v2/top-headlines?country=us&apiKey=b1018dcfde7246b2a3924270fb22d4b1'
);
setArticles(response.data.articles); // 위 주소에서 받아온 데이터에서 articles 꺼냄
} catch (e) {
console.log(e);
}
setLoading(false); // 요청 끝남
};
fetchData();
}, []);
// 대기 중일 때
if (loading) {
return <NewsListBlock>대기 중...</NewsListBlock>
}
// 아직 articles 값 설정안됐을 때
if (!articles) { // 현재값 null 아닌지 검사 필수
return null;
}
// articles 값 유효할 때
return (
<NewsListBlock>
{// 데이터를 불러와서 뉴스 데이터 배열을 map함수 사용해 컴포넌트 배열로 변환
// 주의할 점은 map 사용하기 전에 꼭 현재 값이 null이 아닌지 검사해야 함
// 데이터 없을 때 null에는 map함수 없기때문에 렌더링 과정에서 오류 발생함
articles.map(article => (
<NewsItem key = {article.url} article = {article}/>
))
}
</NewsListBlock>
);
};
export default NewsList;
카테고리 기능 구현하기
뉴스 카테고리는 총 6개 존재.
https://newsapi.org/s/us-news-api
카테고리 선택 UI 만들기
import React from "react";
import styled from "styled-components";
// Categories.js
// categories 배열 안에 name과 text값 들어있는
// 객체 넣어 한글로 된 카테고리와 실제 카테고리 값 연결시킴
const categories = [
{
name: 'all', // 실제 카테고리 값
text: '전체보기' // 렌더링 시 사용할 한글 카테고리
},
{
name: 'business',
text: '비즈니스'
},
{
name: 'entertainment',
text: '엔터테인먼트'
},
{
name: 'health',
text: '건강'
},
{
name: 'science',
text: '과학'
},
{
name: 'sports',
text: '비즈니스'
},
{
name: 'technology',
text: '기술'
},
];
const CategoriesBlock = styled.div`
display: flex;
padding: 1rem;
width:768px;
margin:0 auto;
margin-top: 2rem;
@media screen and (max-width:768px) {
width:100%;
overflow-x:auto;
}`
const Category = styled.div`
font-size: 1.125rem;
cursor: pointer;
white-space: pre;
text-decoration: none;
color: inherit;
padding-bottom: 0.25rem;
&:hover {
color: #495057;
}
& + & {
margin-left: 1rem;
}
`;
const Categories = () => {
return (
<CategoriesBlock>
{categories.map(c => ( // categories배열을 Category컴포넌트 배열로 변환
<Category key = {c.name}>{c.text}</Category>
))}
</CategoriesBlock>
);
};
export default Categories;
import React from "react";
import Categories from "./Categories";
import NewsList from "./NewsList";
// App.js
const App = () => {
return (
<>
<Categories />
<NewsList />
</>
);
};
export default App;
이제 App에서 category 상태를 useState로 관리해보자.
추가로 category 값 업데이트하는 onSelect 함수도 만들기.
그 후 category와 onSelect 함수를 Category 컴포넌트에게 props로 전달.
category값을 NewsList 컴포넌트에게도 전달해줘야 함.
import React, {useState, useCallback} from "react";
import Categories from "./Categories";
import NewsList from "./NewsList";
// App.js
const App = () => {
const [category, setCategory] = useState('all');
// category값을 업데이트하는 함수
const onSelect = useCallback(category => setCategory(category), []);
return (
<>
{/* 만든 후 Categories, NewsList 컴포넌트에 props 로 전달. */}
<Categories category = {category} onSelect={onSelect}/>
<NewsList category = {category}/>
</>
);
};
export default App;
Categories에서는 props로 전달받은 onSelect를 각 Category컴포넌트의 onClick으로 설정해 주고 현재 선택된 카테고리 값에 따라 다른 스타일을 적용시켜보자.
import React from "react";
import styled, {css} from "styled-components";
// Categories.js
// categories 배열 안에 name과 text값 들어있는
// 객체 넣어 한글로 된 카테고리와 실제 카테고리 값 연결시킴
const categories = [
{
name: 'all', // 실제 카테고리 값
text: '전체보기' // 렌더링 시 사용할 한글 카테고리
},
{
name: 'business',
text: '비즈니스'
},
{
name: 'entertainment',
text: '엔터테인먼트'
},
{
name: 'health',
text: '건강'
},
{
name: 'science',
text: '과학'
},
{
name: 'sports',
text: '비즈니스'
},
{
name: 'technology',
text: '기술'
},
];
const CategoriesBlock = styled.div`
display: flex;
padding: 1rem;
width:768px;
margin:0 auto;
margin-top: 2rem;
@media screen and (max-width:768px) {
width:100%;
overflow-x:auto;
}`
const Category = styled.div`
font-size: 1.125rem;
cursor: pointer;
white-space: pre;
text-decoration: none;
color: inherit;
padding-bottom: 0.25rem;
&:hover {
color: #495057;
}
${props =>
props.active && css`
font-weight: 600;
border-bottom: 2px solid #22b8cf;
color: #22b8cf;
&:hover {
color: #3bc9db;
}
`}
& + & {
margin-left: 1rem;
}
`;
const Categories = ({onSelect, category}) => { // 파라미터에 props
return (
<CategoriesBlock>
{categories.map(c => ( // categories배열을 Category컴포넌트 배열로 변환
<Category key = {c.name}
active = {category === c.name}
onClick={() => onSelect(c.name)}>{c.text}</Category>
))}
</CategoriesBlock>
);
};
export default Categories;
선택된 카레고리의 색이 변한것을 확인할 수 있다.
// NewsList.js
...
useEffect(() => {
// async 사용하는 함수 따로 선언
const fetchData = async() => {
setLoading(true); // 요청 대기중
try{
// 현재 category값 무엇인지에 따라 요청할 주소가 동적으로 바뀌고 있다
// category값이 all 이면 query값은 공백, 아니면 '&categpry=카테고리' 형태의 문자열 만들도록 함
// 그리고 이 query 요청할 때 주소에 포함시켜줌
const query = category === 'all' ? '' : `&category=${category}`;
const response = await axios.get(
`https://newsapi.org/v2/top-headlines?country=us${query}&apiKey=b1018dcfde7246b2a3924270fb22d4b1`
);
setArticles(response.data.articles); // 위 주소에서 받아온 데이터에서 articles 꺼냄
} catch (e) {
console.log(e);
}
setLoading(false); // 요청 끝남
};
fetchData();
}, [category]); // category값 바뀔때마다 뉴스 새로 불러와야 함.
...
카테고리에 따른 뉴스가 잘 나오는걸 확인할 수 있다.
리액트 라우터 적용하기
뉴스 뷰어에 리액트 라우터를 적용해보자.
기존에는 카테고리 값을 useState로 관리했었는데
이번에는 이 값을 리액트 라우터의 URL 파라미터 사용해 관리해보자.
리액트 라우터의 설치 및 적용
현재 프로젝트에 리액트 라우터 설치하고
yarn add react-router-dom
index.js에서 리액트 라우터 적용
...
import { BrowserRouter } from ‘react-router-dom‘;
ReactDOM.render(
<BrowserRouter>
<App />
</BrowserRouter>,
document.getElementById('root')
);
...
-NewsPage 생성
이번 프로젝트에서 라우터 적용할 때 만들어야 할 페이지는 단 하나이다.
import React from "react";
import Categories from "../Categories";
import NewsList from "../NewsList";
// NewsPages.js
const NewsPage = ({match}) => {
// 카테고리가 선택안됐으면 기본값 all로 사용
const category = match.params.category || 'all';
return (
<>
<Categories />
<NewsList category = {category} />
</>
);
};
export default NewsPage;
현재 선택된 category값을 URL 파라미터를 통해 사용할 것이므로 Categories 컴포넌트에서 현재 선택된 값 알려줄 필요도 없고, onSelect 함수 따로 전달할 필요도 없음.
import React from "react";
import { Route } from "react-router-dom";
import NewsPage from "./pages/NewsPages";
// App.js
const App = () => {
// 물음표는 category값이 선택적(optional) 이라는 뜻
// 즉 있을수도 있고 없을수도 있다는 뜻
// category URL 파라미터가 없다면 전체 카테고리 택한것으로 간주
return <Route path = '/:category?' component = {NewsPage} />;
};
export default App;
div, a, button, input처럼 일반 html 요소가 아닌 특정 컴포넌트에 styled-components 사용시 styled(컴포넌트이름)`` 과 같은 식으로 사용한다.
import React from "react";
import styled from "styled-components";
import { NavLink } from "react-router-dom";
// Categories.js
// categories 배열 안에 name과 text값 들어있는
// 객체 넣어 한글로 된 카테고리와 실제 카테고리 값 연결시킴
const categories = [
{
name: 'all', // 실제 카테고리 값
text: '전체보기' // 렌더링 시 사용할 한글 카테고리
},
{
name: 'business',
text: '비즈니스'
},
{
name: 'entertainment',
text: '엔터테인먼트'
},
{
name: 'health',
text: '건강'
},
{
name: 'science',
text: '과학'
},
{
name: 'sports',
text: '비즈니스'
},
{
name: 'technology',
text: '기술'
},
];
const CategoriesBlock = styled.div`
display: flex;
padding: 1rem;
width:768px;
margin:0 auto;
margin-top: 2rem;
@media screen and (max-width:768px) {
width:100%;
overflow-x:auto;
}`
//const Category = styled.div`
const Category = styled(NavLink)`
font-size: 1.125rem;
cursor: pointer;
white-space: pre;
text-decoration: none;
color: inherit;
padding-bottom: 0.25rem;
&:hover {
color: #495057;
}
&.props.active {
font-weight: 600;
border-bottom: 2px solid #22b8cf;
color: #22b8cf;
&:hover {
color: #3bc9db;
}
}
& + & {
margin-left: 1rem;
}
`;
const Categories = ({onSelect, category}) => { // 파라미터에 props
return (
<CategoriesBlock>
{categories.map(c => ( // categories배열을 Category컴포넌트 배열로 변환
<Category key = {c.name}
activeclassName = 'active'
exact = {c.name === 'all'}
to = {c.name === 'all' ? '/' : `/${c.name}`}
>{c.text}</Category>
))}
</CategoriesBlock>
);
};
export default Categories;
NavLink로 만들어진 Category 컴포넌트에 to 값은 '/카테고리이름'으로 설정.
카테고리 중 전테보기의 경우 '/all' 대신 '/'로 설정.
to 값이 '/'가리킬때는 exact값을 true로 해줘야 함.
그렇지 않으면 다른 카테고리 선택됐을때도 전체보기 링크에 active스타일 적용됨.
import {useState, useEffect} from 'react';
// usePromise.js
export default function usePromise(promiseCreator, deps) {
// 대기중 / 완료 / 실패에 대한 상태 관리
// usePromise의 의존 배열 deps를 파라미터로 받아옴
// 받아온 deps 배열은 useEffect의 의존배열로 설정
// 의존배열은 useEffect 두번째 파라미터. []
const [loading, setLoading] = useState(false);
const [resolved, setResolved] = useState(null);
const [error, setError] = useState(null);
useEffect(() => { // 렌더링될때마다 실행
const process = async() => {
setLoading(true); // loading을 true로
try {
const resolved = await promiseCreator();
setResolved(resolved);
} catch (e) {
setError(e);
}
setLoading(false); // 끝나면 loading값 false로
};
process();
}, deps);
return [loading, resolved, error];
}
프로젝트의 다양한 곳에 사용될 수 있는 유틸 함수들은 보통 src디렉터리에 lib디렉터리 만든 후 그 안에 작성.
NewsList 컴포넌트에서 usePromose 사용해보자.
import React from "react";
import styled from "styled-components";
import NewsItem from "./NewsItem";
import axios from 'axios';
import usePromise from "./lib/usePromise";
// NewsList.js
const NewsListBlock = styled.div`
box-sizing:border-box;
padding-bottom:3rem;
width:768px;
margin:0 auto;
margin-top: 2rem
@media screen and (max-width:768px) {
width:100%
padding-left:1rem
padding-right:1rem
}`
const NewsList = ({category}) => {
const [loading, response, error] = usePromise(() => {
const query = category === 'all' ? '' : `&category=${category}`;
return axios.get(`https://newsapi.org/v2/top-headlines?country=us${query}&apiKey=b1018dcfde7246b2a3924270fb22d4b1`);
}, [category]);
// 대기 중일 때
if (loading) {
return <NewsListBlock>대기 중...</NewsListBlock>;
}
// 아직 response 값이 설정되지 않았을 때
if (!response) {
return null;
}
// 에러 발생 시
if (error) {
return <NewsListBlock>에러 발생!!</NewsListBlock>
}
// response 값이 유효할 때
const {articles} = response.data;
return (
<NewsListBlock>
{articles.map(article => (
<NewsItem key = {articles.url} article = {article} />
))}
</NewsListBlock>
);
};
export default NewsList;
usePromise 사용하면 NewsList에서 대기중 상태 관리와 useEffect 설정 직접 안해도 되므로 코드 간결해짐.
요청 상태 관리할 때 커스텀 Hook 사용하면 좋다.
netlify 이용해 사이트 배포해보려 했지만 newsAPI.org는 localhost아닌 다른 곳에서의 키 요청을 거부한다고 한다.
https://agitated-thompson-9e22f1.netlify.app
이 링크를 통해선 오류가 나지만 localhost에서는 오류없이 실행됨.