Andrew Welch
Published , updated · 5 min read · RSS Feed
Please consider 🎗 sponsoring me 🎗 to keep writing articles like this.
Lazy Loading with the Element API & VueJS
Here’s how to use Craft CMS’s Element API to lazy load content and display it via VueJS on the frontend
Whenever you have a significant amount of content, you’re faced with the UX decision of how you’re going to display it. We don’t want to overwhelm people with too many choices, because studies have shown that people can only focus on so many things at once.
However, we also have to provide a way to load more content when the user says Give me more! I explored a few options for the blog index page, from traditional pagination to “infinite scrolling”, but ultimately ended up with a Load More Articles button. I was leaning towards going down this route already, and solidified my decision after reading this article: Infinite Scrolling, Pagination Or “Load More” Buttons? Usability Findings In eCommerce.
So to accomplish this, we’re going to need two things:
- Some way to load additional blog entries dynamically
- Then display these entries on the frontend so the user can see them
A traditional approach to this problem would be using jQuery & ajax to load a template which renders the next X blog entries, and then append it to the DOM. Not a big deal, this approach has been used countless times.
However, we’re using VueJS on this website, so let’s explore how we can use it instead. Additionally, Craft CMS is a content management system, not just a thing that makes web pages. So lets use its Element API plugin to create an actual API for nystudio107, so that, for example, an iPhone app could get at the content we’ve created, too.
Link Making an API
So after we’ve downloaded & installed the Element API plugin, we’ll need to create a file called elementapi.php and put it in our craft/config folder. This is where we’ll define our API: basically, what data are we returning. Our API is incredibly simple, we just want to be able to load the next 9 blog entries. We’ll need to be able to pass in a page number, and we’ll need it to return our blog entries’ title, url, summary, image, category, and tags.
While a full Element API tutorial is beyond the scope of this article, I will give you a quick primer. An endpoint in API parlance is just a unique URL that represents an object or collection of objects. So for instance, our only endpoint is nystudio107.com/api/blog.json which returns to us the next 9 entries in our blog.
The Element API also has a concept of transformers, which are just functions that take the data we’ve been given, transform it into what we want returned by our API, and return it.
Here’s what our elementapi.php looks like:
<?php
namespace Craft;
return [
'endpoints' => [
'api/blog.json' => [
'elementType' => 'Entry',
'elementsPerPage' => 9,
'pageParam' => 'pg',
'criteria' => ['section' => 'blog'],
'transformer' => function(EntryModel $entry) {
if (isset($entry->blogImage[0]))
$srcImage = $entry->blogImage[0];
$imageUrl = "";
if ($srcImage)
{
craft()->config->set('generateTransformsBeforePageLoad', true);
if (craft()->plugins->getPlugin('Imager'))
{
$image = craft()->imager->transformImage($srcImage, [
'width' => 336,
'format' => 'jpg',
'ratio' => 2/1,
'allowUpscale' => false,
'mode' => 'crop',
'jpegQuality' => 60,
'position' => $srcImage->focusPctX() . '% ' . $srcImage->focusPctY() . '%',
'interlace' => true
], null, null);
$imageUrl = $image->url;
}
else
$imageUrl = $srcImage->getUrl(['width' => 336, 'height' => 168]);
}
$categories = [];
foreach ($entry->blogCategory as $cat)
$categories[] = $cat->title;
$tags = [];
foreach ($entry->blogTags as $tag)
$tags[] = $tag->title;
return [
'title' => $entry->title,
'url' => $entry->url,
'blogImageUrl' => $imageUrl,
'blogSummary' => $entry->blogSummary,
'blogCategory' => $categories,
'blogTags' => $tags,
];
},
],
]
];
In our case, we use our transformer function to call the Imager plugin to convert the blog image into an optimized version for display on the frontend as per the Creating Optimized Images in Craft CMS article.
Like a good citizen, we also fall back and just use the built-in Craft image transforms if the Imager plugin isn’t installed. Then we also combine all of our categories and tags into arrays of strings, and then we return the data in JSON format. The elementsPerPage setting is how many blog entries we want returned per page, and the pageParam setting is what key should be used in the query string to send in the page number we want.
So for instance, to query our API to get the second page of blog entries, our URL would look like thi:
https://nystudio107.com/api/blog.json?pg=2
Here’s the data it’s returning right now, after being run through a JSON prettifier:
{
"data":[
{
"title":"The Craft {% cache %} Tag In-Depth",
"url":"https:\/\/nystudio107.com\/blog\/the-craft-cache-tag-in-depth",
"blogImageUrl":"https:\/\/nystudio107-ems2qegf7x6qiqq.netdna-ssl.com\/imager\/img\/blog\/114\/cache-bg_dd07a495253ae605c2c8fcf759aa4e22.jpg",
"blogSummary":"Craft CMS has a {% cache %} tag that can help with performance, if used effectively. Here's how it works.",
"blogCategory":[
"Insights"
],
"blogTags":[
"craftcms",
"cache",
"webperf"
]
},
{
"title":"Twig Processing Order & Scope",
"url":"https:\/\/nystudio107.com\/blog\/twig-processing-order-and-scope",
"blogImageUrl":"https:\/\/nystudio107-ems2qegf7x6qiqq.netdna-ssl.com\/imager\/img\/blog\/112\/twig_code_dd07a495253ae605c2c8fcf759aa4e22.jpg",
"blogSummary":"The Twig templating language has a little-known processing order & scope that is important to understand",
"blogCategory":[
"Insights"
],
"blogTags":[
"craftcms",
"twig",
"frontend"
]
},
{
"title":"Stop using .htaccess files! No, really.",
"url":"https:\/\/nystudio107.com\/blog\/stop-using-htaccess-files-no-really",
"blogImageUrl":"https:\/\/nystudio107-ems2qegf7x6qiqq.netdna-ssl.com\/imager\/img\/blog\/80\/997321-code_dd07a495253ae605c2c8fcf759aa4e22.jpg",
"blogSummary":"Every CMS under the sun has you configure a .htaccess file if you're using Apache. Don't do it!",
"blogCategory":[
"Insights"
],
"blogTags":[
"devops",
"webperf",
"craftcms"
]
}
],
"meta":{
"pagination":{
"total":12,
"count":3,
"per_page":9,
"current_page":2,
"total_pages":2,
"links":{
"previous":"https:\/\/nystudio107.com\/api\/blog.json?pg=1"
}
}
}
}
So here’s our JSON-encoded data. Note that it’s returning two root-level keys in our JSON, data and meta. The data array has all of our blog entry data in it, as returned by our transformer function. The meta array has meta information about the data that’s been returned, such as the total number of entries, the count of how many entries were returned, and so on.
So this is great! We now are using Craft CMS to manage our content, and we can consume that content via an API.
If we wrote an iPhone or Android app, we could use Craft to manage the content, and our API to load it into our app. If we wanted to let other people syndicate our content, they could use our API to do it. This opens up a world of possibilities to use Craft’s fantastic content authoring experience to power some pretty nifty stuff.
Link Consuming Our Own Content
Now that we have an API, the next step is consuming our own content on our webpage, so that when the user clicks on the Load More Articles button, we use our API to grab the next 9 articles, and then display them.
What we do is we use Twig to render our first 9 blog entries that will always be visible on the page. We do this so that we don’t have to wait for the Vue to load, instantiate itself, and process our content.
While a more cohesive model where we use Vue for everything would certainly be better, it’d require server-side rendering so that our above the fold content is rendered immediately as per the A Pretty Website Isn’t Enough article. This is something I plan to tackle in the future, but for now, we’ll use this hybrid approach.
So we’ll need a Load More Articles button, a spinner to show while we’re loading more articles (just in case for some strange reason is takes longer than expected), and the HTML for the newly added blog entries.
Here’s what our HTML markup looks like:
<div id="blog-extra" class="wrap-fixed container" v-cloak>
<div class="row top-xs">
<div v-for="blog in blogs" class="col-xs-12 col-sm-6 col-md-4 blog-archives-column">
<div class="box blog-archives-inner">
<a v-bind:href="blog.url" v-on:mouseover="prerenderLink">
<div class="archives-image-container">
<img class="scale-with-grid archives-image" v-bind:src="blog.blogImageUrl" v-bind:alt="blog.title">
</div>
</a>
<div class="blog-archives-textbox">
<h3 class="blog-archives"><a class="blog-title" v-bind:href="blog.url" v-on:mouseover="prerenderLink">${ blog.title }</a>
</h3>
<p class="blog-archives-credits">
<span class="blog-archives-tags">
<span v-for="category in blog.blogCategory">${ category } </span>
·
<span v-for="tag in blog.blogTags">#${ tag } </span>
</span>
</p>
</div>
</div>
</div>
</div>
<div class="blog-archives-wrapper" v-show="showSpinner">
<div class="row top-xs">
<div class="blog-load-more">
{{ source ('_inlineimg/spinner.svg', ignore_missing = true) }}
</div>
</div>
</div>
<div class="blog-archives-wrapper" v-show="nextPage">
<div class="row top-xs">
<div class="blog-load-more">
<button class="nys" v-on:click="loadMoreBlogs()"><i class="icon-plus-circled button-icon-font"></i> Load More Articles</button>
</div>
</div>
</div>
</div>
Note that as per the Using VueJS 2.0 with Craft CMS article, we’re using the delimiters ${ } for Vue, and we’ve interspersed this with Twig “mustache” {{ }} syntax. And it all just works.
In this example, I’m using the long-form v-bind: and v-on: syntax, but that’s just to be explicit about what’s going on. v-bind lets us bind our data to attributes, and v-on lets us bind our methods to events.
The v-cloak attribute assures that none of this will be shown until Vue has instantiated itself, so we don’t have a flash of unrendered content. We’re loading all of our JavaScript, including Vue, asynchronously via SystemJS, so we can’t assume it’s been loaded by the time the page is being rendered. Here’s the CSS for v-cloak (Vue removes this attribute after it’s been instantiated):
[v-cloak] {
display: none;
}
We use the v-for directive to iterate through our blogs data returned from our API, and we also use it to iterate through the returned blogCategory and blogTags to display them.
We’ve set our spinner to only show when our showSpinner data is truthy via the v-show attribute, and similarly, our Load More Articles button only shows when nextPage is truthy via v-show.
So here’s what our Vue JavaScript looks like:
Vue.use(VueResource);
new Vue({
el: '#blog-extra',
delimiters: ['${', '}'],
data: {
nextPage: 0,
showSpinner: 1,
blogs: [],
},
methods: {
loadMoreBlogs: function() {
this.showSpinner = 1;
this.$http.get('/api/blog.json?pg=' + this.nextPage).then(function(data) {
this.blogs = this.blogs.concat(data.body.data);
this.nextPage = this.nextPage + 1;
if (data.body.meta.pagination.total_pages == data.body.meta.pagination.current_page)
this.nextPage = 0;
this.showSpinner = 0;
});
},
prerenderLink: function(e) {
var head = document.getElementsByTagName("head")[0];
var refs = head.childNodes;
ref = refs[ refs.length - 1];
var elements = head.getElementsByTagName("link");
Array.prototype.forEach.call(elements, function(el, i) {
if (("rel" in el) && (el.rel === "prerender"))
el.parentNode.removeChild(el);
});
var prerenderTag = document.createElement("link");
prerenderTag.rel = "prerender";
prerenderTag.href = e.currentTarget.href;
ref.parentNode.insertBefore(prerenderTag, ref);
},
},
created: function() {
this.$http.get('/api/blog.json').then(function(data) {
if (data.body.meta.pagination.total_pages > 1)
this.nextPage = 2;
this.showSpinner = 0;
});
}
});
We’re using vue-resource for our XMLHttpRequest (think: ajax) which we then access via our Vue instance as this.$http as you can see in the code. We initialize our data to sane defaults, and then the created function that’s executed when our Vue instance is created goes out and asks our API for the first page of blog entries, to see if there are any more pages needed, and then sets our nextPage data as appropriate.
We could avoid this created step, and save ourselves an XMLHttpRequest by letting Twig “prime the pump” for these initial defaults. However with our ultimate goal of using Vue to render all the things once we do server-side rendering, I decided to leave it this way for now. It’s a pretty lightweight, asynchronous request anyway.
The ultimate goal will be to create a Vue Component that renders all of blog archives, with server-side rendering to render the initial state. That’s a few refactors away, though.
Whenever someone clicks on our Load More Articles button, the loadMoreBlogs() method is called, and it grabs the next page of blog entries, and boom! Just by changing the value of our blogs array data, the DOM is updated with the new blog entries. We don’t have to do a thing.
The same thing happens with the nextPage and showSpinner data; as soon as we change the values, Vue updates our DOM to reflect the changes. This is a trivial but fun example of Vue’s reactivity at work. Instead of going into the DOM, finding stuff, and changing it, you just define how the data should be displayed, and when the data changes, so does the DOM.
Tangent: Astute readers may note the prerenderLink() method. What we’re doing here is we’re prerendering blog pages when people hover their mouse over the blog links, so that when they click on a blog entry, the browser will have already fetched and rendered the page. So it will load the next page almost instantaneously, on browsers that support prerender.
Link Being Lazy is Good
While this example uses Vue, the real point of it is that we’re using Craft CMS as a way to provide an API, and we’re lazy loading our entries by consuming this API ourselves. We could just as easily have loaded the API results with jQuery (or even vanilla Javacript), and manipulated the DOM to insert them. But Vue is cooler.
Hopefully you found this relatively simple example useful. Happy lazy loading!
Oh, and the Load More Articles button is at the bottom of this page… if you want to click it.