Vanilla JS 고양이 사진 검색기 구현하기

young-gue Park·2023년 2월 13일
0

JavaScript

목록 보기
20/20
post-thumbnail

⚡ 고양이 사진 검색기


📌 요구사항

1. 검색 키워드를 입력하면 추천 검색어 API를 이용해 추천 검색어를 보여준다.

  • 검색어 입력 후엔 엔터키 등 별도의 추가 액션이 없어도 검색어 목록을 보여주어야 한다.

2. 키보드 혹은 마우스로 추천 검색어를 선택할 수 있게 한다.

  • esc를 누르면 검색창이 닫힌다.
  • 키보드의 위, 아래를 누르면 추천 검색어 하이라이트가 옮겨지고 엔터를 누르면 하이라이트 처리된 검색어가 반영된다.
  • 마우스로는 클릭한 검색어가 반영된다.

3. 검색된 결과에 따라 고양이 사진이 화면에 렌더링 되어야한다.


📌 API, CSS

1. 검색 API : https://cat-search.edu-api.programmers.co.kr/keywords?q={keyword}

2. 사진 검색 API : https://cat-search.edu-api.programmers.co.kr/search?q={keyword}

3. CSS : https://cat-search-dev-serverlessdeploymentbucket-3288xrrz4slb.s3.ap-northeast-2.amazonaws.com/public/css/cat-search.css

👍 프로그래머스에서 제공하였습니다.


📌 컴포넌트 구조


  • 이번 프로젝트에서는 로컬 스토리지가 아닌 세션 스토리지를 이용하였다.
  • 디바운스를 이용하여 지나치게 캐시가 오가면서 낭비되는 메모리를 줄인다.

💡 디바운스(debounce)
타이머로 이벤트를 지연시키다가 이벤트가 발생하기 전에 같은이벤트가 또 들어오면 이전의 이벤트를 취소하고 타이머를 거는 동작의 반복을 뜻한다.


📌 구현

❗ 엔터를 누르면 하이라이트 처리된 검색어가 반영되는 기능이 세션 스토리지 연동 구현 이후 작동하지 않는다.
해당 이슈는 현재 확인 중에 있다.

2/20 해당 이슈 수정 완료

🖥 index.html

<!DOCTYPE html>
<html lang="ko">
<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://cat-search-dev-serverlessdeploymentbucket-3288xrrz4slb.s3.ap-northeast-2.amazonaws.com/public/css/cat-search.css">
</head>
<body>
    <main class="App"></main>
    <script src = "./src/main.js" type="module"></script>
</body>
</html>

🖥 main.js

import App from './App.js'

const $target = document.querySelector('.App')
new App({ $target })

🖥 api.js

const END_POINT = 'https://cat-search.edu-api.programmers.co.kr'

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)
    }
}
  • 여기까지는 달라진거라고는 API와 CSS 뿐이다.
  • Vanilla JS를 계속 배우면서 느낀 점은 비슷하게 사용하던 코드는 늘 비슷하다는 것이다.

🖥 Header.js

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
    })
    
}
  • 이름을 Header로 짓긴 했지만 단순한 Header 기능 외에 키워드에 대한 기능을 호출하였다.
  • 그래서 Keyword와 Header 사이에 연관성이 생겼다.

🖥 keyword.js

export default function Keyword({$target, initialState, onKeywordInput, onEnter}) {
    const $keyword = document.createElement('input')
    $keyword.className = 'Keyword'

    $target.appendChild($keyword)

    this.state = initialState

    this.setState = nextState => {
        this.state = nextState
        $keyword.value = this.state.value
    }

    $keyword.addEventListener('keyup', e => {
        if(e.key === 'Enter') {
            e.preventDefault()
            onEnter()
        } else {
            onKeywordInput(e.target.value)
        }
    })
}
  • 입력한 키워드의 글자와 엔터를 누른 검색어에 기반하여 검색 창의 키워드가 바뀐다.
  • 매개 변수로 들어간 함수들이 App.js에 정의되어 있을 거라는 사실은 이제 뻔하다.

🖥 SuggestKeywords.js

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 && keywords.map((keyword<, i) => `
                <li class = "${cursor === i ? 'active' : ''}">${keyword}</li>
            `).join('')}
        </ul>
        `

        $suggest.style.display = keywords && 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])
            }
        }
    })
}
  • 글자에 기반한 추천 키워드들을 띄운다.
  • 여러 개의 키워드가 뜨면 화살표를 눌러 위 아래로 이동이 가능하고 엔터 입력시 해당 글자가 선택된다.

🖥 SearchResults.js

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 && this.state.map(result => `
                <div>
                    <img src="${result.url}" ?>
                </div>
            `).join('')}
        `
    }
    this.render()
}
  • 검색한 결과를 검색창 하단에 사진들로 보여준다.

🖥 storage.js

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 {
    getItem,
    setItem
}
  • 세션 스토리지를 사용하였다.
  • 검색창에 들어와있는 값을 저장한다.

🖥 debounce.js

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)
    }
}
  • debounce 코드도 사실 api나 main처럼 늘 비슷비슷한 코드다.
  • 워낙 유명해서 VSCode의 copilot같은 확장에서는 저 모양으로 자동으로 만들어줄 정도.

🖥 App.js

import { request } from './api.js'
import storage  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 = storage.getItem('keywords_cache', {})

    this.setState = nextState => {
        this.state = nextState

        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
                    storage.setItem('keywords_cache', this.cache)
                }

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

    const suggestKeywords = new SuggestKeywords({
        $target,
        initialState: {
            keywords: this.state.keywords,
            cursor: -1 // 커서가 -1인 경우는 아무것도 없는 것
        },
        onKeywordSelect: (keyword) => {
            this.setState({
                ...this.state,
                keyword,
                keywords: []
            })
            fetchCatsImage()
        }
    })

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

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

        this.setState({
            ...this.state,
            catImages: data,
            keywords: []
        })
    }
}
  • 필요한 컴포넌트들을 묶고 컴포넌트에 매개변수로 넣을 함수들을 이곳에서 모두 정의한다.

🖨 완성 화면

  • 글자에 기반한 추천

  • 화살표 버튼으로 추천받을 고양이 종류로 커서 내리기

  • 엔터 버튼으로 검색을 완료하였다.

오류 수정에 많은 시간이 걸렸다.
하지만 완성했죠?

profile
Hodie mihi, Cras tibi

0개의 댓글