
Today I Learn📖
- 무한 스크롤 UI 구현 (강의)
아래로 스크롤하다가 컨텐츠의 마지막 요소를 볼 쯤에 다음 컨텐츠가 있으면 불러오는 방식
=> 컨텐츠를 페이징 하는 기법 중 하나
=> 무한 스크롤 사용시 footer에는 접근 못함
높이 계산해서 처리하는 방법 (전통적 방법)Intersection Observer: 내가 지정한 위치에 닿는지를 감지해서 불러오기 처리observe, unobserve를 잘 해야함threshold 값을 통해 감시 대상이 보이는 면적 정할 수 있음https://도메인/cat-photos?_limit=${limit}&_start=${start}
PhotoList 파라미터 onScrollEnded에 비동기로 fetchPhotos 넣기
-> start에 limit 더하면서 누적합 만들기
-> isLoading 이용해서 fetchPhotos() 실행중에는 재요청 막기
// App.js
this.state = {
limit: 5, // 하드코딩 되어있지만 바꿔야함
nextStart: 0, // 하드코딩 되어있지만 바꿔야함
photos: [],
isLoading: false, // 한 번에 여러 개 요청 방지 -> 로딩중이면 반응 안 함
}
const photoListComponent = new PhotoList({
$target,
initialState: {
isLoading: this.state.isLoading,
photos: this.state.photos,
},
onScrollEnded: async() => {
await fetchPhotos()
}
})
this.setState = (nextState) => {
this.state = nextState
photoListComponent.setState({
isLoading: this.state.isLoading,
photos: nextState.photos
})
}
const fetchPhotos = async() => { // 사진 불러오는 함수
this.setState({
...this.state,
isLoading: true,
})
const { limit, nextStart } = this.state
const photos = await request(`/cat-photos?_limit=${limit}&_start=${nextStart}`)
this.setState({
...this.state,
nextstart: nextstart + limit, // nextstart는 누적 합
photos: this.state.photos.concat(photos), // [...this.state.photos, ...photos]으로 풀어 써도 됨
isLoading: false,
})
fetchPhotos()
li로 만들어지고, 그 li는 처음에 만든 ul에 추가됨ul에 각 사진의 id값이 있는지 확인해서, 없을 때만 li로 생성 (이미 불러왔던 사진은 다시 추가하지 않아 중복 렌더링 방지됨)// PhotoList.js
this.render = () => {
if (!isInitialize) { // 처음만 실행됨 (초기화 안 됐을 때만), 기본 HTML 구조 생성
$photoList.innerHTML = `
<ul class="PhotoList_photos"></ul>
<button ="PhotoList__loadMore"> 더 불러오기 </button>
// 사진 불러와지는지 확인하기 위해 버튼 임시 생성
`
isInitialize = true
}
const { photos } = this.state
const $photos = $photoList.querySelector('PhotoList__photos')
photos.forEach (photo => {
/* ⬇️ 새로 불러온 사진인지 id값으로 체크 */
if ($photos.querySelector(`li[data-id="${photo.id}"]`) === null) {
/* ⬇️ 새로 불러온 사진이면 li로 생성하고 $photos에 appendChild */
const $li = document.createElement('li') // li 생성
$li.setAttribute('data-id', photo.id) // li에 사진 id 넣기
$li.style = 'list-style: none;'
$li.innerHTML = '<img width="100%" src="${photo.imagePath}" />'
$photos.appendChild($li) // $photos에 추가
}
})
}
this.render()
$photoList.addEventListener ('click', e => { // 클릭 이벤트는 스크롤 이벤트 생성한 후 없앨 예정
if (e.target.className === 'PhotoList__loadMore' && !.this.state.isLoading) { // 로딩중이 아닐 때만 호출됨
onScrollEnded()
}
})
isScrollEnded을 이용해 페이지 끝인지 판단 후 끝 && 로딩중 아니면 onScrollEnded() 호출로 다음 사진 로딩
-> isScrollEnded의 반환값은 Boolean
// PhotoList.js
...
$photoList.addEventListener ('click', e => { ... })
/* (window.innerHeight + window.scrollY): 현재 브라우저 크기(높이) + 스크롤 한 길이 => 현재 화면의 맨 아래 위치
* (+ 100): 완전 끝에 닿지 않고, 끝 쯤에 가도 인식되도록 여유분 준 것
* document.body.offsetHeight: 렌더링 된 전체 높이
*/
window.addEventListener ('scroll', () => {
const isScrollEnded = (window.innerHeight + window.scrollY + 100) >= document.body.offsetHeight // true || false
/* 수식을 if문이 아닌 변수에 담은 이유는 읽는 사람에게 "페이지 끝에 닿았는지 확인하는 값"이라는 걸 직관적으로 알리기 위해서 ! */
if (isScrollEnded && !this.state.isLoading) {
onScrollEnded()
}
})
- 디바운스: 이벤트 지연, 같은 이벤트 연속 발생시 가장 마지막 것만 수행
- 쓰로틀: 일정 시간동안 같은 이벤트 연속 발생시 가장 처음 것만 수행 후 나머지 무시
지금까지 불러온 개수와 전체 컨텐츠의 개수를 비교하기
-> photos.length < totalCount를 통해서 가능
=> 마지막을 확인하지 않으면 끊임없이 API를 호출하게 됨
// App.js
this.state = {
...
totalCount: 0, // 전체 개수
isLoading: false,
}
const photoListComponent = new PhotoList({
$target,
initialState: {
...
totalCount: this.state.totalCount // 전체 개수
},
})
this.setState = (nextState) => {
this.state = nextState
photoListComponent.setState({
...
totalCount: this.state.totalCount // 전체 개수
})
}
...
const initialize = async () => {
const totalCount = await request ('/cat-photos/count') // 전체 개수 받아오기
this.setState({
...this.state,
totalCount
})
await fetchPhotos()
}
initialize()
// PhotoList.js
...
this.render()
}
window.addEventListener ('scroll', () => {
const { isLoading, totalCount, photos } = this.state
const isScrollEnded = (window.innerHeight + window.scrollY + 100) >= document.body.offsetHeight
/* photos.length < totalCount로 개수 비교 */
if (isScrollEnded && !isLoading && photos.length < totalCount) {
onScrollEnded()
}
})
// PhotoList.js
export default function PhotoList({ $target, initialState, onScrollEnded }) {
...
this.state = initialState
/* 첫 번째 파라미터: 콜백, 지켜볼 대상 (여러 개 가능)
* 두 번째 파라미터: 옵션 (선택 사항)
* -> root: 뷰 포트(감시할 요소)를 제한시킬 때 사용, null 쓰면 최상위(body || document)
* -> threshold: 드러난 면적 정도 (0: 아주 조금이라도 보이면 실행 ~ 1: 완전 다 보여야 실행)
*/
const observer = new IntersectionObserver (entries => {
entries.forEach (entry => { // 콜백들은 forEach로 순회
if (entry.isIntersecting && this.state.isLoading) { // isIntersecting으로 화면 끝인지 감시, 로딩중일 때는 감지 안 함
if (this.state.totalCount > this.state.photos.length) { // 컨텐츠 끝나면 더 안 불러오도록 방어
onScrollEnded() // 감시하는 대상이 보였을 때 (화면 끝에 들어왔을 떄) 실행됨
}
}
})
}, {
threshold: 0.5 // 이미지가 반 이상 들어오면 콜백 실행
})
let $lastLi = null
this.setState = nextState => {
...
photos.forEach (photo => {
...
$li.style = 'list-style: none; min-height: 800px;'
/* 이미지가 렌더링 되기 전에 작은 li가 전부 붙어서 내려오면 스크롤이 끝났다고 인식할 수도 있기 때문에 li 크기를 크게 잡음 (이미지 크기를 고려해서 정하면 됨)
* API에서 이미지 크기 내려주거나
* 이미지 홀더를 넣어서 이미지가 로딩되기 전에 크기 잡고있어도 됨 (바꿔치기)
*/
$li.innerHTML = '<img width="100%" src="${photo.imagePath}" />'
$photos.appendChild($li)
}
})
/* 추가된 마지막 사진 찾기 */
const $nextLi = $photos.querySelector ('li:last-child')
if($nextLi !== null) {
if ($lastLi !== null) {
observer.unobserve($lastLi) // 전에 있던 값 뻬주기
}
$lastLi = $nextLi
observer.observe($lastLi) // 계속 observer가 마지막 요소를 감시하게 하는중
}
}
this.render()
}
무한 스크롤과 구현 방식에 대해 배웠다. Intersection Observer를 이용하는 거라고만 대충 들었는데, 그 전에 높이 계산으로 구현하는 방식이 존재했다는 건 몰랐다.
생각해보면 높이 계산을 통해 무한 스크롤을 구현할 수 있는 게 당연하긴 하니까... ㅎㅎ
옵저버를 이용하면 무한 스크롤 뿐만 아니라 이미지 지연 로딩에도 쓸 수 있다고 하니까, 다음에 이미지 지연 로딩을 구현하게 된다면 사용해보고싶다.
요즘은 모바일을 많이 사용하는 시대니까 무한 스크롤은 꼭 알아두어야하는 방식인 것 같다 !