프로그래머스 고양이 사진 검색 사이트 - 개발 (검색 페이지)

Z6su3·2022년 4월 19일
0

별도의 순서 변경 없이 분석의 검색 페이지 부분을 따라 순차적으로 개발을 진행합니다.

변경되는 프로젝트의 구성도는 다음과 같습니다.

main.js ← App.js ← api/api.js
← components(floder) ← SearchInput.js
← SearchError.js
← SearchKeyword.js
← SearchResult.js
← ImageInfo.js
← Loading.js
← lib ← LocalStorage.js
← LazyLoading.js

🐇 검색 창

기존 검색창 코드의 placeholder를 보면 |이상한 문자가 있음을 알 수 있습니다.

해당 문자는 고양시의 전용 서체로 고양고양이 캐릭터 일러스트를 딩벳으로 사용할 수 있습니다.

Local 연습 환경에서 해당 폰체를 사용하기 위해 다음과 같이 style을 수정해줍니다.

style.css

@font-face {
  font-family: "Goyang";
  src: url("https://cdn.jsdelivr.net/gh/projectnoonnu/noonfonts_one@1.0/Goyang.woff")
    format("woff");
  font-weight: normal;
  font-style: normal;
}
...

🥕 focus 처리

페이지 진입 시 처리되어야 하므로, 요소가 생성된 후 다음과 같이 focus처리를 해줍니다.

SearchInput.js

export default function SearchInput({ $app, onSearch }) {
	...
  $app.appendChild(this.$target);

  this.$target.focus();
	...
}

🥕 기존 키워드 삭제

키워드가 입력된 상태에서 검색창을 클릭하면 키워드를 삭제하도록 구현해야 합니다.

해당 요소를 클릭했을 때, 요소의 value값을 없애주도록 코딩합니다.

SearchInput.js

export default function SearchInput({ $app, onSearch }) {
  ...
	//클릭 시 기존 키워드 삭제
  this.$target.addEventListener("click", (e) => {
    e.target.value = "";
  });

  this.$target.addEventListener("keyup", (e) => {
    ...
  });
}

🐇 사용자 UI

🥕 Loading UI

검색결과가 없는 경우 유저가 파악할 수 있도록 UI 처리를 진행해줍니다.

loading 여부에 따라 변경되는 컴포넌트를 생성하고 App에 렌더링 해줍니다.

추후 개발이 완료된 후 요청이 존재하는 부분에 적용시켜 줍니다.

Loading.js


export default function Loading({ $app, initialState }) {
  this.state = initialState;
  this.$target = document.createElement("div");
  this.$target.className = "Loading";
  $app.appendChild(this.$target);

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

  this.render = () => {
    this.$target.innerHTML = `
      <div class="content">
        <div class="loading">Loading ... \` ─ ┌ </div>
      </div>
    `;

    this.$target.style.display = this.state ? "block" : "none";
  };

  this.render();
}

App.js

...
import Loading from "./components/Loading.js";
...
export default function App($app) {
  this.state = {
    loading: false,
		...
  };
	...

  const loading = new Loading({
    $app,
    initialState: this.state.loading,
  });

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

🥕 Error UI

검색결과가 없는 경우 유저가 파악할 수 있도록 UI처리가 필요합니다.

검색결과에 따라 변경되는 컴포넌트를 생성하여 처리하고 App에 렌더링한 뒤 검색결과가 없는 경우를 고려하여 App.js의 searchInput을 수정 해줍니다.

해당 변경을 변수 error를 통해 처리합니다.

SearchError.js

export default function SearchError({ $app, initialState }) {
  this.state = initialState;
  this.$target = document.createElement("div");
  this.$target.className = "SearchError";
  $app.appendChild(this.$target);

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

  this.render = () => {
    this.$target.innerHTML = `냐옹이들이 없어요. ┃ `;
    this.$target.style.display = this.state ? "block" : "none";
  };

  this.render();
}

App.js

import SearchError from "./components/SearchError.js";
...

export default function App($app) {
  this.state = {
    error: false,
		...
  };
	...
	const searchInput = new SearchInput({
    $app,
    onSearch: async (keyword) => {
      const searchData = await request("search", keyword);

			//데이터가 존재하지 않는 경우 Error UI처리
			if (!searchData.data || !searchData.data.length) {
        ...
        return;
      }

	    ...
			//데이터가 존재하는 경우 Error UI처리
			this.setState({
        ...
        error: false,
      });
    },
  });

  const searchError = new SearchError({
    $app,
    initialState: this.state.error,
  })

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

🐇 검색 키워드

최근 5개의 검색 키워드를 SearchInput아래 표시되도록 생성. 해당 키워드를 선택할 시 검색요청 발생하도록 구현해야합니다.

해당 기능을 구현하기 위해 다음과 같은 순서로 진행합니다.

  • 키워드 컴포넌트 생성
  • 키워드 변수 추가 및 렌더링
  • 키워드 로직 추가

🥕 키워드 컴포넌트 생성

해당 키워드를 선택할 시 검색요청이 발생하도록 구현해야합니다.

여기서 버튼에 이벤트 할당 시 이벤트 위임(EventDelegation)을 고려하여 개발합니다. 이벤트 위임은 closest를 활용하여 고려할 수 있습니다.

SearchKeyword.js

export default function SearchKeyword({ $app, initalState, onClick }) {
  this.state = initalState;
  this.$target = document.createElement("div");
  this.$target.className = "SearchKeyword";
  $app.appendChild(this.$target);

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

  this.render = () => {
    if (this.state) {
      this.$target.innerHTML = this.state
        .map((keyword) => {
          return `
            <button class="Keyword" data-keyword="${keyword}">
              ${keyword}
            </button>
          `;
        })
        .join("");
    }
  };

  this.onClick = onClick;

  this.$target.addEventListener("click", (e) => {
    const $keywordItem = e.target.closest(".Keyword");
    if ($keywordItem) {
      const { keyword } = $keywordItem.dataset;

      const $input = document.querySelector(".SearchInput");
      $input.value = keyword;

      this.onClick(keyword);
    }
  });

  this.render();
}

🥕 키워드 변수 추가 및 렌더링

변수 Keyword를 추가하고, App.js에 렌더링합니다.

키워드를 렌더링 하기 전 가장 최근 검색 및 클릭 한 키워드가 필두에 존재하도록 구현하여 App.js에 렌더링합니다.

App.js

import SearchKeyword from "./components/SearchKeyword.js";
...

export default function App($app) {
  this.state = {
		...
    keyword: [],
  };

	...

  const searchKeyword = new SearchKeyword({
    $app,
    initalState: this.state.keyword,
    onClick: async (keyword) => {
      const keywordData = await request("search", keyword);

			//검색한 키워드가 최근 기록에 있으면 배제
      const nextKeyword = [
        keyword,
        ...this.state.keyword.filter((word) => word != keyword),
      ];

      this.setState({
        ...this.state,
        data: keywordData.data,
        keyword: nextKeyword,
      });
    },
  });

	...

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

🥕 키워드 로직 추가

키워드는 검색을 할 때 생성됩니다. App.js에서 검색 창에 키워드 로직을 추가할 때 다음을 고려하여 개발합니다.

  • 최근에 검색한 키워드 5개 까지 등록
  • 검색 결과가 없는 경우 등록되지 않도록 구현

App.js

...
export default function App($app) {
	...

  const searchInput = new SearchInput({
    $app,
    onSearch: async (keyword) => {
      const searchData = await request("search", keyword);

      //검색 결과가 없는 경우 반환되어 keyword를 생성하지 않음
      if (!searchData.data || !searchData.data.length) {
        ...
        return;
      }

      //최근 키워드를 필두에 저장, 단 중복되는 단어는 제거
			var nextKeyword = [
        keyword,
        ...this.state.keyword.filter((word) => word != keyword),
      ];

      //키워드 추가 시 5개 이상 넘어가는 경우 처리
      if (nextKeyword.length > 5) {
        nextKeyword = nextKeyword.slice(0, 5);
      }

      ...
    },
  });

	...
}

🐇 검색 결과 유지

페이지 새로고침 시 마지막 검색결과를 화면에 유지해야합니다.

이를 위해 검색 요청이 일어나는 모든 부분에 localStorage를 활용하여 처리해줍니다.

해당 요청은 빈번하게 일어나기 때문에 따로 lib/LocalStorage.js로 생성하여 관리합니다.

LocalStorage.js

export const setLocalStorage = (data) => {
  localStorage.setItem("lastSearchData", JSON.stringify(data));
};

export const getLocalStorage = () => {
  return JSON.parse(localStorage.getItem("lastSearchData"));
};

App.js

...
import { setLocalStorage, getLocalStorage } from "./lib/LocalStorage.js";

export default function App($app) {
  ...

  const searchInput = new SearchInput({
    $app,
    onSearch: async (keyword) => {
      const searchData = await request("search", keyword);

      ...
      setLocalStorage(searchData);
			...
    },
  });

	...

  const searchKeyword = new SearchKeyword({
    $app,
    initalState: this.state.keyword,
    onClick: async (keyword) => {
      const keywordData = await request("search", keyword);

      setLocalStorage(keywordData);
			
			...
    },
  });

	//페이지 새로고침으로 리렌더링 될 때 초기설정 진행
  const init = () => {
    const storage = getLocalStorage();
		
		//데이터가 비어있거나, 없거나, 잘못 저장된 경우
    if (!storage || !storage.data || !storage.data.length) {
      return;
    }

    this.setState({
      ...this.state,
      data: storage.data,
    });
  };
  init();
}

🐇 랜덤 검색 추가

SearchInput 옆 자유롭게 버튼을 추가하여 /api/cats/random50을 호출하여 화면에 렌더링합니다.

동등한 위치에서 요소를 생성하기 위해 편의성을 고려하여, SearchInput에 section을 추가하고 검색창과 랜덤버튼을 하위 요소로 추가합니다.

이후 랜덤 버튼 관련한 이벤트 로직을 App.js의 searchInput에 추가합니다.

SearchInput.js

export default function SearchInput({ $app, onSearch, onClick }) {
  this.$target = document.createElement("section");
  this.$target.className = "SearchSection";

  this.$input = document.createElement("input");
  this.$input.type = "text";
  this.$input.className = "SearchInput";
  this.$input.placeholder = "고양이를 검색해보세요.|";

  this.$button = document.createElement("button");
  this.$button.className = "SearchRandom";
  this.$button.innerHTML = `<span>╅</span></br>랜덤냐옹`;

  this.$target.appendChild(this.$input);
  this.$target.appendChild(this.$button);
  $app.appendChild(this.$target);

  this.$target.focus();

  this.onSearch = onSearch;
  this.onClick = onClick;

  this.$input.addEventListener("click", (e) => {
    e.target.value = "";
  });

  this.$input.addEventListener("keyup", (e) => {
    if (e.keyCode === 13) {
      this.onSearch(e.target.value);
    }
  });

  this.$button.addEventListener("click", () => {
    this.onClick();
  });
}

App.js

...
export default function App($app) {
  ...
  const searchInput = new SearchInput({
    $app,
    onSearch: async (keyword) => {
			...
    },
    onClick: async () => {
      const randomData = await request("random");
      
      setLocalStorage(randomData);

      this.setState({
        ...this.state,
        data: randomData.data,
      });
    },
  });

	...
}

🐇 Lazy Load

lazy load에 대해 이해하고 이미지가 화면에 보일 시점에 loading 되도록 처리합니다.

lib/LazyLoading.js 로 파일을 관리하고 SearchResult.js에서 이미지가 렌더링 되는 시점에 lazy loading이 일어나도록 조절합니다.

lazy load에 대한 설명을 참고하여 Intersection Observer를 활용해 개발합니다. 순서는 다음과 같습니다.

  • img tagsrc 속성 아닌 data-src속성에 url을 할당할 것. 또한 lazy load가 적용된 태그는 lazy클래스를 할당할 것.
  • lazy클래스를 가진 모든 요소를 가져와 Intersection의 Observer를 할당
  • API를 통해 요소가 포착되면, lazy클래스를 삭제하고 url을 src속성으로 옮긴뒤 옵저버 제거

LazyLoading.js

[].slice.callMDN의 배열형 객체를 참조해주세요.

export default function LazyLoad() {
  const lazyImages = [].slice.call(document.querySelectorAll("img.lazy"));

  if ("IntersectionObserver" in window) {
    let lazyImageObserver = new IntersectionObserver((entries, observer) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting) {
          let lazyImage = entry.target;
          lazyImage.src = lazyImage.dataset.src;
          lazyImage.classList.remove("lazy");
          lazyImageObserver.unobserve(lazyImage);
        }
      });
    });

    lazyImages.forEach((lazyImage) => {
      lazyImageObserver.observe(lazyImage);
    });
  }
}

SearchResult.js

import LazyLoad from "../lib/LazyLoading.js";

export default function SearchResult({ $app, initialState }) {
  ...
	this.setState = (nextState) => {
    this.state = nextState;
    this.render();
		//렌더링 후 요소에 옵저버 부착
    LazyLoad();
  };

  this.render = () => {
		if (this.state.data) {
	    this.$target.innerHTML = this.state.data
	      .map(
	        (cat, index) => `
	        <div class="item" data-index="${index}">
						${/*이미지 태그에 lazy클래스 및 data-src속성에 url할당*/}
	          <img class="lazy" data-src=${cat.url} alt=${cat.name} />
	        </div>
	        `
	      )
	      .join("");
		}
  };
	
	...

  this.render();
	//렌더링 후 요소에 옵저버 할당
  LazyLoad();
}

🐇 고양이 이름

각 검색결과 아이템에 고양이 이름을 노출합니다.

SearchResult.js

...
export default function SearchResult({ $app, initialState }) {
  ...

  this.render = () => {
		if (this.state.data) {
	    this.$target.innerHTML = this.state.data
	      .map(
	        (cat, index) => `
	        <div class="item" data-index="${index}">
	          <img class="lazy" data-src=${cat.url} alt=${cat.name} />
						${/*고양이 이름 노출*/}
						<div>${cat.name}</div>
	        </div>
	        `
	      )
	      .join("");
		}
  };
	...
}
profile
기억은 기록을 이길수 없다

0개의 댓글