Frameworking my Site
Daniel Schulz
Posted on June 2, 2020
I don't use one of the major frameworks to power my site. I instead opted to write my own - and in the process understand what a framework is and how it's built.
Disclaimer: You probably don't wanna use any of this in live projects. I made this purely for my own purposes. It works good enough for me, but that doesn't mean that it will for you. Stick to something that's better documented and thought out. If you absolutely must, though, feel to copy any of my code.
Structure
Because I value the Progressive Enhancement approach a lot, I don't want my framework to handle rendering and routing. Instead, it simply needs to add functionality to my already existing HTML without breaking its syntax.
That allows me to:
- keep my components simple and scoped to designated HTML elements
- having an early Time to Interactive by loading the entire framework after the page has been rendered
- keep a functional HTML-fallback, in case my JS fails
- keep complexity at a reasonable level. I'm not using a major framework, because those tend to evolve faster than I update my site. I don't want to change my codebase every few months.
It keeps me from massively using DOM manipulations. Those operations are costly and relatively slow. Virtual DOMs handle that really well, but my approach doesn't use one. This is simply not the framework for that. Instead, I'll be cautious about manipulating the DOM. If I need to add new elements to the page, I'll stick to one pattern: Build it as a Javascript object, then render it in an extra step, after the object is ready.
In short, it keeps everything simple and fast.
That leads me to the following stack:
- plain old HTML for content
- SCSS for style (mainly because the parent selector works so well with BEM)
- ES6 for functionality and bundling (which means I will need some tooling for browser compatibility. I'm gonna use what I know: Webpack.)
I'm going to componentize a lot. Loosely following Vue's approach, every component can have an HTML file, an SCSS file and a javascript file, neither of which are mandatory. A component can be loaded instantaneously with the main bundle, or lazily with dynamic imports.
A note on styles
With that component structure, I get CSS code splitting for free when using import './component-style.scss';
within the component's class. Webpack will index the CSS file as a dependency of the javascript file, which is a (lazy) dynamic import. Any styles in the component CSS will only load after the main js bundle has finished. That's not optimal in most cases, because it can trigger slow repaints and Culmulative Layout Shifts (which have gotten a huge importance boost in the latest Lighthouse release).
I could work around that by simply inserting a stylesheet-<link>
into the component's HTML. The same stylesheet won't get transferred twice, so technically it should work. The HTML spec approves as well, surprisingly. However, it's still slower compared to having all my stylesheet metadata inside <head>
.
The best way to do that is by pushing all those links into the <head>
server-side. If that's not an option, having a bundle per page type (as in "article page", "product page", "login page") on top of a global bundle should do the trick, too.
What's a component
What's it made of
As with any other framework, anything can be a component. In this case, they're going to be HTML-based, specifically on data-attributes.
<div data-component="button">
<button data-button-el="clicker">Click Me!</button>
</div>
The component initializes on data-component="button"
. This will be its scope. Anything out of scope should be handled by another component. It also calls a querySelector
on data-button-el="clicker"
, so we can immediately access it as this.clicker
in javascript.
The very verbose syntax enables me to register multiple components onto a single HTML-element. This can be useful for global elements like <body>
or <main>
, where multiple tasks can come together: Think of a dark mode, a scroll-locking overlay. I'd like to have them on the same element, but separated into two components.
The whole component logic will be in its own file in ./path/to/component/button/button.js
. I mostly keep the related HTML and CSS in the same directory right next to it.
./components
+---button
| +---button.html
| +---button.scss
| \---button.js
|
\---headline
+---headline.html
+---headline.scss
\---headline.js
How it works
Every component extends a component superclass, which itself fulfills four tasks
- assigning the DOM elements to
this.elementName
. I found myself repeating that task over and over again, so I just have the component superclass handle that. - initializing the component
- and publishing an event to announce it's all set
- it can also destroy itself, which is useful for things like cleaning up eventListeners and EventBus subscribers
But before we can write a usable component, we need to clear some prerequisites, so let's come back to this later on.
Component Loader
In order to use a component, we need to register and load (or mount) it first.
Registering is necessary to let the Component Loader know what viable components are and how to tree-shake them.
I keep an object called Modules
on a global scope. In there, I utilize Webpacks magic comments to manage code splitting and lazy loading. The trick is that every component is registered as a dynamic import. That way we won't load all the component-related javascript just yet. Instead, we let the Component Loader handle all that.
window.Modules = {
/**
* add skeleton functionality:
* these imports are deferred and bundled into the main chunk
* code that's supposed to run on every page load goes here
*/
body: () => import(/* webpackMode: 'eager' */ './path/to/component/body/body'),
/**
* add module functionality:
* these imports are lazy loaded and bundled into separate chunks
* code that's supposed to run only when it's needed goes here
*/
button: () => import(/* webpackChunkName: 'button' */ './path/to/component/button/button'),
};
Webpack will put eager imports into the main bundle. Components that are not in the main bundle will only be loaded when needed. Suppose you have a site that needs lots of very heavy interactive elements, but you still want to keep your index site sleek and fast: dynamic imports are your friend.
window.Modules
is consumed by the Component Loader - a class that manages all interactive elements in my website. It iterates over all the entries and executes the imports. After a successful import, it then calls an initializing method within each component.
To round things up, we can also remove a component by calling window.componentLoader.removeComponent(domNode)
.
Event Bus
In order to load my components and to provide some functionality that stretches across multiple components, I'll use some helper modules. They'll always be in the main bundle and won't be confined to the scope of a component. For this example, we're going to include the actual component loader and an event bus.
When a component has been built, it's supposed to show good manners and say Hello. Components need to talk to each other for lots of reasons, like sending events to each other. That works best with a broadcast-style approach. You could imagine a radio station inside your component that broadcasts a show, and a boombox in another component, that receives it.
Our Event Bus won't be a component itself, but rather a helper function, that can be used by any component. It can be used in three ways:
- Publish an event (read: broadcasting the show)
- Subscribing to an event (read: listening to the show)
- for the sake of completeness: Unsubscribing from an event (read: switching off your boombox)
Here's the code to my Event Bus. I refrain from pasting that here, since some details might change in the future, but the implementation is likely to stay the same:
const buttonClickSubscriber = EventBus.subscribe('onButtonClick', (event) => {
callbackMethod(event.text);
});
EventBus.publish('onButtonClick', {
text: "The button has been clicked"
});
EventBus.unsubscribe('onButtonClick', buttonClickSubscriber);
That way I can use any Event Bus, that supports the publish/listen pattern, even if I want to get rid of my own implementation.
Implementing a Component
Now we got all the automation and magic working to implement a simple component.
import Component from '../path/to/helpers/component';
import './button.scss';
export default class Button extends Component {
init() {
console.log(this.clicker); //the button element
this.clicker.addEventListener('click', this.sendButtonClickEvent);
}
sendButtonClickEvent() {
const msg = 'Eyyy, you clicked it!';
console.log(msg)
EventBus.publish('onButtonClick', {
el: this.clicker,
message: msg
});
}
destroy() {
this.clicker.removeEventListener('click', this.sendButtonClickEvent);
}
}
Without needing to do anything, this component will have the button element accessible as this.button
and send an event that it's set up: onButtonReady
.
The init()
method will be executed right away, with access to all the DOM elements.
There's an EventListener
in init()
, that registers a click method on the button. So now, whenever someone clicks it, it'll throw an Event and any component listening for it will be notified.
Quick conclusion
As I said, this is still a fairly crude thing. It started out as a way for me to collect code snippets that I use over and over again and kinda grew from there. It's still a personal code dump, but I think I grew enough to be shared. Maybe my homebrew framework isn't the next big thing (and thank god for that), but I hope it'll spark some interest to look up how certain patterns and components actually work. Most of them aren't as complicated as you might think.
(Originally posted on my website: https://iamschulz.com/basic-components-how-to-framework-your-site/)
Posted on June 2, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 28, 2024