프로그래머스 과제관의 고양이 사진 검색 사이트 문제를 풀며 중요하다고 생각하는 문제와 해결 방법을 기록하려고 합니다.
전체 코드는 여기에서 확인할 수 있습니다.
요구 사항
기본적으로는 OS의 다크모드의 활성화 여부를 기반으로 동작하게 하되, 유저가 테마를 토글링 할 수 있도록 좌측 상단에 해당 기능을 토글하는 체크박스를 만듭니다.
다음과 같은 방법으로 구현 하였습니다.
window.matchMedia('(prefers-color-scheme: dark)').matches
코드를 이용해 현재 OS의 다크모드 활성화 여부를 가져옵니다.<body>
태그에 dark
클래스를 설정합니다.constructor() {
...
this.currentMode = window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark' : '';
this.render();
const $checkBox = document.querySelector('.dark-mode-btn');
$checkBox.checked = this.currentMode === 'dark';
$checkBox.addEventListener('change', (e) => {
this.currentMode = e.target.checked ? 'dark' : '';
this.render();
});
}
render() {
const body = document.querySelector('body');
body.className = this.currentMode;
}
css 코드
:root {
--color-bg: white;
--color-text: black;
}
.dark {
--color-bg: black;
--color-text: white;
}
body {
background: var(--color-bg);
color: var(--color-text);
transition: background 300ms ease-in-out, color 300ms ease;
}
css로 'fade-in' 애니메이션을 만들어서 모달의 animation
으로 설정해 줍니다. 이렇게 하면 .ImageInfo
클래스의 요소가 브라우저에 그려질 때 'fade-in' 애니메이션과 함께 그려집니다.
.ImageInfo {
animation: fade-in 300ms;
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
display: block;
visibility: visible;
}
}
'fade-in'과 마찬가지로 'fade-out' 애니메이션을 만들어주고 .ImageInfo
가 fadeOut
클래스를 가질 때 'fade-out' 애니메이션을 실행하도록 합니다.
animation-fill-mode
는 애니메이션 실행과 관련해서 CSS 스타일을 언제까지 적용할 것인지 결정하는 옵션입니다. (MDN 문서)
none
: 애니메이션 실행 도중에만 스타일을 적용합니다. (기본 값)forwards
: 애니메이션 실행 중/후에만 스타일을 적용합니다.backwards
: 애니메이션 실행 전/중에만 스타일을 적용합니다.both
: 애니메이션 실행 전/중/후 모두 스타일을 적용합니다.'fade-out'은 모달이 사라진 뒤에 그 상태 그대로 유지되어야 하니 forwards
로 설정해 줍니다.
.ImageInfo.fadeOut {
animation: fade-out 300ms;
animation-fill-mode: forwards;
}
@keyframes fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
display: none;
visibility: hidden;
}
}
이제 모달을 show 할 때는 'fadeOut' 클래스를 지워주고, close 시점에 'fadeOut' 클래스를 추가하면 모달을 닫을 때 'fade-out' 시킬 수 있습니다.
onShow() {
this.$imageInfo.classList.remove('fadeOut');
}
onClose() {
this.$imageInfo.classList.add('fadeOut');
}
Lazy Loading은 <img>
태그의 data-src
속성과 IntersectionObserver를 이용해 구현하였습니다.
<img>
에 lazy
클래스를 설정하고 보여줄 이미지 소스를 data-src
에 설정합니다.lazy
클래스는 lazy loading을 적용할 요소 마킹용data-src
는 소스 경로 저장용data-src
의 값을 src
로 복사해서 이미지 로딩 시작lazy
클래스 제거 & unobserve
)img.lazy
요소를 대상으로 생성한 IntersectionObserver를 붙여줍니다.export default function lazyLoading() {
const lazyImageObserver = new IntersectionObserver((entries, _) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
let lazyImage = entry.target;
lazyImage.src = lazyImage.dataset.src;
lazyImage.classList.remove('lazy');
lazyImageObserver.unobserve(lazyImage);
}
});
});
const lazyImages = Array.from(document.querySelectorAll('img.lazy'));
lazyImages.forEach((lazyImage) => lazyImageObserver.observe(lazyImage));
}
요구 사항
검색 결과 화면에서 유저가 브라우저 스크롤 바를 끝까지 이동시켰을 경우, 그 다음 페이지를 로딩하도록 만들어야 합니다.
무한 스크롤을 구현하기 위해서는 스크롤을 통해 페이지의 끝에 도달했는지를 먼저 알 수 있어야 합니다. 페이지의 끝에 도달했을 때 이벤트를 발생시키는 infiniteScroll()
함수를 아래와 같이 만듭니다.
// infiniteScroll.js
import throttle from './throttle.js';
export default function infiniteScroll(onScrollEnd) {
window.addEventListener('scroll', () => {
throttle(() => {
const endOfPage =
window.innerHeight + window.pageYOffset >= document.body.offsetHeight;
if (endOfPage) {
onScrollEnd();
}
}, 1000);
});
}
여기서 throttle()
은 아래와 같이 구현한 유틸 함수입니다. 스크롤 이벤트가 너무 자주 발생하기 때문에 이를 1초에 한 번으로 제한하기 위해 사용했습니다.
// throttle.js
let throttleTimer;
const throttle = (callback, time) => {
if (throttleTimer) return;
throttleTimer = true;
setTimeout(() => {
callback();
throttleTimer = false;
}, time);
};
export default throttle;
즉, infiniteScroll()
은 스크롤을 했을 때 1초에 한 번씩 페이지 끝에 도달했는지 체크하고, 페이지 끝에 도달했다면 인자로 받은 onScrollEnd
콜백을 호출하는 역할을 합니다.
infiniteScroll()
을 이용해 무한 스크롤을 구현해 봅시다.
// SearchResult 클래스
constructor() {
...
infiniteScroll(async () => {
// 페이지 끝에 도달하면 다음 50개의 고양이 데이터를 받아옵니다.
const nextData = await fetchNextData();
if (!nextData) return;
// (*) 새로 들어온 데이터를 HTML 요소로 만들어 검색 결과 리스트 뒤에 추가합니다.
const { data } = this.state;
this.$searchResult.innerHTML += this.createCatCards(
nextData,
data.length
);
this.state.data = [...data, ...nextData];
// 새로 추가된 아이템의 <img>를 대상으로 Lazy Loading 옵저버를 달아줍니다.
lazyLoading();
});
}
// data 리스트를 받아 고양이 카드 요소를 만들어냅니다.
createCatCards(data, startOffset) {
return data
.map(
(cat, i) => `
<li class="item" title="${cat.name}" data-index=${startOffset + i}>
<img class="lazy" data-src="${cat.url}" alt="${cat.name}" />
</li>
`
)
.join('');
}
여기서 (*)
표시한 부분이 중요한데, 기존에 보여주던 고양이 아이템을 유지한 채로 새로운 데이터를 뒤에 붙여서 보여주어야 하기 때문에 this.$searchResult.innerHTML
에 +=
로 새로 만든 HTML 요소를 추가해야 합니다.
요구 사항
- 현재 검색 결과 목록 위에 배너 형태의 랜덤 고양이 섹션을 추가합니다.
- 앱이 구동될 때 /api/cats/random50 api를 요청하여 받는 결과를 별도의 섹션에 노출합니다.
- 검색 결과가 많더라도 화면에 5개만 노출하며 각 이미지는 좌, 우 슬라이드 이동 버튼을 갖습니다.
- 좌, 우 버튼을 클릭하면, 현재 노출된 이미지는 사라지고 이전 또는 다음 이미지를 보여줍니다. (트랜지션은 선택)
ImageBanner
클래스를 만들어 아래와 같이 구현 해 줍니다.
startPos = 0;
constructor() {
...
(async () => {
// random 고양이 데이터 가져오기
this.data = await fetchRandomCats();
// 로딩 텍스트 없애고 이미지 리스트 보여주기
$loadingText.style.display = 'none';
$imgList.style.display = 'block';
this.render();
})();
}
render() {
// 받아온 데이터를 5개까지만 이미지 리스트에 표시하기
this.$imgList.innerHTML = this.data
.slice(this.startPos, this.startPos + 5)
.map(
(cat) =>
`<li><img src="${cat.url}" alt="${cat.name}" title="${cat.name}" /></li>`
)
.join('');
}
좌우 버튼을 클릭하면 한 칸씩 이동하며 고양이 이미지를 보여줍니다. (트랜지션은 구현하지 않았습니다.)
$prevButton.addEventListener('click', () => {
if (this.startPos === 0) return;
this.startPos -= 1;
this.render();
});
$nextButton.addEventListener('click', () => {
if (this.startPos + 5 === this.data.length) return;
this.startPos += 1;
this.render();
});
main.js
스크립트를 아래와 같이 추가해 줍니다.
<script type="module" src="src/main.js" />
이제 당연하다는 듯이 사용했던 외부 클래스를 명시적으로 import 해 주어야 합니다. 여기서 주의할 점은, CRA로 만든 리액트 앱에서는 파일 확장자를 생략해서 import 할 수 있었지만 Vanilla JS에서는 파일 확장자까지 넣어줘야 한다는 점입니다.
import App from './components/App.js';
new App(document.querySelector('#App'));
요구 사항
SearchResult 에 각 아이템을 클릭하는 이벤트를 Event Delegation 기법을 이용해 수정해주세요.
$searchResult
하위의 item
클래스를 가지는 모든 요소를 찾아 클릭 이벤트를 설정합니다.
this.$searchResult.querySelectorAll('.item').forEach(($item, index) => {
$item.addEventListener('click', () => {
this.onClick(this.data[index]);
});
});
$searchResult
자체에 클릭 이벤트를 설정합니다. e.target.closest
를 이용해 클릭된 대상으로부터 가장 가까운 item
클래스를 가진 요소를 찾아 onClick
콜백을 호출합니다.
this.$searchResult.addEventListener('click', (e) => {
const $searchItem = e.target.closest('.item');
const { index } = $searchItem.dataset;
this.onClick(this.data[index]);
});