Commit 4552a9f9 authored by Lukas Eipert's avatar Lukas Eipert

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.
parent df73116f
...@@ -2,54 +2,114 @@ import _ from 'underscore'; ...@@ -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() {
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.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 { ...@@ -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');
......
---
title: Improve lazy image loading performance by using IntersectionObserver where
available
merge_request: 21565
author:
type: performance
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();
});
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);
}); });
// Doing everything that happens normally in onload
lazyLoader.loadCheck();
}); });
describe('behavior', function() {
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) { 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');
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'); expect(newImg).not.toHaveClass('js-lazy-loaded');
done(); done();
}, 100); }, 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);
}); });
}); });
}); });
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment