Andrew Welch · Insights · #vuejs #elementapi #frontend

Published , updated · 5 min read ·


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 Ele­ment API to lazy load con­tent and dis­play it via Vue­JS on the frontend

When­ev­er you have a sig­nif­i­cant amount of con­tent, you’re faced with the UX deci­sion of how you’re going to dis­play it. We don’t want to over­whelm peo­ple with too many choic­es, because stud­ies have shown that peo­ple can only focus on so many things at once.

How­ev­er, we also have to pro­vide a way to load more con­tent when the user says Give me more! I explored a few options for the blog index page, from tra­di­tion­al pag­i­na­tion to infi­nite scrolling”, but ulti­mate­ly end­ed up with a Load More Arti­cles but­ton. I was lean­ing towards going down this route already, and solid­i­fied my deci­sion after read­ing this arti­cle: Infi­nite Scrolling, Pag­i­na­tion Or Load More” But­tons? Usabil­i­ty Find­ings In eCom­merce.

So to accom­plish this, we’re going to need two things:

  1. Some way to load addi­tion­al blog entries dynamically
  2. Then dis­play these entries on the fron­tend so the user can see them

A tra­di­tion­al approach to this prob­lem would be using jQuery & ajax to load a tem­plate which ren­ders the next X blog entries, and then append it to the DOM. Not a big deal, this approach has been used count­less times.

How­ev­er, we’re using Vue­JS on this web­site, so let’s explore how we can use it instead. Addi­tion­al­ly, Craft CMS is a con­tent man­age­ment sys­tem, not just a thing that makes web pages. So lets use its Ele­ment API plu­g­in to cre­ate an actu­al API for nystudio107, so that, for exam­ple, an iPhone app could get at the con­tent we’ve cre­at­ed, too.

Link Making an API

So after we’ve down­loaded & installed the Ele­ment API plu­g­in, we’ll need to cre­ate a file called elementapi.php and put it in our craft/config fold­er. This is where we’ll define our API: basi­cal­ly, what data are we return­ing. Our API is incred­i­bly sim­ple, we just want to be able to load the next 9 blog entries. We’ll need to be able to pass in a page num­ber, and we’ll need it to return our blog entries’ title, url, summary, image, category, and tags.

While a full Ele­ment API tuto­r­i­al is beyond the scope of this arti­cle, I will give you a quick primer. An endpoint in API par­lance is just a unique URL that rep­re­sents an object or col­lec­tion 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 Ele­ment API also has a con­cept of transformers, which are just func­tions that take the data we’ve been giv­en, trans­form 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 trans­former func­tion to call the Imager plu­g­in to con­vert the blog image into an opti­mized ver­sion for dis­play on the fron­tend as per the Cre­at­ing Opti­mized Images in Craft CMS article.

Like a good cit­i­zen, we also fall back and just use the built-in Craft image trans­forms if the Imager plu­g­in isn’t installed. Then we also com­bine all of our cat­e­gories and tags into arrays of strings, and then we return the data in JSON for­mat. The elementsPerPage set­ting is how many blog entries we want returned per page, and the pageParam set­ting is what key should be used in the query string to send in the page num­ber we want.

So for instance, to query our API to get the sec­ond page of blog entries, our URL would look like thi:

https://nystudio107.com/api/blog.json?pg=2

Here’s the data it’s return­ing right now, after being run through a JSON pret­ti­fi­er:

{  
   "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-encod­ed data. Note that it’s return­ing two root-lev­el keys in our JSON, data and meta. The data array has all of our blog entry data in it, as returned by our trans­former func­tion. The meta array has meta infor­ma­tion about the data that’s been returned, such as the total num­ber 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 man­age the con­tent, and our API to load it into our app. If we want­ed to let oth­er peo­ple syn­di­cate our con­tent, they could use our API to do it. This opens up a world of pos­si­bil­i­ties to use Craft’s fan­tas­tic con­tent author­ing expe­ri­ence to pow­er some pret­ty nifty stuff.

Link Consuming Our Own Content

Now that we have an API, the next step is con­sum­ing our own con­tent on our web­page, so that when the user clicks on the Load More Arti­cles but­ton, we use our API to grab the next 9 arti­cles, and then dis­play them.

What we do is we use Twig to ren­der our first 9 blog entries that will always be vis­i­ble on the page. We do this so that we don’t have to wait for the Vue to load, instan­ti­ate itself, and process our content.

While a more cohe­sive mod­el where we use Vue for every­thing would cer­tain­ly be bet­ter, it’d require serv­er-side ren­der­ing so that our above the fold con­tent is ren­dered imme­di­ate­ly as per the A Pret­ty Web­site Isn’t Enough arti­cle. This is some­thing I plan to tack­le in the future, but for now, we’ll use this hybrid approach.

So we’ll need a Load More Arti­cles but­ton, a spin­ner to show while we’re load­ing more arti­cles (just in case for some strange rea­son is takes longer than expect­ed), and the HTML for the new­ly 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>
                             &middot;
                            <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 Vue­JS 2.0 with Craft CMS arti­cle, we’re using the delim­iters ${ } for Vue, and we’ve inter­spersed this with Twig mus­tache” {{ }} syn­tax. And it all just works.

In this exam­ple, I’m using the long-form v-bind: and v-on: syn­tax, but that’s just to be explic­it about what’s going on. v-bind lets us bind our data to attrib­ut­es, and v-on lets us bind our meth­ods to events.

The v-cloak attribute assures that none of this will be shown until Vue has instan­ti­at­ed itself, so we don’t have a flash of unren­dered con­tent. We’re load­ing all of our JavaScript, includ­ing Vue, asyn­chro­nous­ly via Sys­temJS, so we can’t assume it’s been loaded by the time the page is being ren­dered. 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 direc­tive to iter­ate through our blogs data returned from our API, and we also use it to iter­ate through the returned blogCategory and blogTags to dis­play them.

We’ve set our spin­ner to only show when our showSpinner data is truthy via the v-show attribute, and sim­i­lar­ly, our Load More Arti­cles but­ton 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 XML­HttpRe­quest (think: ajax) which we then access via our Vue instance as this.$http as you can see in the code. We ini­tial­ize our data to sane defaults, and then the created func­tion that’s exe­cut­ed when our Vue instance is cre­at­ed goes out and asks our API for the first page of blog entries, to see if there are any more pages need­ed, and then sets our nextPage data as appropriate.

We could avoid this created step, and save our­selves an XML­HttpRe­quest by let­ting Twig prime the pump” for these ini­tial defaults. How­ev­er with our ulti­mate goal of using Vue to ren­der all the things once we do serv­er-side ren­der­ing, I decid­ed to leave it this way for now. It’s a pret­ty light­weight, asyn­chro­nous 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.

When­ev­er some­one clicks on our Load More Arti­cles but­ton, the loadMoreBlogs() method is called, and it grabs the next page of blog entries, and boom! Just by chang­ing the val­ue of our blogs array data, the DOM is updat­ed with the new blog entries. We don’t have to do a thing.

The same thing hap­pens with the nextPage and showSpinner data; as soon as we change the val­ues, Vue updates our DOM to reflect the changes. This is a triv­ial but fun exam­ple of Vue’s reac­tiv­i­ty at work. Instead of going into the DOM, find­ing stuff, and chang­ing it, you just define how the data should be dis­played, and when the data changes, so does the DOM.

Tan­gent: Astute read­ers may note the prerenderLink() method. What we’re doing here is we’re pre­ren­der­ing blog pages when peo­ple hov­er their mouse over the blog links, so that when they click on a blog entry, the brows­er will have already fetched and ren­dered the page. So it will load the next page almost instan­ta­neous­ly, on browsers that sup­port prerender.

Link Being Lazy is Good

While this exam­ple uses Vue, the real point of it is that we’re using Craft CMS as a way to pro­vide an API, and we’re lazy load­ing our entries by con­sum­ing this API our­selves. We could just as eas­i­ly have loaded the API results with jQuery (or even vanil­la Javacript), and manip­u­lat­ed the DOM to insert them. But Vue is cooler.

Hope­ful­ly you found this rel­a­tive­ly sim­ple exam­ple use­ful. Hap­py lazy loading!

Oh, and the Load More Arti­cles but­ton is at the bot­tom of this page… if you want to click it.