Adam Ydenius

mollwe

Lazy loading images and disqus in Hexo

I’d recently lookup up IntersectionObserver for some other project and I thought to my self I wanted to make something with it. Lazy loading images was the first thing that got to my mind and that was something I wanted for my site as well. I’ve been working at getting my site as light as possible and I’ve wished I could defer loading of content below the fold earlier. I had found a hexo plugin for lazy loading images but it wasn’t enough for me.

Rendering

Before the image has loaded the placeholder is an gradient of the image that is loading. It’s generated by the npm package image-to-gradient (npm). However, it’s broken at the time of writing this. I’ve sent a pull request but you could use my git repo in the meanwhile. (Edit 2017-12-17: Pull request has been accepted and a new working version is up on npm.)

I was inspired by José M. Pérez’s post How to use SVG as a Placeholder, and Other Image Loading Techniques and I tried out node-potrace but I decided to go for gradients instead because they have smaller footprint and is simpler. The images should load fast so we won’t be seeing much of the placeholders anyways.

For the moment I haven’t made a plugin, instead I use a script directly under my custom theme. It hooks up to the after_render of hexo and replaces img tags with some fancy html and then injects a script tag for loading of images as the user scrolls down.

hexo.extend.filter.register('after_render:html', imageLazyLoadProcess);

First it uses regex to find all img instances.

var matches = htmlContent.match(/<img(\s[^>]*?)src\s*=\s*['\"]([^'\"]*?)['\"]([^>]*?)>/gi);

Then for each match it gets the image stream and converts to a buffer from which size of image and corresponding gradient is calculated.

var imageStream = hexo.route.get(item.url);

var itemPromises = streamToArray(imageStream).then(function (imageArray) {
var imageBuffer = Buffer.concat(imageArray);

var size = imageSize(imageBuffer);

item.width = size.width;
item.height = size.height;

return imageToGradient(imageBuffer, gradientOptions).then(function (gradient) {
item.gradient = gradient;

return item;
}, function (error) {
console.log('Failed to create gradient', error);
});
}, function (error) {
console.log('Failed to stream array', error);
});

I’ve put some extra effort into how the image is displayed until loaded. The image placeholder should have the same size and ratio even when not loaded and independent of screen width. By using padding-top as result of height / width * 100 and height 0 (inspired from Andy Shora’s post Sizing Fluid Image Containers with a Little CSS Padding Hack) we can maintain ratio. I also needed to add a containing div and set the width so the placeholder can’t become larger than it should.

Each image item is replaced like this.

 htmlContent = htmlContent.replace(regex, function (tag, pre, url, post) {
// might be duplicate
if (/data-src/gi.test(tag)) {
return tag;
}

hasLazyLoaded = true;

var result = '<div class="img-container" style="width:'+item.width+'px;background:' + item.gradient + '">' +
'<img' + pre + 'data-src="' + url + '"' + post +
' height="' + item.height + '" width="' + item.width + '" style="padding-top:' + (item.height / item.width * 100) + '%">' +
'</div>';

if (config.noscript) {
result = '<noscript><img' + pre + 'src="' + url + '"' + post + ' height="' + item.height + '" width="' + item.width + '"></noscript>' + result;
}

return result;
});

Which will generate something like this.

<div class="img-container" style="width:647px;background:linear-gradient(rgba(98,159,207,1.0),rgba(248,248,248,1.0),rgba(249,249,249,1.0),rgba(246,246,246,1.0),rgba(246,246,246,1.0),rgba(249,248,248,1.0),rgba(252,249,249,1.0),rgba(246,245,245,1.0),rgba(249,249,249,1.0),rgba(220,232,243,1.0));">
<img data-src="/2017/11/27/hello-hexo/build-configuration.png"
title="Build configuration"
height="644"
width="647"
style="padding-top:99.53632148377125%;">
</div>

And by using the following css we can show the placeholder correctly and with animation on load. To get a nicer effect I added opacity to the image and a transition which removes it when image has loaded. Se how src attribute is set further down.

.img-container {
overflow: hidden;
max-width: 100%;
}

.img-container img {
max-width: 100%;
display: block;
height: auto;
transition: opacity .5s ease-in-out;
}

.img-container img:not([src]) {
height: 0;
opacity: 0;
}

Even prepends with a <noscript>-extra so it works without javascript with images anyway.

<noscript>
<img src="/2017/11/27/hello-hexo/build-configuration.png" title="Build configuration" height="644" width="647">
</noscript>
...
<noscript><style>.img-container { display: none !important; }</style></noscript>

Loading

The lazy loading script checks wether the image is visible and then triggers loading of image. The detection is made by IntersectionObserver if supported else it falls back to getBoundingClientRect and event listeners for scroll and resize.

function imageLazyLoad() {
var elements = document.querySelectorAll('img[data-src]');

if (!elements.length) {
return;
}

if (typeof IntersectionObserver !== 'undefined') {
var observer = new IntersectionObserver(function (entries, observer) {
entries.filter(function (entry) {
return entry.isIntersecting;
}).forEach(function (entry) {
loadImage(entry.target);
observer.unobserve(entry.target);
});
});

elements.forEach(function (element) {
observer.observe(element);
});
}
else {
var timeout;
var verify = function () {
clearTimeout(timeout);
timeout = setTimeout(function () {
elements = elements.filter(function (element) {
return !element.src;
});

if (!elements.length) {
window.removeEventListener('scroll', verify);
window.removeEventListener('resize', verify);
return;
}

elements.filter(function (element) {
var position = element.getBoundingClientRect().top;

if (position < window.innerHeight) {
loadImage(element);
}
})
}, 50);
};

window.addEventListener('scroll', verify);
window.addEventListener('resize', verify);

verify();
}
}

The loading of image is done by loading the source image url to a new Image object before setting src on actual img tag so we can trigger some nice animations when the image has loaded rather than when setting src. The style for padding-top also needs to be removed.

function loadImage(element) {
var src = element.getAttribute('data-src');

var img = new Image();

img.onload = function() {
element.setAttribute('src', src);
element.style.paddingTop = '';
}
img.src = src;
}

The script is triggerd at load.

document.addEventListener('DOMContentLoaded', imageLazyLoad, false);

Demo

Lazy Disqus

I felt it was unnecessary to load comments directly not even knowing if the user would ever scroll down to the comment section. So I added a similar script for Disqus as well.

if (typeof IntersectionObserver !== 'undefined') {
var observer = new IntersectionObserver(function (entries, observer) {
var intersectingEntries = entries.filter(function (entry) {
return entry.isIntersecting;
});

if (intersectingEntries.length) {
embedDisqus(shortname);
observer.disconnect();
}
});

observer.observe(element);
}
else {
var timeout;
var verify = function () {
clearTimeout(timeout);
timeout = setTimeout(function () {
var position = element.getBoundingClientRect().top;

if (position < window.innerHeight) {
embedDisqus(shortname);
window.removeEventListener('scroll', verify);
window.removeEventListener('resize', verify);
}
}, 50);
};

window.addEventListener('scroll', verify);
window.addEventListener('resize', verify);

verify();
}

Embedding is simply done by adding a script tag for embed.js.

function embedDisqus(shortname) {
var script = document.createElement('script');
script.src = 'https://' + shortname + '.disqus.com/embed.js';
script.async = true;
script.setAttribute('data-timestamp', +new Date());
document.body.appendChild(script);
}

You’ll need to call it like below to trigger it.

disqusLazyLoad('mollwe');

Summary

There you have it with loading images and disqus when in view, the full scripts mentioned in post can be downloaded here.