Single page application using Web Components and built with vite
In my last blog post, I showcased how you can build Web Components with their template and styling split into individual files using esbuild. In this post, I want to take this a step further and build a whole single page application.
The goal is to meet the following criteria:
- Component-based architecture is based on web components.
- The development of components is split into individual files for logic, template, and styles.
- Showcases the usage of both native CSS and SCSS.
- HTML is minified.
- Client-side routing with lazy loading.
To achieve all of this I want to use no frameworks or libraries. No runtime dependencies. I just use vite as a build tool. Vite will take care of a smooth development experience and things like splitting the bundles automatically for lazy loading.
You can find the final project here: https://github.com/LeonEck/spa-wc-vite
The rest of this post covers a detailed explanation of how the before-mentioned criteria were achieved.
1. Component-based architecture is based on web components
In the src
folder, you can find 4 components. Two are container components for the pages. The card component is used only on page one, while the content component is used both on page one and page two.
These components are web components (custom elements) with shadow dom. The class for each component is located in the .component.ts
file. The code to define the component is located in a .ts
file with the name of the component. Those are the entry points with the side effect of registering the component.
2. The development of components is split into individual files for logic, template, and styles.
A final custom element contains its HTML template and CSS style in a string. This isn't very ergonomic for development. You can read more about this in my last blog post.
In addition to the information from my last blog post, a couple of extra things are included in this repo. Firstly the import
statements all end in ?inline
. This is an instruction for vite. The last time around I configured a loader in esbuild for HTML and used another plugin for SCSS. Vite also uses esbuild behind the scenes. The ?inline
suffix instructs vite to take the file contents as is similar to the previous esbuild setup.
There is also the types
folder that is new. I could have already talked about this in the last post but I wanted to keep that one to the essentials. Without the contents of this folder, you might get TypeScript errors shown in your IDE (and potentially by other TypeScript related tooling). It doesn't understand our imports. We can't blame TypeScript here since we are abusing the system a bit. We aren't actually importing code but rather just want to import strings. To silence these errors/warnings we can add type declarations for these import patterns. That's what the types
folder includes and it is referenced in the tsconfig.json
file. Each pattern gets a two-line declaration in which we just state that the content is of type string.
3. Showcases the usage of both native CSS and SCSS
The content component uses normal CSS. The only thing special here is the usage of a CSS variable (custom property) to control the text color. That variable is switched in the main index.html file based on prefers-color-scheme
.
The card component uses SASS in the SCSS syntax. To demonstrate that this is working I extracted the border color into a SASS variable. Vite has some great defaults and out-of-the-box support. It can work directly with SASS. All it requires is the sass
dependency to be installed in the project.
Both CSS and SCSS are automatically minified before being put into the string of the custom element.
4. HTML is minified
While TypeScript and styling are minified for us, HTML isn't. This would result in the .html
files being inserted as-is into the string of the custom element. To save a few bytes here we can use the tool html-minifier.
Vite has a Plugin API that we can use to minify the HTML before it is inserted into the custom elements string. You can find the whole code in the vite.config.ts
file. In there we define a config that includes our own htmlMinify
function as a plugin. This function registers a transform
method. In there we check for files ending in component.html?inline
. Those are minified using the before mentioned tool.
5. Client-side routing with lazy loading
Now we come to the part that defines a single page application. The fact that we only have a single index.html
file and handle all route changes on the client. Additionally, we don't want to load the whole application upfront. We only want to load the parts that are necessary for the current route. Only when another route is requested do we load the necessary code for that.
All of this takes place in the 46 lines of code in the script
tag at the top of the index.html
file. Let's walk through this code and see what it does.
In addition to the script part I want to quickly mention two sections of the body HTML:
<nav>
<ul>
<li>
<a href="/" data-route="/">Home</a>
</li>
<li>
<a href="/one" data-route="/one">Page One</a>
</li>
<li>
<a href="/two" data-route="/two">Page Two</a>
</li>
</ul>
</nav>
<hr />
<div id="router-outlet"></div>
Here we can find the navigation and a DIV with the id "router-outlet". The navigation consists of links with a data-route
attribute. Keep this in mind for the explanation of the JavaScript code.
Router links
The first 15 lines of code handle clicks on the navigation links:
window['routerLinkSetup'] = (queryTarget) => {
queryTarget.querySelectorAll('[data-route]').forEach((routerLink) => {
routerLink.addEventListener('click', (event) => {
event.preventDefault();
const path = event.target.dataset.route;
const params = event.target.dataset.routeparams;
if (params) {
window.history.pushState({}, '', `${path}?${params}`);
} else {
window.history.pushState({}, '', path);
}
route(path);
});
});
}
A function routerLinkSetup
is added to the window
that selects all elements, underneath a given target, that have the data-route
attribute set. In a loop, we attach a click
listener to these links. When executed the first thing we do is call preventDefault
on the event. This way the browser doesn't "follow" this link like it normally would. We want to handle the routing ourselves.
Next, we extract the path from the data-route
attribute as well as potential parameters we want to pass along from data-routeparams
.
Then we call window.history.pushState
either with or with out parameters depending on if they where supplied. This pushState
call is necessary to make the browser forward and back buttons work. With this API the user gets the page transition written to their browsing history and they can jump back and forth as if they visited "real" independent pages. You can find out more about this API and its signature here: https://developer.mozilla.org/en-US/docs/Web/API/History/pushState.
Lastly, we call a function we wrote ourselves called route
. We will look at that one later.
The code above was stored in a function on the window so we can execute it whenever we want to initialize router links that have been added to the page. To have one initial setup for all links that are present on the first load, we will call this function immediately after defining it on the whole body: routerLinkSetup(document);
.
Browser back and forward navigation
As described in the last block we want to support the use of the browsers back and forth buttons. One piece of the puzzle was calling pushState
to have the routes appear in the user's history.
Now we need to takle what happens when the user presses the forward or back navigation buttons of their browser. In this case a popstate
event is fired. And the next 4 lines of code handle this:
window.addEventListener('popstate', (event) => {
event.preventDefault();
route(window.location.pathname);
});
We again prevent the default browser behavior from kicking in by calling preventDefault
on the event. The browser has notified us that the user navigated on our page and with that its job is done. Now we call our route
function with the path the user wanted to go to. We will cover the contents of that function shortly.
Handling initial route
Before we jump into the route
function I want to quickly mention the last 3 lines of code in the script tag:
window.onload = () => {
route(window.location.pathname);
};
This code makes sure that the correct page is loaded when the user initially enters the page. If users would always enter through the homepage we wouldn't need this. But since they can enter with any route present in the URL we need to perform an initial routing to that page.
Router outlet and lazy loading
Now finally to the route
function:
window['route'] = (page) => {
switch (page) {
case '/one':
import('./src/page-one/page-one').then(() => {
document.querySelector('#router-outlet').innerHTML =
'<app-page-one></app-page-one>';
});
break;
case '/two':
import('./src/page-two/page-two').then(() => {
document.querySelector('#router-outlet').innerHTML =
'<app-page-two></app-page-two>';
});
break;
default:
document.querySelector('#router-outlet').innerHTML = 'Homepage';
break;
}
}
This function is called with the route to the page we want to go to. It switches between the two routes we know and defaults to the homepage.
On the homepage, we simply fill the router outlet DIV with the string "Homepage".
The interesting part happens for the two pages. Here we dynamically import our page container components. We call import
on the entry points of the page we want to load. This returns a promise. When it completes we set the innerHTML
of the router outlet DIV to the tag for the container page component.
This import syntax is picked up by vite and it automatically splits our code into appropriate bundles. When we run npm run build
we get 4 JavaScript files:
index-<hash>.js
- That's the code we just looked at from the index.html file.
content-<hash>.js
- This is our content component. This component is used on two pages so it makes sense to be split out into its own file.
page-one-<hash>.js
- In here we will find an import to the
content-<hash>.js
file. But we won't find an import or extra file for our card component. The card is only used on page one. Therefore its code was directly inlined into this file.
- In here we will find an import to the
page-two-<hash>.js
- Here we will also find an import to the
content-<hash>.js
file. It was used on multiple pages and therefore extracted out.
- Here we will also find an import to the