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.
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:
- Static Imports
- 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.
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.
The two major changes are:
- Dynamic Imports
- 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.
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:
- Our front-end is lightweight and performant.
- 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
.
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.
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:
We now have a dedicated Svelte file that will ONLY be loaded when a page has an x-svelte
element on the page.
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.
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.
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.