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:// URLYou’ll need to run a local web server that serves
.js
files with theapplication/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.
-
Open up
index.html
. Move all the JavaScript code out of the<script>
block and paste it into a new file calledcounter.js
-
Remove the (now-empty)
<script></script>
tags fromindex.html
-
Add a new tag to the
head
section ofindex.html
which imports our new script, as a JavaScript module: -
Open
counter.js
, find the line which assignsstyle.innerHTML
to a big block of CSS rules (look for the backtick quotes ``). Move all the CSS code into a new filecounter.css
-
Modify the
connectedCallback
method incounter.js
file so that instead of callingdocument.createElement('style')
, it creates alink
element, sets thesrc
attribute tocounter.css
and therel
attribute tostylesheet
, 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.
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 todocument.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.