Introduction
Recently, I was tasked with updating a small project, focusing solely on the frontend. The unique requirement? No frameworks allowed. Initially surprised, I saw it as an intriguing challenge. The first observation was the prevalence of copy-paste code scattered throughout. My approach aimed to simplify and eliminate redundant code, binding JavaScript blocks to HTML sections for reusability — a concept that started resembling MVC. Since I couldn't bring Angular into the mix, I decided to adopt a similar approach. Of course, I can't paste the code here, so I'll share the approach only and for the beginning we need to know what is customElements.
What is customElements in JS
The definition of customElements is pretty clear, this is read-only property of the Window
interface. We have two types of customeElements:
Customized built-in elements - are inherited from standard HTML elements:
HTMLButtonElement
,HTMLInputElement
,HTMLSelectElement
,HTMLTableElement
,HTMLUListElement
,HTMLImageElement
,HTMLAudioElement
,HTMLVideoElement
,HTMLAnchorElement
,HTMLParagraphElement
,HTMLHeadingElement
,HTMLFormElement
Autonomous custom elements - inherit from the HTML element base class
HTMLElement
But I want you to know that all customized built-in elements inherit from the respective built-in HTML elements they extend, and these built-in elements are themselves inherited from HTMLElement
.
so customElements might look like this
class SomeCustomElement extends HTMLElement {
connectedCallback() { }
disconnectedCallback() { }
adoptedCallback() { }
attributeChangedCallback(name, oldValue, newValue) { }
}
customElements.define("some-custom-element", SomeCustomElement );
<some-custom-element></some-custom-element>
To get more information about customElements you can find more here.
Let's try it
For the beginning let's define the structure of the "project" folder. I believe more simpler and easy structure more easy to enhance it in the future. A clear project folder structure is an essential foundation for any web development project.
app
app.js
router.js
components
about
about.html
about.js
home
home.html
home.js
navigation
navigation.html
navigation.js
index.html
For a start, let's create three essential components: Home, About, and Navigation. The primary objective is to establish a seamless navigation experience between the Home and About pages based on the clicked link.
Each component includes two files – a JavaScript file and an HTML file.
- About Component:
<h1>Welcome to our journey in crafting a simple web application!</h1>
class About extends HTMLElement {
href = '#/about';
template = 'components/about/about.html';
connectedCallback() { }
disconnectedCallback() {}
}
Component('app-about', About);
- Home Component:
<h1>Home, Sweet Home</h1>
class Home extends HTMLElement {
href = '#/';
template = 'components/home/home.html';
connectedCallback() { }
disconnectedCallback() {}
}
Component('app-home', Home);
- Navigation Component:
<a href="#/">home</a>|<a href="#/about">about</a>
class Navigation extends HTMLElement {
template = 'components/navigation/navigation.html';
async connectedCallback() {
let response = await fetch(this.template);
if (response.ok) {
this.innerHTML = await response.text();
}
}
disconnectedCallback() {}
}
Component('app-navigation', Navigation);
Now, let's take a moment to review our progress. Every component includes a connectedCallback
function, where you can implement logic specific to each component. Navigation is the only component currently utilizing this function. The main idea is to have a separated HTML section and JS file for each component, promoting maintainability. The href
property is used for page navigation, and the Navigation
component always stays on the main page, serving as the Single Page Application (SPA) navigation hub.
Once the Navigation
element is added to the document, the connectedCallback
will be triggered, fetching and adding HTML content from the navigation.html
file. The Home
and About
components do not read HTML files; instead, this is handled in the app.js
. Lastly, the Component
function encapsulates the logic of customElements.define
.
Component = (name, element) => customElements.define(name, element);
Oops, where will we put components?
On every navigation between pages, we have to update the parent, remove the previous one, and add a new one. But wait, we have to put those components somewhere during navigation. Let's create a router.js
file. The below code does not require an explanation because it depends on the same customElements.
class router extends HTMLElement {
connectedCallback = () => this.innerHTML = `<div class='router'></div>`;
}
Component('app-router', router);
Finally
I want to do some centralization, so it will be more easy to maintain. The app.js
file I mentioned before is responsible for managing all those components, routes in one place. Almost in one place, I believe once project become complicated more and more it will be separated logically for easy extension and improvement.
class App {
routes = {};
templates = {};
currentTemplate; // do not reload same page
#route (path, template) {
if (typeof template === 'string') {
this.routes[path] = this.templates[template];
}
}
#template = (name, func) => this.templates[name] = func;
resolveRoute(route) {
try { return this.routes[route]; }
catch (e) {
//todo: error page navigation.
}
}
async router(evt) {
let url = window.location.hash.slice(1) || '/';
let route = this.resolveRoute(url);
if (route && this.currentTemplate != route) {
let approuter = document.body.getElementsByTagName('app-router')[0]
if (this.currentTemplate) {
approuter.removeChild(this.currentTemplate);
}
let response = await fetch(route.template);
if (response.ok) {
route.innerHTML = await response.text();
approuter.appendChild(route);
this.currentTemplate = route;
} else {
throw new Error(`Template is not defined`);
}
}
}
registrate(path, name, f) {
this.#template(name, f);
this.#route(path, name);
}
ready() {
window.addEventListener('load', this.router.bind(this));
window.addEventListener('hashchange', this.router.bind(this));
}
}
Component = (name, element) => customElements.define(name, element);
Eventually, let me briefly explain what I'm doing here. App class has a few methods to make all work together. registrate
function we are using to map URL and function with a key and using like below. Of course, you might wish to write more efficient code, this is up to you but you already got my idea.
let app = new App();
app.registrate('/about', 'about', new About());
app.registrate('/', 'home', new Home());
app.ready();
And router
function, pretty simple logic, as much as possible just to show you the idea: Read url, verify if page is not opened if not then try to read HTML content for innerHTML. All this code you can easily modify and improve. My goal is not to show your efficient code but to show an interesting approach with no frameworks. Also, I forgot about ready
function. In summary, these event listeners are likely part of a routing mechanism in a web application. The this.router
function is triggered on both the page load and when there is a change in the URL hash, allowing the application to handle routing and update its content accordingly.
And the last one I forgot, this is our index.html
file.
<html>
<title>
</title>
<script src='app/app.js'></script>
<script src='app/router.js'></script>
<script src='components/about/about.js'></script>
<script src='components/home/home.js'></script>
<script src='components/navigation/navigation.js'></script>
<script defer>
let app = new App();
app.registrate('/about', 'about', new About());
app.registrate('/', 'home', new Home());
app.ready();
</script>
<app-navigation></app-navigation>
<app-router></app-router>
</html>
Conclusion
Seeking efficient and simple approaches in development is a common pursuit for every developer. Not every project can be updated with frameworks or libraries due to various circumstances, but understanding architectural approaches can help structure it and enhance maintainability. Keeping code in simple blocks not only aids in the current project but also facilitates migration when incorporating advanced frameworks or libraries.
While this exploration didn't delve into challenges like Dependency Injection or other intricacies, the fundamental point is clear: with a grasp of basic concepts and a curious mindset, developers can find solutions to various challenges.
In next chapter we will try to implement simple two-way bindings. Also you can check the code here.
Happy coding!