안녕하세요!
빅픽처인터렉티브(주)에서 프론트엔드 개발자로 재직 중인 김종완입니다.
이번 글에서는 이미지를 브라우저 뷰포트에 들어왔을때 lazy load 할 수 있게 해 주도록 만든 imglazyLoad 디렉티브에 대한 구현 방법과 사용 설명에 대해 간략히 적어보려고 합니다.
이번에 작성하는 글은 다른 프론트엔드 팀원분들도 아주 쉽게 이해를 할 수 있을 거라 판단하여 정말 간략히 만 설명드리고자 하여 글이 조금 짧을 수도 있습니다 ^^;;
구현 배경
페이지가 나타날때 스크롤이 어떻든 보통 페이지 전체를 랜더링을 하게 되며 이미지 또한 전부 가져오게 됩니다.
하지만 스크롤이 긴 페이지의 경우 즉, 리스트가 굉장히 많은 페이지의 경우 그리고 그 리스트에 용량이 클 수도 있는 이미지들이 섞여 있다면 한 번에 많은 이미지를 로드하기 위해 그만큼 네트워크 요청을 하게 되고 이는 트래픽이 높아져 서버에 부하를 줄 수 있게 됩니다.
그렇게 차이가 있을까?
그렇습니다 커머스가 아니라면 그리고 이미지 용량이 크지만 않다면 딱히 큰 차이가 없을 수도 있지만 꼭 이것만을 위해 구현한것은 아닙니다.
lazy load를 구현하면서 추가적으로 처리를 해주고 싶었던 것은 이미지를 제대로 가져왔는지 아니면 가져오는데에 실패를 하였는지 보다 손쉽게 확인하고 커스텀하게 처리할 수 있도록 하고자 하는 이유도 있었습니다.
구현하면서 저희 목표는 이러했습니다.
1. viewport에 진입하였을 때 lazy load가 되도록 한다.
2. image뿐만 아니라 background-image도 lazy-load할 수 있어야 한다.
3. 로드/에러 여부를 손쉽게 확인할 수 있어야한다.
4. 로드/에러시 style에서도 바로 확인이 가능하며 style(css, less..)만으로 UI를 커스텀하게 분기 처리할 수 있어야 한다.
5. 추가적으로 로드/에러시 이벤트를 받아 script단에서도 추가로 커스텀이 가능하여야 한다.
6. 쓰기 쉽도록 만든다.
구현 방법
viewport에 들어왔는지 아닌지를 확인하기 위해 IntersectionObserver API를 쓰기로 하였고 이미지가 큰 경우 viewport에 진입하고 나서 이미지를 로드하게 되면 조금 부자연스러울 수도 있다고 판단하여 100px 정도의 여유를 주도록 구현을 하여야 했습니다.
const imgLazyLoad = {
inserted: (el, binding, vnode) => {
if (!window) return;
if (window.IntersectionObserver) {
if (!Vue.$imgLazyLoadIo) {
const options = {
root: null,
rootMargin: '100px 0px',
};
Vue.$imgLazyLoadIo = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
const { target } = entry;
if (entry.intersectionRatio > 0) {
imageLoader(vnode, target);
observer.unobserve(target);
}
});
}, options);
}
Vue.$imgLazyLoadIo.observe(el);
} else {
imageLoader(vnode, el);
}
},
};
바인딩된 엘리먼트가 DOM에 삽입되었을 때 IntersectionObserver API를 사용할 수 있는 브라우저 환경인지 확인한 후 지원이 안 되는 브라우저 환경일 경우 lazy load를 하지 않고 이미지를 로드합니다. 하지만 지원을 하는 경우 IntersectionObserver객체가 아직 생성되지 않았다면 Vue 객체에 $imgLazyLoadIo라는 객체에 초기화해주고 해당 엘리먼트가 observe가 되도록 해줍니다.
그 후 entry가 viewport에 조금이라도 진입되면 이미지를 로드하고 다시는 관찰하지 않도록 unobserve를 해줍니다.
그럼 imageLoader 함수를 보도록 하겠습니다.
const imageLoader = (vnode, el) => {
const src = el.getAttribute('data-src');
const bgSrc = el.getAttribute('data-bg-src');
const imageEl = bgSrc ? document.createElement('img') : el;
el.setAttribute('lazy', 'loading');
if(src) el.setAttribute('src', src);
else if(bgSrc) {
imageEl.setAttribute('src', bgSrc);
el.style.backgroundImage = `url(${bgSrc})`;
}
else {
el.setAttribute('lazy', 'error');
}
imageEl.onload = () => {
el.removeAttribute('data-src');
el.removeAttribute('data-bg-src');
el.removeAttribute('data-error');
el.setAttribute('lazy', 'loaded');
emitEvent(vnode, 'lazy-load', { el });
};
imageEl.onerror = () => {
const errorImg = el.getAttribute('data-error');
el.removeAttribute('data-error');
if (errorImg) {
if(src) el.setAttribute('src', errorImg);
else if(bgSrc) el.style.backgroundImage = `url(${bgSrc})`;
} else {
el.setAttribute('lazy', 'error');
emitEvent(vnode, 'lazy-error', { el });
}
};
};
imageLoader함수는 말 그대로 이미지를 불러오는 부분을 담당하는 함수입니다.
엘리먼트의 dataset 중 src와 bg-src를 가져오고 로드해오고자 하는 이미지가 백그라운드 이미지인지 확인을 합니다.
백그라운드 이미지를 로드해오고자 한다면 태그가 img가 아닌 다른 태그일 수도 있기 때문에 onload & onerror 이벤트가 존재하지 않을 수 있습니다. 그렇기 때문에 백그라운드 이미지를 로드해오고자 할 시 img 엘리먼트를 만들어 주고 onload & onerror 이벤트를 감지할 수 있도록 합니다.
소스를 보다 보면 엘리먼트에 attribute를 set 해주는 부분을 중간 중간 보실 수 있으실 겁니다. 여기서 lazy라는 attribute를 set해주는 경우가 있는데 이 부분이 바로 style에서도 이미지가 제대로 로드가 되었는지 아니면 에러가 났는지 로드 중인지 확인할 수 있게 하는 부분입니다.
>img{ .hidden;
&[lazy=loaded]{ .visible; }
&[lazy=error]{ background-color:red; }
&[lazy=loading] { background-color:gray; }
}
이렇게 할 경우 저희는 더 이상 불필요한 script를 작성하지 않고 오직 style부분만 커스텀하여 더 나은 UI를 만들어낼 수 있게 되겠죠?
또한 이미지 로드에 실패한 경우나 로드가 된 경우 directive를 사용한 엘리먼트가 이벤트를 emit 해주도록 되어 있어 script 적으로도 추가적으로 대처할 수 있도록 하였습니다.
...
<img v-img-lazy-load v-if="imgLoad" :data-src="imgSrc" :alt="alt" @load="onLoad" @error="onError">
...
<script>
import imgLazyLoad from '@/directives/imgLazyLoad';
export default {
...
data() {
return {
imgLoad: false,
};
},
...
methods: {
onLoad() {
this.imgLoad = true;
},
onError() {
console.log('에러 발생!!!');
},
},
};
</script>
다른 부분은 소스를 보시면 이해가 쉽게 되겠지만 추가적으로 말씀드릴 부분은 onerror 부분입니다.
이미지 로드에 실패했을 경우 대체 이미지를 보여주고자 할 때 ImgLazyLoad directive에서는 error라는 dataset을 받도록 되어있습니다.
<img v-img-lazy-load :data-src="imgSrc" :alt="alt" data-error="error.jpg">
근데 대체 이미지 또한 에러가 난다면? 아마.. 무한루프가 발생하겠죠.
그렇기 때문에 한번 이미지를 로드하는데 실패하였을 경우 data-error의 이미지를 다시 불러오는데 불러오기 전 error dataset을 지워줍니다. 그렇게 되면 또다시 이미지 로드에 실패할 경우 대체 이미지가 없다고 판단하고 error이벤트를 emit 해주게 되며 더 이상 이미지를 로드해오지 않습니다.
이벤트를 emit 해주는 emitEvent 함수는 아래와 같으며 별다른 설명이 없다고 판단하여 소스만 공유하도록 하겠습니다.
const emitEvent = (vnode, eventName, args = undefined) => {
if (vnode.componentInstance) {
vnode.componentInstance.$emit('lazy-error', args);
} else {
vnode.elm.dispatchEvent(new CustomEvent('lazy-error', args));
}
};
사용법
1. 이미지 lazy load
<img v-img-lazy-load data-src="default.jpg" data-error="error.jpg" :alt="alt" @load="onLoad" @error="onError">
...
methods: {
onLoad() {
this.imgLoad = true;
},
onError(el) { // element를 인자로 넘겨받는다.
console.log('에러 발생!!!');
},
},
...
<style lang="scss">
img{ visiblity: hidden;
&[lazy=loaded]{ visiblity: visible; }
&[lazy=error]{ background-color:red; }
&[lazy=loading] { background-color:gray; }
}
</style>
1. 백그라운드 이미지 lazy load
<div v-img-lazy-load data-bg-src="default.jpg" data-error="error.jpg" :alt="alt" @load="onLoad" @error="onError">
...
methods: {
onLoad() {
this.imgLoad = true;
},
onError(el) { // element를 인자로 넘겨받는다.
console.log('에러 발생!!!');
},
},
...
<style lang="scss">
> div{ visiblity: hidden;
&[lazy=loaded]{ visiblity: visible; }
&[lazy=error]{ background-color:red; }
&[lazy=loading] { background-color:gray; }
}
</style>
마치며..
구현하는데 크게 어려움이 없었지만 세심한 부분이 퀄리티를 높여준다고 생각하기 때문에 구현하게 된 디렉티브입니다.
팀원분들께서 잘 사용할지 안 할지는 모르겠지만 많은 플랫폼에서 image lazy load를 많이 활용하고 있다고 생각하였고 써드파티를 가져와서 쓰기보다는 직접 만들어서 쓰면 저희 서비스에 맞게 수정도 용이할 거 같아 구현하였습니다.
빅픽처 프론트 팀원들은 아마 디렉티브 소스만 보시면 바로 아~ 하며 이해할만한 정도의 수준이어서 별다른 피드백이 없을 거 같긴 하지만 아쉬운 부분이 있다거나 제가 놓친 부분이 있다면 언제든지 피드백 주시면 감사하겠습니다.
추가로 LVUP에서 쓰고 있는 CdnImg 같은 공용 컴포넌트도 v-lazy-load를 이용해서 만들고 있으니까
나중에 시간 되시면 한 번씩 봐주시고 적극적으로 사용해주시면 감사하겠습니다!
감사합니다~~~
'레벨업의 테크노트' 카테고리의 다른 글
[프론트엔드]3D 웹앱 깊이 빠져보기 - 2: webGL 퍼포먼스 향상시키기 (0) | 2022.06.07 |
---|---|
[유저리서치4] 결과 정리하고 활용하기 (0) | 2022.04.29 |
[유저리서치3] 고객을 만나고 인터뷰 하기 (0) | 2022.04.22 |
[프론트엔드/기획/디자인]3D 웹앱 깊이 빠져보기 - 1 (0) | 2022.04.11 |
[프론트엔드] React 뒤로가기 시 상태값 & 스크롤 위치 유지 (0) | 2022.04.05 |
댓글