저번에 영화를 검색하면 데이터를 가져올 수 있게 작성했습니다.
이번에는 해당 데이터를 화면에 보여줄 수 있도록 작성해보겠습니다.
우선 저번에 작성한 데이터를 console.log로 작성해서 보면 Search라는 속성이 존재합니다.
Search 속성에는 영화 정보를 최대 10개가 들어있습니다. 이러한 정보를 바탕으로 movie.js를 더 작성해 보겠습니다.
import { Store } from '../core/heropy'
const store = new Store({
searchText: '',
page: 1,
movies: []
})
export default store
export const searchMovies = async page => {
if (page === 1) {
store.state.page = 1
store.state.movies = []
}
const res = await fetch(`https://omdbapi.com?apikey=?&s=${store.state.searchText}&page=${page}`)
const { Search } = await res.json()
store.state.movies = [ ...store.state.movies, ...Search]
}
이 코드에서 새롭게 작성된 코드를 살펴보겠습니다.
if (page === 1) {
store.state.page = 1
store.state.movies = []
}
첫 번째 페이지 초기화
const { Search } = await res.json()
{ Search }: JSON 응답에서 Search라는 속성을 디스트럭처링 할당을 통해 추출합니다. Search는 검색된 영화 목록을 포함합니다.
즉, OMDb API에 영화 검색 요청을 보내면, 응답으로 받은 데이터중 Search라는 속성만을 추출해 별도의 변수로 할당하는 역할을 합니다.
store.state.movies = [ ...store.state.movies, ...json.Search]
이러한 데이터들을 활용하는 Movie list라는 컴포넌트를 한번 작성해 보겠습니다.
import { Component } from "../core/heropy";
import movieStore from "../store/movie";
export default class MovieList extends Component {
render() {
this.el.classList.add("movie-list");
this.el.innerHTML = /* html */ `
<div class ="movies"></div>
`
const moviesEl = this.el.querySelector(".movies");
moviesEl.append(
movieStore.state.movies.map(movie => {
return movie.Title
})
)
}
}
이 코드는 영화 목록을 화면에 보여주기 위한 컴포넌트입니다.
movie.js 즉 movieStore에서 관리하는 영화 데이터를 가지고 와서 HTML 구조에 반영하는 코드구조입니다.
우선 Component에서 상속받아 만들어진 div 요소에 클래스를 추가하고,
그 하위요소로 movies라는 클래스의 div요소를 추가합니다.
그다음 movies라는 클래스를 가진 요소를 querySelector로 찾아 moviesEl 변수에 넣어줍니다.
moviesEl.append(
movieStore.state.movies.map(movie => {
return movie.Title
})
);
}
}
movieStore.state.movies: movieStore 객체의 state 속성에서 movies 배열을 가져옵니다. 이 배열은 현재 검색된 영화 목록을 포함하고 있습니다.
이렇게 작성된 내용을 화면에 보여줘야 합니다.
import { Component } from "../core/heropy";
import Headline from "../components/Headline";
import Search from "../components/Search";
import MovieList from "../components/MovieList";
export default class Home extends Component {
render() {
const headline = new Headline().el;
const search = new Search().el;
const movielist = new MovieList().el;
this.el.classList.add("container");
this.el.append(headline, search, movielist);
}
}
이렇게 작성한 후에 실행하면 잘 동작이 될까요?
그렇지 않습니다. 이유는 MovieList부분을 단순하게 만들어서 출력만 하고 있습니다.
그런데 Search라는 컴포넌트에서 사용자가 입력하고 Enter나 Button을 클릭하면 영화 정보를 가져오게 됩니다.
가져온 후에 movies라는 이름의 스토어 상태를 갱신하게 됩니다.
하지만 이미 MovieList는 화면에 출력되고 난 다음입니다.
이 스토어 부분에 movies라는 이름의 상태 같은 경우 제일 먼저 빈 배열로 출발을 했기 때문에 화면에는 아무것도 출력하지 못하는 상태 그대로 있게 됩니다.
그렇기 때문에 아무리 검색을 해서 데이터를 갱신하더라도 이미 화면에 한번 출력된 내용은 바뀌지 않습니다.
이러한 점을 대비하여 우리가 heropy.js에서 옵저버라는 이름의 객체 데이터를 만들어서 밑에
subscribe라는 스토어에 상태를 구독할 수 있는 개념을 만들었습니다.
그래서 상태가 변경되면 등록해 놓은 메소드를 통해서 각각의 콜백 함수가 실행되는 구조였습니다.
이제 우리는 moives라는 이름의 상태가 갱신됐을 때 이 MovieList 부분에 렌더 함수가 다시 동작할 수 있도록 만들어주면 됩니다. 그렇게 되면 영화 정보를 갱신할 때마다 화면에 보여지는 MovieList 부분이 다시 만들어지면서
데이터를 기반으로 내용을 잘 보여줄 수 있게 됩니다.
import { Component } from "../core/heropy";
import movieStore from "../store/movie";
import MoiveItem from "./MovieItem";
export default class MovieList extends Component {
constructor() {
super();
movieStore.subscribe("movies", () => {
this.render();
});
}
render() {
this.el.classList.add("movie-list");
this.el.innerHTML = /* html */ `
<div class="movies"></div>
`;
const moviesEl = this.el.querySelector(".movies");
moviesEl?.append(
...movieStore.state.movies.map(
(movie) =>
new MoiveItem({
movie,
}).el
)
)
}
}
새롭게 추가된 코드만 설명하겠습니다.
constructor() {
super();
movieStore.subscribe("movies", () => {
this.render();
});
}
movieStore에서 moives라는 state를 만들었습니다. 그리고 빈 배열로 정의했습니다.
이러한 movies라는 상태를 구독하여, 그 상태가 변화나 갱신되었을 경우,
this.render() 즉, render 함수 = 콜백 함수를 실행하는 코드입니다.
이렇게 된다면 movies라는 데이터가 갱신되면서 화면에 데이터가 갱신되어 잘 보여지게 됩니다.
이번에는 MovieItem이라는 컴포넌트를 만들어 보겠습니다.
components 폴더 안에 MovieItem이라는 파일을 하나 만들어 주세요.
import { Component } from "../core/heropy";
export default class MoiveItem extends Component {
constructor(props) {
super({
props,
tagName: "a",
});
}
render() {
const { movie } = this.props;
this.el.setAttribute("href", `#/movie?id=${movie.imdbID}`);
this.el.classList.add("movie");
this.el.style.backgroundImage = `url(${movie.Poster})`;
this.el.innerHTML = /* html */ `
<div class="info">
<div class="year">${movie.Year}</div>
<div class="title">${movie.Title}</div>
</div>
`;
}
}
constructor 메서드에서 props를 받아 부모 클래스의 생성자를 호출합니다.
tagName을 "a"로 설정하여 a 태그로 렌더링됩니다.
heropy.js에 컴포넌트 부분에서 payload가 없다면 div로 생성이 되고 payload가 있으면 해당
요소로 생성이 된다는 것을 기억해주세요, 이번 컴포넌트는 div가 아닌 a태그를 payload에 요청하여 요소로 생성되었습니다.
MovieItem 이 생성자 함수로 호출되는 자리에서 props 라는 객체 데이터로 영화 정보를 받아올거고, 그 영화 정보를 상속하는 컴포넌트 클래스로 넣어줬기 때문에 heropy.js에서 만든 컴포넌트 구조에 맞게 this.props 부분에서 해당하는 정보를 사용하고, 그 내용을 객체 구조 분해 할당으로 꺼내서 사용할 수 있습니다.
간단히 말하면, MoiveItem 컴포넌트는 영화 정보를 받아와 화면에 링크, 배경 이미지, 연도, 제목을 표시하는 역할을 합니다.
그렇다면 이렇게 만든 MovieItem 컴포넌트를 MovieList 컴포넌트에 출력을 해야겠습니다.
import { Component } from "../core/heropy";
import movieStore from "../store/movie";
import MoiveItem from "./MovieItem";
export default class MovieList extends Component {
constructor() {
super();
movieStore.subscribe("movies", () => {
this.render();
});
}
render() {
this.el.classList.add("movie-list");
this.el.innerHTML = /* html */ `
<div class="movies"></div>
`;
const moviesEl = this.el.querySelector(".movies");
moviesEl?.append(
...movieStore.state.movies.map(
(movie) =>
new MoiveItem({
movie,
}).el
})
)
}
}
이코드에서 새롭게 추가된 부분만 확인해보겠습니다.
moviesEl?.append(
...movieStore.state.movies.map(movie =>
new MoiveItem({
movie,
}).el
코드를 보면 new MovieItem().el만 작성하면 될텐데, 왜 안에 객체데이터로 movie를 넣어줬을까요?
그 이유는 우리가 MovieItem 컴포넌트를 사용할 때 부모 컴포넌트로부터 props를 매개변수로 받아서 사용하기로 작성한 부분을 기억하시나요? 이러한 부분을 통해 우리가 props로 데이터를 받아줄 수 있어야 하고 그 데이터를 우리는 이 코드에서 movie 라는 이름의 속성에 실제 데이터가 들어있는 구조여야 합니다.
그래서 우리는 movie:movie라는 데이터를 받겠다고 작성하면 되는데 이렇게 속성와 데이터의 이름이 같다면 데이터의 이름을 생략할 수 있습니다. 그래서 우리는 movie만 작성을 했습니다.
그런데 여기서 map이라는 메소드는 콜백에서 최종적으로 각각의 데이터로 배열을 반환합니다.
그랬을 때 우리가 movies element의 append를 통해 출력하는 내용이 배열데이터면 안됩니다.
각각의 movieitem 컴포넌트의 요소여야 하기 때문에 전개연산자를 통해 배열의 대괄호를 지워줄 수 있게 만들어줘야 합니다.
이후 데이터가 출력되는 구조는 완성되었습니다 css만 작성하여 정리해주면 됩니다.
css는 main.css를 참고해주세요.
우리가 123이라는 제목으로 영화를 검색했을때 네트워크창에 payload를 살펴보면
34개의 totalResults로 34라고 확인할 수 있습니다. 우리는 한번 검색했을경우 10개의 영화 정보를 가져오는데 실제로 123이라고 검색했을 경우 34개의 영화데이터중 10개만을 가져오게 되는겁니다.
영화목록을 더 가져오기 위해서는 1페이지에 10개의 데이터만을 가져오는데 그렇다면 다음 10개의 데이터 2페이지의 내용을 요청하면 되겠죠.
34개의 데이터라면 3페이지를 출력하면 30개의 목록을 가져오게 됩니다. 그렇다면 남은 4개의 데이터는 어떻게 가져올 수 있을까요? 34개를 10으로 나누어 보면 3.4 라는 숫자가 나오게 됩니다.
페이지의 수는 소수점으로 작성을 할 수 없기 때문에 이러한 상황에서는 소수점 자리의 숫자만큼 페이지 내용도 가져오기 위해서 올림처리를 해주면 됩니다. 이러한 내용을 한번 작성해보겠습니다.
우리는 componets 폴더에 MovieListMore.js 파일을 만들어 작성해보겠습니다.
import { Component } from "../core/heropy";
import movieStore, { searchMovies } from "../store/movie";
export default class MovieListMore extends Component {
constructor() {
super({
tagName: "button",
});
}
render() {
this.el.classList.add("btn", "view-more", "hide");
this.el.textContent = "View more..";
this.el.addEventListener("click", async () => {
await searchMovies(movieStore.state.page + 1);
});
}
}
이렇게 작성된 코드를 한번 확인해보겠습니다.
우선 tagName을 작성해서 button이라는 요소가 생성되게 해줍니다. 그리고 해당 버튼을 클릭하면 더 많은 영화목록을 보여줄 수 있게 되어야 겠죠.
하지만 영화목록이 10개가 끝이거나 이미 모든 영화를 다 보여준 상태라면 이 버튼은 필요가 없기때문에 class 이름으로 hide를 추가해줍니다.
그리고 버튼을 클릭 했을 경우에는 형재 page의 값보다 +1 더한 값의 page를 보여주게 됩니다.
그렇다면 이렇게 작성된 코드에서 moive.js에서도 이러한 내용이 잘 작동되도록 수정해줘야 합니다.
import { Store } from "../core/heropy";
const store = new Store({
searchText: "",
page: 1,
pageMax: 1,
movies: [],
});
export default store;
export const searchMovies = async (page) => {
store.state.page = page;
if (page === 1) {
store.state.moives = [];
}
const res = await fetch(`https://omdbapi.com?apikey=?&s=${store.state.searchText}&p=${page}`)
const { Search, totalResults } = await res.json()
store.state.movies = [
...store.state.movies,
...Search
]
store.state.pageMax = Math.ceil(Number(totalResults) / 10)
}
추가된 코드들을 확인해 보겠습니다.
우선 Search요소 말고도 totalResults 요소도 추가되었습니다. 당연하게도 영화목록의 모든 결과값을 확인할 수 있어야 최대 페이지수와 현재 페이지 수를 비교하여 목록을 더 보여줄지 말지를 선택할 수 있기 때문이죠.
그리고 totalResults의 값을 10으로 나누어 올림처리를 하여 pageMax의 값에 할당해줍니다.
그리고 우리는 다시 MovieListMore.js로 돌아와서 추가로 작성해보겠습니다.
import { Component } from "../core/heropy";
import movieStore, { searchMovies } from "../store/movie";
export default class MovieListMore extends Component {
constructor() {
super({
tagName: "button",
});
movieStore.subscribe("pageMax", () => {
const { page, pageMax } = movieStore.state;
pageMax > page
? this.el.classList.remove("hide")
: this.el.classList.add("hide");
});
}
render() {
this.el.classList.add("btn", "view-more", "hide");
this.el.textContent = "View more..";
this.el.addEventListener("click", async () => {
this.el.classList.add("hide");
await searchMovies(movieStore.state.page + 1);
});
}
}
추가 작성된 코드들을 확인해 보겠습니다.
우선 subscribe의 구독 기능이 추가되었습니다. 그이유는 무엇일까요?
바로 movie.js에서 pageMax의 값이 갱신되도록 작성해주었습니다. 이러한 부분을 구독하여 상태가 변경이 된다면,
현재 page와 비교하여 그값이 작다면 더보기 버튼을 보여주고, 그게 아니라면 숨겨주는 기능을 처리하도록 만들었습니다.
여기까지 작성이 되었다면 우리는 영화를 검색하고, 해당 영화의 총 목록개수를 확인하여 더보기 버튼을 통해
검색한 모든 영화의 목록을 확인할 수 있게 되었습니다.
우리 프로젝트는 현재 영화제목을 검색하여 해당 영화제목과 연간된 목록들을 찾아볼 수 있는 영화소개 프로젝트입니다.
이렇게 우리는 비동기로, fetch를 통해 OMDb API를 통해 영화 목록을 가져와 화면에 표시하고 있습니다.
이렇게 표시되는동안 정보를 가져오는 처리시간이 길어진다면 어떻게 될까요?
사용자는 이러한 상황에서 아무것도 보이지 않고 그저 기다리게 되고, 검색이 잘 되었는지 알 수도 없을겁니다.
이러한 상황에서 우리는 로딩모션을 통해 데이터가 처리중이라는 것을 사용자에게 확인시켜 줄 수 있게끔 만들어 보겠습니다.
/* html */
<div class="the-loader"></div>
/* css */
.the-loader {
width: 30px;
height: 30px;
margin: 30px auto;
border: 4px solid var(--color-primary);
border-top-color: transparent;
border-radius: 50%;
animation: loader 1s infinite linear;
}
.the-loader.hide {
display: none;
}
@keyframes loader {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
이 코드내용을 codepen에서 확인해본다면, 우리가 실제로 로딩화면일때 볼 수 있는 애니메이션을 볼 수 있을겁니다.
이 애니메이션을 우리 프로젝트에도 한번 도입해보도록 하겠습니다.
일단 해당 내용을 우리 css에 추가해주세요.
/만약 제가 작성한 css를 복사해서 사용 중이시라면 추가하실 필요는 없습니다./
이러한 로딩 애니메이션을 추가하기위해서는 어느 컴포넌트에 적용해야 할까요?
정답은 목록을 가져오는 컴포넌트인 MovieList 컴포넌트에 적용해야 합니다.
import { Component } from "../core/heropy";
import movieStore from "../store/movie";
import MoiveItem from "./MovieItem";
export default class MovieList extends Component {
constructor() {
super();
movieStore.subscribe("movies", () => {
this.render();
});
movieStore.subscribe("loading", () => {
this.render();
});
}
render() {
this.el.classList.add("movie-list");
this.el.innerHTML = /* html */ `
<div class="the-loader hide"></div>
`;
const moviesEl = this.el.querySelector(".movies");
moviesEl?.append(
...movieStore.state.movies.map(
(movie) =>
new MoiveItem({
movie,
}).el
)
)
const loaderEl = this.el.querySelector('.the-loader')
movieStore.state.loading
? loaderEl.classList.remove('hide')
: loaderEl.classList.add('hide')
}
}
이렇게 작성을 한 후에, 한번 생각해봅시다. 로딩 애니메이션은 언제 보여야 할까요?
정답은 movie.js에서 상태를 통해 관리하여 언제 보여줄지 관리할 수 있게 만들어야 합니다.
import { Store } from "../core/heropy";
const store = new Store({
searchText: "",
page: 1,
pageMax: 1,
movies: [],
loading: false
});
export default store;
export const searchMovies = async (page) => {
store.state.loading = true
store.state.page = page;
if (page === 1) {
store.state.moives = [];
}
const res = await fetch(`https://omdbapi.com?apikey=?&s=${store.state.searchText}&p=${page}`)
const { Search, totalResults } = await res.json()
store.state.movies = [
...store.state.movies,
...Search
]
store.state.pageMax = Math.ceil(Number(totalResults) / 10)
store.state.loading = false
}
해당 코드를 보면 상태에 loading이라는 값을 false로 설정하여 적용시켜준 후
searchMovies 함수가 실행이 되면 바로 loading의 값을 true로 바꾸어 실행시켜줍니다.
그리고 fetch로 영화 정보를 가져오고 -> json데이터로 변환하고 -> movies에 상태에다가 영화 정보를 할당하고
-> 최대 페이지 개수도 구하면 로딩을 종료해주면 됩니다.
그렇다면 다시한번 MovieList.js 코드를 확인해봅시다.
import { Component } from "../core/heropy";
import movieStore from "../store/movie";
import MoiveItem from "./MovieItem";
export default class MovieList extends Component {
constructor() {
super();
movieStore.subscribe("movies", () => {
this.render();
});
movieStore.subscribe("loading", () => {
this.render();
});
}
render() {
this.el.classList.add("movie-list");
this.el.innerHTML = /* html */ `
<div class="the-loader hide"></div>
`;
const moviesEl = this.el.querySelector(".movies");
moviesEl?.append(
...movieStore.state.movies.map(
(movie) =>
new MoiveItem({
movie,
}).el
)
)
const loaderEl = this.el.querySelector('.the-loader')
movieStore.state.loading
? loaderEl.classList.remove('hide')
: loaderEl.classList.add('hide')
}
}
코드를 보면, 우리가 기존에 movies라는 데이터가 변경이 되면 다시 렌더 함수를 돌려서 밑에 있는 내용을 출력하게 해줬는데 이번에도 마찬가지로 작성해주면 됩니다.
이번에는 로딩이라는 상태가 변할 때 마다 다시 렌더 함수를 실행시켜 화면에 컴포넌트를 보이게 해주면 됩니다.
이러한 내용을 처리하는 코드를 querySelector을 통해 the-loader라는 클래스를 찾아서, 변수에 저장하고 그 로딩의 상태가 true이면 보이게하고, 아니라면 다시 숨겨주는 처리를 해주면 됩니다.
만약 사용자가 데이터베이스에 없는 영화를 검색을 하였거나, 혹은 존재하지 않는 영화를 검색했을 경우에는 어떻게 화면에 표시해줘야 할까요?
또는 검색하기전에 아무런 데이터도 표시되어 있지 않다면 우리는 영화를 검색해주세요, 라는 안내문구도 있으면 사용자에게 좋은 경험을 줄 수 있을것 같습니다.
이러한 경우를 대비해서 우리는 예외처리 및 메세지 상태를 관리 해주어야 합니다.
이러한 영화와 관련된 상태들을 관리하기 위해서는 movie.js을 통해 작성할 수 있습니다.
import { Store } from "../core/heropy";
const store = new Store({
searchText: "",
page: 1,
pageMax: 1,
movies: [],
loading: false,
message: "Search for the movie title!",
});
export default store;
export const searchMovies = async (page) => {
store.state.loading = true
store.state.page = page;
if (page === 1) {
store.state.moives = [];
store.state.message = '';
}
const res = await fetch(`https://omdbapi.com?apikey=?&s=${store.state.searchText}&p=${page}`)
const { Search, totalResults, Response, Error } = await res.json()
if (Response === 'True') {
store.state.movies = [
...store.state.movies,
...Search
]
store.state.pageMax = Math.ceil(Number(totalResults) / 10)
}
else {
store.state.message = Error
}
store.state.loading = false
}
역시나 state에 message라는 상태를 추가해주고, 기본값으로 영화의 제목을 검색해주세요 라는 안내문구를 작성해줍니다
이후, 영화를 검색하여 1page에 목록을 가져왔을 경우에는 안내문구가 필요가 없으니 값은 빈 문자열로 초기화시켜 줍니다.
그리고 우리가 영화목록을 가져올때 받는 데이터중에 Search, totalResults 말고도 어떤 데이터를 받을 수 있기 때문에 Response를, 그리고 예외처리나, 에러상황을 위해 Error 데이터를 받아옵니다.
여기서 만약 우리가 정상적으로 영화목록을 가져왔다면 Response는 ture 값일 것이고, 만약 false라면 영화의 정보를 제대로 가져오지 못했기 때문에 error를 표시해주면 되겠죠
그래서 if문을 통해 만약 Response가 ture라면 전에 작성했던대로, 영화목록을 가져오고,pageMax의 값을 구해주는 동작을 진행하고, 만약 false라면 에러를 출력하는 코드를 화면에 보여주면 되는것입니다.
하지만 이러한 동작들도 상태가 변경되어있기 때문에, 실제 변경된 내용이 출력될 수 있어야 하겠죠
그래서 출력할 내용을 MovieList 컴포넌트에서 보여줄 수 있게 바꿔주면 됩니다.
import { Component } from "../core/heropy";
import movieStore from "../store/movie";
import MoiveItem from "./MovieItem";
export default class MovieList extends Component {
constructor() {
super();
movieStore.subscribe("movies", () => {
this.render();
});
movieStore.subscribe("loading", () => {
this.render();
});
movieStore.subscribe("message", () => {
this.render();
});
}
render() {
this.el.classList.add("movie-list");
this.el.innerHTML = /* html */ `
${movieStore.state.message
? `<div class="message">${movieStore.state.message}</div>`
: '<div class="movies"></div>'}
<div class="the-loader hide"></div>
`;
const moviesEl = this.el.querySelector(".movies");
moviesEl?.append(
...movieStore.state.movies.map(
(movie) =>
new MoiveItem({
movie,
}).el
)
)
const loaderEl = this.el.querySelector('.the-loader')
movieStore.state.loading
? loaderEl.classList.remove('hide')
: loaderEl.classList.add('hide')
}
}
추가된 코드들을 보면
subscribe코드를 보기전에 innerHTML 코드를 보면 삼항연산자로 작성된 코드가 있습니다.
해당 코드를 보면, 만약 moiveStore의 상태 메시지가 true라면 즉, 영화가 검색이 되어있지 않은 상태거나, 혹은 영화가 존재하지 않는 경우, 그 응답에 맞는 메시지를 출력하여 보여주고, 만약 false라면 메시지는 빈 문자열 상태이고, 영화목록이 제대로 출력한 경우이기 때문에 해당 영화 목록을 보여줄 수 있게 됩니다.
그리고 이러한 상태가 변할때마다 해당 코드를 동작을 시켜줘야 하기 때문에
이제 위의 subscribe코드를 확인해 봅시다.
전에 loading과 moives의 내용과 같이 해당 상태가 변경이 되면 아래 render함수를 실행시켜 바뀐 상태의 해당 요소들이 화면에 잘 보여질 수 있도록 실행시켜 줍니다.