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:
Tim Zallmann 2018-10-02 07:29:57 +00:00
commit 1883e97e7c
3 changed files with 268 additions and 34 deletions

View File

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

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