Link Search Menu Expand Document

Module 7: Component Architecture

In the last section of the workshop, we learned about ES modules, which provide a way to expose functions, variables and classes from one script file that we can import from other files or pages.

In this section, we’ll learn about how to organise your components into modules and classes, so that you can keep your application logic safely decoupled from browser-specific code that deals with event handlers and HTML tags.

Our demo app for this section is a simple counter component, and our initial implementation follows an architectural style known as the “big ball of mud” 😁

Exercise 1: HTML, CSS and JavaScript

The first and most obvious problem with our counter application is that everything is in a single file. This makes it difficult to navigate our project, but it also means that we can’t use tools like intelligent code completion and syntax highlighting.

We’re going to refactor our solution so that we have three separate files - index.html will be the HTML page that hosts our component, counter.js will be the component itself, and counter.css will contain all our CSS styles.

View and download the code for this exercise at exercise01

Unzip it into a local folder, and set up a web server so you can browse and run the example.

⚠ This project uses <script type="module"> and ES modules, so it will not work from a file:// URL

You’ll need to run a local web server that serves .js files with the application/javascript MIME type - if you don’t have one set up, check out rif/spark, a tiny web server written in Go that will turn any folder on your machine into a website.

  1. Open up index.html. Move all the JavaScript code out of the <script> block and paste it into a new file called counter.js

  2. Remove the (now-empty) <script></script> tags from index.html

  3. Add a new tag to the head section of index.html which imports our new script, as a JavaScript module:

  4. Open counter.js, find the line which assigns style.innerHTML to a big block of CSS rules (look for the backtick quotes ``). Move all the CSS code into a new file counter.css

  5. Modify the connectedCallback method in counter.js file so that instead of calling document.createElement('style'), it creates a link element, sets the src attribute to counter.css and the rel attribute to stylesheet, and appends this link to the shadow root.

You should now have three files in your solution:

  • index.html – contains only HTML tags. There shouldn’t be any JavaScript or CSS code in your HTML file.
  • counter.css – should contain only your CSS rules.
  • counter.js – should contain the JavaScript code for your component. There shouldn’t be any HTML tags or CSS rules in this file.

You can view the solution to this exercise at solution01

Introducing the Model/View/Controller Pattern

Although we’ve split our component into separate HTML, CSS and JavaScript files, our JavaScript file is still a bit of a mess. What we’re going to do now is use the ES module system we learned about earlier to break our component into smaller classes, so that each class is responsible for a different set of concerns.

You may be familiar with an architectural pattern called model/view/controller (MVC) - we’re going to use a very similar pattern to organise the code in our JS component.

image-20210424161908122

The JavaScript code in our component is actually doing three different things here:

  • Some of our code is defining our custom element, attaching to the shadow DOM, and responding to events raised by the browser like button clicks and key presses.
  • Some of our code is managing our application logic – incrementing, decrementing and resetting our counter. This code doesn’t have anything to do with web browsers or HTML - it’s pure logic.
  • Some of our code is taking the state of our counter and translating it into HTML elements which get rendered into the shadow DOM by the browser.

It can take a while to work out exactly how to organise the code in our component, but here’s a few guidelines that can help figure out what goes where:

  • All calls to addEventListener should be inside the controller (counter.js) – this should be the only part of the application that deals with browser events.
  • Any code that manipulates the DOM should be inside renderer.js. This means any calls to document.createElement, setAttribute, and any code that interacts with HTML elements and CSS classes.
  • The application logic (counting-engine.js) shouldn’t call any browser methods or use any web APIs.

Exercise 2: Refactoring to MVC

For this exercise, you can use your code from part 1, or download the code for exercise02

This version of the counter application has been split into separate JS modules, but those modules have not been implemented. The missing methods are marked with //TODO comments - you’ll need to figure out what should go in these methods and add the missing code.

Introducing the HTML Helper

Once you’ve refactored the application to follow the model/view/controller pattern, you’ll probably notice that in the renderer, you see this pattern repeated several times:

// Create an element
let tag = document.createElement('tag');

// Set some attributes
tag.setAttribute('foo', 'bar');
tag.setAttribute('href', 'some-file.css');

// Set the tag innerHTML property
tag.innerHTML = 'This tag contains some HTML.';

// Add the tag to the shadow DOM
this.root.appendChild(tag);

This is repetitive and error-prone, so we’re going to introduce a new module to our project that exposes a helper method we can use to create HTML elements.

Exercise 3: Working with the HTML helper

For this exercise, you can use your solution from exercise 2, or download the code from exercise03

Take a look at the file html.js; this file is an ES module which exposes a helper method for creating HTML tags:

// html.js

/**
 * Creates an HTML element based on a tag name, attributes and innerText properties.
 * @param {*} tagName - the HTML tag to create, e.g. 'h1', 'div', 'span'
 * @param {*} attributes - a JS object that will be converted to HTML attributes. { id: 'my-heading', style: 'color: red;' }
 * @param {*} innerText - a string to passed to the new element's innerText property.
 * @returns an HTML Element
 */
function element(tagName, attributes, innerText) {
    const element = document.createElement(tagName);
    for (const [key, value] of Object.entries(attributes || {})) element.setAttribute(key, value);
    if (innerText) element.innerText = innerText;
    return element;
}

export { element }


Your task is to update the renderer.js file so that it uses the html.element() method to create HTML tags, passing in the attributes as a JavaScript object literal such as {'src': 'counter.css', 'rel': 'stylesheet'}, and removing the repetive calls to createElement, setAttribute and innerHTML =

Exercise 4: Demonstrating Component Architecture

For the final architecture exercise, you can either use your code from exercise 3, or download the code from exercise04

In this exercise, we’re going to make some simple changes to our application, that demonstrate how our project structure means changes to different aspects of our implementation can be made independently.

Modify the counter app so that:

  • Left-clicking on the counter with the mouse will increment the counter.
  • Pressing the spacebar should reset the counter to its initial value.

This part should only require changes to the counter.js custom component. You should not need to make any changes to the application logic.

Modify the counter app so that:

  • The counter will not accept an initial value greater than 25. If you specify an initial value greater than 25, the counter will initialise with 25 as the default value.
  • It cannot decrement below zero; if the counter already reads zero, decrementing it should have no effect
  • If you increment the counter above 25, it resets to zero. (i.e. the counter will never display the value 26)

This part should only require changes to the logic in counting-engine.js.

Component Architecture: Review & Recap

  • Use ES modules to separate your components into logical units
  • You should keep your application logic separate from your component and rendering code
  • You can structure your web components based on the popular model/view/controller pattern:
    • Controller: the custom element class - responsible for event handlers and translating user actions and events into application logic
    • Model: a set of JS classes containing your application’s logic and behaviour
    • View: a dedicated JS class that will translate the state of your model back into HTML elements and DOM operations.