Commit 52b8a0db authored by Tim Zallmann's avatar Tim Zallmann Committed by Jacob Schatz

Resolve "Lazy load images on the Frontend"

parent 3a26bce8
/* eslint-disable class-methods-use-this, object-shorthand, no-unused-vars, no-use-before-define, no-new, max-len, no-restricted-syntax, guard-for-in, no-continue */
import './lib/utils/common_utils';
import { placeholderImage } from './lazy_loader';
const gfmRules = {
// The filters referenced in lib/banzai/pipeline/gfm_pipeline.rb convert
......@@ -56,6 +57,11 @@ const gfmRules = {
return text;
},
},
ImageLazyLoadFilter: {
'img'(el, text) {
return `![${el.getAttribute('alt')}](${el.getAttribute('src')})`;
},
},
VideoLinkFilter: {
'.video-container'(el) {
const videoEl = el.querySelector('video');
......@@ -163,7 +169,9 @@ const gfmRules = {
return text.trim().split('\n').map(s => `> ${s}`.trim()).join('\n');
},
'img'(el) {
return `![${el.getAttribute('alt')}](${el.getAttribute('src')})`;
const imageSrc = el.src;
const imageUrl = imageSrc && imageSrc !== placeholderImage ? imageSrc : (el.dataset.src || '');
return `![${el.getAttribute('alt')}](${imageUrl})`;
},
'a.anchor'(el, text) {
// Don't render a Markdown link for the anchor link inside a heading
......
/* eslint-disable one-export, one-var, one-var-declaration-per-line */
import _ from 'underscore';
export const placeholderImage = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
const SCROLL_THRESHOLD = 300;
export default class LazyLoader {
constructor(options = {}) {
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());
}
searchLazyImages() {
this.lazyImages = [].slice.call(document.querySelectorAll('.lazy'));
this.checkElementsInView();
}
startContentObserver() {
const contentNode = document.querySelector(this.observerNode) || document.querySelector('body');
if (contentNode) {
const observer = new MutationObserver(() => this.searchLazyImages());
observer.observe(contentNode, {
childList: true,
subtree: true,
});
}
}
loadCheck() {
this.searchLazyImages();
this.startContentObserver();
}
scrollCheck() {
requestAnimationFrame(() => this.checkElementsInView());
}
checkElementsInView() {
const scrollTop = pageYOffset;
const visHeight = scrollTop + innerHeight + SCROLL_THRESHOLD;
let imgBoundRect, imgTop, imgBound;
// Loading Images which are in the current viewport or close to them
this.lazyImages = this.lazyImages.filter((selectedImage) => {
if (selectedImage.getAttribute('data-src')) {
imgBoundRect = selectedImage.getBoundingClientRect();
imgTop = scrollTop + imgBoundRect.top;
imgBound = imgTop + imgBoundRect.height;
if (scrollTop < imgBound && visHeight > imgTop) {
LazyLoader.loadImage(selectedImage);
return false;
}
return true;
}
return false;
});
}
static loadImage(img) {
if (img.getAttribute('data-src')) {
img.setAttribute('src', img.getAttribute('data-src'));
img.removeAttribute('data-src');
img.classList.remove('lazy');
img.classList.add('js-lazy-loaded');
}
}
}
......@@ -109,6 +109,7 @@ import './label_manager';
import './labels';
import './labels_select';
import './layout_nav';
import LazyLoader from './lazy_loader';
import './line_highlighter';
import './logo';
import './member_expiration_date';
......@@ -166,6 +167,11 @@ window.addEventListener('load', function onLoad() {
gl.utils.handleLocationHash();
}, false);
gl.lazyLoader = new LazyLoader({
scrollContainer: window,
observerNode: '#content-body'
});
$(function () {
var $body = $('body');
var $document = $(document);
......
......@@ -35,6 +35,8 @@
width: 40px;
height: 40px;
padding: 0;
background: $avatar-background;
overflow: hidden;
&.avatar-inline {
float: none;
......
......@@ -11,8 +11,17 @@
}
img {
max-width: 100%;
/*max-width: 100%;*/
margin: 0 0 8px;
min-width: 200px;
min-height: 100px;
background-color: $gray-lightest;
}
img.js-lazy-loaded {
min-width: none;
min-height: none;
background-color: none;
}
p a:not(.no-attachment-icon) img {
......
......@@ -379,7 +379,9 @@ $issue-boards-card-shadow: rgba(186, 186, 186, 0.5);
* Avatar
*/
$avatar_radius: 50%;
$avatar-border: $border-color;
$avatar-border: $gray-normal;
$avatar-border-hover: $gray-darker;
$avatar-background: $gray-lightest;
$gl-avatar-size: 40px;
/*
......
......@@ -11,17 +11,12 @@ module AvatarsHelper
def user_avatar_without_link(options = {})
avatar_size = options[:size] || 16
user_name = options[:user].try(:name) || options[:user_name]
css_class = options[:css_class] || ''
avatar_url = options[:url] || avatar_icon(options[:user] || options[:user_email], avatar_size)
data_attributes = { container: 'body' }
if options[:lazy]
data_attributes[:src] = avatar_url
end
image_tag(
options[:lazy] ? '' : avatar_url,
class: "avatar has-tooltip s#{avatar_size} #{css_class}",
avatar_url,
class: %W[avatar has-tooltip s#{avatar_size}].push(*options[:css_class]),
alt: "#{user_name}'s avatar",
title: user_name,
data: data_attributes
......
......@@ -61,8 +61,8 @@ module EmailsHelper
else
image_tag(
image_url('mailers/gitlab_header_logo.gif'),
size: "55x50",
alt: "GitLab"
size: '55x50',
alt: 'GitLab'
)
end
end
......
module LazyImageTagHelper
def placeholder_image
"data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=="
end
# Override the default ActionView `image_tag` helper to support lazy-loading
def image_tag(source, options = {})
options = options.symbolize_keys
unless options.delete(:lazy) == false
options[:data] ||= {}
options[:data][:src] = path_to_image(source)
options[:class] ||= ""
options[:class] << " lazy"
source = placeholder_image
end
super(source, options)
end
# Required for Banzai::Filter::ImageLazyLoadFilter
module_function :placeholder_image
end
......@@ -2,7 +2,7 @@ module VersionCheckHelper
def version_status_badge
if Rails.env.production? && current_application_settings.version_check_enabled
image_url = VersionCheck.new.url
image_tag image_url, class: 'js-version-status-badge'
image_tag image_url, class: 'js-version-status-badge', lazy: false
end
end
end
......@@ -11,7 +11,7 @@ module CacheMarkdownField
extend ActiveSupport::Concern
# Increment this number every time the renderer changes its output
CACHE_VERSION = 1
CACHE_VERSION = 2
# changes to these attributes cause the cache to be invalidates
INVALIDATED_BY = %w[author project].freeze
......
.file-content.image_file
%img{ src: blob_raw_url, alt: viewer.blob.name }
%img{ 'data-src': blob_raw_url, alt: viewer.blob.name }
......@@ -8,7 +8,7 @@
.image
%span.wrap
.frame{ class: (diff_file.deleted_file? ? 'deleted' : 'added') }
%img{ src: blob_raw_path, alt: diff_file.file_path }
%img{ 'data-src': blob_raw_path, alt: diff_file.file_path }
%p.image-info= number_to_human_size(blob.size)
- else
.image
......@@ -16,7 +16,7 @@
%span.wrap
.frame.deleted
%a{ href: project_blob_path(@project, tree_join(diff_file.old_content_sha, diff_file.old_path)) }
%img{ src: old_blob_raw_path, alt: diff_file.old_path }
%img{ 'data-src': old_blob_raw_path, alt: diff_file.old_path }
%p.image-info.hide
%span.meta-filesize= number_to_human_size(old_blob.size)
|
......@@ -28,7 +28,7 @@
%span.wrap
.frame.added
%a{ href: project_blob_path(@project, tree_join(diff_file.content_sha, diff_file.new_path)) }
%img{ src: blob_raw_path, alt: diff_file.new_path }
%img{ 'data-src': blob_raw_path, alt: diff_file.new_path }
%p.image-info.hide
%span.meta-filesize= number_to_human_size(blob.size)
|
......@@ -41,10 +41,10 @@
.swipe.view.hide
.swipe-frame
.frame.deleted
%img{ src: old_blob_raw_path, alt: diff_file.old_path }
%img{ 'data-src': old_blob_raw_path, alt: diff_file.old_path }
.swipe-wrap
.frame.added
%img{ src: blob_raw_path, alt: diff_file.new_path }
%img{ 'data-src': blob_raw_path, alt: diff_file.new_path }
%span.swipe-bar
%span.top-handle
%span.bottom-handle
......@@ -52,9 +52,9 @@
.onion-skin.view.hide
.onion-skin-frame
.frame.deleted
%img{ src: old_blob_raw_path, alt: diff_file.old_path }
%img{ 'data-src': old_blob_raw_path, alt: diff_file.old_path }
.frame.added
%img{ src: blob_raw_path, alt: diff_file.new_path }
%img{ 'data-src': blob_raw_path, alt: diff_file.new_path }
.controls
.transparent
.drag-track
......
---
title: Lazy load images for better Frontend performance
merge_request: 12503
author:
......@@ -23,6 +23,18 @@ controlled by the server.
1. The backend code will most likely be using etags. You do not and should not check for status
`304 Not Modified`. The browser will transform it for you.
### Lazy Loading
To improve the time to first render we are using lazy loading for images. This works by setting
the actual image source on the `data-src` attribute. After the HTML is rendered and JavaScript is loaded,
the value of `data-src` will be moved to `src` automatically if the image is in the current viewport.
* Prepare images in HTML for lazy loading by renaming the `src` attribute to `data-src`
* If you are using the Rails `image_tag` helper, all images will be lazy-loaded by default unless `lazy: false` is provided.
If you are asynchronously adding content which contains lazy images then you need to call the function
`gl.lazyLoader.searchLazyImages()` which will search for lazy images and load them if needed.
## Reducing Asset Footprint
### Page-specific JavaScript
......
......@@ -114,7 +114,7 @@ class Spinach::Features::ProjectWiki < Spinach::FeatureSteps
end
step 'Image should be shown on the page' do
expect(page).to have_xpath("//img[@src=\"image.jpg\"]")
expect(page).to have_xpath("//img[@data-src=\"image.jpg\"]")
end
step 'I click on image link' do
......
......@@ -118,7 +118,7 @@ module Banzai
end
if path
content_tag(:img, nil, src: path, class: 'gfm')
content_tag(:img, nil, data: { src: path }, class: 'gfm')
end
end
......
module Banzai
module Filter
# HTML filter that moves the value of the src attribute to the data-src attribute so it can be lazy loaded
class ImageLazyLoadFilter < HTML::Pipeline::Filter
def call
doc.xpath('descendant-or-self::img').each do |img|
img['class'] ||= '' << 'lazy'
img['data-src'] = img['src']
img['src'] = LazyImageTagHelper.placeholder_image
end
doc
end
end
end
end
......@@ -10,7 +10,7 @@ module Banzai
link = doc.document.create_element(
'a',
class: 'no-attachment-icon',
href: img['src'],
href: img['data-src'] || img['src'],
target: '_blank',
rel: 'noopener noreferrer'
)
......
......@@ -22,6 +22,7 @@ module Banzai
doc.css('img, video').each do |el|
process_link_attr el.attribute('src')
process_link_attr el.attribute('data-src')
end
doc
......
......@@ -16,6 +16,7 @@ module Banzai
Filter::MathFilter,
Filter::UploadLinkFilter,
Filter::VideoLinkFilter,
Filter::ImageLazyLoadFilter,
Filter::ImageLinkFilter,
Filter::EmojiFilter,
Filter::TableOfContentsFilter,
......
......@@ -63,11 +63,11 @@ feature 'Admin Appearance', feature: true do
end
def logo_selector
'//img[@src^="/uploads/-/system/appearance/logo"]'
'//img[data-src^="/uploads/-/system/appearance/logo"]'
end
def header_logo_selector
'//img[@src^="/uploads/-/system/appearance/header_logo"]'
'//img[data-src^="/uploads/-/system/appearance/header_logo"]'
end
def logo_fixture
......
......@@ -100,7 +100,7 @@ describe 'GitLab Markdown', feature: true do
end
it 'permits img elements' do
expect(doc).to have_selector('img[src*="smile.png"]')
expect(doc).to have_selector('img[data-src*="smile.png"]')
end
it 'permits br elements' do
......
......@@ -18,7 +18,7 @@ feature 'User uploads avatar to group', feature: true do
visit group_path(group)
expect(page).to have_selector(%Q(img[src$="/uploads/-/system/group/avatar/#{group.id}/dk.png"]))
expect(page).to have_selector(%Q(img[data-src$="/uploads/-/system/group/avatar/#{group.id}/dk.png"]))
# Cheating here to verify something that isn't user-facing, but is important
expect(group.reload.avatar.file).to exist
......
......@@ -16,7 +16,7 @@ feature 'User uploads avatar to profile', feature: true do
visit user_path(user)
expect(page).to have_selector(%Q(img[src$="/uploads/-/system/user/avatar/#{user.id}/dk.png"]))
expect(page).to have_selector(%Q(img[data-src$="/uploads/-/system/user/avatar/#{user.id}/dk.png"]))
# Cheating here to verify something that isn't user-facing, but is important
expect(user.reload.avatar.file).to exist
......
......@@ -62,13 +62,13 @@ describe ApplicationHelper do
avatar_url = "/uploads/-/system/project/avatar/#{project.id}/banana_sample.gif"
expect(helper.project_icon(project.full_path).to_s)
.to eq "<img src=\"#{avatar_url}\" alt=\"Banana sample\" />"
.to eq "<img data-src=\"#{avatar_url}\" class=\" lazy\" src=\"#{LazyImageTagHelper.placeholder_image}\" />"
allow(ActionController::Base).to receive(:asset_host).and_return(gitlab_host)
avatar_url = "#{gitlab_host}/uploads/-/system/project/avatar/#{project.id}/banana_sample.gif"
expect(helper.project_icon(project.full_path).to_s)
.to eq "<img src=\"#{avatar_url}\" alt=\"Banana sample\" />"
.to eq "<img data-src=\"#{avatar_url}\" class=\" lazy\" src=\"#{LazyImageTagHelper.placeholder_image}\" />"
end
it 'gives uploaded icon when present' do
......@@ -77,7 +77,8 @@ describe ApplicationHelper do
allow_any_instance_of(Project).to receive(:avatar_in_git).and_return(true)
avatar_url = "#{gitlab_host}#{project_avatar_path(project)}"
expect(helper.project_icon(project.full_path).to_s).to match(image_tag(avatar_url))
expect(helper.project_icon(project.full_path).to_s)
.to eq "<img data-src=\"#{avatar_url}\" class=\" lazy\" src=\"#{LazyImageTagHelper.placeholder_image}\" />"
end
end
......
......@@ -27,11 +27,11 @@ describe AvatarsHelper do
it 'displays user avatar' do
is_expected.to eq image_tag(
avatar_icon(user, 16),
class: 'avatar has-tooltip s16 ',
LazyImageTagHelper.placeholder_image,
class: 'avatar has-tooltip s16 lazy',
alt: "#{user.name}'s avatar",
title: user.name,
data: { container: 'body' }
data: { container: 'body', src: avatar_icon(user, 16) }
)
end
......@@ -40,22 +40,8 @@ describe AvatarsHelper do
it 'uses provided css_class' do
is_expected.to eq image_tag(
avatar_icon(user, 16),
class: "avatar has-tooltip s16 #{options[:css_class]}",
alt: "#{user.name}'s avatar",
title: user.name,
data: { container: 'body' }
)
end
end
context 'with lazy parameter' do
let(:options) { { user: user, lazy: true } }
it 'uses data-src instead of src' do
is_expected.to eq image_tag(
'',
class: 'avatar has-tooltip s16 ',
LazyImageTagHelper.placeholder_image,
class: "avatar has-tooltip s16 #{options[:css_class]} lazy",
alt: "#{user.name}'s avatar",
title: user.name,
data: { container: 'body', src: avatar_icon(user, 16) }
......@@ -68,11 +54,11 @@ describe AvatarsHelper do
it 'uses provided size' do
is_expected.to eq image_tag(
avatar_icon(user, options[:size]),
class: "avatar has-tooltip s#{options[:size]} ",
LazyImageTagHelper.placeholder_image,
class: "avatar has-tooltip s#{options[:size]} lazy",
alt: "#{user.name}'s avatar",
title: user.name,
data: { container: 'body' }
data: { container: 'body', src: avatar_icon(user, options[:size]) }
)
end
end
......@@ -82,11 +68,11 @@ describe AvatarsHelper do
it 'uses provided url' do
is_expected.to eq image_tag(
options[:url],
class: 'avatar has-tooltip s16 ',
LazyImageTagHelper.placeholder_image,
class: 'avatar has-tooltip s16 lazy',
alt: "#{user.name}'s avatar",
title: user.name,
data: { container: 'body' }
data: { container: 'body', src: options[:url] }
)
end
end
......@@ -99,22 +85,22 @@ describe AvatarsHelper do
it 'prefers user parameter' do
is_expected.to eq image_tag(
avatar_icon(user, 16),
class: 'avatar has-tooltip s16 ',
LazyImageTagHelper.placeholder_image,
class: 'avatar has-tooltip s16 lazy',
alt: "#{user.name}'s avatar",
title: user.name,
data: { container: 'body' }
data: { container: 'body', src: avatar_icon(user, 16) }
)
end
end
it 'uses user_name and user_email parameter if user is not present' do
is_expected.to eq image_tag(
avatar_icon(options[:user_email], 16),
class: 'avatar has-tooltip s16 ',
LazyImageTagHelper.placeholder_image,
class: 'avatar has-tooltip s16 lazy',
alt: "#{options[:user_name]}'s avatar",
title: options[:user_name],
data: { container: 'body' }
data: { container: 'body', src: avatar_icon(options[:user_email], 16) }
)
end
end
......
import LazyLoader from '~/lazy_loader';
let lazyLoader = null;
describe('LazyLoader', function () {
preloadFixtures('issues/issue_with_comment.html.raw');
beforeEach(function () {
loadFixtures('issues/issue_with_comment.html.raw');
lazyLoader = new LazyLoader({
observerNode: 'body',
});
// Doing everything that happens normally in onload
lazyLoader.loadCheck();
});
describe('behavior', function () {
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(img.getAttribute('src')).toBe(originalDataSrc);
expect(document.getElementsByClassName('js-lazy-loaded').length).toBeGreaterThan(0);
done();
}, 100);
});
it('should lazy load dynamically added data-src images', function (done) {
const newImg = document.createElement('img');
const testPath = '/img/testimg.png';
newImg.className = 'lazy';
newImg.setAttribute('data-src', testPath);
document.body.appendChild(newImg);
newImg.scrollIntoView();
setTimeout(() => {
expect(newImg.getAttribute('src')).toBe(testPath);
expect(document.getElementsByClassName('js-lazy-loaded').length).toBeGreaterThan(0);
done();
}, 100);
});
it('should not alter normal images', function (done) {
const newImg = document.createElement('img');
const testPath = '/img/testimg.png';
newImg.setAttribute('src', testPath);
document.body.appendChild(newImg);
newImg.scrollIntoView();
setTimeout(() => {
expect(newImg).not.toHaveClass('js-lazy-loaded');
done();
}, 100);
});
});
});
......@@ -22,7 +22,7 @@ describe Banzai::Filter::GollumTagsFilter, lib: true do
tag = '[[images/image.jpg]]'
doc = filter("See #{tag}", project_wiki: project_wiki)
expect(doc.at_css('img')['src']).to eq "#{project_wiki.wiki_base_path}/images/image.jpg"
expect(doc.at_css('img')['data-src']).to eq "#{project_wiki.wiki_base_path}/images/image.jpg"
end
it 'does not creates img tag if image does not exist' do
......@@ -40,7 +40,7 @@ describe Banzai::Filter::GollumTagsFilter, lib: true do
tag = '[[http://example.com/image.jpg]]'
doc = filter("See #{tag}", project_wiki: project_wiki)
expect(doc.at_css('img')['src']).to eq "http://example.com/image.jpg"
expect(doc.at_css('img')['data-src']).to eq "http://example.com/image.jpg"
end
it 'does not creates img tag for invalid URL' do
......
require 'spec_helper'
describe Banzai::Filter::ImageLazyLoadFilter, lib: true do
include FilterSpecHelper
def image(path)
%(<img src="#{path}" />)
end
it 'transforms the image src to a data-src' do
doc = filter(image('/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg'))
expect(doc.at_css('img')['data-src']).to eq '/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg'
end
it 'works with external images' do
doc = filter(image('https://i.imgur.com/DfssX9C.jpg'))
expect(doc.at_css('img')['data-src']).to eq 'https://i.imgur.com/DfssX9C.jpg'
end
end
......@@ -17,7 +17,7 @@ module MarkdownMatchers
image = actual.at_css('img[alt="Relative Image"]')
expect(link['href']).to end_with('master/doc/README.md')
expect(image['src']).to end_with('master/app/assets/images/touch-icon-ipad.png')
expect(image['data-src']).to end_with('master/app/assets/images/touch-icon-ipad.png')
end
end
......@@ -70,7 +70,7 @@ module MarkdownMatchers
# GollumTagsFilter
matcher :parse_gollum_tags do
def have_image(src)
have_css("img[src$='#{src}']")
have_css("img[data-src$='#{src}']")
end
prefix = '/namespace1/gitlabhq/wikis'
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment