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

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