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
|
@ -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');
|
||||||
|
|
|
@ -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 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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in New Issue