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:
Lukas Eipert 2018-09-06 16:36:05 +02:00
parent df73116f75
commit 4552a9f9fb
No known key found for this signature in database
GPG Key ID: 148BEA37CB35B2AC
3 changed files with 268 additions and 34 deletions

View File

@ -2,54 +2,114 @@ import _ from 'underscore';
export const placeholderImage = export const placeholderImage =
'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='; 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
const SCROLL_THRESHOLD = 300; const SCROLL_THRESHOLD = 500;
export default class LazyLoader { export default class LazyLoader {
constructor(options = {}) { constructor(options = {}) {
this.intersectionObserver = null;
this.lazyImages = []; this.lazyImages = [];
this.observerNode = options.observerNode || '#content-body'; 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; const scrollContainer = options.scrollContainer || window;
scrollContainer.addEventListener('load', () => this.loadCheck()); scrollContainer.addEventListener('load', () => this.register());
} }
static supportsIntersectionObserver() {
return 'IntersectionObserver' in window;
}
searchLazyImages() { searchLazyImages() {
const that = this;
requestIdleCallback( requestIdleCallback(
() => { () => {
that.lazyImages = [].slice.call(document.querySelectorAll('.lazy')); const lazyImages = [].slice.call(document.querySelectorAll('.lazy'));
if (that.lazyImages.length) { if (LazyLoader.supportsIntersectionObserver()) {
that.checkElementsInView(); if (this.intersectionObserver) {
lazyImages.forEach(img => this.intersectionObserver.observe(img));
}
} else if (lazyImages.length) {
this.lazyImages = lazyImages;
this.checkElementsInView();
} }
}, },
{ timeout: 500 }, { timeout: 500 },
); );
} }
startContentObserver() { startContentObserver() {
const contentNode = document.querySelector(this.observerNode) || document.querySelector('body'); const contentNode = document.querySelector(this.observerNode) || document.querySelector('body');
if (contentNode) { if (contentNode) {
const observer = new MutationObserver(() => this.searchLazyImages()); this.mutationObserver = new MutationObserver(() => this.searchLazyImages());
observer.observe(contentNode, { this.mutationObserver.observe(contentNode, {
childList: true, childList: true,
subtree: true, subtree: true,
}); });
} }
} }
loadCheck() {
this.searchLazyImages(); stopContentObserver() {
this.startContentObserver(); 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() { scrollCheck() {
requestAnimationFrame(() => this.checkElementsInView()); requestAnimationFrame(() => this.checkElementsInView());
} }
checkElementsInView() { checkElementsInView() {
const scrollTop = window.pageYOffset; const scrollTop = window.pageYOffset;
const visHeight = scrollTop + window.innerHeight + SCROLL_THRESHOLD; const visHeight = scrollTop + window.innerHeight + SCROLL_THRESHOLD;
@ -61,18 +121,29 @@ export default class LazyLoader {
const imgTop = scrollTop + imgBoundRect.top; const imgTop = scrollTop + imgBoundRect.top;
const imgBound = imgTop + imgBoundRect.height; const imgBound = imgTop + imgBoundRect.height;
if (scrollTop < imgBound && visHeight > imgTop) { if (scrollTop <= imgBound && visHeight >= imgTop) {
requestAnimationFrame(() => { requestAnimationFrame(() => {
LazyLoader.loadImage(selectedImage); LazyLoader.loadImage(selectedImage);
}); });
return false; 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 true;
} }
return false; return false;
}); });
} }
static loadImage(img) { static loadImage(img) {
if (img.getAttribute('data-src')) { if (img.getAttribute('data-src')) {
let imgUrl = img.getAttribute('data-src'); let imgUrl = img.getAttribute('data-src');

View File

@ -0,0 +1,6 @@
---
title: Improve lazy image loading performance by using IntersectionObserver where
available
merge_request: 21565
author:
type: performance

View File

@ -1,57 +1,214 @@
import LazyLoader from '~/lazy_loader'; import LazyLoader from '~/lazy_loader';
import { TEST_HOST } from './test_constants';
let lazyLoader = null; let lazyLoader = null;
const execImmediately = callback => {
callback();
};
describe('LazyLoader', function() { describe('LazyLoader', function() {
preloadFixtures('issues/issue_with_comment.html.raw'); preloadFixtures('issues/issue_with_comment.html.raw');
beforeEach(function() { describe('with IntersectionObserver disabled', () => {
loadFixtures('issues/issue_with_comment.html.raw'); beforeEach(function() {
lazyLoader = new LazyLoader({ loadFixtures('issues/issue_with_comment.html.raw');
observerNode: 'body',
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(); afterEach(() => {
}); lazyLoader.unregister();
describe('behavior', function() { });
it('should copy value from data-src to src for img 1', function(done) { it('should copy value from data-src to src for img 1', function(done) {
const img = document.querySelectorAll('img[data-src]')[0]; const img = document.querySelectorAll('img[data-src]')[0];
const originalDataSrc = img.getAttribute('data-src'); const originalDataSrc = img.getAttribute('data-src');
img.scrollIntoView(); img.scrollIntoView();
setTimeout(() => { setTimeout(() => {
expect(LazyLoader.loadImage).toHaveBeenCalled();
expect(img.getAttribute('src')).toBe(originalDataSrc); expect(img.getAttribute('src')).toBe(originalDataSrc);
expect(document.getElementsByClassName('js-lazy-loaded').length).toBeGreaterThan(0); expect(img).toHaveClass('js-lazy-loaded');
done(); done();
}, 100); }, 50);
}); });
it('should lazy load dynamically added data-src images', function(done) { it('should lazy load dynamically added data-src images', function(done) {
const newImg = document.createElement('img'); const newImg = document.createElement('img');
const testPath = '/img/testimg.png'; const testPath = `${TEST_HOST}/img/testimg.png`;
newImg.className = 'lazy'; newImg.className = 'lazy';
newImg.setAttribute('data-src', testPath); newImg.setAttribute('data-src', testPath);
document.body.appendChild(newImg); document.body.appendChild(newImg);
newImg.scrollIntoView(); newImg.scrollIntoView();
setTimeout(() => { setTimeout(() => {
expect(LazyLoader.loadImage).toHaveBeenCalled();
expect(newImg.getAttribute('src')).toBe(testPath); expect(newImg.getAttribute('src')).toBe(testPath);
expect(document.getElementsByClassName('js-lazy-loaded').length).toBeGreaterThan(0); expect(newImg).toHaveClass('js-lazy-loaded');
done(); done();
}, 100); }, 50);
}); });
it('should not alter normal images', function(done) { it('should not alter normal images', function(done) {
const newImg = document.createElement('img'); const newImg = document.createElement('img');
const testPath = '/img/testimg.png'; const testPath = `${TEST_HOST}/img/testimg.png`;
newImg.setAttribute('src', testPath); newImg.setAttribute('src', testPath);
document.body.appendChild(newImg); document.body.appendChild(newImg);
newImg.scrollIntoView(); newImg.scrollIntoView();
setTimeout(() => { setTimeout(() => {
expect(LazyLoader.loadImage).not.toHaveBeenCalled();
expect(newImg).not.toHaveClass('js-lazy-loaded'); expect(newImg).not.toHaveClass('js-lazy-loaded');
done(); 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);
}); });
}); });
}); });