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 =
'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');

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 { 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);
});
});
});