Introduction #
Lazy loading is a technique of loading additional content just when the user needs it, instead of displaying an excessive amount of content at once. In the context of webpages, lazy loading is often used to append content to the website without refreshing whole page. It is usually implemented using one of two ways:
- Loading more content when the user scrolls down - also called infinite scrolling.
- Loading more content when the user interacts with website - usually by clicking a button.
In this article, I will describe lazy loading functionality based on Element API plugin for Craft CMS. You can check out how it works in practice by visiting demo pages - for infinite scrolling and for click based lazy loading.
Basic concepts #
Element API is a first-party Craft CMS plugin that can be used to create API endpoints. Element API returns data in JSON format - our API will however return chunks of HTML rendered from Twig code. In fact, element API will use the same templates which will be used to render initial content displayed on page. You can learn more about Element API by watching "Headless Craft CMS" CraftQuest course.
Thanks to that, javascript code handling lazy loading can be vastly simplified, only taking care of performing AJAX requests and appending HTML returned by the endpoint to the page - instead of rendering markup from returned data. This makes our lazy loading method easy to implement and more universal.
For sake of this article, let's assume that we want to lazy load list of entries - called articles. Our lazy loading functionality will be composed of three main parts:
- Articles list template - this will be initially rendered content. Basic settings (like handle of section from which entries are taken) are placed there, as well as element query and
for
loop rendering list of articles. This file also contains the Javascript code responsible for AJAX requests and interaction with user. - Element API endpoint - our element API config will be pretty universal - it won't contain any specific settings (except setting queried element type to the entry) and will use ones received over AJAX requests. Endpoint makes use of a custom transformer class that is used to return content as HTML.
- Single article template. Both the article list template and Element API endpoint transformer make use of this file.
Let's analyze each of these components up close.
Articles list template #
This file can exist in two variants - infinite scrolling and click-based lazy loading. Both version are pretty similar, differing mostly by JS code. First, lets take a look at click-based lazy loading:
{# settings #}
{% set element_api_url = 'lazy-load' %}
{% set settings = {
section: 'articles',
variableName: 'article',
templatePath: '_article.twig',
orderBy: 'id',
limit: 3,
} %}
{# hashed settings #}
{% set hashed_settings = [] %}
{% for key, setting in settings %}
{% set hashed_settings = hashed_settings|merge({(key): setting|hash}) %}
{% endfor %}
{# initial content #}
<div class="js-lazy-wrapper">
<ul class="js-lazy-list">
{% for page in craft.entries.section(settings.section).order(settings.orderBy).limit(settings.limit) %}
{% include settings.templatePath with {(settings.variableName): page} %}
{% endfor %}
</ul>
<button class="button js-load-more">{{'load more'|t}}</button>
</div>
{# ajax request #}
{% js %}
// twig to js
var lazy_settings = {{hashed_settings|json_encode|raw}};
var element_api_url = '{{url(element_api_url)}}';
//variables
var lazy_offset = 1
var current_request = null;
//html elements
var lazy_wrapper = $('.js-lazy-wrapper');
var lazy_button = lazy_wrapper.find('.js-load-more');
var lazy_list = lazy_wrapper.find('.js-lazy-list');
lazy_button.on('click', function(){
if (current_request == null){
current_request = $.ajax({
url: element_api_url,
method: 'GET',
data: {
offset: lazy_offset,
settings: lazy_settings,
},
beforeSend: function(){
lazy_button.addClass('is-loading');
},
}).always(function(returned) {
lazy_button.removeClass('is-loading');
current_request = null;
}).done(function(returned) {
lazy_offset ++
var html = ''
$.each(returned.data, function(index, article){
html += article.html
})
lazy_list.append(html)
if(returned.data.length == 0){
lazy_button.hide();
}
}).fail(function(data){
alert('error');
})
}
});
{% endjs %}
At the beginning of the file, an array of settings is defined. These settings determine contents of articles list - how much of articles should be visible at first and then appended after successful request, order at which they should be appended and file name of single article template.
Each of these settings is later hashed using hash function to make sure that data is not tampered with. If everyone could freely send over any template path over AJAX request to be rendered, it could lead to serious security vulnerabilities.
Next, there is an element query and for
loop responsible for rendering the initial articles list. The button element is used as a trigger to load more content. Note that HTML elements have classes with js-
prefix. These classes are meant to be used only by Javascript and not for styling elements. Thanks to that, JS and CSS are decoupled and modifying one won't cause unexpected errors with another.
At the end of the template, there is jQuery code handling user interaction and AJAX requests. It is placed within {% js %}
tag - you can read more about it in Using Javascript in Twig templates with Craft CMS article. Note the lazy_offset
variable used as a parameter of AJAX request. Each consecutive successful AJAX request will increase lazy_offset
value so the further requests receive subsequent entries.
current_request
variable makes sure that no overlapping requests that query for the same data are created. Variable is initially set to null
and when the request starts it is set to object representing it. When the request is completed, it is set to null
again. When the user tries to make a request, the value of current_request
is checked - request is made only if current_request
is set to null
which means that no request is currently running.
Upon each request, button with class button
used to load more content has is-loading
class appended, and when the request is finished, the class is removed. If you use Bulma, this class will add a nice animated preloader to the button. When the request returns no entries, button is finally hidden - there are no more entries to be loaded, so it will not be needed.
Infinite scrolling #
This version of the article list template is mostly the same as with click-based lazy loading. Differences are:
- Button was replaced with a preloader that is hidden by default. Preloader contains only "loading" text - you can put some nice spinner animation here instead if you wish.
- Instead of the click event, we rely on
wheel
event. When the last batch of entries is loaded,not_all_displayed
variable is set to false - to prevent further requests being triggered by scrolling.
{# settings #}
{% set element_api_url = 'lazy-load' %}
{% set settings = {
section: 'articles',
variableName: 'article',
templatePath: '_article.twig',
orderBy: 'id',
limit: 3,
} %}
{# hashed settings #}
{% set hashed_settings = [] %}
{% for key, setting in settings %}
{% set hashed_settings = hashed_settings|merge({(key): setting|hash}) %}
{% endfor %}
{# initial content #}
<div class="js-lazy-wrapper">
<ul class="js-lazy-list">
{% for page in craft.entries.section(settings.section).order(settings.orderBy).limit(settings.limit) %}
{% include settings.templatePath with {(settings.variableName): page} %}
{% endfor %}
</ul>
<div class="js-lazy-preloader" hidden>{{'Loading'|t}}</div>
</div>
{# ajax request #}
{% js %}
// twig to js
var lazy_settings = {{hashed_settings|json_encode|raw}};
var element_api_url = '{{url(element_api_url)}}';
//variables
var lazy_offset = 1
var current_request = null;
var not_all_displayed = true
//html elements
var lazy_wrapper = $('.js-lazy-wrapper');
var lazy_list = lazy_wrapper.find('.js-lazy-list');
var lazy_preloader = $('.js-lazy-preloader');
$('body').bind('wheel', function(e) {
if(e.originalEvent.wheelDelta / 120 <= 0) {
if (current_request == null && not_all_displayed === true){
current_request = $.ajax({
url: element_api_url,
method: 'GET',
data: {
offset: lazy_offset,
settings: lazy_settings,
},
beforeSend: function(){
lazy_preloader.show();
},
}).always(function(returned) {
lazy_preloader.hide();
current_request = null;
}).done(function(returned) {
lazy_offset ++
var html = ''
$.each(returned.data, function(index, article){
html += article.html
})
lazy_list.append(html)
if(returned.data.length == 0){
not_all_displayed = false;
}
}).fail(function(data){
alert('error');
})
}
}
});
{% endjs %}
Element API endpoint #
This file needs to be placed in config
directory as element-api.php
.
<?php
use Craft;
use craft\elements\Entry;
use League\Fractal\TransformerAbstract;
use craft\web\View;
class TwigTransformer extends TransformerAbstract
{
public function __construct($settings)
{
$this->settings = $settings;
}
public function transform( $entry )
{
$oldMode = Craft::$app->view->getTemplateMode();
Craft::$app->view->setTemplateMode(View::TEMPLATE_MODE_SITE);
$html = Craft::$app->view->renderTemplate( $this->settings['templatePath'], [$this->settings['variableName'] => $entry]);
Craft::$app->view->setTemplateMode($oldMode);
return [
'id' => $entry->id,
'html' => $html
];
}
}
return [
'endpoints' => [
'lazy-load' => function() {
// validate and decode settings
$hashed_settings = Craft::$app->request->getParam('settings');
$settings = [];
foreach ($hashed_settings as $key => $value) {
$value = Craft::$app->security->validateData($value);
if($value === false){
return false;
}else{
$settings[$key] = $value;
}
}
// criteria
$criteria = [
'section' => $settings['section'],
'limit' => $settings['limit'],
'offset' => $settings['limit'] * Craft::$app->request->getParam('offset'),
'order' => $settings['orderBy'],
];
return [
'elementType' => Entry::class,
'criteria' => $criteria,
'paginate' => false,
'transformer' => new TwigTransformer($settings),
];
},
]
];
Unless you want your endpoint to return categories or other elements instead of entries, endpoint will probably remain unchanged - it just uses settings set in articles list and received over AJAX. At the beginning of the file, there is custom transformer, rendering each entry into HTML using Twig template.
Upon receiving settings, their values are validated and decoded using calidateData function. If any of the values is incorrect, endpoint won't return any data.
Single article template #
This template is used by both articles list template and by transformer in Element API endpoint. Here is a basic example - you can modify it as you wish. Just make sure that variable name available inside that represents single entry matches variableName
setting set in the article list file.
{% if article is defined %}
<li>
{{article.id}} - {{article.title}}
</li>
{% endif %}
SEO-friendly lazy loading #
To implement lazy loading in an SEO-friendly manner, you will need to use it along with other methods of accessing your content - for example pagination. You can learn more in official Google guidelines.
Further reading #
- Lazy Loading with the Element API & VueJS - article on nystudio107 blog about using lazy loading withe Element API. The solution presented in that article is more complicated, using Vue JS to render content returned by Element API endpoint. You can also read more about Element API in general there.
- Stack exchange post by Dimitri van der Vliet about Twig based transformer for Element API.
- Headless Craft - video course on CraftQuest, introducing basics of Element API plugin. Registration and payment required to watch full course.