Merge branch '35476-lazy-image-intersectionobserver' into 'master'
Improve Lazy Image Loading by using IntersectionObserver Closes #49511 and #35476 See merge request gitlab-org/gitlab-ce!21565
This commit is contained in:
commit
1883e97e7c
3 changed files with 268 additions and 34 deletions
|
@ -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');
|
||||||
|
|
||||||
|
describe('with IntersectionObserver disabled', () => {
|
||||||
beforeEach(function() {
|
beforeEach(function() {
|
||||||
loadFixtures('issues/issue_with_comment.html.raw');
|
loadFixtures('issues/issue_with_comment.html.raw');
|
||||||
|
|
||||||
lazyLoader = new LazyLoader({
|
lazyLoader = new LazyLoader({
|
||||||
observerNode: 'body',
|
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
|
// Doing everything that happens normally in onload
|
||||||
lazyLoader.loadCheck();
|
lazyLoader.register();
|
||||||
});
|
});
|
||||||
describe('behavior', function() {
|
|
||||||
|
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');
|
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 a new issue