Improve performance of LazyLoader by using IntersectionObserver
Every browser which supports IntersectionObserver will now use it over observing scroll and resize events. Older browsers without support fall back on the previous behavior. Additionally the MutationObserver can be enabled and disabled manually via the helper method `startContentObserver` and `stopContentObserver`. This might prove useful on pages where we manipulate the DOM extensively.
This commit is contained in:
parent
df73116f75
commit
4552a9f9fb
3 changed files with 268 additions and 34 deletions
|
@ -2,54 +2,114 @@ import _ from 'underscore';
|
|||
|
||||
export const placeholderImage =
|
||||
'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
|
||||
const SCROLL_THRESHOLD = 300;
|
||||
const SCROLL_THRESHOLD = 500;
|
||||
|
||||
export default class LazyLoader {
|
||||
constructor(options = {}) {
|
||||
this.intersectionObserver = null;
|
||||
this.lazyImages = [];
|
||||
this.observerNode = options.observerNode || '#content-body';
|
||||
|
||||
const throttledScrollCheck = _.throttle(() => this.scrollCheck(), 300);
|
||||
const debouncedElementsInView = _.debounce(() => this.checkElementsInView(), 300);
|
||||
|
||||
window.addEventListener('scroll', throttledScrollCheck);
|
||||
window.addEventListener('resize', debouncedElementsInView);
|
||||
|
||||
const scrollContainer = options.scrollContainer || window;
|
||||
scrollContainer.addEventListener('load', () => this.loadCheck());
|
||||
scrollContainer.addEventListener('load', () => this.register());
|
||||
}
|
||||
|
||||
static supportsIntersectionObserver() {
|
||||
return 'IntersectionObserver' in window;
|
||||
}
|
||||
|
||||
searchLazyImages() {
|
||||
const that = this;
|
||||
requestIdleCallback(
|
||||
() => {
|
||||
that.lazyImages = [].slice.call(document.querySelectorAll('.lazy'));
|
||||
const lazyImages = [].slice.call(document.querySelectorAll('.lazy'));
|
||||
|
||||
if (that.lazyImages.length) {
|
||||
that.checkElementsInView();
|
||||
if (LazyLoader.supportsIntersectionObserver()) {
|
||||
if (this.intersectionObserver) {
|
||||
lazyImages.forEach(img => this.intersectionObserver.observe(img));
|
||||
}
|
||||
} else if (lazyImages.length) {
|
||||
this.lazyImages = lazyImages;
|
||||
this.checkElementsInView();
|
||||
}
|
||||
},
|
||||
{ timeout: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
startContentObserver() {
|
||||
const contentNode = document.querySelector(this.observerNode) || document.querySelector('body');
|
||||
|
||||
if (contentNode) {
|
||||
const observer = new MutationObserver(() => this.searchLazyImages());
|
||||
this.mutationObserver = new MutationObserver(() => this.searchLazyImages());
|
||||
|
||||
observer.observe(contentNode, {
|
||||
this.mutationObserver.observe(contentNode, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
loadCheck() {
|
||||
this.searchLazyImages();
|
||||
this.startContentObserver();
|
||||
|
||||
stopContentObserver() {
|
||||
if (this.mutationObserver) {
|
||||
this.mutationObserver.takeRecords();
|
||||
this.mutationObserver.disconnect();
|
||||
this.mutationObserver = null;
|
||||
}
|
||||
}
|
||||
|
||||
unregister() {
|
||||
this.stopContentObserver();
|
||||
if (this.intersectionObserver) {
|
||||
this.intersectionObserver.takeRecords();
|
||||
this.intersectionObserver.disconnect();
|
||||
this.intersectionObserver = null;
|
||||
}
|
||||
if (this.throttledScrollCheck) {
|
||||
window.removeEventListener('scroll', this.throttledScrollCheck);
|
||||
}
|
||||
if (this.debouncedElementsInView) {
|
||||
window.removeEventListener('resize', this.debouncedElementsInView);
|
||||
}
|
||||
}
|
||||
|
||||
register() {
|
||||
if (LazyLoader.supportsIntersectionObserver()) {
|
||||
this.startIntersectionObserver();
|
||||
} else {
|
||||
this.startLegacyObserver();
|
||||
}
|
||||
this.startContentObserver();
|
||||
this.searchLazyImages();
|
||||
}
|
||||
|
||||
startIntersectionObserver = () => {
|
||||
this.throttledElementsInView = _.throttle(() => this.checkElementsInView(), 300);
|
||||
this.intersectionObserver = new IntersectionObserver(this.onIntersection, {
|
||||
rootMargin: `${SCROLL_THRESHOLD}px 0px`,
|
||||
thresholds: 0.1,
|
||||
});
|
||||
};
|
||||
|
||||
onIntersection = entries => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
this.intersectionObserver.unobserve(entry.target);
|
||||
this.lazyImages.push(entry.target);
|
||||
}
|
||||
});
|
||||
this.throttledElementsInView();
|
||||
};
|
||||
|
||||
startLegacyObserver() {
|
||||
this.throttledScrollCheck = _.throttle(() => this.scrollCheck(), 300);
|
||||
this.debouncedElementsInView = _.debounce(() => this.checkElementsInView(), 300);
|
||||
window.addEventListener('scroll', this.throttledScrollCheck);
|
||||
window.addEventListener('resize', this.debouncedElementsInView);
|
||||
}
|
||||
|
||||
scrollCheck() {
|
||||
requestAnimationFrame(() => this.checkElementsInView());
|
||||
}
|
||||
|
||||
checkElementsInView() {
|
||||
const scrollTop = window.pageYOffset;
|
||||
const visHeight = scrollTop + window.innerHeight + SCROLL_THRESHOLD;
|
||||
|
@ -61,18 +121,29 @@ export default class LazyLoader {
|
|||
const imgTop = scrollTop + imgBoundRect.top;
|
||||
const imgBound = imgTop + imgBoundRect.height;
|
||||
|
||||
if (scrollTop < imgBound && visHeight > imgTop) {
|
||||
if (scrollTop <= imgBound && visHeight >= imgTop) {
|
||||
requestAnimationFrame(() => {
|
||||
LazyLoader.loadImage(selectedImage);
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
/*
|
||||
If we are scrolling fast, the img we watched intersecting could have left the view port.
|
||||
So we are going watch for new intersections.
|
||||
*/
|
||||
if (LazyLoader.supportsIntersectionObserver()) {
|
||||
if (this.intersectionObserver) {
|
||||
this.intersectionObserver.observe(selectedImage);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
static loadImage(img) {
|
||||
if (img.getAttribute('data-src')) {
|
||||
let imgUrl = img.getAttribute('data-src');
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
title: Improve lazy image loading performance by using IntersectionObserver where
|
||||
available
|
||||
merge_request: 21565
|
||||
author:
|
||||
type: performance
|
|
@ -1,57 +1,214 @@
|
|||
import LazyLoader from '~/lazy_loader';
|
||||
import { TEST_HOST } from './test_constants';
|
||||
|
||||
let lazyLoader = null;
|
||||
|
||||
const execImmediately = callback => {
|
||||
callback();
|
||||
};
|
||||
|
||||
describe('LazyLoader', function() {
|
||||
preloadFixtures('issues/issue_with_comment.html.raw');
|
||||
|
||||
beforeEach(function() {
|
||||
loadFixtures('issues/issue_with_comment.html.raw');
|
||||
lazyLoader = new LazyLoader({
|
||||
observerNode: 'body',
|
||||
describe('with IntersectionObserver disabled', () => {
|
||||
beforeEach(function() {
|
||||
loadFixtures('issues/issue_with_comment.html.raw');
|
||||
|
||||
lazyLoader = new LazyLoader({
|
||||
observerNode: 'foobar',
|
||||
});
|
||||
|
||||
spyOn(LazyLoader, 'supportsIntersectionObserver').and.callFake(() => false);
|
||||
|
||||
spyOn(LazyLoader, 'loadImage').and.callThrough();
|
||||
|
||||
spyOn(window, 'requestAnimationFrame').and.callFake(execImmediately);
|
||||
spyOn(window, 'requestIdleCallback').and.callFake(execImmediately);
|
||||
|
||||
// Doing everything that happens normally in onload
|
||||
lazyLoader.register();
|
||||
});
|
||||
// Doing everything that happens normally in onload
|
||||
lazyLoader.loadCheck();
|
||||
});
|
||||
describe('behavior', function() {
|
||||
|
||||
afterEach(() => {
|
||||
lazyLoader.unregister();
|
||||
});
|
||||
|
||||
it('should copy value from data-src to src for img 1', function(done) {
|
||||
const img = document.querySelectorAll('img[data-src]')[0];
|
||||
const originalDataSrc = img.getAttribute('data-src');
|
||||
img.scrollIntoView();
|
||||
|
||||
setTimeout(() => {
|
||||
expect(LazyLoader.loadImage).toHaveBeenCalled();
|
||||
expect(img.getAttribute('src')).toBe(originalDataSrc);
|
||||
expect(document.getElementsByClassName('js-lazy-loaded').length).toBeGreaterThan(0);
|
||||
expect(img).toHaveClass('js-lazy-loaded');
|
||||
done();
|
||||
}, 100);
|
||||
}, 50);
|
||||
});
|
||||
|
||||
it('should lazy load dynamically added data-src images', function(done) {
|
||||
const newImg = document.createElement('img');
|
||||
const testPath = '/img/testimg.png';
|
||||
const testPath = `${TEST_HOST}/img/testimg.png`;
|
||||
newImg.className = 'lazy';
|
||||
newImg.setAttribute('data-src', testPath);
|
||||
document.body.appendChild(newImg);
|
||||
newImg.scrollIntoView();
|
||||
|
||||
setTimeout(() => {
|
||||
expect(LazyLoader.loadImage).toHaveBeenCalled();
|
||||
expect(newImg.getAttribute('src')).toBe(testPath);
|
||||
expect(document.getElementsByClassName('js-lazy-loaded').length).toBeGreaterThan(0);
|
||||
expect(newImg).toHaveClass('js-lazy-loaded');
|
||||
done();
|
||||
}, 100);
|
||||
}, 50);
|
||||
});
|
||||
|
||||
it('should not alter normal images', function(done) {
|
||||
const newImg = document.createElement('img');
|
||||
const testPath = '/img/testimg.png';
|
||||
const testPath = `${TEST_HOST}/img/testimg.png`;
|
||||
newImg.setAttribute('src', testPath);
|
||||
document.body.appendChild(newImg);
|
||||
newImg.scrollIntoView();
|
||||
|
||||
setTimeout(() => {
|
||||
expect(LazyLoader.loadImage).not.toHaveBeenCalled();
|
||||
expect(newImg).not.toHaveClass('js-lazy-loaded');
|
||||
done();
|
||||
}, 100);
|
||||
}, 50);
|
||||
});
|
||||
|
||||
it('should not load dynamically added pictures if content observer is turned off', done => {
|
||||
lazyLoader.stopContentObserver();
|
||||
|
||||
const newImg = document.createElement('img');
|
||||
const testPath = `${TEST_HOST}/img/testimg.png`;
|
||||
newImg.className = 'lazy';
|
||||
newImg.setAttribute('data-src', testPath);
|
||||
document.body.appendChild(newImg);
|
||||
newImg.scrollIntoView();
|
||||
|
||||
setTimeout(() => {
|
||||
expect(LazyLoader.loadImage).not.toHaveBeenCalled();
|
||||
expect(newImg).not.toHaveClass('js-lazy-loaded');
|
||||
done();
|
||||
}, 50);
|
||||
});
|
||||
|
||||
it('should load dynamically added pictures if content observer is turned off and on again', done => {
|
||||
lazyLoader.stopContentObserver();
|
||||
lazyLoader.startContentObserver();
|
||||
|
||||
const newImg = document.createElement('img');
|
||||
const testPath = `${TEST_HOST}/img/testimg.png`;
|
||||
newImg.className = 'lazy';
|
||||
newImg.setAttribute('data-src', testPath);
|
||||
document.body.appendChild(newImg);
|
||||
newImg.scrollIntoView();
|
||||
|
||||
setTimeout(() => {
|
||||
expect(LazyLoader.loadImage).toHaveBeenCalled();
|
||||
expect(newImg).toHaveClass('js-lazy-loaded');
|
||||
done();
|
||||
}, 50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with IntersectionObserver enabled', () => {
|
||||
beforeEach(function() {
|
||||
loadFixtures('issues/issue_with_comment.html.raw');
|
||||
|
||||
lazyLoader = new LazyLoader({
|
||||
observerNode: 'foobar',
|
||||
});
|
||||
|
||||
spyOn(LazyLoader, 'loadImage').and.callThrough();
|
||||
|
||||
spyOn(window, 'requestAnimationFrame').and.callFake(execImmediately);
|
||||
spyOn(window, 'requestIdleCallback').and.callFake(execImmediately);
|
||||
|
||||
// Doing everything that happens normally in onload
|
||||
lazyLoader.register();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
lazyLoader.unregister();
|
||||
});
|
||||
|
||||
it('should copy value from data-src to src for img 1', function(done) {
|
||||
const img = document.querySelectorAll('img[data-src]')[0];
|
||||
const originalDataSrc = img.getAttribute('data-src');
|
||||
img.scrollIntoView();
|
||||
|
||||
setTimeout(() => {
|
||||
expect(LazyLoader.loadImage).toHaveBeenCalled();
|
||||
expect(img.getAttribute('src')).toBe(originalDataSrc);
|
||||
expect(img).toHaveClass('js-lazy-loaded');
|
||||
done();
|
||||
}, 50);
|
||||
});
|
||||
|
||||
it('should lazy load dynamically added data-src images', function(done) {
|
||||
const newImg = document.createElement('img');
|
||||
const testPath = `${TEST_HOST}/img/testimg.png`;
|
||||
newImg.className = 'lazy';
|
||||
newImg.setAttribute('data-src', testPath);
|
||||
document.body.appendChild(newImg);
|
||||
newImg.scrollIntoView();
|
||||
|
||||
setTimeout(() => {
|
||||
expect(LazyLoader.loadImage).toHaveBeenCalled();
|
||||
expect(newImg.getAttribute('src')).toBe(testPath);
|
||||
expect(newImg).toHaveClass('js-lazy-loaded');
|
||||
done();
|
||||
}, 50);
|
||||
});
|
||||
|
||||
it('should not alter normal images', function(done) {
|
||||
const newImg = document.createElement('img');
|
||||
const testPath = `${TEST_HOST}/img/testimg.png`;
|
||||
newImg.setAttribute('src', testPath);
|
||||
document.body.appendChild(newImg);
|
||||
newImg.scrollIntoView();
|
||||
|
||||
setTimeout(() => {
|
||||
expect(LazyLoader.loadImage).not.toHaveBeenCalled();
|
||||
expect(newImg).not.toHaveClass('js-lazy-loaded');
|
||||
done();
|
||||
}, 50);
|
||||
});
|
||||
|
||||
it('should not load dynamically added pictures if content observer is turned off', done => {
|
||||
lazyLoader.stopContentObserver();
|
||||
|
||||
const newImg = document.createElement('img');
|
||||
const testPath = `${TEST_HOST}/img/testimg.png`;
|
||||
newImg.className = 'lazy';
|
||||
newImg.setAttribute('data-src', testPath);
|
||||
document.body.appendChild(newImg);
|
||||
newImg.scrollIntoView();
|
||||
|
||||
setTimeout(() => {
|
||||
expect(LazyLoader.loadImage).not.toHaveBeenCalled();
|
||||
expect(newImg).not.toHaveClass('js-lazy-loaded');
|
||||
done();
|
||||
}, 50);
|
||||
});
|
||||
|
||||
it('should load dynamically added pictures if content observer is turned off and on again', done => {
|
||||
lazyLoader.stopContentObserver();
|
||||
lazyLoader.startContentObserver();
|
||||
|
||||
const newImg = document.createElement('img');
|
||||
const testPath = `${TEST_HOST}/img/testimg.png`;
|
||||
newImg.className = 'lazy';
|
||||
newImg.setAttribute('data-src', testPath);
|
||||
document.body.appendChild(newImg);
|
||||
newImg.scrollIntoView();
|
||||
|
||||
setTimeout(() => {
|
||||
expect(LazyLoader.loadImage).toHaveBeenCalled();
|
||||
expect(newImg).toHaveClass('js-lazy-loaded');
|
||||
done();
|
||||
}, 50);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Reference in a new issue