Skip to Main Content
Let's Talk

Let’s face it—not all of us are developing Single-Page Applications. And if you’re developing with Craft CMS it’s less likely that you are building an SPA, given the ability to leverage Twig and PHP to do the heavy lifting. But just because we are developing sites with server-side rendered templates doesn’t mean we won't also be delivering highly interactive UI with Javascript. In the words of Uncle Ben (or Aunt May depending on your Spider-Man 😉), “With great power comes great responsibility.” And it's on us as developers to use our scripting power wisely—optimizing for performance so that our sites are speedy and lightweight.

The Slower Way — Statically Import All the Things

Take a look at this example main entry file.

import intro from './modules/intro'; import popup from './modules/popup'; import slider from './modules/slider'; import toggle from './modules/toggle'; import example from './modules/example'; import reframe from './modules/reframe'; import foobar from './modules/foobar'; import accordion from './modules/accordion'; import formLabels from './modules/form-labels'; import lazyLoad from './modules/lazy-load'; (ready => { if (document.readyState !== 'loading') { ready(); } else { document.addEventListener('DOMContentLoaded', ready); } })(() => { intro(document.querySelectorAll('.intro')); popup(document.querySelectorAll('#popup')); slider(document.querySelectorAll('.slider-wrapper')); toggle(document.querySelectorAll('.toggle, .random-callout, .modal-trigger')); example(document.querySelectorAll('.example')); reframe(document.querySelectorAll('.media-frame, .special-module #video-frame')); foobar(document.querySelectorAll('#foo, .callout .item')); accordion(document.querySelectorAll('.accordion')); formLabels(document.querySelectorAll('form')); lazyLoad(document.querySelectorAll('.lazy-load')); // ... });

We’ve all seen some version of the above—a Javascript index file where all the module dependencies are imported at the top, and then they are initialized once the DOM is ready. All things considered, the above example is relatively easy to understand; but, it has two underlying issues:

  1. Static Imports
  2. Generic Selectors

Static Imports

What happens when all those modules are imported? It creates one massive Javascript file of 2000+ lines, easily approaching 400KB. All that Javascript is downloaded, parsed, and executed on every page load. Any minor change would result in the client having to re-download the entire file, slowing down the site as a result.

Generic Selectors

All the selectors in the example are valid ways to query the DOM; but, mixing arbitrary class, tag, and id selectors in this way tells us nothing regarding their function or context. Our styling is combined with our interactivity. And the selectors are so generic that we could easily cause one of the modules to initialize on a component that we did NOT intend.

To see why this is an issue check out the example code below; we're initializing the foobar() module on a few different elements using a variety selectors.

foobar(document.querySelectorAll('#foo, .callout .item'));

Now imagine what would happen when we are asked to make updates to the markup and styles for .callout? We may see that .item has undesirable styles applied for the purposes of the update, so we remove it—now whatever foobar() was going to do has been completely broken.

Maybe generic selectors are less of an issue for a solo developer who is familiar with the code base. But what about for a new Jr. Developer on your team who's been asked to make these changes? We’ve set them up for failure and made the task more likely to go over budget. In the long run, code organization issues like Static Imports and Generic Selectors make a project more difficult to debug, maintain, and work on as a team.

The Faster Way — Conditionally Import Some of the Things

Let's see how we can resolve these issues and improve our entry file.

import init from './init'; (ready => { if (document.readyState !== 'loading') { ready(); } else { document.addEventListener('DOMContentLoaded', ready); } })(() => { init(document); // Any additional global code… });
const modules = { '.intro': () => import('./modules/intro'), '[data-popup]': () => import('./modules/popup'), '[data-slider]': () => import('./modules/slider'), '[data-toggle]': () => import('./modules/toggle'), '[data-example]': () => import('./modules/example'), '[data-reframe]': () => import('./modules/reframe'), '[data-controls]': () => import('./modules/controls'), '[data-accordion]': () => import('./modules/accordion'), '[data-form-labels]': () => import('./modules/form-labels'), '[data-lazy-load]': () => import('./modules/lazy-load') }; export default scope => { Object.keys(modules).forEach(selector => { const request = modules[selector]; (els => { if (els && els.length) { request().then(({ default: module }) => module(els)); } })(scope.querySelectorAll(selector)); }); };

The two major changes are:

  1. Dynamic Imports
  2. Data Attribute Selectors

Dynamic Imports

If you're unfamiliar with static imports vs. dynamic imports in Javascript the simple difference is that the static import declaration will be immediately and synchronously included in your Javascript while the dynamic import() expression will load your additional Javascript at execution time asynchronously. Dynamic imports (also known as code-splitting) allows us to add conditions under which that Javascript should be downloaded to the client.

We’ve also moved all of our modules into a separate init.js file which exports a single function to only load the modules we need on-demand within an explicit scope, in this case the document. (There’s another reason for moving this to a separate file that we’ll get to in a moment.)

Data Attribute Selectors

All (except one) of our selectors have been changed to data- attributes. We've found this is the most helpful convention for understanding code long term, for both new and experienced developers alike.

Data attributes exist primarily for one thing, associating arbitrary data in our markup with functionality in our Javascript. Seeing a data attribute in our markup immediately tells us that there is associated functionality with that component. Our styles—applied with tag, class, or id selectors—are now completely separated from our Javascript. And we no longer need to hunt down multiple class names in a giant JS file to make sure we’re not breaking anything.

Why leave the one .intro selector as a class? With any convention comes at least some exceptions for the sake of simplicity—sometimes a class will always and only be for a single specific purpose that is associated with both a specific set of styles and functionality. Always remember, conventions exist to make our lives easier—if a convention ultimately feels "over-engineered" for a specific use case use your best judgment!

And finally, it’s incredibly easy to pass along additional data to our JS modules with additional data attributes.

<div data-example data-example-argument="{{ someVariable }}"></div>
export default els => { // This code will now only get fetched, parsed, // and executed on pages that actually use it. Array.prototype.forEach.call(els, el => { const arg = el.dataset.exampleArgument; console.log(arg); }); };

This refactored approach using Dynamic Imports and Data Attribute Selectors immediately gives us a number of benefits for long-term, scalable maintenance.

Going a Little Further

This is all well and good when it comes to vanilla JS utility modules. But what if we need a more complex UI? When writing a traditional Multi-Page Application (MPA) we don’t necessarily have all the bells and whistles of a front-end UI framework such as React or Vue in all areas of the site.

We could implement a React or Vue runtime on top of our server-side rendered site to intercept user interaction, or we could serve HTML over the wire in specific areas of our pages with tools such as Livewire. But we like to use Svelte (almost) exclusively for creating dynamic and engaging front-end UIs

If you’re unfamiliar with Svelte, I highly encourage you to check it out. Unlike frameworks such as Reach or Vue, there is no additional runtime to load on the client before loading your component. All of your Svelte components are compiled to vanilla JS at compilation, resulting in less time spent loading, parsing, and executing your site’s Javascript.

Svelte gives us the best of both worlds:

  1. Our front-end is lightweight and performant.
  2. And our developer experience is simple and flexible.

Dynamically Importing Svelte Components

Let’s apply what we've done above and create a Svelte adapter for our HTML. (We are aware that Svelte can be compiled into web components, however, we do not take this approach.)

In our template, we’re going to define a new, custom HTML element, x-svelte and set its display to contents.

<head> <script> window.customElements.define('x-svelte', class extends HTMLElement {}); </script> <style> x-svelte { display: contents; } </style> </head>

This will give us a very explicit and clear hook in our markup for adding Svelte components while also allowing us to pass along properties and slots to our component. And by setting display: contents we can essentially ignore the x-svelte element in our document flow.

<x-svelte component="example" data-block-id="{{ block.id }}"> <template> <h2>Hello, from _example.twig.</h2> </template> </x-svelte>

We use a similar approach to organizing our Svelte components as we do our vanilla Javascript modules, just in a separate file. (We really can’t overstate how helpful it can be for maintenance to split your code into separate files.)

Let’s make a small addition to our init.js file by adding to the list of modules:

const modules = { // ... 'x-svelte': () => import('./svelte') };

We now have a dedicated Svelte file that will ONLY be loaded when a page has an x-svelte element on the page.

import { detach, insert, noop } from 'svelte/internal'; export default els => { const store = () => import('./store'); const components = { 'example': () => import('./components/Example.svelte') }; els.forEach(target => { const component = target.getAttribute('component'); const request = components[component]; if (request) { Promise.all([ request(), store() ]).then(([ { default: Component }, { default: store } ]) => { const slots = {}; const slotElements = target.querySelectorAll(':scope > template'); slotElements.forEach(el => { const name = el.getAttribute('slot'); slots[name ? name : 'default'] = el.content; }); target.innerHTML = ''; new Component({ target, store, props: { ...target.dataset, $$slots: createSlots(slots), $$scope: {} } }); }); } }); }; function createSlots(templates) { const slots = {}; for (const name in templates) { if (templates.hasOwnProperty(name)) { slots[name] = [ createSlot(templates[name]) ]; } } function createSlot(element) { const nodes = [ ...element.childNodes ]; return function() { return { c: noop, l: noop, m: (target, anchor) => { Array.prototype.forEach.call(nodes, node => { insert(target, node, anchor); }); }, d: detaching => { if (detaching) { Array.prototype.forEach.call(nodes, node => { detach(node); }); } } }; }; } return slots; }

There’s a lot happening in this file, however most of it simply allows us to pass along markup to our Svelte components’ slots and to pass along any data attributes as component properties. For the purposes of this article, we will not go into detail about this, but the general organizational approach is exactly the same as our original init.js file.

Once in our Svelte component, we can use our slots and properties however we need.

<slot><!-- Content: <h2>Hello, from _example.twig.</h2> --></slot> <ul> {#each block.items as item (item.id)} <li>{item.title}</li> {/each} </ul> <script> export let blockId; let block = { items: [] }; fetch(`/api/example/${blockId}`) .then(res => res.json()) .then(res => block = res); </script>
<x-svelte component="example" data-block-id="{{ block.id }}"> <template slot="header"> <h2>Hello, world.</h2> </template> <template slot="footer"> <h2>Bye, world.</h2> </template> </x-svelte>

Remember how we put all of our modules in a separate src/js/init.js file earlier? Here’s why it is so helpful. There’s no guarantee (and in fact, it is extremely unlikely) that your Svelte components will be mounted and rendered before your other modules query their targets and are initialized. However, because we can now initialize these modules whenever we want for whatever scope we require, we can initialize any modules within our Svelte component quickly and dependably.

Let’s take our original [data-example] module and use it in a Svelte component’s slot.

<x-svelte component="new-example"> <template> <div data-example data-example-argument="{{ someVariable }}"></div> </template> </x-svelte>
<div bind:this={scope}> <slot><!-- Content: <div data-example data-example-argument="..."></div> --></slot> </div> <script> import init from '../init'; import { onMount } from 'svelte'; let scope; onMount(() => { init(scope); }); </script>

These techniques allow us at Mostly Serious to both take full advantage of Twig in Craft CMS as well as deliver complex, dynamic UI. The flexibility of Dynamic Imports along with the implied semantics of Data Attribute Selectors have allowed our entire dev team to move quickly on all project types (both new and ongoing). This approach ensures we are delivering quality work to our clients, and our users never waste bandwidth by being forced to download superfluous Javascript. Uncle Ben would be proud.

Tell us about your project.