Day 3 Overview: Routing + Web Components

Day 3 Overview: Routing + Web Components

·

14 min read

Browser Routing & History API

Single Page Applications: how to change the content

The idea of a SPA, vs a multi-page app, is that we’re only using one HTML page that will render all of our content. Otherwise, we would need multiple HTML pages for each part of our site, “About”, “Projects”, etc. This was the way people would make pages back in the day for simple static sites. Now that modern sites are so interactive, a SPA allows us to use only one HTML page and dynamically render content based on what a user has engaged with.

How do we do this from a DOM perspective?

There are two main techniques to change things from the DOM’s perspective:

  • Remove previous page or content and inject the new page or content into the DOM

      <nav>
          <a href=''>Section 1</a>
          <a href=''>Section 2</a>
          <a href=''>Section 3</a>
      </nav>
    
      ~~<section id='section1'>
      </section>~~
    
      <section id='section2'>
      </section>
    
  • Hide previous page and show current page

      <nav>
          <a href=''>Section 1</a>
          <a href=''>Section 2</a>
          <a href=''>Section 3</a>
      </nav>
    
      <section id='section1'>
      </section>
    
      <section id='section2' hidden>
      </section>
    
      <section id='section3' hidden>
      </section>
    

    This isn’t ideal for large-scale apps, but can still be very performant.

Single Page Application and Router

We want to change the content of the pages based on what the user selects:

  • Home Page: menu

    • Details of one particular product

    • Order: cart details and order form

We won’t have multiple HTML files, we will use the DOM APIs and Web Components.

We will use the History API to push new entries to the navigations history

History API

We can push new “fake” navigation URLs and listen for changes

//pushing a new URL; the second argument is unused
history.pushState(optional_state, null, '/new-url');

//to listen for changes in URL within the same page navigation
window.addEventListener('popstate', event => {
    let url = location.href;
});
💡
popstate won’t be fired if the user clicks on an external link or changes the URL manually.

If you’re creating a SPA, configure your server properly and check the URL when the whole page loads for the first time.

SPA Router from Scratch

Now, I can go ahead and code a router from scratch. I’ll start with an object with two methods: init and go.

//services/Router.js
const Router = {
    init: () => {

    },
    // go() will ask the router to go to a new URL, a use case for this 
    // might be when implementing a login page, wouldn't want to go 
    // back to log in form after successfully logging in. 
    go: (route, addToHistory=true) => {

    }
};

export default Router;

Once we’ve exported our Router, we’ll want to import it into our app.js and add it to our app object, adding it as a router property.

//app.js
//..
import Router from './services/Router.js';

app.router = Router;

Now, if I go back to my app and click on any of the current links, we can see that there’s a refresh happening, which means that every time the icons get clicked, it makes a new request to the server to render out the current page. And we don’t want links that we want to manage to make calls to the server. So to end this, we need to trap the click on those links with the DOM API, to prevent those links from hitting the server.

For that, we need to use e.preventDefault() by first grabbing all the link elements we’re working with by using document.querySelectorAll() since this method returns us a static Node List, we can use all the modern array methods at our disposal to loop through each and add an event listener that, on click, will prevent the default behavior:

//services/Router.js
const Router = {
    init: () => {
        document.querySelectorAll('.navlink').forEach(link => {
            link.addEventListener('click', (e) => {
                e.preventDefault();
            });
        });
    },
    //go: () => {}
}

Now that we’ve successfully prevented the default operation of the browser from going to the href found in our nav element, we need to find a way to go there. This begins by first figuring out where our current location is.

One way to get the URL is to use use the .href property from our anchor element called link. However, there is also another way, using the .getAttribute('href') DOM API method.

💡
One thing to point out here, is that we can use closure by referencing our link variable in the forEach loop, which is perfectly acceptable, but we can also directly reference the event object which also has direct access to the DOM APIs we’re working with.

In our specific example, this doesn’t matter as either technique will work, but the difference in using the closure vs the direct event target, is that the event target is giving us the target or subject of the event aka the element that was clicked.

init: () => {
        document.querySelectorAll('.navlink').forEach(link => {
            link.addEventListener('click', (e) => {
                e.preventDefault();
                //const url1 = event.target.href;
                const url2 = event.target.getAttribute('href');
                Router.go(url);
            });
        });
        Router.go(location.pathname);
    },

One last thing to do in my init() function, is to check the initial URL bc maybe the user is starting from a specific route. We can do this by using the location.pathname property (this property exists on Window) and pass it to our Router.go() function.

Now, we need to consider handling the case where we want to add to history. We can simply add to our history using the pushState() method. Again, history here is a property from our global Window object.

//services/Router.js
const Router = {
    //init: () => {}
    go: (route, addToHistory=true) => {
        if(addToHistory) {
            history.pushState({route});
        }
    })
}

Changing DOM Element Content

As we saw before, us manually adding a route using pushState() will simply “fake” that route in our browser. From there we can make a switch case based on what the route is and do something with the DOM (either hide/show or remove/inject the page). Where should we do this? Well, we can use a <main> element and inject/remove elements there:

//services/Router.js
const Router = {
    //init: () => {}
    go: (route, addToHistory=true) => {
        if(addToHistory) {
            history.pushState({route});
        }

        let pageElement = null;

        switch(route){
            case '/':
                pageElement = document.createElement('h1');
                pageElement.textContent = 'Menu';
                break;
            case '/order':
                pageElement = document.createElement('h1');
                pageElement.textContent = 'Your Order';
                break;
        }

        document.querySelector('main').appendChild(pageElement)

    })
}

Now that we can add HTML elements to our page, one quick problem we can see is that there is no current way to remove previous or outdated elements on the DOM. For this example we see multiple <h1> tags being appended to the DOM on every click to the respective icons and routes. This is very common in VanillaJS since a lot of frameworks take care of this or abstract it away like React.

Two ways to do it:

  • Quick and dirty: Change innerHTML from the element to an empty string.

      document.querySelector('main').innerHTML = '';
    
  • Using the children property and calling the remove() method on it

      document.querySelector('main').children[0].remove();
    
💡
Difference between children and childNodes: children gives us an HTML collection of DOM elements, whereas childNodes returns a NodeList that gives comments as objects and text as objects aka \n and empty spaces.

Now, what happens if we go to an invalid route? If we don’t have a page to render, it’s because the router couldn’t find a route. For this, we could do a client-side 404 page and add logic to handle this case:

//services/Router.js
//go: () => {
//...
let pageElement = null;

        switch(route){
            case '/':
                pageElement = document.createElement('h1');
                pageElement.textContent = 'Menu';
                break;
            case '/order':
                pageElement = document.createElement('h1');
                pageElement.textContent = 'Your Order';
                break;
        }

        if(pageElement){
            const cache = document.querySelector('main');
            cache.innerHTML = '';
            cache.appendChild(pageElement);
            window.scrollX = 0;
            window.scrollY = 0;
        }
}
💡
It’s a best practice when implementing a router to set the window.scrollY and window.scrollX because when you have a large page with a lot of content and are scrolling the page, when you change the route, you’ll still be in your current scroll position, and we want to start from the top of the new page.

Dynamic Routing

Now that we’ve got our router working, another consideration we have to take in is how to handle a dynamic route, ie: a route like product details that has a ID associated with the specific product we want more details on.

Turns out we can add a default case to our switch statement and check if the route that doesn’t fit with our other pre-defined routes starts with something in particular that we’re looking to handle such as a product details page. From there we can make use of the dataset property of our HTML element. Which is a read-only property that provides read/write access for custom data attributes— similar to data-* that I’ve seen in things like React or Jest and doesn’t get parsed by the browser.

//services/Router.js

switch(route){
    //...
    default:
        if(route.startsWith('/product-')) {
            pageElement = document.createElement('h1');
            pageElement.textContent = 'Details';
            const paramId = route.substring(route.lastIndexOf('-') + 1);
            pageElement.dataset.id = paramId;
        }
}

Now that we’ve got that, if you go back and navigate forwards and backward in our app, our HTML content doesn’t change, even though our browser URL does. This happens because we’re not listening to the popstate event just yet. So let’s go do that right now:

const Router = {
    init: () => {
        //...
        // Event Handler for URL changes
        window.addEventListener('popstate', event => {
            //setting addToHistory as false here since the user is going back
      Router.go(event.state.route, false);
        })
    }
}
💡
Could make a Router reusable by receiving a collection of routes (path as a regex and component to render).

Web Components

Overview & Custom Elements

Web Component:

A modular, reusable building block for web development that encapsulates a set of related functionality and user interface elements. AKA in short our own custom HTML element.

  • Compatible with every browser

  • It’s actually a set of standards:

    • Custom Elements

    • HTML Templates

    • Shadow DOM

    • Declarative Shadow DOM (new)

  • Similar to the idea of components on most of the libraries for JS

  • We have freedom of choice on how to define and use them

Custom Element:

A way to define new, reusable HTML elements with custom behavior and functionality using JS. We can define our own HTML tags using the Custom Elements API:

class MyElement extends HTMLElement {
    constructor(){
        super();
    }
}

customElements.define('my-element', MyElement);

Would output the following:

<body>
    <my-element></my-element>
</body>

<script>
document.createElement('my-element');
</script>
💡
The HTML tag we define must contain a hyphen to assure future compatibility.

Custom Elements with Attributes

We can define our own custom attributes using the data-* spec:

class MyElement extends HTMLElement {
    constructor(){
        super();
        this.dataset.language
    }
}
customElements.define('my-element', MyElement);

Our HTML would be:

<body>
    <my-element data-language='en'></my-element>
</body>

Custom Elements Lifecycle

We can override some methods of the super class

class MyElement extends HTMLElement {

    constructor(){            //set up initial state, event listeners, etc.
        super();
    }

    connectedCallback() {}    //The element is added to the document
    disconnectedCallback() {} //The element is removed from the document
    adoptedCallback() {}      //The element has been moved to a new document

    attributeChangedCallback(name, oldValue, new Value() {})

}

Custom Elements with Slots

The slot is the content that we can define as the component’s children. With templates, we can have more than one slot. Like the children prop in React components.

<body>
    <my-element data-language='en'>

        <div>
            <h1>Slot of My Element</h1>
        </div>

    </my-element>
</body>

Custom Elements with Customized Builtins

We can extend HTML elements with our own implementation. Not available in Safari in 2023.

<div is='my-element'>

</div>

HTML Templates

Template Element:

Fragments of markup that can be cloned and inserted into the document at runtime, with reusable HTML content that can be rendered and modified dynamically.

It allows us to define HTML content that is not going to be parsed by the browser and it’s going to be available for usage only if we clone it.

<template id='template1'>
    <header>
        <h1>This is a template</h1>
        <p>This content is not rendered initially</p>
    </header>
</template>

Typically to clone it we need to access it, which is why we put an id attribute on it as well.

Template Element Cloning:

We clone the template and append it as a child; typically in the connectedCallback method of the Custom Element.

connectedCallback() {
    const template = document.getElementById('template1');
    const content = template.content.cloneNode(true);
    this.appendChild(content);
}
💡
By default, the nodes of our custom elements are part of the same page’s DOM, so CSS style declaration applies to the entire document.
<template id='template1'>
    <style>
        /*This declaration also changes h1s outside of the custom element*/
        h1 {color: red}
    </style>

    <header>
        <h1>This is a template</h1>
        <p>This content is not rendered initially</p>
    </header>
</template>

Shadow DOM

A private, isolated DOM tree within a web component that is separate from the main document’s DOM tree.

  • Allows more control over styling and encapsulation of functionality of a Custom Element

  • By default, CSS declared in the main DOM won’t be applied to the Shadow DOM

  • CSS declared in the Shadow DOM applies only there, not the outside

  • There are new pseudo-classes and pseudo-element to allow communication between DOMs in stylesheets

  • It can be opened or closed defining visibility from the outer DOM

We have to create a Shadow DOM manually in our Custom Element, typically in the constructor and we save it.

class MyElement extends HTMLElement {
    constructor() {
        super();
        this.root = this.attachShadow({mode: 'open'});
    }

    connectedCallback() {
        this.root.appendChild(...);
    }
}

The Shadow DOM is completely separate from the browser DOM.

Where to define HTML for a Custom Element

  • There are several alternatives:

    • Use DOM APIs

    • Use a <template> in the main HTML

    • Use an external HTML file loaded with fetch (it can be prefetched) (preload link element)

      • Using innerHTML

      • Use DOMParser

Declarative Shadow DOM

A way to define Shadow DOM directly in HTML markup using a new set of attributes and tags. This is dependent on browser compatibility.

Where to define CSS for a Custom Element

There are several alternatives:

  • Use CSSOM APIs (A DOM for CSS) API in JS to create style sheets

  • Add a <script> to a <template>

  • Add a <link> in the <template>

  • Use an external CSS file loaded with fetch (can be prefetched) and injected in the Shadow DOM as a <style>

Web Components are made up of 3 independent APIs:

  • Custom Elements

  • HTML Templates (and cloning them to use them)

  • Shadow DOM

These three APIs end up being a sort of Design Pattern that is Web Components.

Creating Web Components

Now let’s create our 3 web components representing our Menu Page, Details Page, and Order Page.

//components/MenuPage.js
class MenuPage extends HTMLElement {
  constructor() {
    super();
    this.root = this.attachShadow({ mode: 'open' });
  }

  connectedCallback() {
    this.root.appendChild();
  }
}

customElements.define('menu-page', MenuPage);

Now, as an experiment, say I add my new custom element <menu-page> to my HTML, it shows up in the DOM, but it’s not rendered. This is because when the browser finds an unknown element, it just ignores it bc there’s no link from the HTML file to my MenuPage.js file.

Once I export my class, import it in my app.js file, it will execute that code, which will register my custom element with the DOM through the customElements.define() function.

However, we’re not going to use our custom element by adding it manually to our HTML file, we’re going to do it from the Router. If we go back to our switch case in our Router.js file, instead of creating random h1 elements, we can insert our custom elements instead. This works similarly to react-router, which I've used in the past in React apps.

switch (route) {
      case '/':
        pageElement = document.createElement('menu-page');
        pageElement.textContent = 'Menu';
        break;

      case '/order':
        pageElement = document.createElement('order-page');
        pageElement.textContent = 'Your Order';
        break;
}

Loading Templates

Now, let’s get into templates. One thing that we have to remember is that templates are unusable until they’re cloned. Meaning that we can’t just inject/append them into the DOM— we need to clone and create a real instance of the template first.

To load templates, by convention, we call it the content and clone by using the following syntax template.content.cloneNode()

//components/MenuPage.js
export class MenuPage extends HTMLElement {
    constructor(){
        super();

        const template = document.getElementById('menu-page-template');
        const content = template.content.cloneNode(true);
        this.appendChild(content);
    }
}

However, once we load this in the browser, we get the following error:

Apparently, this is because when we are constructing an element, we cannot have children. Two ways around this: using the shadow DOM or using the connectedCallback() method. For this example, let's use the connectedCallback, so we'll move our template cloning logic to the body of the connectCallback method.

//when the component is attached to the DOM
  connectedCallback() {
    const template = document.getElementById('menu-page-template');
    const content = template.content.cloneNode(true);
    this.appendChild(content);
  }

The connectedCallback() is sort of a component lifecycle like React class components (not sure but this is what it reminds me of), when the component gets attached to the DOM.

Applying a Shadow DOM

Typically you create Shadow DOM in the constructor and in fact, could move templating cloning in the constructor and append it to the newly constructed Shadow DOM.

export class MenuPage extends HTMLElement {
  constructor() {
    super();
    this.root = this.attachShadow({ mode: 'open' });
  }

    connectedCallback() {
        //...
        this.root.appendChild(content);
    }

}

Styling Web Components

The best way to load CSS in JS might be to define a function within the constructor.

export class MenuPage extends HTMLElement {
  constructor() {
    super();
    this.root = this.attachShadow({ mode: 'open' });

        const styles = document.createElement('style');
        this.root.appendChild(styles);

        async function loadCSS() {
            //request our CSS file
            const request = await fetch('/components/MenuPage.css');
            //Read CSS file using text() not json()
            const css = await request.text();
            styles.textContent = css;
        }

        loadCSS();
  }

  //when the component is attached to the DOM
  connectedCallback() {
    const template = document.getElementById('menu-page-template');
    const content = template.content.cloneNode(true);
    this.root.appendChild(content);
  }
}

customElements.define('menu-page', MenuPage);

Could also load it through a <link> in our HTML file as well, it would look like this:

<link rel='preload' href='components/DetailsPage.css' as='stylesheet'></link>

That's it for today! I covered all the basics of routing and dynamic routing, as well as Web Components, and what goes into them. Additionally, I created my own custom Web Component and utilized my templates to clone and then inject my Web Components based on the router.