고양이 사진 검색기 만들기

요구사항

  • 검색 키워드를 입력하면 추천 검색어 API를 이용해 추천 검색어를 보여줌
    • 검색어 입력 후 엔터키 등의 추가 액션 없이도 검색어 목록을 보여줌
  • 키보드 혹은 마우스로 추천 검색어 선택 가능
    • esc를 누르면 검색창 닫힘
    • 키보드의 위, 아래를 누르면 추천 검색어 하이라이트가 옮겨지고 엔터를 누르면 하이라이트 처리된 검색어가 반영됨
    • 마우스로는 클릭한 검색어가 반영됨
  • 검색된 결과에 따라 고양이 사진이 화면에 렌더링

 

더보기
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>고양이 사진 검색기</title>
    <link rel="stylesheet" href="https://cdn.roto.codes/css/cat-search.css" />
  </head>
  <body>
    <main class="App"></main>
    <script src="/src/main.js" type="module"></script>
  </body>
</html>
더보기
import App from "./App.js";

const $target = document.querySelector(".App");

new App({ $target });
더보기
const END_POINT = "...";

export const request = async (url) => {
  try {
    const res = await fetch(`${END_POINT}${url}`);

    if (!res.ok) {
      throw new Error("API 호출 실패");
    }

    return await res.json();
  } catch (e) {
    alert(e.message);
  }
};
더보기
const storage = window.sessionStorage;

export const getItem = (key, defaultValue) => {
  try {
    const storedValue = storage.getItem(key);
    if (storedValue) {
      return JSON.parse(storedValue);
    }
    return defaultValue;
  } catch {
    return defaultValue;
  }
};

export const setItem = (key, value) => {
  storage.setItem(key, JSON.stringify(value));
};
더보기
export default function debounce(fn, delay) {
  let timer = null;
  return function () {
    const context = this;
    const args = arguments;
    clearTimeout(timer);
    timer = setTimeout(() => {
      fn.apply(context, args);
    }, delay);
  };
}
더보기
import { request } from "./api.js";
import { getItem, setItem } from "./storage.js";
import debounce from "./debounce.js";
import Header from "./Header.js";
import SuggestKeywords from "./SuggestKeywords.js";
import SearchResults from "./SearchResults.js";

export default function App({ $target }) {
  this.state = {
    keyword: "",
    keywords: [],
    catImages: [],
  };

  this.cache = getItem("keywords_cache", {});

  this.setState = (nextState) => {
    this.state = nextState;

    if (this.state.keyword !== nextState.keyword) {
      header.setState({ keyword: this.state.keyword });
    }

    suggestKeywords.setState({
      keywords: this.state.keywords,
    });

    if (this.state.catImages.length > 0) {
      searchResults.setState(this.state.catImages);
    }
  };

  const header = new Header({
    $target,
    initialState: {
      keyword: this.state.keyword,
    },
    onKeywordInput: debounce(async (keyword) => {
      if (keyword.trim().length > 1) {
        let keywords = null;

        if (this.cache[keyword]) {
          keywords = this.cache[keyword];
        } else {
          keywords = await request(`/keywords?q=${keyword}`);
          this.cache[keyword] = keywords;
          setItem("keywords_cache", this.cache);
        }

        this.setState({
          ...this.state,
          keyword,
          keywords,
        });
      }
    }, 300),
    onEnter: () => {
      fetchCatsImage();
    },
  });

  const searchResults = new SearchResults({
    $target,
    initialState: this.state.catImages,
  });

  const suggestKeywords = new SuggestKeywords({
    $target,
    initialState: {
      keywords: this.state.keywords,
      cursor: -1,
    },
    onKeywordSelect: (keyword) => {
      this.setState({
        ...this.state,
        keyword,
        keywords: [],
      });
      fetchCatsImage();
    },
  });

  const fetchCatsImage = async () => {
    const { data } = await request(`/search?q=${this.state.keyword}`);

    this.setState({
      ...this.state,
      catImages: data,
      keywords: [],
    });
  };
}
더보기
import Keyword from "./Keyword.js";

export default function Header({
  $target,
  initialState,
  onKeywordInput,
  onEnter,
}) {
  const $header = document.createElement("header");
  $header.className = "Header";

  $target.appendChild($header);

  this.state = initialState;

  this.setState = (nextState) => {
    if (this.state.keyword !== nextState.keyword) {
      this.state = nextState;

      keyword.setState({
        value: this.state.keyword,
      });
    }
  };

  const $title = document.createElement("h1");
  $title.style.textAlign = "center";
  $title.innerHTML = "🐈고양이 사진 검색기🔎";
  $header.appendChild($title);

  const keyword = new Keyword({
    $target: $header,
    initialState: {
      keyword: this.state.keyword,
    },
    onKeywordInput,
    onEnter,
  });
}
더보기
export default function Keyword({
  $target,
  initialState,
  onKeywordInput,
  onEnter,
}) {
  const $keyword = document.createElement("input");
  $keyword.className = "Keyword";

  $target.appendChild($keyword);

  this.state = initialState;

  this.setState = (nextStae) => {
    this.state = nextStae;
    $keyword.value = this.state.value;
  };

  $keyword.addEventListener("keyup", (e) => {
    if (e.key === "Enter") {
      onEnter();
    } else {
      onKeywordInput(e.target.value);
    }
  });
}
더보기
export default function SuggestKeywords({
  $target,
  initialState,
  onKeywordSelect,
}) {
  const $suggest = document.createElement("div");
  $suggest.className = "Keywords";
  $target.appendChild($suggest);

  this.state = initialState;

  this.setState = (nextState) => {
    this.state = { ...this.state, ...nextState };
    this.render();
  };

  this.render = () => {
    const { keywords, cursor } = this.state;
    $suggest.innerHTML = `
    <ul>
      ${keywords
        .map(
          (keyword, i) => `
        <li class="${cursor === i ? "active" : ""}">${keyword}</li>
      `
        )
        .join("")}
    </ul>`;

    $suggest.style.display = keywords.length > 0 ? "block" : "none";
  };

  this.render();

  $suggest.addEventListener("click", (e) => {
    const $li = e.target.closest("li");

    if ($li) {
      onKeywordSelect($li.textContent);
    }
  });

  window.addEventListener("keydown", (e) => {
    if ($suggest.style.display !== "none") {
      const { key } = e;
      if (key === "ArrowUp") {
        const nextCursor = this.state.cursor - 1;
        this.setState({
          ...this.state,
          cursor: nextCursor < 0 ? this.state.keywords.length - 1 : nextCursor,
        });
      } else if (key === "ArrowDown") {
        const nextCursor = this.state.cursor + 1;

        this.setState({
          ...this.state,
          cursor: nextCursor > this.state.keywords.length - 1 ? 0 : nextCursor,
        });
      } else if (key === "Enter") {
        onKeywordSelect(this.state.keywords[this.state.cursor]);
      }
    }
  });
}
더보기
export default function SearchResults({ $target, initialState }) {
  const $searchResults = document.createElement("div");
  $searchResults.className = "SearchResults";
  $target.appendChild($searchResults);

  this.state = initialState;

  this.setState = (nextState) => {
    this.state = nextState;
    this.render();
  };

  this.render = () => {
    $searchResults.innerHTML = `
      ${this.state
        .map(
          (result) => `
        <div>
          <img src="${result.url}" />
        </div>
      `
        )
        .join("")}
    `;
  };

  this.render();
}

 

main.js와 App.js를 분리하는 이유

  • App 컴포넌트의 생성을 main.js에서 하니까 App 컴포넌트를 한 화면에서 여러개 만드는 것이 가능
  • 분리의 가장 큰 목적은 컴포넌트를 선언하는 쪽과 실제로 이니셜라이징해서 실행하는 쪽을 분리시키기 위함
    • 선언과 실행을 분리하지 않으면 코드 가독성이 떨어지고 코드의 흐름을 따라가기가 어려움
    • 분리하면 유닛 테스트를 하기가 훨씬 수월함

 

onKeywordInput 흐름

App 컴포넌트에서는 Header에, Header는 Keyword에 onKeywordInput을 내려주고, Keyword에서 onKeywordInput를 호출하면 다시 거꾸로 Keyword에서 Header로, Header에서 App으로 가게 되는 것이다.

 

debounce

input창에 글자가 입력될 때마다 api가 호출되는데 이를 방지 하기 위해서 debounce를 추가하였다.

 

sessionStorage를 활용한 캐싱

검색어를 입력하면 검색 결과를  sessionStorage에 저장하고, 동일한 검색어가 입력될 시에 api를 호출하지 않고 storage에서 결과를 불러온다.

브라우저 종료시 저장된 결과를 모두 제거하기 위해 sesstionStorage를 사용하였다.

 


출처: 프로그래머스 프론트엔드 데브코스 

[Day 27] VanillaJS를 통한 자바스크립트 기본 역량 강화 II (4)

'데브코스' 카테고리의 다른 글

[Day 29] float, position, flex, grid  (0) 2022.11.24
[Day 28] TodoList Drag&Drop  (0) 2022.11.23
[Day 26] 고양이 사진첩 만들기  (0) 2022.11.22
[Day 25] 무한 스크롤 구현하기  (0) 2022.11.21
[Day 24] 프로젝트 배포  (0) 2022.11.17
복사했습니다!