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